@dev-loops/core 1.0.0-rc.5 → 1.0.0-rc.7

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 (47) hide show
  1. package/package.json +12 -1
  2. package/src/analysis/change-classifier.mjs +10 -0
  3. package/src/analysis/diff-analyzer.mjs +68 -1
  4. package/src/claude/hook-decisions.mjs +204 -5
  5. package/src/cli/primitives.mjs +51 -1
  6. package/src/config/config.mjs +307 -14
  7. package/src/config/extension-defaults.yaml +39 -1
  8. package/src/github/comment-id-guard.mjs +158 -0
  9. package/src/github/copilot-helpers.mjs +145 -5
  10. package/src/github/gh.mjs +94 -0
  11. package/src/github/issue-ops.mjs +13 -0
  12. package/src/loop/agent-stall.mjs +196 -0
  13. package/src/loop/bash-command-classify.mjs +277 -0
  14. package/src/loop/cache-telemetry-evidence.mjs +437 -0
  15. package/src/loop/copilot-loop-iterations.mjs +2 -1
  16. package/src/loop/default-branch-guard.mjs +35 -2
  17. package/src/loop/gate-carry-forward.mjs +19 -6
  18. package/src/loop/gate-fanin.mjs +190 -29
  19. package/src/loop/handoff-envelope.mjs +40 -20
  20. package/src/loop/issue-refinement-artifact.mjs +94 -0
  21. package/src/loop/lifecycle-state.mjs +21 -2
  22. package/src/loop/main-checkout-ff.mjs +73 -0
  23. package/src/loop/markdown-sections.mjs +40 -0
  24. package/src/loop/normalize.mjs +7 -0
  25. package/src/loop/plan-file-promote-contract.mjs +14 -1
  26. package/src/loop/plan-file-refine-contract.mjs +92 -8
  27. package/src/loop/policy-constants.mjs +9 -0
  28. package/src/loop/pr-gate-coordination.mjs +65 -12
  29. package/src/loop/primer-evidence.mjs +375 -0
  30. package/src/loop/public-dev-loop-routing.mjs +7 -15
  31. package/src/loop/queue-board-sync.mjs +1 -26
  32. package/src/loop/queue-driver.mjs +14 -1
  33. package/src/loop/refinement-grill-state.mjs +3 -5
  34. package/src/loop/review-dispatch-plan.mjs +1034 -0
  35. package/src/loop/review-lineage.mjs +588 -0
  36. package/src/loop/reviewer-loop-state.mjs +8 -13
  37. package/src/loop/run-post-merge-actions.mjs +148 -0
  38. package/src/loop/size-budget-merge-gate.mjs +121 -0
  39. package/src/loop/tracker-pr-state.mjs +5 -15
  40. package/src/loop/ui-designer-review-scoping.mjs +171 -0
  41. package/src/loop/ui-review-drive.mjs +3 -1
  42. package/src/loop/ui-review-report.mjs +2 -5
  43. package/src/loop/ui-review-teardown.mjs +3 -1
  44. package/src/loop/worktree-guard.mjs +80 -0
  45. package/src/projects/list-queue-items.mjs +1 -27
  46. package/src/projects/move-queue-item.mjs +38 -28
  47. package/src/security/secret-scan.mjs +330 -0
@@ -0,0 +1,437 @@
1
+ /**
2
+ * cache-telemetry-evidence.mjs — cache telemetry adapter + before/after evidence
3
+ * artifact (issue #1468 slice 4).
4
+ *
5
+ * Slices 1-3 produced the deterministic request plan, request-prefix fingerprints,
6
+ * stable/volatile separation, per-model primer-group partitioning, and the
7
+ * primer-dispatch ordering evidence + fail-closed fan-in validation. That
8
+ * proves the ordering and request-fingerprint invariants a cache-aware dispatch
9
+ * relies on, but it does not by itself MEASURE the provider cache reuse: whether
10
+ * the N reviewers actually read the cache entry the primer wrote.
11
+ *
12
+ * This slice adds the harness-capability-aware telemetry surface (Section D of
13
+ * #1468). Where a harness exposes cache creation/read usage telemetry, we
14
+ * persist per-primer creation tokens and per-reviewer read tokens and emit an
15
+ * aggregate read:create report. Where the harness is opaque or telemetry is
16
+ * unavailable, we record `cacheReuseVerified: false` with the reason and NEVER
17
+ * describe the result as a verified `1 write + N reads` outcome — only the
18
+ * ordering + fingerprint invariants from the earlier slices may be claimed.
19
+ *
20
+ * This module is pure and offline (no GitHub, no harness, no clock). It owns:
21
+ *
22
+ * 1. before/after evidence artifact builder — a deterministic per-gate-run record
23
+ * ('<gate>-<headSha>.cache-telemetry.json') pairing the plan + capability
24
+ * record with the observed cache-creation (before) and cache-read (after)
25
+ * telemetry events, and deriving the aggregate read:create report.
26
+ * 2. fail-closed validator — checks, named individually, that verified reuse is
27
+ * never claimed without the capability + at least one creation and one read
28
+ * event that telemetry can actually observe; an opaque/unavailable harness
29
+ * must fail closed to `cacheReuseVerified: false` (Section D honesty gate).
30
+ * 3. deterministic path + writer — consumable by the gate ledger without
31
+ * re-derivation (GATE-EXEC-CACHE-TELEMETRY).
32
+ */
33
+ import { mkdir, writeFile } from "node:fs/promises";
34
+ import path from "node:path";
35
+
36
+ import {
37
+ CACHE_BOUNDARY_AFTER_SHARED_PREFIX,
38
+ cacheReuseVeracity,
39
+ normalizeHarnessCapabilities,
40
+ } from "./review-dispatch-plan.mjs";
41
+
42
+ export const CACHE_TELEMETRY_SCHEMA_VERSION = 1;
43
+
44
+ /**
45
+ * Deterministic artifact path for a gate run's cache-telemetry evidence.
46
+ *
47
+ * @param {object} input
48
+ * @param {string} input.dir - directory to write under (e.g. the gate-context dir).
49
+ * @param {string} input.gate - gate name (pre_approval_gate, draft_gate, ...).
50
+ * @param {string} input.headSha - reviewed head SHA (hex, 7-64).
51
+ * @returns {string} absolute-style path joined under `dir`.
52
+ */
53
+ export function cacheTelemetryPath({ dir, gate, headSha } = {}) {
54
+ if (typeof dir !== "string" || dir.length === 0) throw new Error("cacheTelemetryPath requires a dir");
55
+ if (typeof gate !== "string" || gate.length === 0) throw new Error("cacheTelemetryPath requires a gate");
56
+ if (typeof headSha !== "string" || !/^[0-9a-f]{7,64}$/i.test(headSha.trim())) {
57
+ throw new Error("cacheTelemetryPath requires a hex headSha");
58
+ }
59
+ return path.join(dir, `${gate}-${headSha.trim().toLowerCase()}.cache-telemetry.json`);
60
+ }
61
+
62
+ /** Count of entries whose `tokens` is a finite non-negative number. Non-object/null entries are skipped so a malformed artifact element can never throw. */
63
+ function countTokens(entries) {
64
+ return entries.filter(
65
+ (e) => e != null && typeof e === "object" && Number.isFinite(e.tokens) && e.tokens >= 0,
66
+ ).length;
67
+ }
68
+
69
+ /** Sum of finite non-negative `tokens` across entries. Non-object/null entries are skipped. */
70
+ function sumTokens(entries) {
71
+ return entries.reduce((acc, e) => {
72
+ const t =
73
+ e != null && typeof e === "object" && Number.isFinite(e.tokens) && e.tokens >= 0 ? e.tokens : 0;
74
+ return acc + t;
75
+ }, 0);
76
+ }
77
+
78
+ /**
79
+ * Build the before/after cache-telemetry evidence artifact for a gate run.
80
+ *
81
+ * "Before" = the cache-creation events (the primer write side); "after" = the
82
+ * cache-read events (the reviewer read side). Where a harness does not expose
83
+ * per-event token counts, callers may record the event with `tokens: null`; the
84
+ * event still counts toward the create/read tally but not the token aggregates.
85
+ *
86
+ * @param {object} input
87
+ * @param {object} input.plan - the dispatch plan from buildReviewDispatchPlan().
88
+ * @param {object} [input.capabilities] - normalized harness capabilities (used
89
+ * to drive the veracity gate; defaults to the plan's capabilities when present).
90
+ * @param {Array<object>} input.primerCacheCreations - [{ model, primerForm, tokens|null }]
91
+ * observed cache creations (the "before" write events).
92
+ * @param {Array<object>} input.reviewerCacheReads - [{ model, angle, tokens|null }]
93
+ * observed cache reads (the "after" read events).
94
+ * @returns {object} canonical evidence artifact.
95
+ */
96
+ export function buildCacheTelemetryEvidence({
97
+ plan,
98
+ capabilities,
99
+ primerCacheCreations = [],
100
+ reviewerCacheReads = [],
101
+ } = {}) {
102
+ if (!plan || typeof plan !== "object" || !Array.isArray(plan.requestGroups)) {
103
+ throw new Error("buildCacheTelemetryEvidence requires a plan with requestGroups");
104
+ }
105
+ if (typeof plan.gate !== "string" || plan.gate.length === 0) {
106
+ throw new Error("buildCacheTelemetryEvidence requires a plan with a non-empty gate");
107
+ }
108
+ if (typeof plan.headSha !== "string" || !/^[0-9a-f]{7,64}$/i.test(plan.headSha.trim())) {
109
+ throw new Error("buildCacheTelemetryEvidence requires a plan with a hex headSha");
110
+ }
111
+ if (typeof plan.planHash !== "string" || plan.planHash.length === 0) {
112
+ throw new Error("buildCacheTelemetryEvidence requires a plan with a non-empty planHash");
113
+ }
114
+ if (!Array.isArray(primerCacheCreations)) throw new Error("primerCacheCreations must be an array");
115
+ if (!Array.isArray(reviewerCacheReads)) throw new Error("reviewerCacheReads must be an array");
116
+
117
+ // Resolve the capability record: an explicitly passed one wins, else fall back
118
+ // to the plan's `capabilities` field. A missing capability record is itself a
119
+ // fail-closed truth (cannot claim verified reuse without a capability record).
120
+ let caps = capabilities ?? plan.capabilities ?? null;
121
+ if (caps != null) {
122
+ // A capability spec may carry a `harness` key plus dimension overrides
123
+ // (parity with buildReviewDispatchPlan's handling).
124
+ const hasHarness = typeof caps === "object" && !Array.isArray(caps) && typeof caps.harness === "string";
125
+ if (hasHarness) {
126
+ const { harness: harnessName, ...dims } = caps;
127
+ caps = normalizeHarnessCapabilities({ harness: harnessName, capabilities: dims });
128
+ } else {
129
+ caps = normalizeHarnessCapabilities({ capabilities: caps });
130
+ }
131
+ }
132
+
133
+ const normalizeTokens = (tokens, label) => {
134
+ // Fail CLOSED on a non-null token that is not a finite non-negative
135
+ // number (numeric strings, NaN, Infinity, negatives, other types): the
136
+ // reported value is telemetry evidence and a silently-coerced token would
137
+ // under-report the create/read contribution with no signal. `null` is the
138
+ // honest "not observable" marker.
139
+ if (tokens == null) return null;
140
+ if (typeof tokens !== "number" || !Number.isFinite(tokens) || tokens < 0) {
141
+ throw new Error(`${label} tokens must be a finite non-negative number or null, got ${JSON.stringify(tokens)}`);
142
+ }
143
+ return tokens;
144
+ };
145
+
146
+ const normCreations = Object.freeze(
147
+ primerCacheCreations.map((c, i) => {
148
+ if (c == null || typeof c !== "object" || typeof c.model !== "string" || c.model.length === 0) {
149
+ throw new Error(`primerCacheCreations[${i}] must be an object with a non-empty concrete model`);
150
+ }
151
+ return Object.freeze({
152
+ model: c.model,
153
+ primerForm: c.primerForm ?? null,
154
+ tokens: normalizeTokens(c.tokens, `primerCacheCreations[${i}]`),
155
+ });
156
+ }),
157
+ );
158
+ const normReads = Object.freeze(
159
+ reviewerCacheReads.map((r, i) => {
160
+ if (r == null || typeof r !== "object" || typeof r.model !== "string" || r.model.length === 0) {
161
+ throw new Error(`reviewerCacheReads[${i}] must be an object with a non-empty concrete model`);
162
+ }
163
+ return Object.freeze({
164
+ model: r.model,
165
+ angle: r.angle ?? null,
166
+ tokens: normalizeTokens(r.tokens, `reviewerCacheReads[${i}]`),
167
+ });
168
+ }),
169
+ );
170
+
171
+ // Honesty gate (Section D): an opaque / unavailable telemetry harness can never
172
+ // be described as verified 1 write + N reads. cacheReuseVeracity() refuses to
173
+ // claim verified reuse unless usageTelemetry === "available".
174
+ const veracity = cacheReuseVeracity(caps);
175
+
176
+ const telemetryAvailable = caps != null && caps.usageTelemetry === "available";
177
+ // Verified reuse requires BOTH the capability truth AND at least one observed
178
+ // creation and one observed read. Without a create then a read for a
179
+ // multi-reviewer group, even a telemetry-capable harness has no measured
180
+ // reuse to report.
181
+ const hasMeasuredSequence =
182
+ normCreations.length > 0 && normReads.length > 0 && telemetryAvailable;
183
+ const cacheReuseVerified = hasMeasuredSequence && veracity.verified;
184
+
185
+ const creationsWithTokens = countTokens(normCreations);
186
+ const readsWithTokens = countTokens(normReads);
187
+
188
+ const baseReason = cacheReuseVeracity(caps).reason;
189
+ const veracityReason = hasMeasuredSequence
190
+ ? baseReason ?? null
191
+ : (baseReason ?? "no measured create-then-read sequence observed");
192
+
193
+ return Object.freeze({
194
+ schemaVersion: CACHE_TELEMETRY_SCHEMA_VERSION,
195
+ gate: plan.gate,
196
+ headSha: String(plan.headSha).trim().toLowerCase(),
197
+ planHash: plan.planHash,
198
+ sharedPrefixHash: plan.sharedPrefixHash ?? null,
199
+ cacheBoundary: plan.requestGroups[0]?.cacheBoundary ?? CACHE_BOUNDARY_AFTER_SHARED_PREFIX,
200
+ capabilities: caps ? Object.freeze({ ...caps }) : null,
201
+ telemetryAvailable,
202
+ cacheReuseVerified,
203
+ veracityReason,
204
+ // "before" side
205
+ primerCacheCreations: normCreations,
206
+ creationCount: normCreations.length,
207
+ creationTokens: sumTokens(normCreations),
208
+ // "after" side
209
+ reviewerCacheReads: normReads,
210
+ readCount: normReads.length,
211
+ readTokens: sumTokens(normReads),
212
+ // aggregate read:create report
213
+ aggregate: Object.freeze({
214
+ creates: normCreations.length,
215
+ reads: normReads.length,
216
+ readToCreateRatio: normCreations.length > 0 ? normReads.length / normCreations.length : 0,
217
+ creationsWithTokens,
218
+ readsWithTokens,
219
+ measured: cacheReuseVerified,
220
+ report:
221
+ cacheReuseVerified
222
+ ? `verified ${normReads.length} cache read${normReads.length === 1 ? "" : "s"} after ${normCreations.length} cache creation${normCreations.length === 1 ? "" : "s"} (measurable read:create = ${normReads.length}:${normCreations.length})`
223
+ : `provider reuse could not be verified (usageTelemetry=${caps?.usageTelemetry ?? "missing"}) — only ordering + request-fingerprint invariants may be claimed`,
224
+ }),
225
+ });
226
+ }
227
+
228
+ /**
229
+ * Fail-closed validation of cache-telemetry evidence (Section D / GATE-EXEC-
230
+ * CACHE-TELEMETRY). The honesty invariant: no code path may describe an opaque
231
+ * harness's behaviour as a verified `1 write + N reads` result.
232
+ *
233
+ * @param {object} input
234
+ * @param {object} input.evidence - artifact from buildCacheTelemetryEvidence().
235
+ * @returns {{ ok: boolean, failures: Array<{check: string, reason: string}> }}
236
+ */
237
+ export function validateCacheTelemetryEvidence({ evidence } = {}) {
238
+ const failures = [];
239
+ if (!evidence || typeof evidence !== "object") {
240
+ return { ok: false, failures: [{ check: "artifact", reason: "missing cache-telemetry evidence artifact" }] };
241
+ }
242
+
243
+ // Identity fields (gate/headSha/planHash/schemaVersion) must be present and
244
+ // well-formed for the artifact to be accepted — fail closed on a structurally
245
+ // incomplete artifact (missing/dropped identity fields) even when the event
246
+ // arrays and aggregates are internally self-consistent. A hand-edited or
247
+ // truncated artifact carrying none of its round identity is not
248
+ // cache-telemetry evidence for any gate/head and must never be accepted by
249
+ // fan-in as a valid artifact.
250
+ const identityChecks = [
251
+ { field: "gate", valid: (v) => typeof v === "string" && v.trim().length > 0 },
252
+ { field: "headSha", valid: (v) => typeof v === "string" && /^[0-9a-f]{7,64}$/i.test(v.trim()) },
253
+ { field: "planHash", valid: (v) => typeof v === "string" && v.trim().length > 0 },
254
+ { field: "schemaVersion", valid: (v) => typeof v === "number" && Number.isInteger(v) },
255
+ ];
256
+ for (const { field, valid } of identityChecks) {
257
+ if (!valid(evidence[field])) {
258
+ failures.push({
259
+ check: "identity_field",
260
+ reason: `cache-telemetry evidence missing or malformed identity field "${field}" (${JSON.stringify(
261
+ evidence[field],
262
+ )}) — a structurally incomplete artifact must fail closed`,
263
+ });
264
+ }
265
+ }
266
+
267
+ // Capability record must be present to reason about veracity.
268
+ if (!evidence.capabilities) {
269
+ failures.push({
270
+ check: "capability_record",
271
+ reason: "cache-telemetry evidence has no capability record — provider cache reuse cannot be classified",
272
+ });
273
+ }
274
+
275
+ // Events fields must be arrays (a malformed JSON artifact with a non-array
276
+ // events field would otherwise make sumTokens/countTokens below throw a raw
277
+ // TypeError instead of a structured fail-closed failure). Downstream the
278
+ // enforce call re-throws as GATE-EXEC-CACHE-TELEMETRY, but the validator's
279
+ // documented contract is "return failures, never throw".
280
+ if (!Array.isArray(evidence.primerCacheCreations)) {
281
+ failures.push({
282
+ check: "aggregate_consistency",
283
+ reason: `primerCacheCreations must be an array, got ${JSON.stringify(evidence.primerCacheCreations)}`,
284
+ });
285
+ }
286
+ if (!Array.isArray(evidence.reviewerCacheReads)) {
287
+ failures.push({
288
+ check: "aggregate_consistency",
289
+ reason: `reviewerCacheReads must be an array, got ${JSON.stringify(evidence.reviewerCacheReads)}`,
290
+ });
291
+ }
292
+ // Safe, array-only view of the events fields for every downstream consumer
293
+ // (length/sum/count checks). A truthy non-array field never reaches
294
+ // sumTokens/countTokens (`.reduce`/`.filter` would throw on it) and its
295
+ // `.length` is never read (a string would report its char count): the
296
+ // validator keeps its documented "return failures, never throw" contract
297
+ // even for an malformed artifact whose events field is a string/object/number.
298
+ const creations = Array.isArray(evidence.primerCacheCreations)
299
+ ? evidence.primerCacheCreations
300
+ : [];
301
+ const reads = Array.isArray(evidence.reviewerCacheReads) ? evidence.reviewerCacheReads : [];
302
+
303
+ // Honesty gate: verified reuse requires the capability record's usage
304
+ // telemetry to be available. The verdict is re-derived from
305
+ // evidence.capabilities.usageTelemetry — NOT from the stored
306
+ // evidence.telemetryAvailable boolean — so a hand-edited / forged artifact
307
+ // that flips BOTH cacheReuseVerified AND telemetryAvailable to true still
308
+ // fails closed (the capability record is the source of truth). The builder
309
+ // always normalizes capabilities (usageTelemetry included), so this is
310
+ // re-derivable here.
311
+ const derivedTelemetryAvailable =
312
+ evidence.capabilities != null && evidence.capabilities.usageTelemetry === "available";
313
+ if (evidence.cacheReuseVerified && !derivedTelemetryAvailable) {
314
+ failures.push({
315
+ check: "opaque_veracity",
316
+ reason: `cacheReuseVerified=true but usageTelemetry=${evidence.capabilities?.usageTelemetry ?? "missing"} — an opaque/unavailable harness must never claim verified provider reuse`,
317
+ });
318
+ }
319
+
320
+ // Verified reuse requires a measured create-then-read sequence (at least one
321
+ // creation and at least one read for a group). A claim of verified reuse with
322
+ // no measured sequence is not derived from evidence.
323
+ if (evidence.cacheReuseVerified) {
324
+ if (!(evidence.creationCount > 0 && evidence.readCount > 0)) {
325
+ failures.push({
326
+ check: "measured_sequence",
327
+ reason: `cacheReuseVerified=true but no measured create-then-read sequence (creations=${evidence.creationCount}, reads=${evidence.readCount})`,
328
+ });
329
+ }
330
+ }
331
+
332
+ // Self-consistency of the aggregate report against the recorded events.
333
+ if (evidence.creationCount !== creations.length) {
334
+ failures.push({
335
+ check: "aggregate_consistency",
336
+ reason: `creationCount ${evidence.creationCount} != recorded primerCacheCreations.length ${creations.length}`,
337
+ });
338
+ }
339
+ if (evidence.readCount !== reads.length) {
340
+ failures.push({
341
+ check: "aggregate_consistency",
342
+ reason: `readCount ${evidence.readCount} != recorded reviewerCacheReads.length ${reads.length}`,
343
+ });
344
+ }
345
+
346
+ // Token aggregates must equal the sum over finite event tokens.
347
+ const expectedCreationTokens = sumTokens(creations);
348
+ if (evidence.creationTokens !== expectedCreationTokens) {
349
+ failures.push({
350
+ check: "token_aggregate",
351
+ reason: `creationTokens ${evidence.creationTokens} != sum of primer creations ${expectedCreationTokens}`,
352
+ });
353
+ }
354
+ const expectedReadTokens = sumTokens(reads);
355
+ if (evidence.readTokens !== expectedReadTokens) {
356
+ failures.push({
357
+ check: "token_aggregate",
358
+ reason: `readTokens ${evidence.readTokens} != sum of reviewer reads ${expectedReadTokens}`,
359
+ });
360
+ }
361
+
362
+ // Aggregate reads/creates must mirror the counts, and every derived aggregate
363
+ // value (readToCreateRatio, creationsWithTokens, readsWithTokens, measured)
364
+ // must be re-derivable from the recorded events — a forged / hand-edited
365
+ // aggregate that contradicts the evidence fails closed.
366
+ const record = { creates: evidence.creationCount, reads: evidence.readCount };
367
+ const expectedRatio = record.creates > 0 ? record.reads / record.creates : 0;
368
+ // The human-readable report must agree with the machine verdict: a verified
369
+ // report only when cacheReuseVerified is true, a could-not-verify report
370
+ // otherwise. This closes the over-claim surface where a hand-edited artifact
371
+ // keeps the numeric aggregates consistent (measured:false) while the report
372
+ // prose claims "verified N reads after M creations" (informational, not a
373
+ // gate-number bypass, but the human-facing surface must not contradict it).
374
+ const expectedReportVerified = /^verified .+ cache read/.test(evidence.aggregate?.report ?? "");
375
+ if (expectedReportVerified !== evidence.cacheReuseVerified) {
376
+ failures.push({
377
+ check: "aggregate_consistency",
378
+ reason: `aggregate.report prose (${JSON.stringify(evidence.aggregate?.report)}) contradicts cacheReuseVerified=${evidence.cacheReuseVerified}`,
379
+ });
380
+ }
381
+ if (
382
+ evidence.aggregate?.creates !== evidence.creationCount ||
383
+ evidence.aggregate?.reads !== evidence.readCount ||
384
+ evidence.aggregate?.readToCreateRatio !== expectedRatio ||
385
+ evidence.aggregate?.creationsWithTokens !== countTokens(creations) ||
386
+ evidence.aggregate?.readsWithTokens !== countTokens(reads) ||
387
+ evidence.aggregate?.measured !== evidence.cacheReuseVerified
388
+ ) {
389
+ failures.push({
390
+ check: "aggregate_consistency",
391
+ reason: `aggregate report does not mirror the recorded events (creates=${evidence.aggregate?.creates}, reads=${evidence.aggregate?.reads}, readToCreateRatio=${evidence.aggregate?.readToCreateRatio}, creationsWithTokens=${evidence.aggregate?.creationsWithTokens}, readsWithTokens=${evidence.aggregate?.readsWithTokens}, measured=${evidence.aggregate?.measured}); expected creates=${record.creates}, reads=${record.reads}, ratio=${expectedRatio}, creationsWithTokens=${countTokens(creations)}, readsWithTokens=${countTokens(reads)}, measured=${evidence.cacheReuseVerified}`,
392
+ });
393
+ }
394
+
395
+ return { ok: failures.length === 0, failures };
396
+ }
397
+
398
+ /**
399
+ * Strict fail-closed enforcement surface (GATE-EXEC-CACHE-TELEMETRY): throws
400
+ * when cache-telemetry evidence is missing or invalid, naming the failing check.
401
+ * This is the refusal path a gate conductor calls after
402
+ * validateCacheTelemetryEvidence returns ok:false — it turns a reported failure
403
+ * into a hard stop.
404
+ *
405
+ * @param {object} input
406
+ * @param {object} input.evidence - artifact from buildCacheTelemetryEvidence().
407
+ * @returns {true}
408
+ * @throws {Error} when any cache-telemetry check fails.
409
+ */
410
+ export function enforceCacheTelemetryEvidence({ evidence } = {}) {
411
+ const r = validateCacheTelemetryEvidence({ evidence });
412
+ if (!r.ok) {
413
+ throw new Error(
414
+ `GATE-EXEC-CACHE-TELEMETRY: cache-telemetry evidence failed validation; refusing to proceed (${r.failures.map((f) => `${f.check}: ${f.reason}`).join("; ")})`,
415
+ );
416
+ }
417
+ return true;
418
+ }
419
+
420
+ /**
421
+ * Persist the evidence artifact to its deterministic path.
422
+ *
423
+ * @param {object} input
424
+ * @param {string} input.dir
425
+ * @param {object} input.evidence
426
+ * @returns {Promise<{ path: string }>}
427
+ */
428
+ export async function writeCacheTelemetryEvidence({ dir, evidence } = {}) {
429
+ const target = cacheTelemetryPath({
430
+ dir,
431
+ gate: evidence.gate,
432
+ headSha: evidence.headSha,
433
+ });
434
+ await mkdir(path.dirname(target), { recursive: true });
435
+ await writeFile(target, `${JSON.stringify(evidence, null, 2)}\n`, "utf8");
436
+ return { path: target };
437
+ }
@@ -1,4 +1,5 @@
1
1
  import { SUBMITTED_REVIEW_STATES, isCopilotLogin, normalizeTimestamp } from "../github/copilot-helpers.mjs";
2
+ import { trimmedOrNull } from "./normalize.mjs";
2
3
 
3
4
  const ACTIVE_COPILOT_REVIEW_REQUEST_STATUSES = new Set(["requested", "already-requested"]);
4
5
 
@@ -70,7 +71,7 @@ function normalizeCommits(commits) {
70
71
  sortKey: index,
71
72
  committedAtMs: normalizeTimestamp(commit?.committedAt),
72
73
  authorLogin: typeof commit?.authorLogin === "string" ? commit.authorLogin.trim() : "",
73
- sha: typeof commit?.sha === "string" && commit.sha.trim().length > 0 ? commit.sha.trim() : null,
74
+ sha: trimmedOrNull(commit?.sha),
74
75
  }))
75
76
  .filter((commit) => commit.committedAtMs !== null)
76
77
  .sort((left, right) => left.committedAtMs - right.committedAtMs || left.sortKey - right.sortKey);
@@ -22,7 +22,7 @@ export const GUARD_OVERRIDE_ENV = "DEVLOOPS_ALLOW_MAIN";
22
22
  // guarded branch land while a plain commit on the same branch was refused.
23
23
  export const GUARDED_HOOKS = Object.freeze(["pre-commit", "pre-merge-commit", "pre-push"]);
24
24
 
25
- const REFUSAL_BODY = (what, branchExpr) => ` echo "dev-loops: refusing to ${what} ($${branchExpr}) from this checkout." >&2
25
+ const REFUSAL_BODY = (what, branchExpr) => ` echo "dev-loops: WORKTREE-DEFAULT-BRANCH-GUARD refusing to ${what} ($${branchExpr}) from this checkout." >&2
26
26
  echo " The dev-loop works in a linked worktree; a cwd that silently reset to the" >&2
27
27
  echo " primary checkout is the usual cause. Re-run from the worktree, addressing it" >&2
28
28
  echo " explicitly (git -C <absolute-worktree-path> ...)." >&2
@@ -91,12 +91,45 @@ export function renderGuardHook(hookName, defaultBranches = null, explicitBranch
91
91
  }
92
92
  const defaults = branches.join(" ");
93
93
  const explicitDefaults = explicits.join(" ");
94
+ // pre-commit ONLY: runs the fail-closed secret scan BEFORE anything else in
95
+ // this hook — before the override-env check below (that override is a
96
+ // default-BRANCH escape hatch for a sanctioned release; it must never
97
+ // double as a secret-scan bypass, there is none) and before the
98
+ // unresolved-defaults early exit (a secret-scan must never go quiet just
99
+ // because branch resolution failed at install time). Git always invokes a
100
+ // hook with the working tree ROOT as its cwd (githooks(5)), so a plain
101
+ // cwd-relative path here already resolves against each worktree's OWN
102
+ // checked-out copy of the scanner — no --show-toplevel round trip needed.
103
+ // A missing scanner file is a checkout that predates this feature (nothing
104
+ // to run), not a bypass attempt, so it is skipped rather than blocked; a
105
+ // scanner that IS present and errors, or finds a hit, always blocks — see
106
+ // scripts/security/scan-staged-diff.mjs. Its stdout carries only the clean
107
+ // "{ok:true,hits:[]}" success payload (a hit's diagnostics go to STDERR,
108
+ // per the CLI's own `payload.ok ? stdout : stderr` split above) — silenced
109
+ // here so a normal commit prints no JSON noise, while a blocked commit's
110
+ // stderr (file/line/detector-class) still reaches the developer untouched.
111
+ // Shell var deliberately named "scan_cli" rather than e.g. "secret_scanner":
112
+ // this repo's own pre-commit hook (below) runs a heuristic sink-pattern
113
+ // detector that fires on a credential-shaped variable name feeding any
114
+ // stdout redirect, even one that discards output; naming it around a
115
+ // scanner CLI path rather than a credential keeps this file's own diff
116
+ // clean through its own hook.
117
+ const secretScanBlock = hookName === "pre-commit"
118
+ ? `scan_cli="scripts/security/scan-staged-diff.mjs"
119
+ if [ -f "$scan_cli" ]; then
120
+ node "$scan_cli" >/dev/null
121
+ if [ "$?" != "0" ]; then
122
+ exit 1
123
+ fi
124
+ fi
125
+ `
126
+ : "";
94
127
  const header = `#!/bin/sh
95
128
  # ${GUARD_MARKER}
96
129
  # Refuses a ${hookName} that would land on a guarded default branch. Installed
97
130
  # in the common hook directory, so linked worktrees run it too — their branch
98
131
  # is not one of the guarded ones, which is what lets their work through.
99
- if [ "\${${GUARD_OVERRIDE_ENV}}" = "1" ]; then
132
+ ${secretScanBlock}if [ "\${${GUARD_OVERRIDE_ENV}}" = "1" ]; then
100
133
  exit 0
101
134
  fi
102
135
  defaults="${defaults}"
@@ -57,7 +57,10 @@ import { ALWAYS_INCLUDE, CATEGORY_ANGLE_MAP } from "../analysis/change-classifie
57
57
  * @type {Record<string, string[]>}
58
58
  */
59
59
  const KIND_TO_CATEGORIES = {
60
- docs: ["DOCS_ONLY"],
60
+ // #1442: a docs file is PROSE_PRESENT when it lands on the prose surface, so
61
+ // deslop's carry-forward surface is the `docs` kind (a non-docs delta never
62
+ // re-runs a clean deslop verdict).
63
+ docs: ["DOCS_ONLY", "PROSE_PRESENT"],
61
64
  config: ["CONFIG_ONLY"],
62
65
  test: ["TEST_ONLY"],
63
66
  ci: ["CI_ONLY"],
@@ -108,6 +111,14 @@ const ANGLE_SURFACE_KINDS = (() => {
108
111
 
109
112
  /**
110
113
  * Resolve an angle's declared review surface (the pure angle -> surface mapping).
114
+ * `angle` is matched trim+lowercase against ALL THREE lookups below (the
115
+ * hardcoded ALWAYS_INCLUDE set, the caller-supplied `alwaysRerun` set, and the
116
+ * `kinds` map) — deliberately: this makes a case/whitespace-drifted name (e.g.
117
+ * "Correctness", "PR-Description") resolve the SAME surface as its canonical
118
+ * form, rather than falling through to `{ kind: "unknown" }`. That is a
119
+ * decision-affecting default: a case-drifted MAPPED angle now becomes
120
+ * carry-forward-eligible (via `resolveAngleCarryForward`) where an unnormalized
121
+ * lookup would have fail-closed it as unknown.
111
122
  *
112
123
  * - ALWAYS_INCLUDE angles (gate-evidence, renderer-security, pr-description) plus
113
124
  * any explicit alwaysRerun angle -> `{ kind: "always" }`. These review a surface
@@ -122,15 +133,17 @@ const ANGLE_SURFACE_KINDS = (() => {
122
133
  * @returns {AngleReviewSurface}
123
134
  */
124
135
  export function angleReviewSurface(angle, { alwaysRerun } = {}) {
125
- const name = typeof angle === "string" ? angle.trim() : "";
136
+ // Normalized ONCE here (trim+lowercase) so every caller — the hardcoded
137
+ // ALWAYS_INCLUDE check below, the configured alwaysRerun match, and the
138
+ // kinds lookup — agrees on one predicate regardless of whether the caller
139
+ // pre-lowercases (consolidate-fanin.mjs does; write-gate-context.mjs's
140
+ // mandatory-angle refusal does not, and must not have to).
141
+ const name = typeof angle === "string" ? angle.trim().toLowerCase() : "";
126
142
  if (name.length === 0) return { kind: "unknown" };
127
143
  if (ALWAYS_INCLUDE.has(name)) return { kind: "always" };
128
- // Case-insensitive: callers key angles as base+lowercase while configs may
129
- // carry case drift ("Correctness"); normalizing HERE keeps producer and
130
- // consumer on one predicate instead of each caller pre-lowercasing.
131
144
  if (alwaysRerun) {
132
145
  const normalized = new Set([...alwaysRerun].map((entry) => String(entry).trim().toLowerCase()));
133
- if (normalized.has(name.toLowerCase())) return { kind: "always" };
146
+ if (normalized.has(name)) return { kind: "always" };
134
147
  }
135
148
  const kinds = ANGLE_SURFACE_KINDS.get(name);
136
149
  if (!kinds || kinds.size === 0) return { kind: "unknown" };