@ai-sdlc/orchestrator 0.9.0 → 0.13.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 (172) hide show
  1. package/dist/__test-helpers/git-env.d.ts +40 -0
  2. package/dist/__test-helpers/git-env.js +73 -0
  3. package/dist/adapters.d.ts +42 -3
  4. package/dist/adapters.js +133 -3
  5. package/dist/admission-composite.d.ts +134 -1
  6. package/dist/admission-composite.js +170 -6
  7. package/dist/admission-enrichment.d.ts +12 -3
  8. package/dist/admission-enrichment.js +71 -10
  9. package/dist/admission-hc.d.ts +20 -1
  10. package/dist/admission-hc.js +12 -1
  11. package/dist/admission-score.d.ts +44 -0
  12. package/dist/admission-score.js +11 -2
  13. package/dist/backlog-adapter.d.ts +45 -0
  14. package/dist/backlog-adapter.js +145 -1
  15. package/dist/calibration.d.ts +81 -0
  16. package/dist/calibration.js +76 -0
  17. package/dist/cli/commands/git-remote.d.ts +25 -0
  18. package/dist/cli/commands/git-remote.js +80 -1
  19. package/dist/cli/commands/init-features.d.ts +507 -0
  20. package/dist/cli/commands/init-features.js +1100 -0
  21. package/dist/cli/commands/init-templates.d.ts +301 -0
  22. package/dist/cli/commands/init-templates.js +1341 -0
  23. package/dist/cli/commands/init.d.ts +67 -0
  24. package/dist/cli/commands/init.js +301 -2
  25. package/dist/cli/commands/run.js +9 -1
  26. package/dist/cli/index.d.ts +11 -0
  27. package/dist/cli/index.js +42 -8
  28. package/dist/compliance/composer.d.ts +79 -0
  29. package/dist/compliance/composer.js +258 -0
  30. package/dist/compliance/errors.d.ts +64 -0
  31. package/dist/compliance/errors.js +85 -0
  32. package/dist/compliance/loader.d.ts +52 -0
  33. package/dist/compliance/loader.js +124 -0
  34. package/dist/compliance/types.d.ts +184 -0
  35. package/dist/compliance/types.js +41 -0
  36. package/dist/compliance-clearance.d.ts +269 -0
  37. package/dist/compliance-clearance.js +269 -0
  38. package/dist/config.js +17 -0
  39. package/dist/cost-tracker.d.ts +22 -0
  40. package/dist/cost-tracker.js +41 -0
  41. package/dist/database/adapters/external.js +5 -1
  42. package/dist/design-authority.d.ts +13 -0
  43. package/dist/design-authority.js +13 -0
  44. package/dist/embedding/adapters/openai-text-embedding-3-small.d.ts +71 -0
  45. package/dist/embedding/adapters/openai-text-embedding-3-small.js +190 -0
  46. package/dist/embedding/consumers/tessellation-drift.d.ts +74 -0
  47. package/dist/embedding/consumers/tessellation-drift.js +76 -0
  48. package/dist/embedding/cross-provider.d.ts +78 -0
  49. package/dist/embedding/cross-provider.js +75 -0
  50. package/dist/embedding/deprecation.d.ts +151 -0
  51. package/dist/embedding/deprecation.js +229 -0
  52. package/dist/embedding/errors.d.ts +90 -0
  53. package/dist/embedding/errors.js +150 -0
  54. package/dist/embedding/index.d.ts +29 -0
  55. package/dist/embedding/index.js +24 -0
  56. package/dist/embedding/pipeline-load.d.ts +146 -0
  57. package/dist/embedding/pipeline-load.js +178 -0
  58. package/dist/embedding/registry.d.ts +45 -0
  59. package/dist/embedding/registry.js +61 -0
  60. package/dist/embedding/stale-vector.d.ts +110 -0
  61. package/dist/embedding/stale-vector.js +92 -0
  62. package/dist/embedding/storage/index.d.ts +51 -0
  63. package/dist/embedding/storage/index.js +43 -0
  64. package/dist/embedding/storage/jsonl-backend.d.ts +150 -0
  65. package/dist/embedding/storage/jsonl-backend.js +332 -0
  66. package/dist/embedding/storage/types.d.ts +135 -0
  67. package/dist/embedding/storage/types.js +13 -0
  68. package/dist/embedding/types.d.ts +180 -0
  69. package/dist/embedding/types.js +10 -0
  70. package/dist/execute.d.ts +29 -2
  71. package/dist/execute.js +179 -35
  72. package/dist/index.d.ts +14 -2
  73. package/dist/index.js +19 -2
  74. package/dist/journey/inheritance-validator.d.ts +396 -0
  75. package/dist/journey/inheritance-validator.js +370 -0
  76. package/dist/journey/state-id-drift-rule.d.ts +137 -0
  77. package/dist/journey/state-id-drift-rule.js +245 -0
  78. package/dist/journey-sa2-router.d.ts +395 -0
  79. package/dist/journey-sa2-router.js +308 -0
  80. package/dist/models/classifier.d.ts +3 -1
  81. package/dist/models/classifier.js +61 -5
  82. package/dist/pillar-breakdown.d.ts +23 -1
  83. package/dist/pillar-breakdown.js +9 -0
  84. package/dist/runners/review-agent.js +6 -2
  85. package/dist/runners/runner-registry.d.ts +36 -0
  86. package/dist/runners/runner-registry.js +90 -0
  87. package/dist/runtime/attestations.d.ts +663 -11
  88. package/dist/runtime/attestations.js +971 -24
  89. package/dist/runtime/index.d.ts +1 -1
  90. package/dist/runtime/index.js +1 -1
  91. package/dist/runtime/parallelism-flag.d.ts +21 -3
  92. package/dist/runtime/parallelism-flag.js +27 -6
  93. package/dist/runtime/port-allocator.d.ts +11 -0
  94. package/dist/runtime/port-allocator.js +30 -0
  95. package/dist/sa-scoring/layer3-llm.js +6 -1
  96. package/dist/sa-scoring/revision-proposal-config.d.ts +178 -0
  97. package/dist/sa-scoring/revision-proposal-config.js +198 -0
  98. package/dist/sa-scoring/revision-proposal.d.ts +285 -0
  99. package/dist/sa-scoring/revision-proposal.js +417 -0
  100. package/dist/signal-ingestion/adapters/community-thread.d.ts +43 -0
  101. package/dist/signal-ingestion/adapters/community-thread.js +55 -0
  102. package/dist/signal-ingestion/adapters/in-app-feedback.d.ts +67 -0
  103. package/dist/signal-ingestion/adapters/in-app-feedback.js +51 -0
  104. package/dist/signal-ingestion/adapters/manual.d.ts +78 -0
  105. package/dist/signal-ingestion/adapters/manual.js +112 -0
  106. package/dist/signal-ingestion/adapters/support-ticket.d.ts +47 -0
  107. package/dist/signal-ingestion/adapters/support-ticket.js +51 -0
  108. package/dist/signal-ingestion/classifier.d.ts +205 -0
  109. package/dist/signal-ingestion/classifier.js +494 -0
  110. package/dist/signal-ingestion/clustering-types.d.ts +36 -0
  111. package/dist/signal-ingestion/clustering-types.js +14 -0
  112. package/dist/signal-ingestion/clustering.d.ts +200 -0
  113. package/dist/signal-ingestion/clustering.js +413 -0
  114. package/dist/signal-ingestion/config.d.ts +351 -0
  115. package/dist/signal-ingestion/config.js +587 -0
  116. package/dist/signal-ingestion/d1.d.ts +252 -0
  117. package/dist/signal-ingestion/d1.js +235 -0
  118. package/dist/signal-ingestion/errors.d.ts +73 -0
  119. package/dist/signal-ingestion/errors.js +108 -0
  120. package/dist/signal-ingestion/governance-events.d.ts +181 -0
  121. package/dist/signal-ingestion/governance-events.js +189 -0
  122. package/dist/signal-ingestion/index.d.ts +35 -0
  123. package/dist/signal-ingestion/index.js +53 -0
  124. package/dist/signal-ingestion/manual-share-metric.d.ts +93 -0
  125. package/dist/signal-ingestion/manual-share-metric.js +106 -0
  126. package/dist/signal-ingestion/registry.d.ts +40 -0
  127. package/dist/signal-ingestion/registry.js +137 -0
  128. package/dist/signal-ingestion/residency.d.ts +227 -0
  129. package/dist/signal-ingestion/residency.js +238 -0
  130. package/dist/signal-ingestion/significance.d.ts +554 -0
  131. package/dist/signal-ingestion/significance.js +555 -0
  132. package/dist/signal-ingestion/types.d.ts +191 -0
  133. package/dist/signal-ingestion/types.js +8 -0
  134. package/dist/substrate/drift-composition.d.ts +270 -0
  135. package/dist/substrate/drift-composition.js +306 -0
  136. package/dist/substrate/drift-tui-surface.d.ts +61 -0
  137. package/dist/substrate/drift-tui-surface.js +102 -0
  138. package/dist/substrate/identity-class.d.ts +176 -0
  139. package/dist/substrate/identity-class.js +201 -0
  140. package/dist/tessellation/cross-soul-provenance-rule.d.ts +133 -0
  141. package/dist/tessellation/cross-soul-provenance-rule.js +171 -0
  142. package/dist/tessellation/inter-soul-embedding-distance-rule.d.ts +61 -0
  143. package/dist/tessellation/inter-soul-embedding-distance-rule.js +67 -0
  144. package/dist/tessellation/rule-registry.d.ts +269 -0
  145. package/dist/tessellation/rule-registry.js +92 -0
  146. package/dist/tessellation/soul-slug-ast-scan-rule.d.ts +90 -0
  147. package/dist/tessellation/soul-slug-ast-scan-rule.js +158 -0
  148. package/dist/tessellation-admission.d.ts +162 -0
  149. package/dist/tessellation-admission.js +146 -0
  150. package/dist/tessellation-drift.d.ts +246 -0
  151. package/dist/tessellation-drift.js +250 -0
  152. package/dist/validate-config.js +13 -0
  153. package/dist/validate-issue.js +2 -2
  154. package/dist/variant/cardinality-activation.d.ts +126 -0
  155. package/dist/variant/cardinality-activation.js +101 -0
  156. package/dist/variant/deprecation-lifecycle.d.ts +184 -0
  157. package/dist/variant/deprecation-lifecycle.js +208 -0
  158. package/dist/variant/drift-extension.d.ts +136 -0
  159. package/dist/variant/drift-extension.js +164 -0
  160. package/dist/variant/engineering-review.d.ts +185 -0
  161. package/dist/variant/engineering-review.js +142 -0
  162. package/dist/variant/index.d.ts +32 -0
  163. package/dist/variant/index.js +32 -0
  164. package/dist/variant/inheritance-validator.d.ts +165 -0
  165. package/dist/variant/inheritance-validator.js +139 -0
  166. package/dist/variant/internal-adopter/index.d.ts +11 -0
  167. package/dist/variant/internal-adopter/index.js +10 -0
  168. package/dist/variant/internal-adopter/products.d.ts +156 -0
  169. package/dist/variant/internal-adopter/products.js +366 -0
  170. package/dist/variant-admission.d.ts +316 -0
  171. package/dist/variant-admission.js +247 -0
  172. package/package.json +11 -8
@@ -32,15 +32,32 @@
32
32
  * supports it natively.
33
33
  */
34
34
  import { createHash, generateKeyPairSync, sign, verify } from 'node:crypto';
35
+ import { execFileSync } from 'node:child_process';
36
+ import { cleanGitEnv } from './git-env.js';
35
37
  /**
36
38
  * The currently-accepted predicate schema versions. CI rejects any envelope
37
39
  * whose `payload.schemaVersion` is not in this allowlist — this is the
38
- * forward-compatibility hatch (we add a new version here when we change the
39
- * predicate shape, and CI keeps accepting v1 until we explicitly remove it).
40
+ * forward-compatibility hatch.
41
+ *
42
+ * AISDLC-103 (Verifier Phase 3) narrowed this to `['v3']` only:
43
+ * - `v1` envelopes (pre-AISDLC-94, diffHash-only) are rejected.
44
+ * - `v2` was never landed as a distinct schemaVersion — the AISDLC-94
45
+ * `contentHash` and AISDLC-101 `contentHashV3` shipped under the v1
46
+ * schemaVersion as additive optional fields during the dual- and
47
+ * triple-hash soak windows.
48
+ * - `v3` envelopes carry `contentHashV3` as a required field and DO NOT
49
+ * carry `diffHash` or `contentHash` (the legacy hashes are forbidden;
50
+ * a v3 envelope smuggling either field is rejected by
51
+ * `validatePredicateShape`).
52
+ *
53
+ * AISDLC-362 (contentHashV5): adds `'v5'` to the allowlist. v5 envelopes
54
+ * carry `contentHashV5` AND `signedMergeBase` in addition to v3+v4 hashes
55
+ * (backward-compat dual-write). The schemaVersion field is bumped to `'v5'`
56
+ * on new envelopes so verifiers can detect the v5 fast-path immediately.
40
57
  *
41
58
  * Exported so the `verify-attestation` workflow can `import`/inline it.
42
59
  */
43
- export const ACCEPTED_SCHEMA_VERSIONS = ['v1'];
60
+ export const ACCEPTED_SCHEMA_VERSIONS = ['v3', 'v5'];
44
61
  /**
45
62
  * The DSSE PAE payload type for our predicate. DSSE spec mandates a payload
46
63
  * type URI — we use a project-controlled vendor URI rather than the
@@ -59,8 +76,11 @@ export const DSSE_PAYLOAD_TYPE = 'application/vnd.ai-sdlc.attestation+json';
59
76
  // value — never give the attacker a way to smuggle their payload past
60
77
  // us by burying it in our reason text.
61
78
  //
62
- // Mirror of `.ai-sdlc/schemas/attestation.v1.schema.json` — kept in
63
- // sync by the `validatePredicateShape` test ('schema mirror in sync').
79
+ // Mirror of `.ai-sdlc/schemas/attestation.v3.schema.json` — the v3 schema
80
+ // requires `contentHashV3` and forbids the legacy `diffHash` / `contentHash`
81
+ // fields. AISDLC-103 (Verifier Phase 3) narrowed the schemaVersion allowlist
82
+ // to `['v3']` only; envelopes carrying the legacy hashes (= v1/v2 envelopes
83
+ // smuggling themselves into the v3 window) are rejected with a fixed reason.
64
84
  /** sha1 git commit (40 lowercase hex chars). */
65
85
  const SHA1_HEX = /^[0-9a-f]{40}$/;
66
86
  /** sha256 hex (64 lowercase hex chars). */
@@ -69,6 +89,13 @@ const SHA256_HEX = /^[0-9a-f]{64}$/;
69
89
  const ISO_8601 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
70
90
  /** Free-form short identifier — letters, digits, dot, dash, underscore. */
71
91
  const SHORT_ID = /^[A-Za-z0-9._-]+$/;
92
+ /**
93
+ * Semver-shape pattern for `pipelineVersion` (AISDLC-100.6). Accepts
94
+ * `MAJOR.MINOR.PATCH` and the optional `-prerelease` suffix used by npm
95
+ * tags (e.g. `0.1.0-rc.2`). Mirrors the schema's regex so JSON-Schema
96
+ * validators and the in-process shape validator agree.
97
+ */
98
+ const SEMVER = /^[0-9]+\.[0-9]+\.[0-9]+(-[a-z0-9.]+)?$/;
72
99
  /**
73
100
  * `harnessNote` is the only field the operator can put long-form text
74
101
  * in. We allow letters/digits/punctuation/whitespace but reject CR/LF
@@ -76,13 +103,18 @@ const SHORT_ID = /^[A-Za-z0-9._-]+$/;
76
103
  */
77
104
  const SAFE_TEXT = /^[^\r\n]*$/;
78
105
  /**
79
- * Validate a parsed predicate against the v1 schema regex patterns.
106
+ * Validate a parsed predicate against the v3 schema regex patterns.
80
107
  *
81
108
  * Returns `null` when the predicate is shape-valid; otherwise returns
82
109
  * a static failure reason that does NOT embed any user-controlled
83
110
  * value (just the field path). This is the load-bearing property:
84
111
  * the malicious value never reaches the `reason` string, so it can't
85
112
  * propagate to GITHUB_OUTPUT or commit-status descriptions.
113
+ *
114
+ * AISDLC-103 (Verifier Phase 3): `contentHashV3` is now required, and the
115
+ * legacy `diffHash` / `contentHash` fields are FORBIDDEN — a predicate
116
+ * carrying either is treated as a v1/v2 envelope smuggling itself into the
117
+ * v3 window and rejected with a static reason.
86
118
  */
87
119
  export function validatePredicateShape(parsed) {
88
120
  if (parsed === null || typeof parsed !== 'object') {
@@ -112,11 +144,57 @@ export function validatePredicateShape(parsed) {
112
144
  if (typeof sha1 !== 'string' || !SHA1_HEX.test(sha1)) {
113
145
  return 'schema validation failed: subject.digest.sha1 does not match pattern';
114
146
  }
115
- // diffHash + policyHash — 64 hex chars each.
116
- for (const field of ['diffHash', 'policyHash']) {
117
- const v = p[field];
147
+ // policyHash — 64 hex chars. Required.
148
+ {
149
+ const v = p['policyHash'];
118
150
  if (typeof v !== 'string' || !SHA256_HEX.test(v)) {
119
- return `schema validation failed: ${field} does not match pattern`;
151
+ return 'schema validation failed: policyHash does not match pattern';
152
+ }
153
+ }
154
+ // AISDLC-103 (Phase 3): legacy `diffHash` (v1) and `contentHash` (v2)
155
+ // are FORBIDDEN in v3 envelopes. A predicate that claims `schemaVersion:
156
+ // 'v3'` but carries either field is a v1/v2 envelope smuggling itself
157
+ // into the v3 window — reject with a fixed reason that doesn't embed
158
+ // the bad value.
159
+ if (p['diffHash'] !== undefined) {
160
+ return 'schema validation failed: diffHash is forbidden in v3 envelopes (legacy v1 field)';
161
+ }
162
+ if (p['contentHash'] !== undefined) {
163
+ return 'schema validation failed: contentHash is forbidden in v3 envelopes (legacy v2 field)';
164
+ }
165
+ // contentHashV3 (AISDLC-101) — REQUIRED in v3 envelopes. Must be a
166
+ // 64-char hex sha256.
167
+ {
168
+ const ch3 = p['contentHashV3'];
169
+ if (typeof ch3 !== 'string' || !SHA256_HEX.test(ch3)) {
170
+ return 'schema validation failed: contentHashV3 does not match pattern';
171
+ }
172
+ }
173
+ // contentHashV4 (AISDLC-193.1) — OPTIONAL during the v3+v4 dual-write
174
+ // transition window. When present, MUST be a 64-char hex sha256;
175
+ // when absent (legacy v3-only envelopes signed before this field
176
+ // landed), the verifier falls back to the v3 ancestor walk.
177
+ if (p['contentHashV4'] !== undefined) {
178
+ const ch4 = p['contentHashV4'];
179
+ if (typeof ch4 !== 'string' || !SHA256_HEX.test(ch4)) {
180
+ return 'schema validation failed: contentHashV4 does not match pattern';
181
+ }
182
+ }
183
+ // contentHashV5 (AISDLC-362) — OPTIONAL. When present, MUST be a 64-char
184
+ // hex sha256. v5 envelopes also carry `signedMergeBase` (40-char SHA-1).
185
+ if (p['contentHashV5'] !== undefined) {
186
+ const ch5 = p['contentHashV5'];
187
+ if (typeof ch5 !== 'string' || !SHA256_HEX.test(ch5)) {
188
+ return 'schema validation failed: contentHashV5 does not match pattern';
189
+ }
190
+ }
191
+ // signedMergeBase (AISDLC-362) — OPTIONAL, accompanies contentHashV5.
192
+ // When present, MUST be a 40-char SHA-1 hex string (the frozen merge-base
193
+ // that the v5 diff was computed against at sign time).
194
+ if (p['signedMergeBase'] !== undefined) {
195
+ const smb = p['signedMergeBase'];
196
+ if (typeof smb !== 'string' || !SHA1_HEX.test(smb)) {
197
+ return 'schema validation failed: signedMergeBase does not match SHA-1 pattern';
120
198
  }
121
199
  }
122
200
  // pluginVersion — short ID (no CR/LF, no `=`).
@@ -126,6 +204,17 @@ export function validatePredicateShape(parsed) {
126
204
  !SHORT_ID.test(pluginVersion)) {
127
205
  return 'schema validation failed: pluginVersion does not match pattern';
128
206
  }
207
+ // pipelineVersion (AISDLC-100.6) — optional. When present, must be a
208
+ // semver-shaped string (`MAJOR.MINOR.PATCH` with optional `-prerelease`).
209
+ // Absence is OK (legacy v1 envelopes signed before pipeline-cli existed
210
+ // / before Phase 6 landed). The verifier logs but does NOT enforce a
211
+ // specific version — see `scripts/verify-attestation.mjs`.
212
+ if (p['pipelineVersion'] !== undefined) {
213
+ const pv = p['pipelineVersion'];
214
+ if (typeof pv !== 'string' || pv.length === 0 || !SEMVER.test(pv)) {
215
+ return 'schema validation failed: pipelineVersion does not match pattern';
216
+ }
217
+ }
129
218
  // iterationCount — positive integer.
130
219
  const iterationCount = p['iterationCount'];
131
220
  if (typeof iterationCount !== 'number' ||
@@ -139,6 +228,27 @@ export function validatePredicateShape(parsed) {
139
228
  if (typeof harnessNote !== 'string' || !SAFE_TEXT.test(harnessNote)) {
140
229
  return 'schema validation failed: harnessNote contains forbidden characters';
141
230
  }
231
+ // harness (AISDLC-202.3) — optional envelope-level harness field.
232
+ // Absent on pre-202.3 envelopes — accepted for backward compatibility.
233
+ // When present, must be an object with a SHORT_ID `name` and an optional
234
+ // SEMVER `version`. Validated before interpolation to prevent injection.
235
+ const harness = p['harness'];
236
+ if (harness !== undefined) {
237
+ if (harness === null || typeof harness !== 'object') {
238
+ return 'schema validation failed: harness must be an object when present';
239
+ }
240
+ const h = harness;
241
+ const hName = h['name'];
242
+ if (typeof hName !== 'string' || hName.length === 0 || !SHORT_ID.test(hName)) {
243
+ return 'schema validation failed: harness.name does not match SHORT_ID pattern';
244
+ }
245
+ const hVersion = h['version'];
246
+ if (hVersion !== undefined) {
247
+ if (typeof hVersion !== 'string' || !SEMVER.test(hVersion)) {
248
+ return 'schema validation failed: harness.version does not match SEMVER pattern';
249
+ }
250
+ }
251
+ }
142
252
  // signedAt — ISO 8601.
143
253
  const signedAt = p['signedAt'];
144
254
  if (typeof signedAt !== 'string' || !ISO_8601.test(signedAt)) {
@@ -195,6 +305,315 @@ export const REQUIRED_REVIEWER_AGENT_IDS = Object.freeze([
195
305
  'test-reviewer',
196
306
  'security-reviewer',
197
307
  ]);
308
+ /**
309
+ * Name-equivalence map for the reviewer-set completeness check (AISDLC-252).
310
+ *
311
+ * A "role" is satisfied when any of the listed agentIds is present in the
312
+ * envelope's reviewer set. This lets codex-harness variants (`code-reviewer-codex`,
313
+ * `test-reviewer-codex`) satisfy the same role as their Claude counterparts,
314
+ * enabling the bidirectional cross-harness review goal without requiring a
315
+ * redundant Claude review on Codex-reviewed PRs.
316
+ *
317
+ * Security stays Claude-only: `security-reviewer` has no codex variant per
318
+ * `feedback_subagent_model_selection.md` (Claude Opus for security reasoning
319
+ * depth is not yet validated for Codex o4-mini).
320
+ *
321
+ * The map is keyed by role name (= the canonical agentId), each value is the
322
+ * set of ALL agentIds that satisfy the role (including the canonical one).
323
+ *
324
+ * Frozen to discourage callers from mutating it.
325
+ */
326
+ export const REVIEWER_ROLE_EQUIVALENCES = Object.freeze({
327
+ 'code-reviewer': Object.freeze(['code-reviewer', 'code-reviewer-codex']),
328
+ 'test-reviewer': Object.freeze(['test-reviewer', 'test-reviewer-codex']),
329
+ 'security-reviewer': Object.freeze(['security-reviewer']),
330
+ });
331
+ /**
332
+ * When the implementer ran in Codex (`predicate.harness.name === 'codex'`),
333
+ * these reviewer roles MUST be satisfied by a reviewer whose `harness` field
334
+ * differs from `codex`. Per RFC-0010 §13.10 `requiresIndependentHarnessFrom`:
335
+ * code and test reviewers must come from a different harness than the
336
+ * implementer to preserve cross-harness independence.
337
+ *
338
+ * Security is excluded — it is always Claude-only regardless.
339
+ *
340
+ * Frozen to discourage callers from mutating it.
341
+ */
342
+ export const INDEPENDENCE_REQUIRED_ROLES = Object.freeze([
343
+ 'code-reviewer',
344
+ 'test-reviewer',
345
+ ]);
346
+ /**
347
+ * Regex matching the envelope self-exclusion path pattern
348
+ * `.ai-sdlc/attestations/<sha>.dsse.json`. Used to filter out the
349
+ * envelope file itself from the file collector for AISDLC-193.1
350
+ * `contentHashV4` and AISDLC-101 `contentHashV3` purposes.
351
+ *
352
+ * The chore-commit pattern signs the predicate at the dev-commit (HEAD
353
+ * BEFORE the envelope file exists), then the chore commit on top adds
354
+ * the envelope file at `.ai-sdlc/attestations/<sha>.dsse.json`. If the
355
+ * collector includes the envelope file in the hashed file set, the
356
+ * verifier (which runs against PR HEAD = dev-commit + chore commit)
357
+ * will see an EXTRA entry for the envelope that the signer never saw
358
+ * → mismatch even on direct PR HEAD without any rebase.
359
+ *
360
+ * The exclusion applies to the file COLLECTOR for HASHING purposes
361
+ * only. The verifier's chore-commit allowlist (`scripts/verify-attestation.mjs`
362
+ * `CHORE_COMMIT_PATH_ALLOWLIST`) STILL allows the envelope file in the
363
+ * chore commit's diff — that's a separate concern from "what is in the
364
+ * file set we hash."
365
+ *
366
+ * Anchored with `^...$` against the forward-slash-normalized path so
367
+ * an attacker cannot bypass with `./.ai-sdlc/attestations/x.dsse.json`
368
+ * or `foo/.ai-sdlc/attestations/x.dsse.json`. Note that git's
369
+ * `--name-only` always emits paths relative to the repo root with
370
+ * forward slashes, so the match is straightforward in practice.
371
+ */
372
+ export const ATTESTATION_ENVELOPE_PATH_PATTERN = /^\.ai-sdlc\/attestations\/[^/]+\.dsse\.json$/;
373
+ /**
374
+ * Predicate to determine whether a file path identifies an attestation
375
+ * envelope and should therefore be excluded from `contentHashV3` /
376
+ * `contentHashV4` file enumeration. Defensive about backslash
377
+ * normalization (Windows callers).
378
+ */
379
+ export function isAttestationEnvelopePath(path) {
380
+ if (typeof path !== 'string')
381
+ return false;
382
+ const normalized = path.replace(/\\/g, '/');
383
+ return ATTESTATION_ENVELOPE_PATH_PATTERN.test(normalized);
384
+ }
385
+ /**
386
+ * The "shared churn" exclude list for content-hash computations (AISDLC-258,
387
+ * AISDLC-362). Applied to v3, v4, and v5 file collectors on BOTH the signer
388
+ * and verifier sides.
389
+ *
390
+ * Files in this list are EXCLUDED from all file collectors. When a file
391
+ * appears in this list, changes to it after signing (e.g. from a merge-queue
392
+ * rebase that regenerated `pnpm-lock.yaml`) do NOT cause content-hash
393
+ * mismatches, so the operator is never asked to re-sign just because a shared
394
+ * tooling file was regenerated automatically.
395
+ *
396
+ * **Security trade-off (operator-approved, 2026-05-10):** An attacker
397
+ * COULD slip malicious changes through these files undetected (the
398
+ * attestation would still pass even if the ignore-listed file was
399
+ * tampered). The operator accepted this risk because:
400
+ * - None of these files contain reviewable hand-written code.
401
+ * - `pnpm-lock.yaml` is generated from `package.json` (which IS hashed).
402
+ * - `CHANGELOG.md` variants are auto-generated by release-please from
403
+ * commit history (which IS hashed via the commit-level binding).
404
+ * - `generated-schemas.ts` is generated from spec schemas (reviewed
405
+ * separately in the spec/ PR that changed them).
406
+ *
407
+ * **DO NOT add to this list:** `package.json` (real dep changes are
408
+ * reviewable), source files, test files, configs, RFCs, or anything a
409
+ * human writes by hand. The list is intentionally narrow.
410
+ *
411
+ * Paths are exact matches against the forward-slash-normalized repo-relative
412
+ * path emitted by `git diff --name-only`. Patterns (globs/regex) are NOT
413
+ * supported to keep the list auditable — every entry must be exact.
414
+ *
415
+ * Exported so `scripts/verify-attestation.mjs` can import it from the
416
+ * orchestrator barrel and apply the same exclusions on the verifier side.
417
+ */
418
+ export const CONTENTHASH_SHARED_CHURN_FILES = Object.freeze([
419
+ 'pnpm-lock.yaml',
420
+ 'CHANGELOG.md',
421
+ 'pipeline-cli/CHANGELOG.md',
422
+ 'orchestrator/CHANGELOG.md',
423
+ // AISDLC-342 — re-added after PR #498 was kicked from the merge queue 3+
424
+ // times in 2 hours with `contentHashV4 mismatch` triggered by sibling PRs
425
+ // touching this file. The earlier AISDLC-258 concern (attacker hand-edits
426
+ // the generated file to bypass attestation) is mitigated by keeping the
427
+ // SOURCE-of-truth in the hash: every byte in this file is derived from
428
+ // `spec/schemas/*.schema.json` via `pnpm build`, and those schema JSONs
429
+ // remain in v4/v5. An attacker who hand-edits generated-schemas.ts without
430
+ // also editing a source schema produces output that the next `pnpm build`
431
+ // regenerates away — the change is non-load-bearing.
432
+ 'reference/src/core/generated-schemas.ts',
433
+ ]);
434
+ /**
435
+ * Backward-compatible alias for `CONTENTHASH_SHARED_CHURN_FILES` (renamed in
436
+ * AISDLC-362). Callers that imported the v4-specific name continue to work.
437
+ * @deprecated Use `CONTENTHASH_SHARED_CHURN_FILES` instead.
438
+ */
439
+ export const CONTENTHASHV4_IGNORE_FILES = CONTENTHASH_SHARED_CHURN_FILES;
440
+ /**
441
+ * Predicate to determine whether a file path should be excluded from
442
+ * content-hash computations (v3/v4/v5) because it is a "shared churn" file
443
+ * (see `CONTENTHASH_SHARED_CHURN_FILES`). Defensive about backslash
444
+ * normalization.
445
+ *
446
+ * Note: this predicate is intentionally separate from
447
+ * `isAttestationEnvelopePath` because the two exclusions serve different
448
+ * purposes and may diverge independently. Merge them only if the list
449
+ * becomes large enough to warrant a single unified predicate.
450
+ */
451
+ export function isIgnoredForContentHash(path) {
452
+ if (typeof path !== 'string')
453
+ return false;
454
+ const normalized = path.replace(/\\/g, '/');
455
+ return CONTENTHASH_SHARED_CHURN_FILES.includes(normalized);
456
+ }
457
+ /**
458
+ * Compute the rebase-stable `contentHashV5` (AISDLC-362) over a file set
459
+ * and a frozen merge-base SHA.
460
+ *
461
+ * Canonical form: `SHA-256(JSON.stringify({schemaVersion:'v5',
462
+ * signedMergeBase:'<40-char>', files:[{path,blobSha}...]}))` where `files`
463
+ * is sorted ascending by path.
464
+ *
465
+ * Why v5 beats v4 for rebase-stability:
466
+ * - v4 enumerates files via `git diff origin/main..HEAD`. When a sibling
467
+ * PR merges between sign-time and verify-time, `origin/main` moves
468
+ * forward. Files the sibling touched now appear in the v4 diff that
469
+ * weren't there at sign-time → v4 hash diverges even though OUR blobs
470
+ * are unchanged.
471
+ * - v5 enumerates files via `git diff <signedMergeBase>..HEAD`. The
472
+ * `signedMergeBase` is computed ONCE at sign time and frozen in the
473
+ * envelope. At verify time, the verifier recomputes
474
+ * `git diff <signedMergeBase>..<probe-HEAD>` using the SAME frozen
475
+ * base → same file set → same hash, regardless of how many sibling
476
+ * PRs merged on `main` in the interim.
477
+ * - Overlapping sibling merges (sibling changed a file OUR PR also touches)
478
+ * → the head blob SHA of that file differs from what we signed → v5
479
+ * hash flips → verifier correctly rejects (operator must re-review).
480
+ *
481
+ * Threat model preserved: any genuine post-sign content tampering flips the
482
+ * head blob SHA → v5 hash flips → verifier rejects.
483
+ *
484
+ * Pure function. Idempotent against double-enumeration via dedup-by-path.
485
+ */
486
+ export function computeContentHashV5(entries, signedMergeBase) {
487
+ if (typeof signedMergeBase !== 'string' || !/^[0-9a-f]{40}$/i.test(signedMergeBase)) {
488
+ throw new Error(`computeContentHashV5: signedMergeBase must be a 40-char hex SHA-1, got ${JSON.stringify(signedMergeBase)}`);
489
+ }
490
+ // Dedup by path (last entry wins) — mirrors computeContentHashV4 and
491
+ // computeContentHashV3 for idempotency.
492
+ const byPath = new Map();
493
+ for (const e of entries) {
494
+ if (typeof e?.path !== 'string' || e.path.length === 0) {
495
+ throw new Error(`computeContentHashV5: entry path must be a non-empty string`);
496
+ }
497
+ if (typeof e.blobSha !== 'string') {
498
+ throw new Error(`computeContentHashV5: entry blobSha must be a string for path ${e.path}`);
499
+ }
500
+ // Reject path entries containing JSON-control characters.
501
+ if (e.path.includes('\t') || e.path.includes('\n')) {
502
+ throw new Error(`computeContentHashV5: entry path must not contain tab or newline characters (got ${JSON.stringify(e.path)})`);
503
+ }
504
+ const normalizedPath = e.path.replace(/\\/g, '/');
505
+ byPath.set(normalizedPath, e.blobSha.toLowerCase());
506
+ }
507
+ const sortedFiles = [...byPath.entries()]
508
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
509
+ .map(([path, blobSha]) => ({ path, blobSha }));
510
+ const canonical = JSON.stringify({
511
+ schemaVersion: 'v5',
512
+ signedMergeBase: signedMergeBase.toLowerCase(),
513
+ files: sortedFiles,
514
+ });
515
+ return sha256Hex(canonical);
516
+ }
517
+ /**
518
+ * Collect the file set for `computeContentHashV5` (AISDLC-362).
519
+ *
520
+ * This is the LOAD-BEARING CHANGE vs v4:
521
+ * - v4: `git diff origin/main..HEAD` — the base moves as siblings merge.
522
+ * - v5: `git merge-base <baseRef> <headRef>` → frozen SHA, then
523
+ * `git diff <signedMergeBase>..HEAD` — the base is FROZEN.
524
+ *
525
+ * The frozen merge-base is returned alongside the entries so the signer can
526
+ * embed it in both the v5 hash and the predicate's `signedMergeBase` field.
527
+ *
528
+ * File enumeration applies the same exclusions as v3/v4:
529
+ * - Attestation envelope files (`ATTESTATION_ENVELOPE_PATH_PATTERN`)
530
+ * - Shared churn files (`CONTENTHASH_SHARED_CHURN_FILES`)
531
+ *
532
+ * Blob SHAs are resolved from `headRef` (= HEAD at sign time).
533
+ *
534
+ * @param repoRoot Absolute path to the git worktree root.
535
+ * @param baseRef Typically `'origin/main'`. Used ONLY for computing the
536
+ * merge-base — the diff itself is against the frozen SHA.
537
+ * @param headRef Typically `'HEAD'`.
538
+ * @param options Optional injection points for tests (stub `runGit`).
539
+ */
540
+ export function collectChangedFileEntriesForV5(repoRoot, baseRef = 'origin/main', headRef = 'HEAD', options = {}) {
541
+ const runGit = options.runGit ??
542
+ ((args, cwd) => execFileSync('git', args, {
543
+ cwd,
544
+ env: cleanGitEnv(),
545
+ encoding: 'utf-8',
546
+ maxBuffer: 64 * 1024 * 1024,
547
+ }));
548
+ // Step 1: compute and FREEZE the merge-base. This is the key invariant:
549
+ // sign once, freeze the base, verify against the frozen base — NOT against
550
+ // the moving `origin/main`.
551
+ let signedMergeBase;
552
+ try {
553
+ signedMergeBase = runGit(['merge-base', baseRef, headRef], repoRoot).trim();
554
+ }
555
+ catch (err) {
556
+ const msg = err instanceof Error ? err.message : String(err);
557
+ throw new Error(`collectChangedFileEntriesForV5: git merge-base failed: ${msg}`);
558
+ }
559
+ if (!/^[0-9a-f]{40}$/i.test(signedMergeBase)) {
560
+ throw new Error(`collectChangedFileEntriesForV5: git merge-base returned non-SHA output: ${JSON.stringify(signedMergeBase)}`);
561
+ }
562
+ // Step 2: enumerate files changed between the FROZEN merge-base and HEAD.
563
+ // Using two-dot range (`<signedMergeBase>..<headRef>`) — NOT three-dot —
564
+ // because the merge-base is already resolved. Three-dot would re-compute
565
+ // merge-base(mergeBase, headRef) = mergeBase itself, which is fine, but
566
+ // two-dot is more explicit and avoids any ambiguity.
567
+ let nameOnly;
568
+ try {
569
+ nameOnly = runGit([
570
+ '-c',
571
+ 'core.quotepath=false',
572
+ 'diff',
573
+ '--name-only',
574
+ '--no-renames',
575
+ `${signedMergeBase}..${headRef}`,
576
+ ], repoRoot);
577
+ }
578
+ catch (err) {
579
+ const msg = err instanceof Error ? err.message : String(err);
580
+ throw new Error(`collectChangedFileEntriesForV5: git diff --name-only failed: ${msg}`);
581
+ }
582
+ const paths = nameOnly.split('\n').filter((p) => p.length > 0);
583
+ const entries = [];
584
+ /**
585
+ * Resolve a file's blob SHA at `headRef` via `git ls-tree -r`. Returns the
586
+ * empty string when the path doesn't exist at the ref (= deleted file).
587
+ */
588
+ const resolveBlobSha = (path) => {
589
+ try {
590
+ const lsOut = runGit(['-c', 'core.quotepath=false', 'ls-tree', '-r', headRef, '--', path], repoRoot);
591
+ const line = lsOut.split('\n').find((l) => l.length > 0);
592
+ if (line) {
593
+ const m = line.match(/^[0-9]+\s+blob\s+([0-9a-f]{40})\t/);
594
+ if (m)
595
+ return m[1];
596
+ }
597
+ }
598
+ catch {
599
+ // ls-tree failed → treat as deleted.
600
+ }
601
+ return '';
602
+ };
603
+ for (const path of paths) {
604
+ if (path.includes('\t') || path.includes('\n')) {
605
+ throw new Error(`collectChangedFileEntriesForV5: path must not contain tab or newline characters (got ${JSON.stringify(path)})`);
606
+ }
607
+ // Exclude the attestation envelope itself (chore-commit pattern).
608
+ if (isAttestationEnvelopePath(path))
609
+ continue;
610
+ // Exclude shared-churn files (same set as v3/v4).
611
+ if (isIgnoredForContentHash(path))
612
+ continue;
613
+ entries.push({ path, blobSha: resolveBlobSha(path) });
614
+ }
615
+ return { entries, signedMergeBase: signedMergeBase.toLowerCase() };
616
+ }
198
617
  /**
199
618
  * Compute a sha256 hex digest. Single source of truth for the hashing
200
619
  * algorithm — every predicate field that ends in `Hash` flows through here.
@@ -206,19 +625,458 @@ export function sha256Hex(input) {
206
625
  export function sha1Hex(input) {
207
626
  return createHash('sha1').update(input).digest('hex');
208
627
  }
628
+ /**
629
+ * Compute the rebase-tolerant `contentHash` (AISDLC-94) over a changed-file
630
+ * set. The canonical encoding is one line per entry, sorted ascending by
631
+ * path, with `<path>\t<blobSha>\n` per line. The whole string is sha256-ed.
632
+ *
633
+ * Why this beats `diffHash`:
634
+ * - Rebasing PR-X onto a new `main` that already touched the same files
635
+ * does NOT change the post-apply blob SHAs (assuming no conflict),
636
+ * so `contentHash` stays stable across the rebase.
637
+ * - A conflict resolution that picks different content WILL change the
638
+ * blob SHA → `contentHash` changes → attestation correctly invalidated.
639
+ * - Force-pushing a no-op edit (e.g. `git commit --amend --no-edit`) keeps
640
+ * blob SHAs identical → `contentHash` stays stable.
641
+ *
642
+ * The deduplication step makes the function idempotent if a caller
643
+ * accidentally passes the same path twice (last-write-wins per path).
644
+ *
645
+ * Pure function. The caller (sign-attestation script) is responsible for
646
+ * gathering the file set (via `git diff --name-only` + `git ls-tree`).
647
+ */
648
+ export function computeContentHash(entries) {
649
+ // Dedup by path (last entry wins) so callers passing the same file
650
+ // twice — e.g. an add+modify in two diff invocations — don't produce
651
+ // a different hash than a clean run.
652
+ const byPath = new Map();
653
+ for (const e of entries) {
654
+ if (typeof e?.path !== 'string' || e.path.length === 0) {
655
+ throw new Error(`computeContentHash: entry path must be a non-empty string`);
656
+ }
657
+ if (typeof e.blobSha !== 'string') {
658
+ throw new Error(`computeContentHash: entry blobSha must be a string for path ${e.path}`);
659
+ }
660
+ // Reject path entries containing the canonical-encoding delimiters
661
+ // (\t between path and sha, \n between lines). Without this, a
662
+ // single entry `{ path: 'a\tB1\nb', blobSha: 'B2' }` and the
663
+ // two-entry set `[{ a, B1 }, { b, B2 }]` produce the same canonical
664
+ // string and therefore the same hash — defeating the binding. Git's
665
+ // default config already disallows \n in tracked filenames on most
666
+ // platforms; we defend in depth here so the hash itself is injective
667
+ // regardless of what the caller hands us.
668
+ if (e.path.includes('\t') || e.path.includes('\n')) {
669
+ throw new Error(`computeContentHash: entry path must not contain tab or newline characters (got ${JSON.stringify(e.path)})`);
670
+ }
671
+ // Normalize: forward-slashes (git already emits forward-slashes
672
+ // regardless of platform but be defensive), lowercase blob SHA.
673
+ const normalizedPath = e.path.replace(/\\/g, '/');
674
+ byPath.set(normalizedPath, e.blobSha.toLowerCase());
675
+ }
676
+ const sorted = [...byPath.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
677
+ const canonical = sorted.map(([path, sha]) => `${path}\t${sha}\n`).join('');
678
+ return sha256Hex(canonical);
679
+ }
680
+ /**
681
+ * Collect the changed-file set used to compute `contentHash` (AISDLC-94).
682
+ *
683
+ * Returns one `{ path, blobSha }` entry per file in
684
+ * `git diff --name-only <baseRef>...<headRef>` with the blob SHA from
685
+ * `git ls-tree -r <headRef> -- <path>`. Deleted files get an empty
686
+ * `blobSha` (the path still appears so the canonical encoding distinguishes
687
+ * "deleted" from "kept").
688
+ *
689
+ * `--no-renames` so a rename shows up as add+delete (= two entries) — that
690
+ * way a rebase that resolved a conflict by renaming differently produces a
691
+ * different hash. `-c core.quotepath=false` mirrors the verifier's git
692
+ * helper so unicode paths come back as raw UTF-8.
693
+ *
694
+ * Path entries containing `\t` or `\n` are rejected to keep the canonical
695
+ * encoding injective (mirrors the rejection in `computeContentHash`). Such
696
+ * paths are exceedingly rare in practice — git's default config disallows
697
+ * `\n` in tracked filenames on most platforms — but we defend in depth so
698
+ * malicious or pathological inputs can't smuggle entries past the binding.
699
+ *
700
+ * Extracted from the previously-duplicated helpers in
701
+ * `ai-sdlc-plugin/scripts/sign-attestation.mjs` so a single source of truth
702
+ * applies the same parsing + validation at every signing site.
703
+ */
704
+ export function collectChangedFileEntries(baseRef, headRef, repoRoot, options = {}) {
705
+ const runGit = options.runGit ??
706
+ ((args, cwd) => execFileSync('git', args, {
707
+ cwd,
708
+ env: cleanGitEnv(),
709
+ encoding: 'utf-8',
710
+ maxBuffer: 64 * 1024 * 1024,
711
+ }));
712
+ let nameOnly;
713
+ try {
714
+ nameOnly = runGit([
715
+ '-c',
716
+ 'core.quotepath=false',
717
+ 'diff',
718
+ '--name-only',
719
+ '--no-renames',
720
+ `${baseRef}...${headRef}`,
721
+ ], repoRoot);
722
+ }
723
+ catch (err) {
724
+ const msg = err instanceof Error ? err.message : String(err);
725
+ throw new Error(`collectChangedFileEntries: git diff --name-only failed: ${msg}`);
726
+ }
727
+ const paths = nameOnly.split('\n').filter((p) => p.length > 0);
728
+ const entries = [];
729
+ for (const path of paths) {
730
+ // Reject delimiters here too so the error surfaces at the enumeration
731
+ // site (cleaner than failing later inside computeContentHash).
732
+ if (path.includes('\t') || path.includes('\n')) {
733
+ throw new Error(`collectChangedFileEntries: path must not contain tab or newline characters (got ${JSON.stringify(path)})`);
734
+ }
735
+ // `git ls-tree -r <ref> -- <path>` returns blank when the path doesn't
736
+ // exist at <ref> (= deleted file). Empty blobSha is then used as the
737
+ // marker — see computeContentHash for canonical encoding.
738
+ let blobSha = '';
739
+ try {
740
+ const lsOut = runGit(['-c', 'core.quotepath=false', 'ls-tree', '-r', headRef, '--', path], repoRoot);
741
+ // ls-tree output: `<mode> <type> <sha>\t<path>` (one line per file).
742
+ const line = lsOut.split('\n').find((l) => l.length > 0);
743
+ if (line) {
744
+ const m = line.match(/^[0-9]+\s+blob\s+([0-9a-f]{40})\t/);
745
+ if (m)
746
+ blobSha = m[1];
747
+ }
748
+ }
749
+ catch {
750
+ // ls-tree failed (path missing) → treat as deleted, leave blobSha=''.
751
+ }
752
+ entries.push({ path, blobSha });
753
+ }
754
+ return entries;
755
+ }
756
+ /**
757
+ * Compute the per-file-delta `contentHashV3` (AISDLC-101) over a set of
758
+ * `{path, baseBlobSha, headBlobSha}` triples. The canonical encoding is
759
+ * one line per entry, sorted ascending by path, with
760
+ * `<path>\t<fileDeltaHash>\n` per line, where
761
+ * `fileDeltaHash = sha256(baseBlobSha + ' -> ' + headBlobSha)`. The
762
+ * outer `contentHashV3` is the sha256 of the concatenated lines.
763
+ *
764
+ * Why per-file delta hashing — and what it adds vs. AISDLC-94's `contentHash`:
765
+ * - `contentHash` (AISDLC-94) hashes the post-apply blob SHA per file.
766
+ * If a sibling PR landed between OUR sign + OUR merge AND modified
767
+ * the SAME file, the rebased file's HEAD blob SHA contains both the
768
+ * sibling contribution AND ours → contentHash diverges (false reject).
769
+ * - `contentHashV3` (AISDLC-101) hashes the (base, head) blob-pair
770
+ * transition per file. Provides a stricter "we moved file F from blob
771
+ * A to blob B" binding than just "we ended up at blob B". Any genuine
772
+ * content change still flips the head blob SHA → fileDeltaHash flips
773
+ * → contentHashV3 flips → reject (threat model preserved).
774
+ *
775
+ * This is the SECOND line of defense in the 3-layer rebase-tolerance
776
+ * plan (AISDLC-94 = Phase 1 verifier-side dual-hash, AISDLC-102 = Phase 1.5
777
+ * producer-side pre-sign rebase, AISDLC-101 = Phase 2 per-file delta).
778
+ * The verifier OR's all three legs during the triple-hash window.
779
+ *
780
+ * Path-delimiter rejection (\t / \n) mirrors `computeContentHash` so the
781
+ * canonical encoding stays injective regardless of caller input.
782
+ *
783
+ * Pure function. Idempotent against double-enumeration via dedup-by-path
784
+ * (last-write-wins per path), same as `computeContentHash`.
785
+ */
786
+ export function computeContentHashV3(entries) {
787
+ // Dedup by path (last entry wins) so callers passing the same file
788
+ // twice — e.g. add+modify in two diff invocations — don't produce a
789
+ // different hash than a clean run.
790
+ const byPath = new Map();
791
+ for (const e of entries) {
792
+ if (typeof e?.path !== 'string' || e.path.length === 0) {
793
+ throw new Error(`computeContentHashV3: entry path must be a non-empty string`);
794
+ }
795
+ if (typeof e.baseBlobSha !== 'string') {
796
+ throw new Error(`computeContentHashV3: entry baseBlobSha must be a string for path ${e.path}`);
797
+ }
798
+ if (typeof e.headBlobSha !== 'string') {
799
+ throw new Error(`computeContentHashV3: entry headBlobSha must be a string for path ${e.path}`);
800
+ }
801
+ // Reject path entries containing the canonical-encoding delimiters
802
+ // (\t between path and delta hash, \n between lines). See the same
803
+ // rejection in `computeContentHash` for the injectivity rationale.
804
+ if (e.path.includes('\t') || e.path.includes('\n')) {
805
+ throw new Error(`computeContentHashV3: entry path must not contain tab or newline characters (got ${JSON.stringify(e.path)})`);
806
+ }
807
+ const normalizedPath = e.path.replace(/\\/g, '/');
808
+ byPath.set(normalizedPath, {
809
+ baseBlobSha: e.baseBlobSha.toLowerCase(),
810
+ headBlobSha: e.headBlobSha.toLowerCase(),
811
+ });
812
+ }
813
+ const sorted = [...byPath.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
814
+ const canonical = sorted
815
+ .map(([path, { baseBlobSha, headBlobSha }]) => {
816
+ const fileDeltaHash = sha256Hex(`${baseBlobSha} -> ${headBlobSha}`);
817
+ return `${path}\t${fileDeltaHash}\n`;
818
+ })
819
+ .join('');
820
+ return sha256Hex(canonical);
821
+ }
822
+ /**
823
+ * Compute the BASE-INDEPENDENT per-file head-blob `contentHashV4`
824
+ * (AISDLC-193.1) over a set of `{path, headBlobSha}` pairs. The
825
+ * canonical encoding is `JSON.stringify(sorted-by-path-array-of-{path,
826
+ * headBlobSha}-objects)`, hashed with sha256.
827
+ *
828
+ * Why JSON-of-sorted-array (and not the v3 `<path>\t<fileDeltaHash>\n`
829
+ * canonical) for v4:
830
+ * - JSON's quoting rules already cover delimiter injection
831
+ * (a malicious path containing tab/newline can't smuggle through
832
+ * because they round-trip as escape sequences). We still reject
833
+ * such paths defensively so the canonical stays injective and
834
+ * the on-the-wire representation is what readers expect.
835
+ * - JSON is unambiguous about field ordering (stringify of a
836
+ * `{path, headBlobSha}` literal always emits `path` first,
837
+ * `headBlobSha` second — V8's object-key ordering is insertion
838
+ * order, and we insert in this order in the .map() below).
839
+ * - Easier to extend: future hash versions can add fields
840
+ * (`mode`, `executable bit`, etc) to the entry objects without
841
+ * breaking the canonical encoding scheme.
842
+ *
843
+ * Why this is BASE-INDEPENDENT (= the whole point):
844
+ * - v3's per-file delta hashes the (base_blob, head_blob) pair.
845
+ * When the merge queue rebases the PR onto current main (which
846
+ * advanced past the merge-base the producer signed against), the
847
+ * base blob SHA for any shared file changes → v3 invalidates.
848
+ * - v4 hashes only `{path, headBlobSha}`. Whatever the rebase does
849
+ * to the base ref or the merge-base, as long as the head blob SHA
850
+ * (= the actual reviewed file content) is unchanged, v4 matches.
851
+ * - The reviewer never approved "base_blob X → head_blob Y"; they
852
+ * approved "the file contents at head_blob Y." v4 binds to that
853
+ * directly.
854
+ *
855
+ * Threat model preserved:
856
+ * - Genuine post-sign content tampering (someone amends the PR to
857
+ * add unreviewed code) flips the head blob SHA → v4 hash flips →
858
+ * verifier rejects. Same threat-model surface as v3.
859
+ * - The signing key still has to be a trusted reviewer's; v4
860
+ * doesn't change the signature/key flow, just what the predicate
861
+ * binds to.
862
+ *
863
+ * Pure function. Idempotent against double-enumeration via dedup-by-path
864
+ * (last-write-wins per path), same as `computeContentHash` and
865
+ * `computeContentHashV3`.
866
+ */
867
+ export function computeContentHashV4(entries) {
868
+ // Dedup by path (last entry wins) — see computeContentHash and
869
+ // computeContentHashV3 for the idempotency rationale.
870
+ const byPath = new Map();
871
+ for (const e of entries) {
872
+ if (typeof e?.path !== 'string' || e.path.length === 0) {
873
+ throw new Error(`computeContentHashV4: entry path must be a non-empty string`);
874
+ }
875
+ if (typeof e.headBlobSha !== 'string') {
876
+ throw new Error(`computeContentHashV4: entry headBlobSha must be a string for path ${e.path}`);
877
+ }
878
+ // Reject path entries containing JSON-control characters that we
879
+ // can't unambiguously round-trip. JSON.stringify would happily
880
+ // escape these, but our canonical form is supposed to be readable
881
+ // + reversible — defense in depth keeps the on-the-wire form
882
+ // injective regardless of caller input. Same rejection list as
883
+ // computeContentHash / computeContentHashV3 for consistency.
884
+ if (e.path.includes('\t') || e.path.includes('\n')) {
885
+ throw new Error(`computeContentHashV4: entry path must not contain tab or newline characters (got ${JSON.stringify(e.path)})`);
886
+ }
887
+ const normalizedPath = e.path.replace(/\\/g, '/');
888
+ byPath.set(normalizedPath, e.headBlobSha.toLowerCase());
889
+ }
890
+ const sorted = [...byPath.entries()]
891
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
892
+ .map(([path, headBlobSha]) => ({ path, headBlobSha }));
893
+ return sha256Hex(JSON.stringify(sorted));
894
+ }
895
+ /**
896
+ * Collect the per-file-delta set used to compute `contentHashV3` (AISDLC-101).
897
+ *
898
+ * Returns one `{ path, baseBlobSha, headBlobSha }` entry per file in
899
+ * `git diff --name-only <baseRef>...<headRef>`. The base blob SHA is read
900
+ * from the *merge-base* of `<baseRef>` and `<headRef>` (which the `...`
901
+ * 3-dot diff range already targets — `A...B` diffs against
902
+ * `merge-base(A,B)`); the head blob SHA from `<headRef>`. Files newly
903
+ * added in the PR have empty `baseBlobSha`; deleted files have empty
904
+ * `headBlobSha`.
905
+ *
906
+ * Mirrors `collectChangedFileEntries`'s flag set (`--no-renames`,
907
+ * `core.quotepath=false`) for consistency with the other binding's file
908
+ * enumeration.
909
+ *
910
+ * Extracted so a single source of truth handles the two ls-tree lookups
911
+ * (one per endpoint) at every signing site (`sign-attestation.mjs`).
912
+ */
913
+ export function collectChangedFileDeltaEntries(baseRef, headRef, repoRoot, options = {}) {
914
+ const runGit = options.runGit ??
915
+ ((args, cwd) => execFileSync('git', args, {
916
+ cwd,
917
+ env: cleanGitEnv(),
918
+ encoding: 'utf-8',
919
+ maxBuffer: 64 * 1024 * 1024,
920
+ }));
921
+ // Resolve the merge-base ONCE so each ls-tree below uses a stable
922
+ // commit (`<baseRef>` may be a moving ref like `origin/main`). The
923
+ // `A...B` diff range already targets merge-base(A,B), so reading
924
+ // base blob SHAs at the merge-base keeps the per-file delta
925
+ // semantically aligned with the file enumeration.
926
+ let mergeBase;
927
+ try {
928
+ mergeBase = runGit(['merge-base', baseRef, headRef], repoRoot).trim();
929
+ }
930
+ catch (err) {
931
+ const msg = err instanceof Error ? err.message : String(err);
932
+ throw new Error(`collectChangedFileDeltaEntries: git merge-base failed: ${msg}`);
933
+ }
934
+ if (!/^[0-9a-f]{40}$/.test(mergeBase)) {
935
+ throw new Error(`collectChangedFileDeltaEntries: git merge-base returned non-SHA output: ${JSON.stringify(mergeBase)}`);
936
+ }
937
+ let nameOnly;
938
+ try {
939
+ nameOnly = runGit([
940
+ '-c',
941
+ 'core.quotepath=false',
942
+ 'diff',
943
+ '--name-only',
944
+ '--no-renames',
945
+ `${baseRef}...${headRef}`,
946
+ ], repoRoot);
947
+ }
948
+ catch (err) {
949
+ const msg = err instanceof Error ? err.message : String(err);
950
+ throw new Error(`collectChangedFileDeltaEntries: git diff --name-only failed: ${msg}`);
951
+ }
952
+ const paths = nameOnly.split('\n').filter((p) => p.length > 0);
953
+ const entries = [];
954
+ /**
955
+ * Resolve a file's blob SHA at a given ref via `git ls-tree -r`. Returns
956
+ * the empty string when the path doesn't exist at the ref (= the file
957
+ * was added in the PR for `mergeBase`, or deleted in the PR for `headRef`).
958
+ */
959
+ const resolveBlobSha = (ref, path) => {
960
+ try {
961
+ const lsOut = runGit(['-c', 'core.quotepath=false', 'ls-tree', '-r', ref, '--', path], repoRoot);
962
+ const line = lsOut.split('\n').find((l) => l.length > 0);
963
+ if (line) {
964
+ const m = line.match(/^[0-9]+\s+blob\s+([0-9a-f]{40})\t/);
965
+ if (m)
966
+ return m[1];
967
+ }
968
+ }
969
+ catch {
970
+ // ls-tree failed (path missing at ref) → empty blob marker.
971
+ }
972
+ return '';
973
+ };
974
+ for (const path of paths) {
975
+ if (path.includes('\t') || path.includes('\n')) {
976
+ throw new Error(`collectChangedFileDeltaEntries: path must not contain tab or newline characters (got ${JSON.stringify(path)})`);
977
+ }
978
+ // AISDLC-193.1 envelope self-exclusion: the chore-commit pattern
979
+ // signs the predicate at the dev commit (HEAD before the envelope
980
+ // file exists), then a chore commit on top adds the envelope file
981
+ // at `.ai-sdlc/attestations/<sha>.dsse.json`. If the collector
982
+ // includes the envelope file in the hashed file set, the verifier
983
+ // (which runs against PR HEAD = dev-commit + chore commit) will
984
+ // see an EXTRA entry the signer never saw → contentHashV3 mismatch
985
+ // even on direct PR HEAD without any rebase.
986
+ //
987
+ // Applied to BOTH v3 (this collector) AND v4 (the v4 collector
988
+ // delegates to this one + projects to head-only) so existing v3
989
+ // envelopes that touched .ai-sdlc/attestations/ as part of their
990
+ // diff still work after the dual-write switchover. The verifier's
991
+ // chore-commit allowlist STILL allows the envelope file to appear
992
+ // in the chore-commit diff — the exclusion is for HASHING only.
993
+ if (isAttestationEnvelopePath(path))
994
+ continue;
995
+ // AISDLC-258: shared-churn exclude list. Files like `pnpm-lock.yaml`
996
+ // and `CHANGELOG.md` change in nearly every PR (auto-generated by
997
+ // tooling or release-please). When a merge-queue rebase regenerates
998
+ // them, their blob SHAs shift → v4 mismatches → operator must re-sign
999
+ // despite no hand-written code change. Excluding them here (and on the
1000
+ // verifier side in `computeHeadContentHashV4`) prevents that loop.
1001
+ // Security trade-off accepted by operator 2026-05-10 (see
1002
+ // `CONTENTHASHV4_IGNORE_FILES` for full rationale).
1003
+ if (isIgnoredForContentHash(path))
1004
+ continue;
1005
+ const baseBlobSha = resolveBlobSha(mergeBase, path);
1006
+ const headBlobSha = resolveBlobSha(headRef, path);
1007
+ entries.push({ path, baseBlobSha, headBlobSha });
1008
+ }
1009
+ return entries;
1010
+ }
1011
+ /**
1012
+ * Project a v3 `ChangedFileDeltaEntry` set down to the v4
1013
+ * `ChangedFileHeadEntry` shape (`{path, headBlobSha}`). Convenience
1014
+ * for callers that already collected v3 deltas and want to dual-emit
1015
+ * both hashes from the same file enumeration. Pure function.
1016
+ *
1017
+ * The envelope self-exclusion is enforced upstream by
1018
+ * `collectChangedFileDeltaEntries`, so this projection is a simple
1019
+ * field-pick — no path filtering needed here.
1020
+ */
1021
+ export function projectDeltaEntriesToHeadEntries(deltas) {
1022
+ return deltas.map((d) => ({ path: d.path, headBlobSha: d.headBlobSha }));
1023
+ }
209
1024
  /**
210
1025
  * Build the predicate payload from raw inputs. Pure function — no I/O,
211
1026
  * no signing. The caller (`/ai-sdlc execute` Step 10) reads files and git
212
1027
  * output, then hands them here.
1028
+ *
1029
+ * AISDLC-103 (Verifier Phase 3): always emits a v3 envelope. The caller
1030
+ * MUST provide `changedFileDeltas` (use `[]` for no-op PRs); the legacy
1031
+ * `diff` + `changedFiles` inputs were dropped along with the legacy
1032
+ * `diffHash` + `contentHash` fields.
1033
+ *
1034
+ * AISDLC-362 (contentHashV5): when `v5Entries` + `v5MergeBase` are provided,
1035
+ * also emits `contentHashV5` and `signedMergeBase` in the predicate and bumps
1036
+ * `schemaVersion` to `'v5'`. The verifier prefers v5 when present.
213
1037
  */
214
1038
  export function buildPredicate(inputs) {
215
1039
  if (!/^[0-9a-f]{40}$/i.test(inputs.commitSha)) {
216
1040
  throw new Error(`buildPredicate: commitSha must be a 40-char hex SHA-1, got ${inputs.commitSha}`);
217
1041
  }
218
- return {
219
- schemaVersion: 'v1',
1042
+ if (!Array.isArray(inputs.changedFileDeltas)) {
1043
+ throw new Error(`buildPredicate: changedFileDeltas must be an array (pass [] for no-op PRs)`);
1044
+ }
1045
+ // Per-element shape guard catches producer-side bugs early — without this,
1046
+ // a malformed delta would surface as an opaque contentHashV3 mismatch on the
1047
+ // verifier side (different machine), making debugging much harder.
1048
+ for (let i = 0; i < inputs.changedFileDeltas.length; i++) {
1049
+ const delta = inputs.changedFileDeltas[i];
1050
+ if (!delta || typeof delta !== 'object') {
1051
+ throw new Error(`buildPredicate: changedFileDeltas[${i}] must be an object`);
1052
+ }
1053
+ if (typeof delta.path !== 'string' || delta.path.length === 0) {
1054
+ throw new Error(`buildPredicate: changedFileDeltas[${i}].path must be a non-empty string`);
1055
+ }
1056
+ if (typeof delta.baseBlobSha !== 'string') {
1057
+ throw new Error(`buildPredicate: changedFileDeltas[${i}].baseBlobSha must be a string`);
1058
+ }
1059
+ if (typeof delta.headBlobSha !== 'string') {
1060
+ throw new Error(`buildPredicate: changedFileDeltas[${i}].headBlobSha must be a string`);
1061
+ }
1062
+ }
1063
+ // AISDLC-193.1: derive v4 head-entry set from the v3 delta set so
1064
+ // the file enumeration (and the envelope self-exclusion built into
1065
+ // the v3 collector) is shared between both hashes by construction.
1066
+ // Producers therefore can't accidentally compute v3 over one file
1067
+ // set and v4 over another.
1068
+ const headEntries = projectDeltaEntriesToHeadEntries(inputs.changedFileDeltas);
1069
+ // AISDLC-362: emit v5 when the caller provided v5 collection results.
1070
+ const hasV5 = Array.isArray(inputs.v5Entries) &&
1071
+ typeof inputs.v5MergeBase === 'string' &&
1072
+ /^[0-9a-f]{40}$/i.test(inputs.v5MergeBase);
1073
+ const predicate = {
1074
+ // Bump schemaVersion to 'v5' when v5 data is present. Backward-compat:
1075
+ // the verifier's ACCEPTED_SCHEMA_VERSIONS now includes both 'v3' and 'v5'.
1076
+ schemaVersion: hasV5 ? 'v5' : 'v3',
220
1077
  subject: { digest: { sha1: inputs.commitSha.toLowerCase() } },
221
- diffHash: sha256Hex(inputs.diff),
1078
+ contentHashV3: computeContentHashV3(inputs.changedFileDeltas),
1079
+ contentHashV4: computeContentHashV4(headEntries),
222
1080
  policyHash: sha256Hex(inputs.policy),
223
1081
  reviewers: inputs.reviewers.map((r) => ({
224
1082
  agentId: r.agentId,
@@ -232,6 +1090,28 @@ export function buildPredicate(inputs) {
232
1090
  harnessNote: inputs.harnessNote,
233
1091
  signedAt: inputs.signedAt ?? new Date().toISOString(),
234
1092
  };
1093
+ // AISDLC-362: embed the frozen merge-base and v5 hash when available.
1094
+ if (hasV5) {
1095
+ predicate.signedMergeBase = inputs.v5MergeBase.toLowerCase();
1096
+ predicate.contentHashV5 = computeContentHashV5(inputs.v5Entries, inputs.v5MergeBase);
1097
+ }
1098
+ // AISDLC-100.6: include `pipelineVersion` only when the caller provided
1099
+ // it. Omitted otherwise so envelopes signed in environments without
1100
+ // pipeline-cli installed still round-trip identically through
1101
+ // validatePredicateShape.
1102
+ if (typeof inputs.pipelineVersion === 'string' && inputs.pipelineVersion.length > 0) {
1103
+ predicate.pipelineVersion = inputs.pipelineVersion;
1104
+ }
1105
+ // AISDLC-202.3: include `harness` only when the caller provided it.
1106
+ // Omitted on legacy / Claude Code paths so pre-202.3 envelopes round-trip
1107
+ // cleanly through validatePredicateShape (which treats absence as back-compat).
1108
+ if (inputs.harness && typeof inputs.harness.name === 'string' && inputs.harness.name.length > 0) {
1109
+ predicate.harness = { name: inputs.harness.name };
1110
+ if (typeof inputs.harness.version === 'string' && inputs.harness.version.length > 0) {
1111
+ predicate.harness.version = inputs.harness.version;
1112
+ }
1113
+ }
1114
+ return predicate;
235
1115
  }
236
1116
  /**
237
1117
  * DSSE Pre-Authentication Encoding. Per the spec
@@ -379,8 +1259,51 @@ export function verifyAttestation(opts) {
379
1259
  reason: `subject digest mismatch (envelope was signed for a different commit)`,
380
1260
  };
381
1261
  }
382
- if (predicate.diffHash !== opts.expected.diffHash) {
383
- return { valid: false, reason: 'diffHash mismatch (PR diff changed since attestation)' };
1262
+ // AISDLC-362: v5-prefer, v4-fallback, v3-last-resort.
1263
+ //
1264
+ // Priority order (highest rebase-stability first):
1265
+ // 1. v5 (frozen merge-base delta hash) — prefers when BOTH envelope AND
1266
+ // expected carry contentHashV5. Survives non-overlapping sibling merges.
1267
+ // 2. v4 (base-independent head-blob hash) — prefers when BOTH carry v4
1268
+ // but not v5. Survives queue rebases when files don't overlap.
1269
+ // 3. v3 (base+head blob-pair delta hash) — legacy fallback only.
1270
+ //
1271
+ // When a higher-priority hash matches, lower-priority hashes are NOT
1272
+ // consulted (the merge-base shift that would invalidate them is exactly
1273
+ // what the higher-priority hash was designed to survive).
1274
+ const envelopeHasV5 = typeof predicate.contentHashV5 === 'string';
1275
+ const expectedHasV5 = typeof opts.expected.contentHashV5 === 'string';
1276
+ const envelopeHasV4 = typeof predicate.contentHashV4 === 'string';
1277
+ const expectedHasV4 = typeof opts.expected.contentHashV4 === 'string';
1278
+ if (envelopeHasV5 && expectedHasV5) {
1279
+ if (predicate.contentHashV5 !== opts.expected.contentHashV5) {
1280
+ return {
1281
+ valid: false,
1282
+ reason: 'contentHashV5 mismatch (PR content changed since attestation)',
1283
+ };
1284
+ }
1285
+ // v5 matched → skip v4 and v3 entirely.
1286
+ }
1287
+ else if (envelopeHasV4 && expectedHasV4) {
1288
+ if (predicate.contentHashV4 !== opts.expected.contentHashV4) {
1289
+ return {
1290
+ valid: false,
1291
+ reason: 'contentHashV4 mismatch (PR content changed since attestation)',
1292
+ };
1293
+ }
1294
+ // v4 matched → skip the v3 check entirely. The producer's v3 was
1295
+ // computed against a base ref that may have moved on by now (queue
1296
+ // rebase, sibling overlap); the v4 match is the source of truth.
1297
+ }
1298
+ else {
1299
+ // Legacy v3-only envelope OR caller did not supply expected.contentHashV4/V5
1300
+ // → fall back to v3. Same as pre-AISDLC-193.1 behavior.
1301
+ if (predicate.contentHashV3 !== opts.expected.contentHashV3) {
1302
+ return {
1303
+ valid: false,
1304
+ reason: 'contentHashV3 mismatch (PR content changed since attestation)',
1305
+ };
1306
+ }
384
1307
  }
385
1308
  if (predicate.policyHash !== opts.expected.policyHash) {
386
1309
  return {
@@ -397,20 +1320,44 @@ export function verifyAttestation(opts) {
397
1320
  };
398
1321
  }
399
1322
  }
400
- // ── Reviewer-set completeness ────────────────────────────────
401
- // Every attestation MUST cover all three required reviewers (code,
402
- // test, security). Without this, a contributor could ship an
403
- // attestation containing only `code-reviewer` and bypass the test
404
- // and security review entirely.
405
- const present = new Set(predicate.reviewers.map((r) => r.agentId));
406
- for (const required of REQUIRED_REVIEWER_AGENT_IDS) {
407
- if (!present.has(required)) {
1323
+ // ── Reviewer-set completeness (AISDLC-252) ──────────────────────
1324
+ // Every attestation MUST cover all three required reviewer ROLES (code,
1325
+ // test, security). Each role is satisfied by ANY agentId in its
1326
+ // equivalence group so `code-reviewer-codex` satisfies the `code-reviewer`
1327
+ // role, enabling cross-harness reviews without a redundant Claude review.
1328
+ // Security stays Claude-only (no codex variant, per policy).
1329
+ const presentIds = new Set(predicate.reviewers.map((r) => r.agentId));
1330
+ for (const [role, variants] of Object.entries(REVIEWER_ROLE_EQUIVALENCES)) {
1331
+ const satisfied = variants.some((v) => presentIds.has(v));
1332
+ if (!satisfied) {
408
1333
  return {
409
1334
  valid: false,
410
- reason: `reviewer set incomplete: missing required reviewer '${required}'`,
1335
+ reason: `reviewer set incomplete: missing required reviewer '${role}' (or any variant: ${variants.join(', ')})`,
411
1336
  };
412
1337
  }
413
1338
  }
1339
+ // ── Independence enforcement (AISDLC-252, RFC-0010 §13.10) ──────
1340
+ // When the implementer ran in codex (`predicate.harness.name === 'codex'`),
1341
+ // the code-reviewer and test-reviewer MUST NOT also be codex — that would
1342
+ // defeat the cross-harness independence goal. Security is exempt because it
1343
+ // is always Claude-only.
1344
+ const implementerHarness = predicate.harness?.name?.toLowerCase();
1345
+ if (implementerHarness === 'codex') {
1346
+ for (const role of INDEPENDENCE_REQUIRED_ROLES) {
1347
+ // Find the reviewer entry that satisfied this role.
1348
+ const satisfyingVariants = REVIEWER_ROLE_EQUIVALENCES[role] ?? [];
1349
+ const reviewerEntry = predicate.reviewers.find((r) => satisfyingVariants.includes(r.agentId));
1350
+ if (reviewerEntry) {
1351
+ const reviewerHarness = reviewerEntry.harness?.toLowerCase();
1352
+ if (reviewerHarness === 'codex') {
1353
+ return {
1354
+ valid: false,
1355
+ reason: `independence violation: implementer harness is 'codex' but reviewer '${reviewerEntry.agentId}' also uses codex (requiresIndependentHarnessFrom per RFC-0010 §13.10)`,
1356
+ };
1357
+ }
1358
+ }
1359
+ }
1360
+ }
414
1361
  return { valid: true, predicate, trustedReviewer: matchedReviewer };
415
1362
  }
416
1363
  /**