@momentiq/dark-factory-cli 1.0.0 → 2.0.0

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 (159) hide show
  1. package/README.md +121 -40
  2. package/dist/adapters/codex-sdk.d.ts +66 -0
  3. package/dist/adapters/codex-sdk.d.ts.map +1 -1
  4. package/dist/adapters/codex-sdk.js +321 -4
  5. package/dist/adapters/codex-sdk.js.map +1 -1
  6. package/dist/adapters/critic-result-schema.d.ts +15 -10
  7. package/dist/adapters/critic-result-schema.d.ts.map +1 -1
  8. package/dist/adapters/critic-result-schema.js +10 -0
  9. package/dist/adapters/critic-result-schema.js.map +1 -1
  10. package/dist/adapters/cursor-cli.d.ts +19 -1
  11. package/dist/adapters/cursor-cli.d.ts.map +1 -1
  12. package/dist/adapters/cursor-cli.js +96 -14
  13. package/dist/adapters/cursor-cli.js.map +1 -1
  14. package/dist/adapters/index.d.ts +1 -0
  15. package/dist/adapters/index.d.ts.map +1 -1
  16. package/dist/adapters/index.js +7 -0
  17. package/dist/adapters/index.js.map +1 -1
  18. package/dist/adapters/static-schema-lint.d.ts +68 -0
  19. package/dist/adapters/static-schema-lint.d.ts.map +1 -0
  20. package/dist/adapters/static-schema-lint.js +813 -0
  21. package/dist/adapters/static-schema-lint.js.map +1 -0
  22. package/dist/branch-protection/spec-default.yaml +2 -2
  23. package/dist/cli.d.ts +17 -1
  24. package/dist/cli.d.ts.map +1 -1
  25. package/dist/cli.js +414 -113
  26. package/dist/cli.js.map +1 -1
  27. package/dist/commands/findings.d.ts +6 -0
  28. package/dist/commands/findings.d.ts.map +1 -0
  29. package/dist/commands/findings.js +196 -0
  30. package/dist/commands/findings.js.map +1 -0
  31. package/dist/commands/show.d.ts +11 -0
  32. package/dist/commands/show.d.ts.map +1 -0
  33. package/dist/commands/show.js +78 -0
  34. package/dist/commands/show.js.map +1 -0
  35. package/dist/commands/skills.d.ts +6 -0
  36. package/dist/commands/skills.d.ts.map +1 -0
  37. package/dist/commands/skills.js +248 -0
  38. package/dist/commands/skills.js.map +1 -0
  39. package/dist/commands/status.d.ts +6 -0
  40. package/dist/commands/status.d.ts.map +1 -0
  41. package/dist/commands/status.js +85 -0
  42. package/dist/commands/status.js.map +1 -0
  43. package/dist/compact/index.d.ts +2 -0
  44. package/dist/compact/index.d.ts.map +1 -0
  45. package/dist/compact/index.js +3 -0
  46. package/dist/compact/index.js.map +1 -0
  47. package/dist/compact/lockfile.d.ts +53 -0
  48. package/dist/compact/lockfile.d.ts.map +1 -0
  49. package/dist/compact/lockfile.js +626 -0
  50. package/dist/compact/lockfile.js.map +1 -0
  51. package/dist/cycle-doc-validator/validate_cycle_doc.py +39 -9
  52. package/dist/doctor.d.ts +51 -1
  53. package/dist/doctor.d.ts.map +1 -1
  54. package/dist/doctor.js +497 -1
  55. package/dist/doctor.js.map +1 -1
  56. package/dist/evidence/docker-build.d.ts +6 -0
  57. package/dist/evidence/docker-build.d.ts.map +1 -0
  58. package/dist/evidence/docker-build.js +199 -0
  59. package/dist/evidence/docker-build.js.map +1 -0
  60. package/dist/evidence/index.d.ts +1 -0
  61. package/dist/evidence/index.d.ts.map +1 -1
  62. package/dist/evidence/index.js +6 -0
  63. package/dist/evidence/index.js.map +1 -1
  64. package/dist/handoff/handoff-verb.d.ts.map +1 -1
  65. package/dist/handoff/handoff-verb.js +6 -3
  66. package/dist/handoff/handoff-verb.js.map +1 -1
  67. package/dist/handoff/ports.d.ts +8 -0
  68. package/dist/handoff/ports.d.ts.map +1 -1
  69. package/dist/handoff/real-clients.js +1 -1
  70. package/dist/handoff/real-clients.js.map +1 -1
  71. package/dist/index.d.ts +2 -0
  72. package/dist/index.d.ts.map +1 -1
  73. package/dist/index.js +8 -0
  74. package/dist/index.js.map +1 -1
  75. package/dist/lib/show-status-core.d.ts +63 -0
  76. package/dist/lib/show-status-core.d.ts.map +1 -0
  77. package/dist/lib/show-status-core.js +156 -0
  78. package/dist/lib/show-status-core.js.map +1 -0
  79. package/dist/mcp/prompts.d.ts.map +1 -1
  80. package/dist/mcp/prompts.js +113 -78
  81. package/dist/mcp/prompts.js.map +1 -1
  82. package/dist/mcp/resources.js +1 -1
  83. package/dist/mcp/resources.js.map +1 -1
  84. package/dist/mcp/server.d.ts.map +1 -1
  85. package/dist/mcp/server.js +2 -0
  86. package/dist/mcp/server.js.map +1 -1
  87. package/dist/mcp/tools/doctor.d.ts.map +1 -1
  88. package/dist/mcp/tools/doctor.js +5 -0
  89. package/dist/mcp/tools/doctor.js.map +1 -1
  90. package/dist/mcp/tools/findings.d.ts +0 -22
  91. package/dist/mcp/tools/findings.d.ts.map +1 -1
  92. package/dist/mcp/tools/findings.js +5 -73
  93. package/dist/mcp/tools/findings.js.map +1 -1
  94. package/dist/mcp/tools/handoff.d.ts.map +1 -1
  95. package/dist/mcp/tools/handoff.js +10 -17
  96. package/dist/mcp/tools/handoff.js.map +1 -1
  97. package/dist/mcp/tools/review-bypass.d.ts.map +1 -1
  98. package/dist/mcp/tools/review-bypass.js +22 -1
  99. package/dist/mcp/tools/review-bypass.js.map +1 -1
  100. package/dist/mcp/tools/skills-install.d.ts +6 -0
  101. package/dist/mcp/tools/skills-install.d.ts.map +1 -0
  102. package/dist/mcp/tools/skills-install.js +260 -0
  103. package/dist/mcp/tools/skills-install.js.map +1 -0
  104. package/dist/mcp/tools/stats-gate.d.ts.map +1 -1
  105. package/dist/mcp/tools/stats-gate.js +63 -15
  106. package/dist/mcp/tools/stats-gate.js.map +1 -1
  107. package/dist/policy/baseline.d.ts.map +1 -1
  108. package/dist/policy/baseline.js +11 -3
  109. package/dist/policy/baseline.js.map +1 -1
  110. package/dist/policy/gate.d.ts +1 -1
  111. package/dist/policy/gate.d.ts.map +1 -1
  112. package/dist/policy/gate.js +96 -12
  113. package/dist/policy/gate.js.map +1 -1
  114. package/dist/prompt.d.ts +1 -0
  115. package/dist/prompt.d.ts.map +1 -1
  116. package/dist/prompt.js +123 -4
  117. package/dist/prompt.js.map +1 -1
  118. package/dist/report.d.ts +130 -3
  119. package/dist/report.d.ts.map +1 -1
  120. package/dist/report.js +367 -10
  121. package/dist/report.js.map +1 -1
  122. package/dist/runner.d.ts +6 -0
  123. package/dist/runner.d.ts.map +1 -1
  124. package/dist/runner.js +213 -4
  125. package/dist/runner.js.map +1 -1
  126. package/dist/self-consistency.d.ts +144 -0
  127. package/dist/self-consistency.d.ts.map +1 -0
  128. package/dist/self-consistency.js +368 -0
  129. package/dist/self-consistency.js.map +1 -0
  130. package/dist/skills/config.d.ts +176 -0
  131. package/dist/skills/config.d.ts.map +1 -0
  132. package/dist/skills/config.js +251 -0
  133. package/dist/skills/config.js.map +1 -0
  134. package/dist/skills/index.d.ts +4 -0
  135. package/dist/skills/index.d.ts.map +1 -0
  136. package/dist/skills/index.js +8 -0
  137. package/dist/skills/index.js.map +1 -0
  138. package/dist/skills/install.d.ts +62 -0
  139. package/dist/skills/install.d.ts.map +1 -0
  140. package/dist/skills/install.js +315 -0
  141. package/dist/skills/install.js.map +1 -0
  142. package/dist/skills/template.d.ts +42 -0
  143. package/dist/skills/template.d.ts.map +1 -0
  144. package/dist/skills/template.js +95 -0
  145. package/dist/skills/template.js.map +1 -0
  146. package/dist/trusted-surface/rebind.d.ts.map +1 -1
  147. package/dist/trusted-surface/rebind.js +199 -2
  148. package/dist/trusted-surface/rebind.js.map +1 -1
  149. package/package.json +36 -2
  150. package/skills/README.md +89 -0
  151. package/skills/chief-engineer-blitz/SKILL.md.tmpl +443 -0
  152. package/skills/chief-engineer-blitz/skill.json +53 -0
  153. package/skills/chief-engineer-review/SKILL.md.tmpl +184 -0
  154. package/skills/chief-engineer-review/references/review-examples.md.tmpl +130 -0
  155. package/skills/chief-engineer-review/skill.json +67 -0
  156. package/skills/chief-engineer-review/templates/code-review-prompt.md.tmpl +111 -0
  157. package/skills/chief-engineer-review/templates/escalation-prompt.md.tmpl +56 -0
  158. package/skills/chief-engineer-review/templates/plan-review-prompt.md.tmpl +74 -0
  159. package/skills/skill-schema.json +73 -0
package/dist/report.d.ts CHANGED
@@ -1,5 +1,63 @@
1
1
  import type { LoadedConfig } from "./policy/config.js";
2
- import { type CriticResult, type ReviewArtifact, type ReviewSeverity, type ReviewVerdict } from "@momentiq/dark-factory-schemas";
2
+ import { type CriticResult, type ReviewArtifact, type ReviewFinding, type ReviewSeverity, type ReviewVerdict, type UnilateralVetoRules } from "@momentiq/dark-factory-schemas";
3
+ /**
4
+ * True when the finding carries any of the flags listed in
5
+ * `requireCorroborationFor`. Flags not present in `FLAG_TO_FINDING_KEY`
6
+ * are ignored — an unknown flag in config is a no-op rather than a
7
+ * runtime error so operators can pin policy without first deploying a
8
+ * CLI that knows the flag (forward-compat).
9
+ *
10
+ * Exported so `gate.ts` shares the canonical translation table and
11
+ * cannot drift.
12
+ */
13
+ export declare function findingCarriesCorroborationFlag(finding: ReviewFinding, flagNames: readonly string[]): boolean;
14
+ /**
15
+ * True when at least one OTHER completed critic has a blocking-severity
16
+ * finding on the SAME file within `radius` lines of `target`.
17
+ *
18
+ * Exported so `gate.ts` shares the canonical corroboration predicate
19
+ * and cannot drift.
20
+ *
21
+ * "Other" = different `criticId`. Critics never corroborate themselves.
22
+ * "Blocking-severity" = `blockingSeverities.includes(f.severity)`. The
23
+ * corroborator is checked WITHOUT recursing into the corroboration
24
+ * rule — a corroborator that itself carries `selfInconsistent: true`
25
+ * still corroborates (the spec's safety net is "another critic saw a
26
+ * blocker here too", not "another critic's blocker survived
27
+ * corroboration"). This intentional asymmetry prevents an N-way
28
+ * corroboration-cycle.
29
+ *
30
+ * `target.file === undefined` → returns false (corroboration is
31
+ * file-scoped; a finding without a file cannot be corroborated).
32
+ * `target.line === undefined` → treats the target line as `0` (matches
33
+ * any line in the same file within the radius window — the safety
34
+ * net errs toward "yes, this is corroborated" when the target's
35
+ * location is imprecise).
36
+ */
37
+ export declare function isCorroboratedByOtherCritic(target: ReviewFinding, targetCriticId: string, allResults: readonly CriticResult[], blockingSeverities: readonly ReviewSeverity[], radius: number): boolean;
38
+ /**
39
+ * A finding the aggregator demoted from a unilateral veto to an
40
+ * informational `critic_disagreement` note because the
41
+ * `unilateralVetoRules.requireCorroborationFor` policy fired AND no
42
+ * other critic corroborated within the radius. Surfaced on
43
+ * `QuorumAggregateOutcome` so the runner can emit
44
+ * `critic_disagreement` telemetry and the artifact writer can render
45
+ * the note in the per-run markdown.
46
+ */
47
+ export interface CriticDisagreementNote {
48
+ criticId: string;
49
+ file: string;
50
+ line?: number;
51
+ severity: ReviewSeverity;
52
+ /**
53
+ * The corroboration-flag name from the policy that triggered the
54
+ * demotion (currently always `"self_inconsistent"`). Future flags
55
+ * surface here without a new field.
56
+ */
57
+ flag: string;
58
+ /** Short summary cut from the finding's `evidence` for the note. */
59
+ evidence: string;
60
+ }
3
61
  export interface AggregateInputs {
4
62
  loaded: LoadedConfig;
5
63
  commit: string;
@@ -19,6 +77,26 @@ export declare function buildAggregate(inputs: AggregateInputs): ReviewArtifact;
19
77
  * computation see the same definition of "completed".
20
78
  */
21
79
  export declare function isCriticCompleted(r: CriticResult): boolean;
80
+ /**
81
+ * Optional context for the corroboration check
82
+ * (issue dark-factory-platform#112). When omitted, `criticVetoesGate`
83
+ * behaves exactly as the pre-#112 implementation (back-compat for the
84
+ * many callers that don't know about the policy).
85
+ */
86
+ export interface VetoCorroborationContext {
87
+ /** All critic results in this review run — used to look for
88
+ * cross-critic corroboration on the same file within radius. */
89
+ allResults: readonly CriticResult[];
90
+ rules: UnilateralVetoRules;
91
+ /**
92
+ * Optional sink for findings the aggregator demoted to a
93
+ * `critic_disagreement` note. The caller pre-allocates the array;
94
+ * this function pushes one entry per demotion. Surfaced as
95
+ * `QuorumAggregateOutcome.disagreements` so the runner can emit
96
+ * telemetry without re-walking the results.
97
+ */
98
+ disagreementSink?: CriticDisagreementNote[];
99
+ }
22
100
  /**
23
101
  * Cycle 322.3 — does this completed critic veto the gate?
24
102
  *
@@ -27,8 +105,24 @@ export declare function isCriticCompleted(r: CriticResult): boolean;
27
105
  * CHANGES_REQUESTED verdict) blocks the gate regardless of how many
28
106
  * other critics approved. Returns `false` for non-completed critics
29
107
  * — only a completed critic can veto.
108
+ *
109
+ * Issue dark-factory-platform#112 — when `corroborationCtx` is
110
+ * supplied AND the critic's only veto trigger is a blocking finding
111
+ * carrying one of the configured corroboration-required flags (e.g.
112
+ * `selfInconsistent: true`), the function checks whether ANOTHER
113
+ * critic raises a blocking finding on the same file within
114
+ * `rules.requireCorroborationOnHunkRadius` lines. If not, the
115
+ * finding is demoted to a `critic_disagreement` note (pushed to
116
+ * `disagreementSink`) and DOES NOT contribute to a veto. Findings
117
+ * without a flag still veto unconditionally — the safety net is
118
+ * intact for findings the critic can defend.
119
+ *
120
+ * Note: a critic that vetoes via `CHANGES_REQUESTED` verdict OR
121
+ * `requiresHumanJudgment` is NOT subject to corroboration — those
122
+ * are coarser-grained signals than per-finding flags and the spec
123
+ * narrows the rule to per-finding flag triggers only.
30
124
  */
31
- export declare function criticVetoesGate(r: CriticResult, blockingSeverities: ReviewSeverity[]): boolean;
125
+ export declare function criticVetoesGate(r: CriticResult, blockingSeverities: ReviewSeverity[], corroborationCtx?: VetoCorroborationContext): boolean;
32
126
  export interface QuorumAggregateOutcome {
33
127
  verdict: ReviewVerdict;
34
128
  /**
@@ -48,6 +142,15 @@ export interface QuorumAggregateOutcome {
48
142
  reason: "majority" | "veto" | "quorum_unmet";
49
143
  completedCount: number;
50
144
  totalCount: number;
145
+ /**
146
+ * Issue dark-factory-platform#112 — findings the aggregator demoted
147
+ * from a unilateral veto to an informational disagreement note
148
+ * because the `unilateralVetoRules.requireCorroborationFor` policy
149
+ * fired AND no other critic corroborated. Always present (empty
150
+ * array when no demotions occurred OR no policy was active) so
151
+ * callers can branch on `.length` without an undefined check.
152
+ */
153
+ disagreements: CriticDisagreementNote[];
51
154
  }
52
155
  /**
53
156
  * Cycle 322.3 — compute the aggregate verdict under
@@ -70,7 +173,7 @@ export interface QuorumAggregateOutcome {
70
173
  * property: an outage that knocks out two critics doesn't paralyze
71
174
  * the third critic's BLOCKER finding.
72
175
  */
73
- export declare function quorumAggregateVerdict(results: CriticResult[], blockingSeverities: ReviewSeverity[], quorum: number): QuorumAggregateOutcome;
176
+ export declare function quorumAggregateVerdict(results: CriticResult[], blockingSeverities: ReviewSeverity[], quorum: number, unilateralVetoRules?: UnilateralVetoRules): QuorumAggregateOutcome;
74
177
  export interface CriticReport {
75
178
  /** Full multi-line block for stdout (CI log + local terminal). */
76
179
  stdout: string;
@@ -98,6 +201,30 @@ export interface CriticReport {
98
201
  * "X/Y critics errored" line agrees with the quorum aggregator's view.
99
202
  */
100
203
  export declare function buildCriticReport(artifact: ReviewArtifact, jsonPath: string): CriticReport;
204
+ export interface ZeroEvidenceDiagnostic {
205
+ /**
206
+ * True when completedCount === 0 AND totalCount > 0. The caller writes
207
+ * `stderr` to process.stderr when this is true and skips otherwise.
208
+ */
209
+ isZeroEvidence: boolean;
210
+ /**
211
+ * The formatted stderr block, including the leading "df: review
212
+ * COMPLETED..." headline, per-critic remediation lines, and the
213
+ * "details:" footer. Empty string when `isZeroEvidence` is false.
214
+ */
215
+ stderr: string;
216
+ }
217
+ export interface ZeroEvidenceOptions {
218
+ /**
219
+ * Hint from the caller that the loaded config has no `profiles` block
220
+ * (or the requested profile name is missing from it). When false, the
221
+ * diagnostic surfaces a top-line remediation pointing the operator at
222
+ * `.agent-review/config.json`. When undefined, no top-line config hint
223
+ * is added — only per-critic hints are emitted.
224
+ */
225
+ configHasProfiles?: boolean;
226
+ }
227
+ export declare function buildZeroEvidenceDiagnostic(artifact: ReviewArtifact, jsonPath: string, options?: ZeroEvidenceOptions): ZeroEvidenceDiagnostic;
101
228
  export interface WriteResult {
102
229
  jsonPath: string;
103
230
  markdownPath: string | null;
@@ -1 +1 @@
1
- {"version":3,"file":"report.d.ts","sourceRoot":"","sources":["../src/report.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAOvD,OAAO,EAGL,KAAK,YAAY,EACjB,KAAK,cAAc,EAEnB,KAAK,cAAc,EACnB,KAAK,aAAa,EACnB,MAAM,gCAAgC,CAAC;AAExC,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,YAAY,CAAC;IACrB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,aAAa,EAAE,YAAY,EAAE,CAAC;IAC9B,MAAM,EAAE,cAAc,CAAC,QAAQ,CAAC,CAAC;IACjC,SAAS,EAAE,MAAM,CAAC;IAOlB,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAUD,wBAAgB,cAAc,CAAC,MAAM,EAAE,eAAe,GAAG,cAAc,CA0BtE;AA6ED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,CAAC,EAAE,YAAY,GAAG,OAAO,CAE1D;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAC9B,CAAC,EAAE,YAAY,EACf,kBAAkB,EAAE,cAAc,EAAE,GACnC,OAAO,CAMT;AAED,MAAM,WAAW,sBAAsB;IACrC,OAAO,EAAE,aAAa,CAAC;IACvB;;;;;;;;;;;;;OAaG;IACH,MAAM,EAAE,UAAU,GAAG,MAAM,GAAG,cAAc,CAAC;IAI7C,cAAc,EAAE,MAAM,CAAC;IACvB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,sBAAsB,CACpC,OAAO,EAAE,YAAY,EAAE,EACvB,kBAAkB,EAAE,cAAc,EAAE,EACpC,MAAM,EAAE,MAAM,GACb,sBAAsB,CAqCxB;AAsBD,MAAM,WAAW,YAAY;IAC3B,kEAAkE;IAClE,MAAM,EAAE,MAAM,CAAC;IACf;;;;;;OAMG;IACH,WAAW,EAAE,MAAM,CAAC;IACpB,wDAAwD;IACxD,cAAc,EAAE,MAAM,CAAC;IACvB,0CAA0C;IAC1C,UAAU,EAAE,MAAM,CAAC;IACnB,mEAAmE;IACnE,QAAQ,EAAE,OAAO,CAAC;CACnB;AAED;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,cAAc,EACxB,QAAQ,EAAE,MAAM,GACf,YAAY,CAqFd;AAED,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,MAAM,CAAC;IAEjB,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;CAC7B;AAeD,wBAAsB,cAAc,CAClC,MAAM,EAAE,YAAY,EACpB,QAAQ,EAAE,cAAc,GACvB,OAAO,CAAC,WAAW,CAAC,CAkBtB;AAED,wBAAsB,YAAY,CAChC,MAAM,EAAE,YAAY,EACpB,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC,CAWhC;AAED,wBAAgB,cAAc,CAAC,QAAQ,EAAE,cAAc,GAAG,MAAM,CA+F/D;AAED,wBAAsB,YAAY,CAChC,MAAM,EAAE,YAAY,EACpB,MAAM,EAAE,IAAI,CAAC,eAAe,EAAE,eAAe,GAAG,QAAQ,CAAC,GACxD,OAAO,CAAC,WAAW,CAAC,CAOtB;AAGD,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,OAAO,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,wBAAsB,iBAAiB,CACrC,MAAM,EAAE,YAAY,EACpB,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,iBAAiB,CAAC,CAa5B;AAED,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAQxD"}
1
+ {"version":3,"file":"report.d.ts","sourceRoot":"","sources":["../src/report.ts"],"names":[],"mappings":"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"}
package/dist/report.js CHANGED
@@ -1,6 +1,87 @@
1
1
  import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { artifactJsonPath, artifactLockPath, artifactMarkdownPath, resolveArtifactDir, } from "./paths.js";
3
3
  import { parseReviewArtifact, REVIEW_SEVERITIES, } from "@momentiq/dark-factory-schemas";
4
+ // ---------------------------------------------------------------------------
5
+ // Issue dark-factory-platform#112 — flag-name → ReviewFinding-key mapping.
6
+ //
7
+ // The policy config (`AggregationConfig.unilateralVetoRules.requireCorroborationFor`)
8
+ // uses snake_case opaque identifiers so the wire surface is stable
9
+ // independent of the TypeScript field name. The aggregator translates
10
+ // each identifier to the corresponding `ReviewFinding` boolean property
11
+ // at evaluation time. Future flags (e.g. `requires_human_judgment`,
12
+ // `low_confidence`) land here when they need the same
13
+ // corroboration-or-disagreement-note treatment.
14
+ // ---------------------------------------------------------------------------
15
+ const FLAG_TO_FINDING_KEY = {
16
+ self_inconsistent: "selfInconsistent",
17
+ };
18
+ /**
19
+ * True when the finding carries any of the flags listed in
20
+ * `requireCorroborationFor`. Flags not present in `FLAG_TO_FINDING_KEY`
21
+ * are ignored — an unknown flag in config is a no-op rather than a
22
+ * runtime error so operators can pin policy without first deploying a
23
+ * CLI that knows the flag (forward-compat).
24
+ *
25
+ * Exported so `gate.ts` shares the canonical translation table and
26
+ * cannot drift.
27
+ */
28
+ export function findingCarriesCorroborationFlag(finding, flagNames) {
29
+ for (const name of flagNames) {
30
+ const key = FLAG_TO_FINDING_KEY[name];
31
+ if (key === undefined)
32
+ continue;
33
+ const v = finding[key];
34
+ if (v === true)
35
+ return true;
36
+ }
37
+ return false;
38
+ }
39
+ /**
40
+ * True when at least one OTHER completed critic has a blocking-severity
41
+ * finding on the SAME file within `radius` lines of `target`.
42
+ *
43
+ * Exported so `gate.ts` shares the canonical corroboration predicate
44
+ * and cannot drift.
45
+ *
46
+ * "Other" = different `criticId`. Critics never corroborate themselves.
47
+ * "Blocking-severity" = `blockingSeverities.includes(f.severity)`. The
48
+ * corroborator is checked WITHOUT recursing into the corroboration
49
+ * rule — a corroborator that itself carries `selfInconsistent: true`
50
+ * still corroborates (the spec's safety net is "another critic saw a
51
+ * blocker here too", not "another critic's blocker survived
52
+ * corroboration"). This intentional asymmetry prevents an N-way
53
+ * corroboration-cycle.
54
+ *
55
+ * `target.file === undefined` → returns false (corroboration is
56
+ * file-scoped; a finding without a file cannot be corroborated).
57
+ * `target.line === undefined` → treats the target line as `0` (matches
58
+ * any line in the same file within the radius window — the safety
59
+ * net errs toward "yes, this is corroborated" when the target's
60
+ * location is imprecise).
61
+ */
62
+ export function isCorroboratedByOtherCritic(target, targetCriticId, allResults, blockingSeverities, radius) {
63
+ if (!target.file)
64
+ return false;
65
+ const targetLine = target.line ?? 0;
66
+ for (const other of allResults) {
67
+ if (other.criticId === targetCriticId)
68
+ continue;
69
+ if (other.status !== "complete")
70
+ continue;
71
+ for (const f of other.findings) {
72
+ if (!blockingSeverities.includes(f.severity))
73
+ continue;
74
+ if (f.file !== target.file)
75
+ continue;
76
+ if (target.line === undefined || f.line === undefined)
77
+ return true;
78
+ const distance = Math.abs(f.line - targetLine);
79
+ if (distance <= radius)
80
+ return true;
81
+ }
82
+ }
83
+ return false;
84
+ }
4
85
  const SEVERITY_RANK = {
5
86
  blocker: 0,
6
87
  high: 1,
@@ -14,9 +95,18 @@ export function buildAggregate(inputs) {
14
95
  const aggregationPolicy = loaded.config.aggregation.policy;
15
96
  const blockingSeverities = loaded.config.aggregation.blockingSeverities;
16
97
  let gateVerdict;
98
+ let disagreements = [];
17
99
  if (status === "complete") {
18
- gateVerdict = aggregateVerdict(criticResults, blockingSeverities, loaded, quorumOverride);
100
+ const aggregate = aggregateOutcome(criticResults, blockingSeverities, loaded, quorumOverride);
101
+ gateVerdict = aggregate.verdict;
102
+ disagreements = aggregate.disagreements;
19
103
  }
104
+ // Cursor finding (report.ts:981) — persist the demoted findings on
105
+ // the artifact so operators auditing the on-disk JSON see the
106
+ // demotion without re-running the aggregator. Always emit `[]` on
107
+ // complete artifacts so consumers can branch on `.length` without
108
+ // an undefined check; omit on pending/error artifacts (no
109
+ // aggregation ran, no policy outcome to record).
20
110
  const artifact = {
21
111
  version: 2,
22
112
  status,
@@ -30,9 +120,21 @@ export function buildAggregate(inputs) {
30
120
  criticResults,
31
121
  createdAt,
32
122
  ...(gateVerdict !== undefined ? { gateVerdict } : {}),
123
+ ...(status === "complete" ? { disagreements } : {}),
33
124
  };
34
125
  return artifact;
35
126
  }
127
+ function aggregateOutcome(results, blockingSeverities, loaded, quorumOverride) {
128
+ if (loaded.config.aggregation.policy === "min-complete-quorum") {
129
+ const quorum = quorumOverride ?? loaded.config.aggregation.quorum;
130
+ const outcome = quorumAggregateVerdict(results, blockingSeverities, quorum ?? 2, loaded.config.aggregation.unilateralVetoRules);
131
+ return { verdict: outcome.verdict, disagreements: outcome.disagreements };
132
+ }
133
+ return {
134
+ verdict: aggregateVerdict(results, blockingSeverities, loaded, quorumOverride),
135
+ disagreements: [],
136
+ };
137
+ }
36
138
  function aggregateVerdict(results, blockingSeverities, loaded, quorumOverride) {
37
139
  // Cycle 322.3 — dispatch on policy. `min-complete-quorum` uses the
38
140
  // quorum aggregator below; everything else falls through to the
@@ -45,11 +147,14 @@ function aggregateVerdict(results, blockingSeverities, loaded, quorumOverride) {
45
147
  // report CHANGES_REQUESTED (uses root quorum=2). Codex P2 caught
46
148
  // this divergence on PR #1468.
47
149
  const quorum = quorumOverride ?? loaded.config.aggregation.quorum;
150
+ // Issue dark-factory-platform#112 — thread the optional
151
+ // `unilateralVetoRules` so the persisted artifact's verdict honors
152
+ // the same corroboration policy the runtime gate evaluator does.
48
153
  // Schema validation guarantees `quorum` is set when the policy
49
154
  // is `min-complete-quorum`; the defensive fallback to 2 is for
50
155
  // hand-constructed configs in tests (the parser would already
51
156
  // have rejected a real on-disk config without quorum).
52
- return quorumAggregateVerdict(results, blockingSeverities, quorum ?? 2).verdict;
157
+ return quorumAggregateVerdict(results, blockingSeverities, quorum ?? 2, loaded.config.aggregation.unilateralVetoRules).verdict;
53
158
  }
54
159
  // Cycle 322.2 Component 4 — `required` flag threading.
55
160
  //
@@ -121,16 +226,115 @@ export function isCriticCompleted(r) {
121
226
  * CHANGES_REQUESTED verdict) blocks the gate regardless of how many
122
227
  * other critics approved. Returns `false` for non-completed critics
123
228
  * — only a completed critic can veto.
229
+ *
230
+ * Issue dark-factory-platform#112 — when `corroborationCtx` is
231
+ * supplied AND the critic's only veto trigger is a blocking finding
232
+ * carrying one of the configured corroboration-required flags (e.g.
233
+ * `selfInconsistent: true`), the function checks whether ANOTHER
234
+ * critic raises a blocking finding on the same file within
235
+ * `rules.requireCorroborationOnHunkRadius` lines. If not, the
236
+ * finding is demoted to a `critic_disagreement` note (pushed to
237
+ * `disagreementSink`) and DOES NOT contribute to a veto. Findings
238
+ * without a flag still veto unconditionally — the safety net is
239
+ * intact for findings the critic can defend.
240
+ *
241
+ * Note: a critic that vetoes via `CHANGES_REQUESTED` verdict OR
242
+ * `requiresHumanJudgment` is NOT subject to corroboration — those
243
+ * are coarser-grained signals than per-finding flags and the spec
244
+ * narrows the rule to per-finding flag triggers only.
124
245
  */
125
- export function criticVetoesGate(r, blockingSeverities) {
246
+ export function criticVetoesGate(r, blockingSeverities, corroborationCtx) {
126
247
  if (!isCriticCompleted(r))
127
248
  return false;
128
- if (r.verdict === "CHANGES_REQUESTED")
129
- return true;
249
+ // requiresHumanJudgment is a coarser-grained signal than per-finding
250
+ // flags; the spec narrows corroboration to per-finding triggers
251
+ // only. A critic that flags requiresHumanJudgment vetoes
252
+ // unconditionally regardless of policy.
130
253
  if (r.requiresHumanJudgment)
131
254
  return true;
132
- if (hasBlockingFinding(r.findings, blockingSeverities))
255
+ // Back-compat path: no policy → check verdict OR any blocking
256
+ // finding (the pre-#112 §11 semantic).
257
+ if (corroborationCtx === undefined) {
258
+ if (r.verdict === "CHANGES_REQUESTED")
259
+ return true;
260
+ return hasBlockingFinding(r.findings, blockingSeverities);
261
+ }
262
+ // Issue dark-factory-platform#112 — policy-aware veto evaluation.
263
+ // Walk every blocking finding and partition into:
264
+ // - `anyUnflaggedBlocking`: the safety net — any finding the
265
+ // critic can defend stands as a veto.
266
+ // - `anyCorroboratedFlagged`: flagged AND another critic raised a
267
+ // same-file blocker within radius → veto stands.
268
+ // - `demotions`: flagged AND uncorroborated → recorded as
269
+ // `critic_disagreement` notes; do NOT contribute to veto.
270
+ //
271
+ // A critic with `verdict === "CHANGES_REQUESTED"` is itself subject
272
+ // to demotion under policy WHEN every blocking finding is demoted
273
+ // AND the verdict is downstream of those findings (heuristic: if no
274
+ // unflagged or corroborated blocker remains, the CR verdict is the
275
+ // critic's downstream summary of the now-demoted finding(s)). This
276
+ // is the spec's "verdict flips from CHANGES_REQUESTED to APPROVED"
277
+ // case from the DoD. A critic with CR and NO blocking findings at
278
+ // all retains its veto unconditionally — the verdict in that case
279
+ // is verdict-only with no per-finding flag to demote.
280
+ const { allResults, rules, disagreementSink } = corroborationCtx;
281
+ let anyUnflaggedBlocking = false;
282
+ let anyCorroboratedFlagged = false;
283
+ let anyBlockingFinding = false;
284
+ const demotions = [];
285
+ for (const f of r.findings) {
286
+ if (!blockingSeverities.includes(f.severity))
287
+ continue;
288
+ anyBlockingFinding = true;
289
+ const flagged = findingCarriesCorroborationFlag(f, rules.requireCorroborationFor);
290
+ if (!flagged) {
291
+ anyUnflaggedBlocking = true;
292
+ continue;
293
+ }
294
+ const corroborated = isCorroboratedByOtherCritic(f, r.criticId, allResults, blockingSeverities, rules.requireCorroborationOnHunkRadius);
295
+ if (corroborated) {
296
+ anyCorroboratedFlagged = true;
297
+ continue;
298
+ }
299
+ // Flagged AND uncorroborated → demote to a disagreement note.
300
+ if (f.file) {
301
+ demotions.push({
302
+ criticId: r.criticId,
303
+ file: f.file,
304
+ ...(f.line !== undefined ? { line: f.line } : {}),
305
+ severity: f.severity,
306
+ // The first flag the finding actually carries wins for note
307
+ // attribution; in practice today only `self_inconsistent` is
308
+ // a registered key so this is a single-element search.
309
+ flag: rules.requireCorroborationFor.find((name) => {
310
+ const key = FLAG_TO_FINDING_KEY[name];
311
+ return key !== undefined && f[key] === true;
312
+ }) ?? rules.requireCorroborationFor[0],
313
+ evidence: f.evidence,
314
+ });
315
+ }
316
+ }
317
+ // Veto sustained when any defendable signal remains:
318
+ // - An unflagged blocker (the safety net).
319
+ // - A corroborated flagged blocker (another critic agrees).
320
+ // - A CHANGES_REQUESTED verdict that's NOT downstream of the
321
+ // now-demoted findings — i.e. the critic returned CR without any
322
+ // blocking finding to attribute it to (verdict-only block, the
323
+ // coarser §11 signal). That CR survives policy because there's
324
+ // no per-finding flag to demote it through.
325
+ if (anyUnflaggedBlocking || anyCorroboratedFlagged) {
326
+ if (disagreementSink)
327
+ disagreementSink.push(...demotions);
328
+ return true;
329
+ }
330
+ if (r.verdict === "CHANGES_REQUESTED" && !anyBlockingFinding) {
331
+ // Verdict-only block, no blockers to demote → veto sustained.
133
332
  return true;
333
+ }
334
+ // All blockers demoted (and either APPROVED verdict OR
335
+ // CHANGES_REQUESTED-downstream-of-blockers) → no veto.
336
+ if (disagreementSink)
337
+ disagreementSink.push(...demotions);
134
338
  return false;
135
339
  }
136
340
  /**
@@ -154,15 +358,29 @@ export function criticVetoesGate(r, blockingSeverities) {
154
358
  * property: an outage that knocks out two critics doesn't paralyze
155
359
  * the third critic's BLOCKER finding.
156
360
  */
157
- export function quorumAggregateVerdict(results, blockingSeverities, quorum) {
361
+ export function quorumAggregateVerdict(results, blockingSeverities, quorum, unilateralVetoRules) {
158
362
  const completed = results.filter(isCriticCompleted);
159
- const vetoer = completed.find((r) => criticVetoesGate(r, blockingSeverities));
363
+ const disagreements = [];
364
+ // Issue dark-factory-platform#112 — when the policy is set, thread
365
+ // it through every `criticVetoesGate` call so a single uncorroborated
366
+ // flagged finding doesn't sweep a veto. The disagreement sink is
367
+ // shared across critics so the runner sees all demotions in one
368
+ // pass.
369
+ const ctx = unilateralVetoRules !== undefined
370
+ ? {
371
+ allResults: results,
372
+ rules: unilateralVetoRules,
373
+ disagreementSink: disagreements,
374
+ }
375
+ : undefined;
376
+ const vetoer = completed.find((r) => ctx ? criticVetoesGate(r, blockingSeverities, ctx) : criticVetoesGate(r, blockingSeverities));
160
377
  if (vetoer) {
161
378
  return {
162
379
  verdict: "CHANGES_REQUESTED",
163
380
  reason: "veto",
164
381
  completedCount: completed.length,
165
382
  totalCount: results.length,
383
+ disagreements,
166
384
  };
167
385
  }
168
386
  if (completed.length < quorum) {
@@ -171,12 +389,40 @@ export function quorumAggregateVerdict(results, blockingSeverities, quorum) {
171
389
  reason: "quorum_unmet",
172
390
  completedCount: completed.length,
173
391
  totalCount: results.length,
392
+ disagreements,
174
393
  };
175
394
  }
176
395
  // Quorum met and no veto — vote the majority of completed critics.
177
396
  // Without veto, every completed critic has APPROVED OR (no verdict
178
397
  // — schema impossible, but defensive). Count APPROVED vs other.
179
- const approveCount = completed.filter((r) => r.verdict === "APPROVED").length;
398
+ //
399
+ // Issue dark-factory-platform#112 — when a corroboration policy is
400
+ // active and a critic's CHANGES_REQUESTED verdict is downstream of
401
+ // a now-demoted blocker (every blocking finding the critic produced
402
+ // was demoted under the policy), treat that critic as effectively
403
+ // APPROVED for majority counting. Without this, the critic that
404
+ // produced the demoted blocker would still flip the majority to
405
+ // CHANGES_REQUESTED via its CR verdict, which would undo the
406
+ // demotion the policy just performed. This is the spec's DoD case:
407
+ // "verdict flips from CHANGES_REQUESTED to APPROVED after the
408
+ // probe-tagged blocker is demoted, no other critic finds a
409
+ // corroborating blocker on the same file within radius."
410
+ const effectivelyApproved = (r) => {
411
+ if (r.verdict === "APPROVED")
412
+ return true;
413
+ if (ctx === undefined)
414
+ return false;
415
+ // If the critic returned CR with at least one blocker and every
416
+ // blocker was demoted to a disagreement note attributed to this
417
+ // critic, the CR verdict is downstream of the now-demoted
418
+ // finding(s).
419
+ const blockers = r.findings.filter((f) => blockingSeverities.includes(f.severity));
420
+ if (blockers.length === 0)
421
+ return false;
422
+ const demotedForThisCritic = disagreements.filter((d) => d.criticId === r.criticId).length;
423
+ return demotedForThisCritic >= blockers.length;
424
+ };
425
+ const approveCount = completed.filter(effectivelyApproved).length;
180
426
  const changesCount = completed.length - approveCount;
181
427
  // Ties favor CHANGES_REQUESTED (conservative). With 0 changes, all
182
428
  // approved → APPROVED. With any changes >= approveCount → CHANGES_REQUESTED.
@@ -190,6 +436,7 @@ export function quorumAggregateVerdict(results, blockingSeverities, quorum) {
190
436
  reason: "majority",
191
437
  completedCount: completed.length,
192
438
  totalCount: results.length,
439
+ disagreements,
193
440
  };
194
441
  }
195
442
  /**
@@ -279,6 +526,91 @@ export function buildCriticReport(artifact, jsonPath) {
279
526
  degraded,
280
527
  };
281
528
  }
529
+ export function buildZeroEvidenceDiagnostic(artifact, jsonPath, options = {}) {
530
+ const results = artifact.criticResults;
531
+ const totalCount = results.length;
532
+ const completedCount = results.filter((r) => r.status === "complete").length;
533
+ const isZeroEvidence = totalCount > 0 && completedCount === 0;
534
+ if (!isZeroEvidence) {
535
+ return { isZeroEvidence, stderr: "" };
536
+ }
537
+ const classified = results.map((r) => classifyCritic(r));
538
+ const lines = [
539
+ `df: review COMPLETED with 0/${totalCount} critics producing evidence — gate will block at push time.`,
540
+ `df: fix one of:`,
541
+ ];
542
+ // Top-line config remediation when the caller signaled the profiles
543
+ // block is missing entirely (sage-blueprint seed bug shape).
544
+ if (options.configHasProfiles === false) {
545
+ lines.push(`df: - add a 'profiles' block to .agent-review/config.json (mirror sage3c's pattern; see docs/CONSUMER-ADOPTION.md)`);
546
+ }
547
+ // Per-critic remediation hints. Use a Set to dedupe identical hints
548
+ // (e.g., two critics from the same family both missing the same env).
549
+ const emittedHints = new Set();
550
+ for (const c of classified) {
551
+ const hint = `df: - ${c.criticId}: ${c.remediation}`;
552
+ if (emittedHints.has(hint))
553
+ continue;
554
+ emittedHints.add(hint);
555
+ lines.push(hint);
556
+ }
557
+ lines.push(`df: details: ${jsonPath}`);
558
+ return {
559
+ isZeroEvidence,
560
+ stderr: `${lines.join("\n")}\n`,
561
+ };
562
+ }
563
+ function classifyCritic(result) {
564
+ const message = result.error?.message ?? "";
565
+ const code = result.error?.code ?? "";
566
+ // Codex "no auth source pinned" — the documented failure when the
567
+ // profile lacks the per-critic `auth` entry. The codex-sdk adapter
568
+ // raises this with a stable message prefix.
569
+ if (/no auth source pinned/i.test(message)) {
570
+ return {
571
+ criticId: result.criticId,
572
+ classification: "no_auth_codex_unpinned",
573
+ remediation: `pin profiles.<name>.auth["${result.criticId}"] to "chatgpt" (and run \`codex login\`) or "api" (and set CODEX_API_KEY)`,
574
+ };
575
+ }
576
+ // Env-var-missing — the cursor/gemini/grok/codex API-key paths use a
577
+ // stable "<KEY> is not set" message in their auth probes.
578
+ const envMatch = message.match(/([A-Z][A-Z0-9_]*_API_KEY|[A-Z][A-Z0-9_]*_TOKEN)\s+is\s+not\s+set/);
579
+ if (envMatch && envMatch[1]) {
580
+ const envName = envMatch[1];
581
+ return {
582
+ criticId: result.criticId,
583
+ classification: "no_auth_env_missing",
584
+ remediation: `export ${envName}=... (or add it to your Doppler scope)`,
585
+ };
586
+ }
587
+ // Transport / capacity errors — these surface as SDK error codes
588
+ // (`rate_limited`, `capacity_exceeded`, `timeout`, `service_unavailable`).
589
+ if (/^(rate_limited|capacity_exceeded|timeout|service_unavailable|server_error|unavailable)$/i.test(code) ||
590
+ /capacity_exceeded|rate.?limit|service unavailable/i.test(message)) {
591
+ return {
592
+ criticId: result.criticId,
593
+ classification: "transport_error",
594
+ remediation: `transport/rate-limit error — retry the commit; if persistent, check vendor status`,
595
+ };
596
+ }
597
+ // Schema violations — adapter returned data that doesn't match the
598
+ // expected schema (typically after a vendor model bump).
599
+ if (/^(schema_violation|invalid_response|malformed_response)$/i.test(code) ||
600
+ /schema_violation|malformed verdict|malformed response/i.test(message)) {
601
+ return {
602
+ criticId: result.criticId,
603
+ classification: "schema_violation",
604
+ remediation: `schema violation — upgrade the @momentiq/dark-factory-cli adapter version`,
605
+ };
606
+ }
607
+ // Generic fallback — point the operator at `df doctor` for triage.
608
+ return {
609
+ criticId: result.criticId,
610
+ classification: "unknown",
611
+ remediation: `unclassified error — run \`df doctor --profile <name>\` for per-critic triage`,
612
+ };
613
+ }
282
614
  // JSON is the authoritative gate-input; markdown is a presentation artifact.
283
615
  // The write order is JSON-first, markdown best-effort, so a broken markdown
284
616
  // path (e.g., a previously-created directory at the markdown filename) cannot
@@ -398,7 +730,14 @@ export function renderMarkdown(artifact) {
398
730
  for (const f of subset) {
399
731
  lines.push("");
400
732
  const tag = f.manifestoSection ? ` ${f.manifestoSection}` : "";
401
- lines.push(`- **${f.category}${tag}** \`${f.file ?? "(no file)"}\`${f.line !== undefined ? `:${f.line}` : ""}${f.symbol ? ` (\`${f.symbol}\`)` : ""}`);
733
+ // Cursor finding (report.ts:981) surface the
734
+ // self-consistency-probe verdict alongside the location so a
735
+ // reader scanning the markdown immediately sees which
736
+ // blockers were probe-flagged. The aggregator may have
737
+ // demoted these to disagreement notes — see the
738
+ // "Demoted findings" section at the end of the artifact.
739
+ const probeTag = f.selfInconsistent === true ? " [self-inconsistent]" : "";
740
+ lines.push(`- **${f.category}${tag}** — \`${f.file ?? "(no file)"}\`${f.line !== undefined ? `:${f.line}` : ""}${f.symbol ? ` (\`${f.symbol}\`)` : ""}${probeTag}`);
402
741
  lines.push(` - Evidence: ${f.evidence}`);
403
742
  lines.push(` - Impact: ${f.impact}`);
404
743
  lines.push(` - Required fix: ${f.requiredFix}`);
@@ -425,6 +764,24 @@ export function renderMarkdown(artifact) {
425
764
  }
426
765
  lines.push("");
427
766
  }
767
+ // Cursor finding (report.ts:981) — surface the aggregator's
768
+ // demoted findings so a reader of the per-SHA markdown sees the
769
+ // policy outcome (which blockers became disagreement notes and
770
+ // why). Omitted entirely when no demotions occurred (the common
771
+ // case for runs without `unilateralVetoRules` set or for runs
772
+ // where every flagged blocker corroborated).
773
+ if (artifact.disagreements && artifact.disagreements.length > 0) {
774
+ lines.push("## Demoted findings");
775
+ lines.push("");
776
+ lines.push("Findings the aggregator demoted from a unilateral veto to an informational `critic_disagreement` note under `unilateralVetoRules.requireCorroborationFor`.");
777
+ lines.push("");
778
+ for (const d of artifact.disagreements) {
779
+ const loc = d.line !== undefined ? `${d.file}:${d.line}` : d.file;
780
+ lines.push(`- **${d.criticId}** — \`${loc}\` (${d.severity}, flag: \`${d.flag}\`)`);
781
+ lines.push(` - Evidence: ${d.evidence}`);
782
+ }
783
+ lines.push("");
784
+ }
428
785
  return `${lines.join("\n")}\n`;
429
786
  }
430
787
  export async function writePending(loaded, inputs) {