@momentiq/dark-factory-cli 0.1.0-alpha.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +150 -0
  3. package/dist/adapters/_shared.d.ts +45 -0
  4. package/dist/adapters/_shared.d.ts.map +1 -0
  5. package/dist/adapters/_shared.js +200 -0
  6. package/dist/adapters/_shared.js.map +1 -0
  7. package/dist/adapters/codex-sdk.d.ts +279 -0
  8. package/dist/adapters/codex-sdk.d.ts.map +1 -0
  9. package/dist/adapters/codex-sdk.js +930 -0
  10. package/dist/adapters/codex-sdk.js.map +1 -0
  11. package/dist/adapters/critic-result-schema.d.ts +215 -0
  12. package/dist/adapters/critic-result-schema.d.ts.map +1 -0
  13. package/dist/adapters/critic-result-schema.js +153 -0
  14. package/dist/adapters/critic-result-schema.js.map +1 -0
  15. package/dist/adapters/critic.d.ts +62 -0
  16. package/dist/adapters/critic.d.ts.map +1 -0
  17. package/dist/adapters/critic.js +79 -0
  18. package/dist/adapters/critic.js.map +1 -0
  19. package/dist/adapters/cursor-sdk.d.ts +153 -0
  20. package/dist/adapters/cursor-sdk.d.ts.map +1 -0
  21. package/dist/adapters/cursor-sdk.js +818 -0
  22. package/dist/adapters/cursor-sdk.js.map +1 -0
  23. package/dist/adapters/gemini-sdk.d.ts +113 -0
  24. package/dist/adapters/gemini-sdk.d.ts.map +1 -0
  25. package/dist/adapters/gemini-sdk.js +532 -0
  26. package/dist/adapters/gemini-sdk.js.map +1 -0
  27. package/dist/adapters/grok-direct-sdk.d.ts +148 -0
  28. package/dist/adapters/grok-direct-sdk.d.ts.map +1 -0
  29. package/dist/adapters/grok-direct-sdk.js +694 -0
  30. package/dist/adapters/grok-direct-sdk.js.map +1 -0
  31. package/dist/branch-protection/audit_branch_protection.py +759 -0
  32. package/dist/branch-protection/index.d.ts +25 -0
  33. package/dist/branch-protection/index.d.ts.map +1 -0
  34. package/dist/branch-protection/index.js +70 -0
  35. package/dist/branch-protection/index.js.map +1 -0
  36. package/dist/branch-protection/spec-default.yaml +314 -0
  37. package/dist/cli.d.ts +3 -0
  38. package/dist/cli.d.ts.map +1 -0
  39. package/dist/cli.js +581 -0
  40. package/dist/cli.js.map +1 -0
  41. package/dist/cycle-doc-validator/index.d.ts +43 -0
  42. package/dist/cycle-doc-validator/index.d.ts.map +1 -0
  43. package/dist/cycle-doc-validator/index.js +69 -0
  44. package/dist/cycle-doc-validator/index.js.map +1 -0
  45. package/dist/cycle-doc-validator/validate_cycle_doc.py +1260 -0
  46. package/dist/cycle-tracker-sync/attribute_pr_cycle_ref.py +384 -0
  47. package/dist/cycle-tracker-sync/index.d.ts +20 -0
  48. package/dist/cycle-tracker-sync/index.d.ts.map +1 -0
  49. package/dist/cycle-tracker-sync/index.js +71 -0
  50. package/dist/cycle-tracker-sync/index.js.map +1 -0
  51. package/dist/cycle-tracker-sync/sync_cycle_trackers.py +1093 -0
  52. package/dist/evidence/audit-trail.d.ts +59 -0
  53. package/dist/evidence/audit-trail.d.ts.map +1 -0
  54. package/dist/evidence/audit-trail.js +283 -0
  55. package/dist/evidence/audit-trail.js.map +1 -0
  56. package/dist/evidence/index.d.ts +4 -0
  57. package/dist/evidence/index.d.ts.map +1 -0
  58. package/dist/evidence/index.js +29 -0
  59. package/dist/evidence/index.js.map +1 -0
  60. package/dist/evidence/per-sha.d.ts +13 -0
  61. package/dist/evidence/per-sha.d.ts.map +1 -0
  62. package/dist/evidence/per-sha.js +97 -0
  63. package/dist/evidence/per-sha.js.map +1 -0
  64. package/dist/evidence/quality-gates.d.ts +19 -0
  65. package/dist/evidence/quality-gates.d.ts.map +1 -0
  66. package/dist/evidence/quality-gates.js +212 -0
  67. package/dist/evidence/quality-gates.js.map +1 -0
  68. package/dist/git.d.ts +40 -0
  69. package/dist/git.d.ts.map +1 -0
  70. package/dist/git.js +414 -0
  71. package/dist/git.js.map +1 -0
  72. package/dist/glob.d.ts +4 -0
  73. package/dist/glob.d.ts.map +1 -0
  74. package/dist/glob.js +99 -0
  75. package/dist/glob.js.map +1 -0
  76. package/dist/index.d.ts +17 -0
  77. package/dist/index.d.ts.map +1 -0
  78. package/dist/index.js +33 -0
  79. package/dist/index.js.map +1 -0
  80. package/dist/paths.d.ts +12 -0
  81. package/dist/paths.d.ts.map +1 -0
  82. package/dist/paths.js +42 -0
  83. package/dist/paths.js.map +1 -0
  84. package/dist/policy/baseline.d.ts +15 -0
  85. package/dist/policy/baseline.d.ts.map +1 -0
  86. package/dist/policy/baseline.js +115 -0
  87. package/dist/policy/baseline.js.map +1 -0
  88. package/dist/policy/config.d.ts +64 -0
  89. package/dist/policy/config.d.ts.map +1 -0
  90. package/dist/policy/config.js +363 -0
  91. package/dist/policy/config.js.map +1 -0
  92. package/dist/policy/gate.d.ts +80 -0
  93. package/dist/policy/gate.d.ts.map +1 -0
  94. package/dist/policy/gate.js +1019 -0
  95. package/dist/policy/gate.js.map +1 -0
  96. package/dist/policy/index.d.ts +7 -0
  97. package/dist/policy/index.d.ts.map +1 -0
  98. package/dist/policy/index.js +14 -0
  99. package/dist/policy/index.js.map +1 -0
  100. package/dist/policy/merge-queue.d.ts +183 -0
  101. package/dist/policy/merge-queue.d.ts.map +1 -0
  102. package/dist/policy/merge-queue.js +310 -0
  103. package/dist/policy/merge-queue.js.map +1 -0
  104. package/dist/policy/profile.d.ts +98 -0
  105. package/dist/policy/profile.d.ts.map +1 -0
  106. package/dist/policy/profile.js +156 -0
  107. package/dist/policy/profile.js.map +1 -0
  108. package/dist/policy/tdd-classifier.d.ts +18 -0
  109. package/dist/policy/tdd-classifier.d.ts.map +1 -0
  110. package/dist/policy/tdd-classifier.js +79 -0
  111. package/dist/policy/tdd-classifier.js.map +1 -0
  112. package/dist/prompt.d.ts +13 -0
  113. package/dist/prompt.d.ts.map +1 -0
  114. package/dist/prompt.js +175 -0
  115. package/dist/prompt.js.map +1 -0
  116. package/dist/report.d.ts +88 -0
  117. package/dist/report.d.ts.map +1 -0
  118. package/dist/report.js +376 -0
  119. package/dist/report.js.map +1 -0
  120. package/dist/runner.d.ts +30 -0
  121. package/dist/runner.d.ts.map +1 -0
  122. package/dist/runner.js +456 -0
  123. package/dist/runner.js.map +1 -0
  124. package/dist/security.d.ts +2 -0
  125. package/dist/security.d.ts.map +1 -0
  126. package/dist/security.js +19 -0
  127. package/dist/security.js.map +1 -0
  128. package/dist/trusted-surface/rebind.d.ts +10 -0
  129. package/dist/trusted-surface/rebind.d.ts.map +1 -0
  130. package/dist/trusted-surface/rebind.js +154 -0
  131. package/dist/trusted-surface/rebind.js.map +1 -0
  132. package/package.json +78 -0
@@ -0,0 +1,1019 @@
1
+ import { existsSync, mkdirSync, readFileSync, appendFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { collectChangedPaths, perShaQualityGatePath, } from "../evidence/index.js";
4
+ import { matchAnyGlob } from "../glob.js";
5
+ import { changedFiles, commitDiff, commitMetadata, commitParent, diffHash, resolveCommit, } from "../git.js";
6
+ import { parseCommitTrailers } from "../evidence/index.js";
7
+ import { resolveArtifactDir, resolveArtifactRoot, telemetryPath } from "../paths.js";
8
+ import { isCriticCompleted, criticVetoesGate, quorumAggregateVerdict, readArtifact, } from "../report.js";
9
+ import { classifyTdd as classifyTddImpl, } from "./tdd-classifier.js";
10
+ import { parseQualityGateEvidence, } from "@momentiq/dark-factory-schemas";
11
+ const BYPASS_ENV = "AGENT_REVIEW_BYPASS";
12
+ // Re-export classifyTdd from gate.ts so the cycle 318.2 exit-criteria
13
+ // statement ("gate.ts exports classifyTdd()") is satisfied without
14
+ // mixing the pure classifier logic into this file.
15
+ export { classifyTddImpl as classifyTdd };
16
+ export async function evaluateCommitGate(options) {
17
+ const { loaded, commit } = options;
18
+ const cwd = options.cwd ?? loaded.repoRoot;
19
+ const allowBypass = options.allowBypass ?? loaded.config.policy.allowEmergencyBypass;
20
+ const bypassReasonRaw = options.bypassReason ?? process.env[BYPASS_ENV] ?? "";
21
+ const bypassReason = bypassReasonRaw.trim();
22
+ if (allowBypass && bypassReason) {
23
+ return {
24
+ blocked: false,
25
+ blocks: [],
26
+ warnings: [
27
+ {
28
+ reason: `bypass invoked: ${bypassReason}`,
29
+ },
30
+ ],
31
+ bypass: { reason: bypassReason, at: new Date().toISOString() },
32
+ };
33
+ }
34
+ const blocks = [];
35
+ const warnings = [];
36
+ const sha = await resolveCommit(commit, cwd);
37
+ const artifact = await readArtifact(loaded, sha);
38
+ if (!artifact) {
39
+ if (loaded.config.policy.blockOnMissingReview) {
40
+ blocks.push({
41
+ reason: "missing_review",
42
+ detail: `no review artifact for ${sha}; run \`make agent-review-commit COMMIT=${sha}\``,
43
+ });
44
+ return { blocked: true, blocks, warnings };
45
+ }
46
+ warnings.push({
47
+ reason: "missing_review",
48
+ detail: `no review artifact for ${sha} (blockOnMissingReview disabled)`,
49
+ });
50
+ return { blocked: false, blocks, warnings };
51
+ }
52
+ if (artifact.status === "pending" || artifact.status === "running") {
53
+ blocks.push({
54
+ reason: "review_in_progress",
55
+ detail: `artifact status is ${artifact.status}; wait for completion`,
56
+ });
57
+ return { blocked: true, blocks, warnings };
58
+ }
59
+ if (artifact.status === "error") {
60
+ if (loaded.config.policy.blockOnReviewError) {
61
+ blocks.push({
62
+ reason: "review_error",
63
+ detail: "artifact status is error",
64
+ });
65
+ return { blocked: true, blocks, warnings };
66
+ }
67
+ warnings.push({ reason: "review_error" });
68
+ }
69
+ // Cycle 332 — precondition check for shape compatibility. Feeding a
70
+ // push-range artifact to the commit gate would silently produce
71
+ // stale_diff_hash (commit gate recomputes diffHash over
72
+ // parent..sha; push artifact's diffHash is over base..head). A
73
+ // distinct error keeps the operator's failure-mode dispatch
74
+ // legible. Legacy artifacts (rangeKind undefined) are treated as
75
+ // commit-shape for back-compat.
76
+ if (artifact.rangeKind === "push") {
77
+ blocks.push({
78
+ reason: "artifact_shape_mismatch",
79
+ detail: "evaluateCommitGate received a push-shape artifact (rangeKind=push); use evaluatePushGate for review-push runs.",
80
+ });
81
+ return { blocked: true, blocks, warnings };
82
+ }
83
+ // Verify diff hash freshness
84
+ try {
85
+ const parent = await safeParent(sha, cwd);
86
+ const diff = await commitDiff(parent, sha, cwd);
87
+ const expected = diffHash(diff);
88
+ if (expected !== artifact.diffHash) {
89
+ blocks.push({
90
+ reason: "stale_diff_hash",
91
+ detail: `expected ${expected}, artifact has ${artifact.diffHash}`,
92
+ });
93
+ return { blocked: true, blocks, warnings };
94
+ }
95
+ }
96
+ catch (err) {
97
+ blocks.push({
98
+ reason: "diff_hash_check_failed",
99
+ detail: err.message,
100
+ });
101
+ return { blocked: true, blocks, warnings };
102
+ }
103
+ await enforceRequiredQualityGates(loaded, sha, blocks);
104
+ // Cycle 318.2 Component 5: when a rubric context is supplied (v2-aware
105
+ // orchestrator path), strip naked blocker/high findings on each critic
106
+ // result BEFORE the artifact-level evaluation. The stripped findings
107
+ // never reach `evaluateCriticResults`, so a naked "missing tests"
108
+ // finding with no evidence does not block the push. Stripping is
109
+ // logged to NDJSON for audit by `enforceFindingRubric` itself.
110
+ const rubricArtifact = options.rubricContext
111
+ ? applyRubricToArtifact(artifact, options.rubricContext)
112
+ : artifact;
113
+ // Cycle 322.7 — when a profile is active at gate-push time, narrow the
114
+ // artifact's criticResults to the profile's allowlist BEFORE policy
115
+ // dispatch. Without this, a commit reviewed under `cloud` (3 critics)
116
+ // and pushed under `--profile local` (2 critics) would still see
117
+ // out-of-profile critics' verdicts contribute to the gate decision —
118
+ // a divergence Codex P2 caught on PR #1468. Out-of-profile critics
119
+ // are added as informational warnings so operators still see they
120
+ // existed.
121
+ //
122
+ // After filtering, ALSO recompute the artifact's gateVerdict from the
123
+ // scoped result set. Without this, the block-if-any cross-check at
124
+ // `evaluateCriticResults:503-511` (which checks `artifact.gateVerdict
125
+ // === "CHANGES_REQUESTED" && blocks.length === 0`) would falsely fire:
126
+ // the verdict was set by buildAggregate against the FULL critic list,
127
+ // but after profile filtering the scoped blocks could legitimately be
128
+ // empty. (Cursor critic high finding on PR #1468 commit c7537f34.)
129
+ let scopedArtifact = rubricArtifact;
130
+ if (options.profileCriticIds && options.profileCriticIds.length > 0) {
131
+ const allowed = new Set(options.profileCriticIds);
132
+ const scopedResults = rubricArtifact.criticResults.filter((r) => allowed.has(r.criticId));
133
+ const outOfProfile = rubricArtifact.criticResults.filter((r) => !allowed.has(r.criticId));
134
+ for (const r of outOfProfile) {
135
+ warnings.push({
136
+ reason: "out_of_profile_critic",
137
+ criticId: r.criticId,
138
+ detail: `critic "${r.criticId}" ran but is not in the active profile's criticIds; verdict ignored at gate`,
139
+ });
140
+ }
141
+ // Recompute gateVerdict from the scoped result set so the cross-check
142
+ // in evaluateCriticResults sees a like-for-like aggregate. Only
143
+ // recompute when the artifact is complete (status === "complete"):
144
+ // pending/error artifacts have no gateVerdict to overwrite, and
145
+ // the policy-specific evaluator handles those cases directly.
146
+ let scopedVerdict = rubricArtifact.gateVerdict;
147
+ if (rubricArtifact.status === "complete") {
148
+ scopedVerdict = recomputeArtifactVerdict(scopedResults, loaded, options.quorumOverride);
149
+ }
150
+ scopedArtifact = {
151
+ ...rubricArtifact,
152
+ criticResults: scopedResults,
153
+ ...(scopedVerdict !== undefined ? { gateVerdict: scopedVerdict } : {}),
154
+ };
155
+ }
156
+ // Cycle 322.3 — dispatch on aggregation policy.
157
+ // - `min-complete-quorum`: quorum-aware evaluator (defined below).
158
+ // The `required` flag is semantically irrelevant under the
159
+ // quorum policy (all critics contribute to the quorum count
160
+ // and any one can veto via §11), so the dispatcher does not
161
+ // thread `requiredCriticIds`.
162
+ // - `block-if-any` (default): 322.2-shape `required`-threaded
163
+ // evaluator (preserves shadow-mode semantics).
164
+ if (loaded.config.aggregation.policy === "min-complete-quorum") {
165
+ // Cycle 322.7 — profile.quorum (when supplied) overrides root
166
+ // aggregation.quorum. Fall back to root value otherwise; legacy
167
+ // fallback to 2 preserves cycle 322.3 default for hand-built
168
+ // test configs.
169
+ const effectiveQuorum = options.quorumOverride ?? loaded.config.aggregation.quorum ?? 2;
170
+ evaluateQuorumCriticResults(scopedArtifact, loaded.config.aggregation.blockingSeverities, effectiveQuorum, blocks, warnings);
171
+ }
172
+ else {
173
+ // Cycle 322.2 Component 4 — `required` flag threading.
174
+ // Build the set of required critic ids so the per-critic evaluator
175
+ // demotes findings from optional (`required: false`) critics from
176
+ // `blocks` to `warnings`.
177
+ const requiredCriticIds = new Set(loaded.config.critics.filter((c) => c.required).map((c) => c.id));
178
+ evaluateCriticResults(scopedArtifact, loaded.config.aggregation.blockingSeverities, blocks, warnings, loaded.config.policy.blockOnReviewError, requiredCriticIds);
179
+ }
180
+ return { blocked: blocks.length > 0, blocks, warnings };
181
+ }
182
+ export async function evaluatePushGate(options) {
183
+ const { loaded, artifact } = options;
184
+ const cwd = options.cwd ?? loaded.repoRoot;
185
+ const allowBypass = options.allowBypass ?? loaded.config.policy.allowEmergencyBypass;
186
+ const bypassReasonRaw = options.bypassReason ?? process.env[BYPASS_ENV] ?? "";
187
+ const bypassReason = bypassReasonRaw.trim();
188
+ if (allowBypass && bypassReason) {
189
+ return {
190
+ blocked: false,
191
+ blocks: [],
192
+ warnings: [{ reason: `bypass invoked: ${bypassReason}` }],
193
+ bypass: { reason: bypassReason, at: new Date().toISOString() },
194
+ };
195
+ }
196
+ const blocks = [];
197
+ const warnings = [];
198
+ // Shape precondition: reject anything that isn't a push-shape
199
+ // artifact. Legacy (undefined) and explicit "commit" are both
200
+ // rejected — caller should be dispatching to evaluateCommitGate
201
+ // for those.
202
+ if (artifact.rangeKind !== "push") {
203
+ blocks.push({
204
+ reason: "artifact_shape_mismatch",
205
+ detail: `evaluatePushGate received an artifact with rangeKind=${artifact.rangeKind ?? "undefined"} (expected "push"); use evaluateCommitGate for commit-shape artifacts.`,
206
+ });
207
+ return { blocked: true, blocks, warnings };
208
+ }
209
+ // SHA precondition: the artifact's commit/parent MUST identify the
210
+ // same head/base the gate is being evaluated against. Without this
211
+ // assertion, a fresh artifact for a different push-range could fail
212
+ // loudly only via the downstream diffHash recompute — and only when
213
+ // the two ranges happen to produce different diffs. An explicit
214
+ // SHA check makes mismatched artifacts unambiguous (cycle 332 bot
215
+ // review #5).
216
+ if (artifact.commit !== options.headSha || artifact.parent !== options.baseSha) {
217
+ blocks.push({
218
+ reason: "artifact_sha_mismatch",
219
+ detail: `evaluatePushGate received artifact for commit=${artifact.commit} parent=${artifact.parent} but gate is evaluating headSha=${options.headSha} baseSha=${options.baseSha}`,
220
+ });
221
+ return { blocked: true, blocks, warnings };
222
+ }
223
+ if (artifact.status === "pending" || artifact.status === "running") {
224
+ blocks.push({
225
+ reason: "review_in_progress",
226
+ detail: `artifact status is ${artifact.status}; wait for completion`,
227
+ });
228
+ return { blocked: true, blocks, warnings };
229
+ }
230
+ if (artifact.status === "error") {
231
+ if (loaded.config.policy.blockOnReviewError) {
232
+ blocks.push({
233
+ reason: "review_error",
234
+ detail: "artifact status is error",
235
+ });
236
+ return { blocked: true, blocks, warnings };
237
+ }
238
+ warnings.push({ reason: "review_error" });
239
+ }
240
+ // Verify diff hash freshness over <baseSha>..<headSha>.
241
+ try {
242
+ // The push-shape artifact's diffHash is computed against the
243
+ // base..head range. We recompute the same range here. The
244
+ // commitDiff helper handles non-empty parent, which for
245
+ // base..head is just the base sha.
246
+ const diff = await commitDiff(options.baseSha, options.headSha, cwd);
247
+ const expected = diffHash(diff);
248
+ if (expected !== artifact.diffHash) {
249
+ blocks.push({
250
+ reason: "stale_diff_hash",
251
+ detail: `expected ${expected}, artifact has ${artifact.diffHash}`,
252
+ });
253
+ return { blocked: true, blocks, warnings };
254
+ }
255
+ }
256
+ catch (err) {
257
+ blocks.push({
258
+ reason: "diff_hash_check_failed",
259
+ detail: err.message,
260
+ });
261
+ return { blocked: true, blocks, warnings };
262
+ }
263
+ // Quality-gate evidence enforced at headSha — same contract as
264
+ // the commit gate, scoped to the head of the push range.
265
+ await enforceRequiredQualityGates(loaded, options.headSha, blocks);
266
+ const rubricArtifact = options.rubricContext
267
+ ? applyRubricToArtifact(artifact, options.rubricContext)
268
+ : artifact;
269
+ let scopedArtifact = rubricArtifact;
270
+ if (options.profileCriticIds && options.profileCriticIds.length > 0) {
271
+ const allowed = new Set(options.profileCriticIds);
272
+ const scopedResults = rubricArtifact.criticResults.filter((r) => allowed.has(r.criticId));
273
+ const outOfProfile = rubricArtifact.criticResults.filter((r) => !allowed.has(r.criticId));
274
+ for (const r of outOfProfile) {
275
+ warnings.push({
276
+ reason: "out_of_profile_critic",
277
+ criticId: r.criticId,
278
+ detail: `critic "${r.criticId}" ran but is not in the active profile's criticIds; verdict ignored at gate`,
279
+ });
280
+ }
281
+ let scopedVerdict = rubricArtifact.gateVerdict;
282
+ if (rubricArtifact.status === "complete") {
283
+ scopedVerdict = recomputeArtifactVerdict(scopedResults, loaded, options.quorumOverride);
284
+ }
285
+ scopedArtifact = {
286
+ ...rubricArtifact,
287
+ criticResults: scopedResults,
288
+ ...(scopedVerdict !== undefined ? { gateVerdict: scopedVerdict } : {}),
289
+ };
290
+ }
291
+ if (loaded.config.aggregation.policy === "min-complete-quorum") {
292
+ const effectiveQuorum = options.quorumOverride ?? loaded.config.aggregation.quorum ?? 2;
293
+ evaluateQuorumCriticResults(scopedArtifact, loaded.config.aggregation.blockingSeverities, effectiveQuorum, blocks, warnings);
294
+ }
295
+ else {
296
+ const requiredCriticIds = new Set(loaded.config.critics.filter((c) => c.required).map((c) => c.id));
297
+ evaluateCriticResults(scopedArtifact, loaded.config.aggregation.blockingSeverities, blocks, warnings, loaded.config.policy.blockOnReviewError, requiredCriticIds);
298
+ }
299
+ return { blocked: blocks.length > 0, blocks, warnings };
300
+ }
301
+ function applyRubricToArtifact(artifact, context) {
302
+ // Deep-replace each critic's findings with the rubric-kept subset AND
303
+ // recompute the critic's verdict when stripping removes the last
304
+ // blocking finding (codex P2 follow-up on PR #1349). Without the
305
+ // verdict recompute, an artifact whose CHANGES_REQUESTED verdict was
306
+ // driven by now-stripped blockers still triggers the existing
307
+ // `verdict === "CHANGES_REQUESTED"` block path in `evaluateCriticResults`,
308
+ // re-blocking the push that the rubric just cleared.
309
+ //
310
+ // The artifact itself is returned shallow-copied so callers don't
311
+ // observe mutation of the on-disk artifact.
312
+ const blockingSeverities = context.loaded.config.aggregation.blockingSeverities;
313
+ const newCriticResults = artifact.criticResults.map((cr) => {
314
+ const { kept, stripped } = enforceFindingRubric(cr.findings, context);
315
+ const updated = { ...cr, findings: kept };
316
+ // Only recompute verdict for completed critics — pending/running/error
317
+ // already have specialized handling in `evaluateCriticResults`.
318
+ //
319
+ // Critical: we only flip CHANGES_REQUESTED → APPROVED when the rubric
320
+ // ACTUALLY stripped a blocking finding. If the verdict was already
321
+ // CHANGES_REQUESTED for a different reason (e.g., the critic
322
+ // intentionally returned CHANGES_REQUESTED with only medium findings,
323
+ // or the verdict was set by `aggregateVerdict` for a non-finding
324
+ // reason), the rubric step is silent and the verdict stays.
325
+ // (Codex P2 follow-up on d46b8be7 — predicate was too broad.)
326
+ if (updated.status === "complete" && updated.verdict === "CHANGES_REQUESTED") {
327
+ const strippedBlockingCount = stripped.filter((s) => blockingSeverities.includes(s.finding.severity)).length;
328
+ const stillHasBlocking = kept.some((f) => blockingSeverities.includes(f.severity));
329
+ if (strippedBlockingCount > 0 &&
330
+ !stillHasBlocking &&
331
+ !updated.requiresHumanJudgment) {
332
+ updated.verdict = "APPROVED";
333
+ }
334
+ }
335
+ return updated;
336
+ });
337
+ // The artifact-level `gateVerdict` is also derived from critic verdicts
338
+ // by `buildAggregate()`. Recompute it from the rewritten critic results
339
+ // so the gate-level check stays consistent: a per-critic verdict flip
340
+ // to APPROVED has to surface at the aggregate too, or the existing
341
+ // "aggregate_changes_requested" cross-check in `evaluateCriticResults`
342
+ // re-blocks the same push.
343
+ const recomputedArtifact = {
344
+ ...artifact,
345
+ criticResults: newCriticResults,
346
+ };
347
+ if (artifact.status === "complete") {
348
+ // Cycle 322.2 — recompute is required-aware so an optional critic's
349
+ // surviving blockers don't re-flip the aggregate after rubric strip.
350
+ const requiredCriticIds = new Set(context.loaded.config.critics.filter((c) => c.required).map((c) => c.id));
351
+ recomputedArtifact.gateVerdict = recomputeGateVerdict(newCriticResults, blockingSeverities, requiredCriticIds);
352
+ }
353
+ return recomputedArtifact;
354
+ }
355
+ function recomputeGateVerdict(results, blockingSeverities,
356
+ // Cycle 322.2 — optional critics' verdicts don't drive recomputation;
357
+ // only required critics determine the aggregate gate verdict here.
358
+ requiredCriticIds) {
359
+ for (const r of results) {
360
+ if (!requiredCriticIds.has(r.criticId))
361
+ continue;
362
+ if (r.status !== "complete")
363
+ return "CHANGES_REQUESTED";
364
+ if (r.verdict === "CHANGES_REQUESTED")
365
+ return "CHANGES_REQUESTED";
366
+ if (r.requiresHumanJudgment)
367
+ return "CHANGES_REQUESTED";
368
+ if (r.findings.some((f) => blockingSeverities.includes(f.severity))) {
369
+ return "CHANGES_REQUESTED";
370
+ }
371
+ }
372
+ return "APPROVED";
373
+ }
374
+ /**
375
+ * Cycle 322.7 — recompute the artifact's gateVerdict from a scoped
376
+ * (profile-filtered) result set, dispatching on the same policy the
377
+ * runner used. Always returns a verdict — the caller is expected to
378
+ * only invoke this for completed artifacts (the only artifact status
379
+ * that has a gateVerdict to overwrite).
380
+ *
381
+ * This is the symmetric helper to the recomputation already performed
382
+ * by `applyRubricToArtifact` when the rubric strip removes the
383
+ * blocker that drove the verdict. Without this, profile-filtered
384
+ * gate evaluation could falsely trip the `aggregate_changes_requested`
385
+ * cross-check at `evaluateCriticResults:503-511` (Cursor critic high
386
+ * finding on PR #1468 commit c7537f34).
387
+ */
388
+ function recomputeArtifactVerdict(scopedResults, loaded, quorumOverride) {
389
+ if (loaded.config.aggregation.policy === "min-complete-quorum") {
390
+ const quorum = quorumOverride ?? loaded.config.aggregation.quorum ?? 2;
391
+ return quorumAggregateVerdict(
392
+ // Cast: `quorumAggregateVerdict` reads CriticResult fields by
393
+ // name; scoped results are a subset of the original artifact's
394
+ // results array, so the shape is identical.
395
+ scopedResults, loaded.config.aggregation.blockingSeverities, quorum).verdict;
396
+ }
397
+ // block-if-any: only required critics drive the aggregate.
398
+ const requiredCriticIds = new Set(loaded.config.critics.filter((c) => c.required).map((c) => c.id));
399
+ return recomputeGateVerdict(scopedResults, loaded.config.aggregation.blockingSeverities, requiredCriticIds);
400
+ }
401
+ // Deterministic gate-evidence enforcement, independent of critic verdict.
402
+ // Without this, a critic that returns APPROVED while `qualityGatesMissing`
403
+ // is non-empty (or any required gate has non-zero exit) would let the push
404
+ // through. The gate must enforce the configured required gates directly
405
+ // rather than trusting the model. Adapter populates each critic result's
406
+ // `validation` view from the same packet, so reading any one is sufficient.
407
+ async function enforceRequiredQualityGates(loaded, sha, blocks) {
408
+ const required = loaded.config.validation.requiredQualityGates;
409
+ if (required.length === 0)
410
+ return;
411
+ // Closes #1549. Read the per-SHA quality-gate evidence file DIRECTLY
412
+ // rather than `artifact.criticResults[0]?.validation`. The old path was
413
+ // fragile in two ways the dogfood case made obvious:
414
+ // (a) `criticResults[0]` is whichever critic appears first in
415
+ // `.agent-review/config.json`. When that critic errored (e.g.,
416
+ // cursor sandbox failure in CI runs since the post-#1546/1547
417
+ // cleanup), the adapter builds a `status: "error"` result with
418
+ // `validation: { qualityGateResults: [], qualityGatesMissing: [] }`
419
+ // — so every required gate trips `required_gate_missing` even
420
+ // though gate-prepare wrote real evidence to disk.
421
+ // (b) Even on the success path, the adapter's `validation` block is
422
+ // a SECONDHAND copy of `packet.validation.evidence` (set by the
423
+ // cursor/codex/gemini/grok adapters from the same packet). The
424
+ // gate's authoritative source of truth is the on-disk per-SHA
425
+ // file written by `gate-prepare`; routing through critic state
426
+ // only added a layer that could go wrong.
427
+ // `readPerShaEvidence` returns null when the file is missing OR when
428
+ // its `commit` field doesn't match `sha` (the integrity check in
429
+ // `readPerShaEvidence`). Both states collapse to `required_gate_missing`
430
+ // with the same detail; that's intentional — the operator action is
431
+ // the same either way (re-run `gate-prepare --commit <sha>`).
432
+ const evidence = await readPerShaEvidence(loaded, sha);
433
+ if (!evidence) {
434
+ for (const gate of required) {
435
+ blocks.push({
436
+ reason: "required_gate_missing",
437
+ detail: `no per-SHA evidence file for ${sha}; run \`gate-prepare --commit ${sha}\``,
438
+ });
439
+ }
440
+ return;
441
+ }
442
+ for (const gate of required) {
443
+ const result = evidence.results.find((r) => r.command === gate);
444
+ if (!result) {
445
+ blocks.push({
446
+ reason: "required_gate_missing",
447
+ detail: `no evidence for required gate: ${gate}`,
448
+ });
449
+ continue;
450
+ }
451
+ if (result.exitCode !== 0) {
452
+ blocks.push({
453
+ reason: "required_gate_failed",
454
+ detail: `${gate} exit=${result.exitCode}`,
455
+ });
456
+ }
457
+ }
458
+ }
459
+ function evaluateCriticResults(artifact, blockingSeverities, blocks, warnings, blockOnReviewError,
460
+ // Cycle 322.2 Component 4 — `required` flag threading. Findings,
461
+ // verdict flips, and human-judgment markers from optional
462
+ // (`required: false`) critics are demoted to warnings instead of
463
+ // contributing to blocks. Errors from optional critics are similarly
464
+ // demoted (the `blockOnReviewError` flag still applies, but only for
465
+ // required critics — an optional critic's transient SDK error must
466
+ // never block the gate by definition of being optional).
467
+ requiredCriticIds) {
468
+ // Track per-critic errors that we downgraded to warnings. The aggregate
469
+ // cross-check below must not silently re-block them via the "verdict was
470
+ // CHANGES_REQUESTED but no block captured" path. Without this counter,
471
+ // `buildAggregate()` flips the verdict to CHANGES_REQUESTED for any
472
+ // required errored critic (in `report.ts:aggregateVerdict`), which then
473
+ // re-blocks even though the operator set `blockOnReviewError=false`.
474
+ // (Cycle 3 #13 follow-up — flagged by critic on cerebe df2169a.)
475
+ let errorsDowngraded = 0;
476
+ for (const result of artifact.criticResults) {
477
+ const isRequired = requiredCriticIds.has(result.criticId);
478
+ if (result.status === "error") {
479
+ // Per-critic errors (startup/JSON/schema failures) are persisted
480
+ // inside a `status: "complete"` artifact, so the artifact-level
481
+ // `blockOnReviewError` check above wouldn't apply here. Honor the
482
+ // same flag at the per-critic level — without this, setting
483
+ // `blockOnReviewError=false` had no effect for the common failure
484
+ // path. (Cycle 3 #13)
485
+ // Cycle 322.2 — optional critic errors are unconditionally demoted
486
+ // to warnings; only required critic errors honor blockOnReviewError.
487
+ const detail = result.error?.message ?? "critic returned error";
488
+ if (isRequired && blockOnReviewError) {
489
+ blocks.push({ reason: "critic_error", criticId: result.criticId, detail });
490
+ }
491
+ else {
492
+ warnings.push({ reason: "critic_error", criticId: result.criticId, detail });
493
+ if (isRequired)
494
+ errorsDowngraded++;
495
+ }
496
+ continue;
497
+ }
498
+ if (result.status === "pending" || result.status === "running") {
499
+ // Cycle 322.2 — pending/running on optional critics is a warning
500
+ // (informational); only required critics block on in-progress state.
501
+ if (isRequired) {
502
+ blocks.push({
503
+ reason: "critic_in_progress",
504
+ criticId: result.criticId,
505
+ detail: `critic status is ${result.status}`,
506
+ });
507
+ }
508
+ else {
509
+ warnings.push({
510
+ reason: "critic_in_progress",
511
+ criticId: result.criticId,
512
+ detail: `critic status is ${result.status} (optional)`,
513
+ });
514
+ }
515
+ continue;
516
+ }
517
+ if (result.requiresHumanJudgment) {
518
+ if (isRequired) {
519
+ blocks.push({
520
+ reason: "requires_human_judgment",
521
+ criticId: result.criticId,
522
+ detail: result.summary,
523
+ });
524
+ }
525
+ else {
526
+ warnings.push({
527
+ reason: "requires_human_judgment",
528
+ criticId: result.criticId,
529
+ detail: `${result.summary} (optional)`,
530
+ });
531
+ }
532
+ }
533
+ if (result.verdict === "CHANGES_REQUESTED") {
534
+ if (isRequired) {
535
+ blocks.push({
536
+ reason: "changes_requested",
537
+ criticId: result.criticId,
538
+ detail: result.summary,
539
+ });
540
+ }
541
+ else {
542
+ warnings.push({
543
+ reason: "changes_requested",
544
+ criticId: result.criticId,
545
+ detail: `${result.summary} (optional)`,
546
+ });
547
+ }
548
+ }
549
+ const blocking = result.findings.filter((f) => blockingSeverities.includes(f.severity));
550
+ for (const f of blocking) {
551
+ const detail = `${f.category}@${f.file ?? "?"}: ${f.evidence}`;
552
+ if (isRequired) {
553
+ blocks.push({ reason: `${f.severity}_finding`, criticId: result.criticId, detail });
554
+ }
555
+ else {
556
+ warnings.push({
557
+ reason: `${f.severity}_finding`,
558
+ criticId: result.criticId,
559
+ detail: `${detail} (optional)`,
560
+ });
561
+ }
562
+ }
563
+ const nonBlocking = result.findings.filter((f) => !blockingSeverities.includes(f.severity));
564
+ for (const f of nonBlocking) {
565
+ // Below-threshold findings are always warnings regardless of the
566
+ // critic's required flag.
567
+ warnings.push({
568
+ reason: `${f.severity}_finding`,
569
+ criticId: result.criticId,
570
+ detail: `${f.category}@${f.file ?? "?"}: ${f.evidence}`,
571
+ });
572
+ }
573
+ }
574
+ // Aggregate gate verdict cross-check. Skip when the only reason the
575
+ // aggregate is CHANGES_REQUESTED is downgraded errors — otherwise the
576
+ // operator's `blockOnReviewError=false` setting would be silently
577
+ // overridden by the cross-check for required critics, since
578
+ // `aggregateVerdict()` flips on required+error before this gate runs.
579
+ if (artifact.gateVerdict === "CHANGES_REQUESTED" &&
580
+ blocks.length === 0 &&
581
+ errorsDowngraded === 0) {
582
+ blocks.push({
583
+ reason: "aggregate_changes_requested",
584
+ detail: "gateVerdict is CHANGES_REQUESTED but no per-critic blocker captured",
585
+ });
586
+ }
587
+ }
588
+ // ---------------------------------------------------------------------------
589
+ // Cycle 322.3 — `min-complete-quorum` gate evaluator.
590
+ //
591
+ // Companion to `report.ts:quorumAggregateVerdict` — both functions
592
+ // share the same "completed", "vetoes" predicates via the helper
593
+ // exports so the gate-block enforcement and the aggregate-verdict
594
+ // computation cannot drift.
595
+ //
596
+ // Verdict→block mapping:
597
+ // - veto by any completed critic → `blocker_finding` /
598
+ // `high_finding` / `requires_human_judgment` /
599
+ // `changes_requested` blocks (one per veto-source) PLUS the
600
+ // non-blocking findings as warnings. Identical block reason
601
+ // strings to the block-if-any evaluator so downstream parsers
602
+ // don't need a separate quorum-mode branch.
603
+ // - completed_count < quorum (no veto) → `quorum_unmet` block.
604
+ // Distinct block reason so operators can route alerts
605
+ // differently from content blocks (e.g., page on a sustained
606
+ // `quorum_unmet` spike correlated with `critic_run_error`).
607
+ // - completed_count >= quorum, no veto → no blocks.
608
+ //
609
+ // Per-critic errors are recorded as warnings unconditionally under
610
+ // quorum policy — `quorum_unmet` is the canonical fail-closed
611
+ // signal when too many critics error. This is a deliberate
612
+ // semantic divergence from `block-if-any` (where
613
+ // `policy.blockOnReviewError=true` blocks on any required-critic
614
+ // error): under quorum, the operator-facing "fail-closed" knob is
615
+ // the `quorum` value itself. Setting `quorum = critics.length`
616
+ // forces ALL critics to complete for the gate to pass (any single
617
+ // error → `quorum_unmet` → block); lower quorum values express the
618
+ // operator's tolerance for vendor outages. `blockOnReviewError`
619
+ // would be redundant with `quorum` under this scheme and is
620
+ // therefore NOT threaded into the quorum evaluator. See cycle
621
+ // 322.3 §"Failure modes" + cycle 322.3 Cursor critic feedback
622
+ // (367476d3 finding #3) for the design rationale.
623
+ //
624
+ // The cycle 322.3.1 promotion PR will document the operator
625
+ // runbook for choosing `quorum`; until then, the policy stays at
626
+ // `block-if-any` and `blockOnReviewError` continues to drive the
627
+ // fail-closed behavior in the legacy evaluator below.
628
+ export function evaluateQuorumCriticResults(artifact, blockingSeverities, quorum, blocks, warnings) {
629
+ // Surface every critic's terminal state as a warning so the
630
+ // artifact reader sees all per-critic diagnostics even when no
631
+ // veto fires.
632
+ for (const result of artifact.criticResults) {
633
+ if (result.status === "error") {
634
+ const detail = result.error?.message ?? "critic returned error";
635
+ warnings.push({ reason: "critic_error", criticId: result.criticId, detail });
636
+ continue;
637
+ }
638
+ if (result.status === "pending" || result.status === "running") {
639
+ warnings.push({
640
+ reason: "critic_in_progress",
641
+ criticId: result.criticId,
642
+ detail: `critic status is ${result.status}`,
643
+ });
644
+ continue;
645
+ }
646
+ // Completed critic. Vetoes contribute blocks; non-veto findings
647
+ // contribute warnings.
648
+ const vetoes = criticVetoesGate(result, blockingSeverities);
649
+ if (vetoes) {
650
+ if (result.requiresHumanJudgment) {
651
+ blocks.push({
652
+ reason: "requires_human_judgment",
653
+ criticId: result.criticId,
654
+ detail: result.summary,
655
+ });
656
+ }
657
+ if (result.verdict === "CHANGES_REQUESTED") {
658
+ blocks.push({
659
+ reason: "changes_requested",
660
+ criticId: result.criticId,
661
+ detail: result.summary,
662
+ });
663
+ }
664
+ const blocking = result.findings.filter((f) => blockingSeverities.includes(f.severity));
665
+ for (const f of blocking) {
666
+ const detail = `${f.category}@${f.file ?? "?"}: ${f.evidence}`;
667
+ blocks.push({
668
+ reason: `${f.severity}_finding`,
669
+ criticId: result.criticId,
670
+ detail,
671
+ });
672
+ }
673
+ }
674
+ // Non-blocking findings are always warnings regardless of vetoes.
675
+ const nonBlocking = result.findings.filter((f) => !blockingSeverities.includes(f.severity));
676
+ for (const f of nonBlocking) {
677
+ warnings.push({
678
+ reason: `${f.severity}_finding`,
679
+ criticId: result.criticId,
680
+ detail: `${f.category}@${f.file ?? "?"}: ${f.evidence}`,
681
+ });
682
+ }
683
+ }
684
+ // Quorum check. If `quorum_unmet` AND no veto blocks already
685
+ // captured, surface a single `quorum_unmet` block. The
686
+ // veto-preserves-quorum semantics live in `criticVetoesGate`
687
+ // above — when a veto already blocks, `quorum_unmet` is silent
688
+ // (the §11 invariant). When the verdict already shows a veto
689
+ // contributed to blocks, do NOT also add `quorum_unmet` — the
690
+ // veto is the actionable signal.
691
+ const outcome = quorumAggregateVerdict(artifact.criticResults, blockingSeverities, quorum);
692
+ if (outcome.reason === "quorum_unmet" && blocks.length === 0) {
693
+ const completed = artifact.criticResults.filter(isCriticCompleted).length;
694
+ const total = artifact.criticResults.length;
695
+ const erroredAdapters = artifact.criticResults
696
+ .filter((r) => r.status === "error")
697
+ .map((r) => `${r.criticId}=${r.error?.code ?? "(no code)"}`)
698
+ .join(", ");
699
+ blocks.push({
700
+ reason: "quorum_unmet",
701
+ detail: `only ${completed}/${total} critics completed; quorum is ${quorum}` +
702
+ (erroredAdapters
703
+ ? ` (errored: ${erroredAdapters})`
704
+ : ""),
705
+ });
706
+ }
707
+ }
708
+ async function safeParent(sha, cwd) {
709
+ try {
710
+ return await commitParent(sha, cwd);
711
+ }
712
+ catch {
713
+ return "";
714
+ }
715
+ }
716
+ export function summarizeGate(result) {
717
+ const lines = [];
718
+ lines.push(result.blocked ? "GATE: BLOCKED" : "GATE: PASSED");
719
+ if (result.bypass) {
720
+ lines.push(`bypass: ${result.bypass.reason}`);
721
+ }
722
+ for (const b of result.blocks) {
723
+ lines.push(` block: [${b.reason}]${b.criticId ? ` (${b.criticId})` : ""}${b.detail ? ` ${b.detail}` : ""}`);
724
+ }
725
+ for (const w of result.warnings) {
726
+ lines.push(` warn: [${w.reason}]${w.criticId ? ` (${w.criticId})` : ""}${w.detail ? ` ${w.detail}` : ""}`);
727
+ }
728
+ return lines.join("\n");
729
+ }
730
+ export async function enforceVerificationRoutes(options) {
731
+ const { loaded, sha, changedPaths } = options;
732
+ const routes = loaded.config.validation.verificationRoutes ?? [];
733
+ if (routes.length === 0) {
734
+ return { triggered: [], active: [], perRoute: [] };
735
+ }
736
+ // Step 1 — determine triggered routes.
737
+ const triggered = [];
738
+ for (const route of routes) {
739
+ if (changedPaths.some((p) => matchAnyGlob(p, route.trigger))) {
740
+ triggered.push(route);
741
+ }
742
+ }
743
+ // Step 2 — exclusive-route suppression. The cycle-318.2 doc rule: an
744
+ // exclusive route fires ONLY when every changed path matches its
745
+ // trigger. When it fires, drop all non-exclusive routes. Mixed PRs
746
+ // (e.g., docs + production paths) keep the production routes active.
747
+ let suppressedBy;
748
+ for (const route of triggered) {
749
+ if (!route.exclusive)
750
+ continue;
751
+ const allMatch = changedPaths.every((p) => matchAnyGlob(p, route.trigger));
752
+ if (allMatch) {
753
+ suppressedBy = route;
754
+ break;
755
+ }
756
+ }
757
+ let active;
758
+ if (suppressedBy) {
759
+ active = triggered.filter((r) => r === suppressedBy);
760
+ }
761
+ else {
762
+ active = triggered.filter((r) => !r.exclusive);
763
+ }
764
+ // Step 3 — read per-SHA evidence and evaluate each active route.
765
+ const evidence = await readPerShaEvidence(loaded, sha);
766
+ const perRoute = [];
767
+ for (const route of active) {
768
+ if (route.command === null) {
769
+ // Suppression-only routes have no command; if they're "active"
770
+ // (i.e., suppressedBy === route), there is nothing to enforce —
771
+ // the suppression itself is the gate decision.
772
+ perRoute.push({
773
+ routeId: route.id,
774
+ status: "skipped",
775
+ detail: "suppression-only route (no command to enforce)",
776
+ });
777
+ continue;
778
+ }
779
+ const gateResult = evidence?.gateResults?.[route.id];
780
+ if (!gateResult) {
781
+ perRoute.push({
782
+ routeId: route.id,
783
+ status: "missing",
784
+ detail: `no evidence at agent-reviews/quality-gates/${sha}.json for route "${route.id}"; run \`agent-review gates --route ${route.id}\` or \`${route.command}\` followed by \`agent-review gates --commit ${sha.slice(0, 12)}\``,
785
+ });
786
+ continue;
787
+ }
788
+ if (gateResult.exitCode !== 0) {
789
+ perRoute.push({
790
+ routeId: route.id,
791
+ status: "failed",
792
+ exitCode: gateResult.exitCode,
793
+ detail: `route "${route.id}" command exited ${gateResult.exitCode}`,
794
+ });
795
+ continue;
796
+ }
797
+ perRoute.push({
798
+ routeId: route.id,
799
+ status: "ok",
800
+ exitCode: 0,
801
+ detail: `route "${route.id}" passed`,
802
+ });
803
+ }
804
+ return {
805
+ triggered,
806
+ active,
807
+ ...(suppressedBy !== undefined ? { suppressedBy } : {}),
808
+ perRoute,
809
+ };
810
+ }
811
+ async function readPerShaEvidence(loaded, sha) {
812
+ const root = await resolveArtifactRoot(loaded);
813
+ const path = perShaQualityGatePath(root, loaded.config.git.artifactDir, sha);
814
+ if (!existsSync(path))
815
+ return null;
816
+ let evidence;
817
+ try {
818
+ const raw = JSON.parse(readFileSync(path, "utf8"));
819
+ evidence = parseQualityGateEvidence(raw);
820
+ }
821
+ catch {
822
+ return null;
823
+ }
824
+ // Bind the parsed `commit` field to the SHA being gated. Matches the
825
+ // tighter read semantics in `quality-gates.ts:readEvidenceAtPath` and
826
+ // closes the integrity gap the critic flagged: a hand-edited file at
827
+ // `<sha>.json` that names a different commit would otherwise satisfy
828
+ // verificationRoutes without representing the gated commit.
829
+ if (evidence.commit !== sha)
830
+ return null;
831
+ return evidence;
832
+ }
833
+ export async function runTddClassifier(options) {
834
+ const { loaded, sha } = options;
835
+ const cwd = options.cwd ?? loaded.repoRoot;
836
+ if (!loaded.config.tdd)
837
+ return null;
838
+ const parent = await safeParent(sha, cwd);
839
+ const files = await changedFiles(parent, sha, cwd, { readContent: false });
840
+ const meta = await commitMetadata(sha, cwd);
841
+ const fullMessage = meta.body
842
+ ? `${meta.subject}\n\n${meta.body}`
843
+ : meta.subject;
844
+ const trailers = parseCommitTrailers(fullMessage);
845
+ const classifierConfig = {
846
+ productionGlobs: loaded.config.tdd.classifier.productionGlobs,
847
+ testGlobs: loaded.config.tdd.classifier.testGlobs,
848
+ exclusionGlobs: loaded.config.tdd.classifier.exclusionGlobs,
849
+ justificationTrailer: loaded.config.tdd.classifier.justificationTrailer,
850
+ };
851
+ // Include both `path` (new) and `oldPath` (rename/copy source) so a
852
+ // rename out of a production glob still routes through the classifier.
853
+ // Centralized in `collectChangedPaths` (see evidence.ts).
854
+ return classifyTddImpl(collectChangedPaths(files), trailers, classifierConfig);
855
+ }
856
+ export function enforceFindingRubric(findings, context) {
857
+ const blockingSeverities = context.loaded.config.aggregation.blockingSeverities;
858
+ const v2 = context.loaded.config.version === 2;
859
+ const kept = [];
860
+ const stripped = [];
861
+ for (const f of findings) {
862
+ // Below-threshold severities are never stripped by the rubric.
863
+ if (!blockingSeverities.includes(f.severity)) {
864
+ kept.push(f);
865
+ continue;
866
+ }
867
+ // v1 configs keep the old behavior (no rubric enforcement) so
868
+ // downstream repos can still use the schema. Only v2 enforces.
869
+ if (!v2) {
870
+ kept.push(f);
871
+ continue;
872
+ }
873
+ const verdict = evaluateFindingRubric(f, context);
874
+ if (verdict.kept) {
875
+ kept.push(f);
876
+ }
877
+ else {
878
+ stripped.push({ finding: f, reason: verdict.reason });
879
+ }
880
+ }
881
+ if (stripped.length > 0) {
882
+ void logRubricStrips(context, stripped);
883
+ }
884
+ return { kept, stripped };
885
+ }
886
+ function evaluateFindingRubric(finding, context) {
887
+ // (1) Gate-failure evidence: all three of the following must hold:
888
+ // (a) `evidencePath` points at the canonical per-SHA artifact for
889
+ // the gated SHA (full or short suffix)
890
+ // (b) `routeId` names a specific route
891
+ // (c) `evidence.gateResults[routeId].exitCode !== 0`
892
+ // Without (b)+(c), the finding could ride on any failing gate in the
893
+ // file — closing the loopholes the 729a646c and 244464aa critics flagged.
894
+ if (finding.evidencePath &&
895
+ finding.routeId &&
896
+ context.evidence?.gateResults) {
897
+ if (evidencePathMatchesSha(finding.evidencePath, context.sha) ||
898
+ evidencePathMatchesSha(finding.evidencePath, context.sha.slice(0, 12))) {
899
+ const namedRoute = context.evidence.gateResults[finding.routeId];
900
+ if (namedRoute && namedRoute.exitCode !== 0) {
901
+ return {
902
+ kept: true,
903
+ reason: `evidencePath+routeId bound to ${context.sha.slice(0, 12)}; gateResults["${finding.routeId}"].exitCode=${namedRoute.exitCode}`,
904
+ };
905
+ }
906
+ }
907
+ }
908
+ // (2) File + line + evidence — the spec-defined shape of an ordinary
909
+ // code-review finding (cycle 318.2 §Component 5 + §Exit Criteria). All
910
+ // three are required: file (already required by the schema parser for
911
+ // blocker severities), a concrete line number, and an evidence string.
912
+ // Findings that name a location without pointing at a specific line
913
+ // fall back to evidence_path or justification — this is what keeps the
914
+ // prompt rubric and harness rubric in lock-step (closes the spec drift
915
+ // the local critic flagged on 71c5b3c7).
916
+ if (finding.file &&
917
+ finding.file.trim().length > 0 &&
918
+ finding.line !== undefined &&
919
+ finding.evidence.trim().length > 0) {
920
+ return { kept: true, reason: "file+line+evidence (code-review finding shape)" };
921
+ }
922
+ // (3) Per-finding `justification` — the critic must populate this
923
+ // with the VALUE of a recognized commit trailer (Tdd-Justification,
924
+ // Evidence, Migration-Justification, or the config's
925
+ // `tdd.classifier.justificationTrailer`). The rubric verifies the
926
+ // value actually appears in `context.trailers`, so the critic can't
927
+ // invent a free-form justification string — there has to be a real
928
+ // human-supplied trailer behind it. Closes the loophole the 67b9ecd7
929
+ // critic flagged.
930
+ if (finding.justification && finding.justification.trim().length > 0) {
931
+ const recognizedTrailers = new Set([
932
+ "tdd-justification",
933
+ "evidence",
934
+ "migration-justification",
935
+ ]);
936
+ const customTrailer = context.loaded.config.tdd?.classifier.justificationTrailer;
937
+ if (customTrailer)
938
+ recognizedTrailers.add(customTrailer.toLowerCase());
939
+ const justification = finding.justification.trim();
940
+ for (const key of recognizedTrailers) {
941
+ const value = context.trailers.trailers[key];
942
+ if (value && value.trim() === justification) {
943
+ return {
944
+ kept: true,
945
+ reason: `per-finding justification matches commit trailer ${key}`,
946
+ };
947
+ }
948
+ }
949
+ // Justification text present but does NOT match any recognized
950
+ // trailer value — fall through to the strip path. The critic
951
+ // attached prose but no auditable human override is on the commit.
952
+ }
953
+ return {
954
+ kept: false,
955
+ reason: "no evidencePath+routeId matching gate failure, no file+line+evidence, no per-finding justification backed by a recognized commit trailer",
956
+ };
957
+ }
958
+ // Does the finding's `evidencePath` string anchor to the gated SHA?
959
+ // We accept three shapes:
960
+ // - exact suffix `<sha>.json` (full sha)
961
+ // - exact suffix `<short-sha>.json` (when caller passes a 12-char prefix)
962
+ // - the path is a substring containing the sha — guards against a critic
963
+ // that writes a fully-qualified absolute path
964
+ // The check is intentionally generous on path separators (Windows-style
965
+ // or slash-mixed) but strict on the SHA itself.
966
+ function evidencePathMatchesSha(evidencePath, sha) {
967
+ if (!sha)
968
+ return false;
969
+ const needle = `${sha}.json`;
970
+ // Allow both forward and back slashes in the path; the path may be
971
+ // relative ("agent-reviews/quality-gates/<sha>.json") or absolute.
972
+ return evidencePath.includes(needle);
973
+ }
974
+ async function logRubricStrips(context, stripped) {
975
+ try {
976
+ const dir = await resolveArtifactDir(context.loaded);
977
+ mkdirSync(dir, { recursive: true });
978
+ const path = telemetryPath(dir);
979
+ mkdirSync(dirname(path), { recursive: true });
980
+ for (const s of stripped) {
981
+ const event = {
982
+ ts: new Date().toISOString(),
983
+ event: "rubric_strip",
984
+ commit: context.sha,
985
+ severity: s.finding.severity,
986
+ category: s.finding.category,
987
+ file: s.finding.file ?? null,
988
+ line: s.finding.line ?? null,
989
+ reason: s.reason,
990
+ manifesto_section: s.finding.manifestoSection ?? null,
991
+ };
992
+ appendFileSync(path, `${JSON.stringify(event)}\n`, "utf8");
993
+ }
994
+ }
995
+ catch {
996
+ // Telemetry writes are best-effort; do not fail the gate because
997
+ // we couldn't log a strip event.
998
+ }
999
+ }
1000
+ export async function buildRubricContext(options) {
1001
+ const { loaded, sha } = options;
1002
+ const cwd = options.cwd ?? loaded.repoRoot;
1003
+ const meta = await commitMetadata(sha, cwd);
1004
+ const fullMessage = meta.body
1005
+ ? `${meta.subject}\n\n${meta.body}`
1006
+ : meta.subject;
1007
+ const trailers = parseCommitTrailers(fullMessage);
1008
+ const evidence = await readPerShaEvidence(loaded, sha);
1009
+ return { loaded, sha, trailers, evidence };
1010
+ }
1011
+ // Re-export the per-SHA path helper from `evidence.ts` so callers can
1012
+ // import a single boundary — this matches the cycle-318.2 doc's mental
1013
+ // model of `gate.ts` as the canonical surface for gate-time decisions.
1014
+ export { perShaQualityGatePath, parseCommitTrailers };
1015
+ // Keep `resolve` referenced so bundlers don't strip it from the runtime
1016
+ // import — some downstream tooling depends on this module re-exporting
1017
+ // the same node:path helpers as the previous version.
1018
+ void resolve;
1019
+ //# sourceMappingURL=gate.js.map