@dev-loops/core 1.0.0-rc.5 → 1.0.0-rc.6
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.
- package/package.json +6 -1
- package/src/claude/hook-decisions.mjs +172 -5
- package/src/cli/primitives.mjs +21 -0
- package/src/config/config.mjs +53 -1
- package/src/config/extension-defaults.yaml +5 -0
- package/src/github/comment-id-guard.mjs +70 -0
- package/src/github/copilot-helpers.mjs +31 -0
- package/src/github/issue-ops.mjs +6 -0
- package/src/loop/agent-stall.mjs +194 -0
- package/src/loop/bash-command-classify.mjs +277 -0
- package/src/loop/cache-telemetry-evidence.mjs +437 -0
- package/src/loop/default-branch-guard.mjs +1 -1
- package/src/loop/handoff-envelope.mjs +28 -1
- package/src/loop/issue-refinement-artifact.mjs +94 -0
- package/src/loop/main-checkout-ff.mjs +39 -0
- package/src/loop/primer-evidence.mjs +375 -0
- package/src/loop/review-dispatch-plan.mjs +595 -0
- package/src/loop/review-lineage.mjs +588 -0
- package/src/loop/worktree-guard.mjs +80 -0
- package/src/projects/move-queue-item.mjs +37 -1
|
@@ -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
|
+
}
|
|
@@ -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
|
|
@@ -130,6 +130,21 @@ register(INTERNAL_DEV_LOOP_STRATEGY.LOCAL_IMPLEMENTATION, "default", {
|
|
|
130
130
|
activeNoticeAfterMs: DEFAULT_ACTIVE_NOTICE_MS,
|
|
131
131
|
});
|
|
132
132
|
|
|
133
|
+
// local_implementation · spike run (SPIKE-RELAXED-GATE-PROFILE, #1628): a
|
|
134
|
+
// spike-mode spin resolves the relaxed `spike` gate profile instead of the
|
|
135
|
+
// default local-implementation gate. Kept as its own acceptance key so the
|
|
136
|
+
// generic default can stay approach-agnostic.
|
|
137
|
+
register(INTERNAL_DEV_LOOP_STRATEGY.LOCAL_IMPLEMENTATION, "spike", {
|
|
138
|
+
criteria: [
|
|
139
|
+
{ id: "spike-recorded", must: "The spike exploration and its recommendation are recorded (spike file + summary).", severity: "required" },
|
|
140
|
+
{ id: "verify-green", must: "`npm run verify` passes with no failures.", severity: "required" },
|
|
141
|
+
],
|
|
142
|
+
evidence: ["commands-run", "validation-output", "changed-files"],
|
|
143
|
+
maxFinalizationTurns: 6,
|
|
144
|
+
needsAttentionAfterMs: DEFAULT_NEEDS_ATTENTION_MS,
|
|
145
|
+
activeNoticeAfterMs: DEFAULT_ACTIVE_NOTICE_MS,
|
|
146
|
+
});
|
|
147
|
+
|
|
133
148
|
// wait_watch — dedicated window matching external healthy wait budget (policy-constants)
|
|
134
149
|
register(INTERNAL_DEV_LOOP_STRATEGY.WAIT_WATCH, "default", {
|
|
135
150
|
criteria: [
|
|
@@ -545,6 +560,11 @@ function resolveSubGate(strategy, gateState) {
|
|
|
545
560
|
return "default";
|
|
546
561
|
}
|
|
547
562
|
|
|
563
|
+
/** True when the resolver output identifies a spike-mode run (#1628). */
|
|
564
|
+
function isSpikeRun(resolverOutput) {
|
|
565
|
+
return Boolean(resolverOutput && resolverOutput.spikeIntakeState);
|
|
566
|
+
}
|
|
567
|
+
|
|
548
568
|
|
|
549
569
|
// ---------------------------------------------------------------------------
|
|
550
570
|
// Deep freeze helper
|
|
@@ -580,7 +600,14 @@ export function buildDevLoopHandoffEnvelope(resolverOutput, settings, gateState
|
|
|
580
600
|
if (!repo) throw new Error("handoff-envelope: repo slug is required (owner/name)");
|
|
581
601
|
|
|
582
602
|
const gs = normalizeGateState(gateState);
|
|
583
|
-
|
|
603
|
+
// SPIKE-RELAXED-GATE-PROFILE (#1628): a spike-mode spin (startup resolver
|
|
604
|
+
// result carrying `spikeIntakeState`) resolves the relaxed `spike` gate
|
|
605
|
+
// profile instead of the default local-implementation gate. The spike
|
|
606
|
+
// marker lives at the TOP level of the resolver output (the bundle does not
|
|
607
|
+
// carry it), so it is read off `resolverOutput` directly.
|
|
608
|
+
const subGate = (strategy === INTERNAL_DEV_LOOP_STRATEGY.LOCAL_IMPLEMENTATION && isSpikeRun(resolverOutput))
|
|
609
|
+
? "spike"
|
|
610
|
+
: resolveSubGate(strategy, gs);
|
|
584
611
|
// Normalize each source independently, then fall back on the normalized result
|
|
585
612
|
// (not the raw value): a present-but-invalid gateState value must NOT shadow a
|
|
586
613
|
// valid options.retrospectiveFindings fallback (issue #1077 review finding).
|
|
@@ -241,6 +241,7 @@ export function extractUncheckedChecklistItems(sectionBody) {
|
|
|
241
241
|
* `## Refinement` / `## Plan` / `## Refinement doc` sections.
|
|
242
242
|
*/
|
|
243
243
|
export function detectLinkedRefinementDoc(body) {
|
|
244
|
+
|
|
244
245
|
if (typeof body !== "string" || body.length === 0) {
|
|
245
246
|
return { found: false, path: null, reason: "empty-body" };
|
|
246
247
|
}
|
|
@@ -486,6 +487,59 @@ function sectionHasBody(section) {
|
|
|
486
487
|
* @param {{ body?: string, expectedIssue?: number, issueLess?: boolean }} input
|
|
487
488
|
* @returns {{ checker: "validate-pr-body-spec", ok: boolean, errors: { code: string, message: string }[], sections: string[], acItems: string[], dodItems: string[], closesIssues: number[] }}
|
|
488
489
|
*/
|
|
490
|
+
|
|
491
|
+
// ---------------------------------------------------------------------------
|
|
492
|
+
// Grill sub-loop body predicates (GRILL-SUBLOOP-*, #1628)
|
|
493
|
+
// ---------------------------------------------------------------------------
|
|
494
|
+
// The loop-grill skill writes its raw Q&A transcript and synthesis to an
|
|
495
|
+
// ephemeral tmp artifact and keeps only the canonical synthesized sections
|
|
496
|
+
// (Acceptance criteria / Definition of done / Non-goals) plus the sanctioned
|
|
497
|
+
// `<!-- loop-grill: ... -->` marker in the durable issue/PR body. The body
|
|
498
|
+
// MUST NOT embed the raw grill transcript/synthesis/Q&A headings
|
|
499
|
+
// (GRILL-SUBLOOP-NO-EMBED-SYNTHESIS). These pure predicates are the only
|
|
500
|
+
// mechanically-enforceable part of that contract; the judgment-bound clauses
|
|
501
|
+
// ("resolve every gap the grill decided", "stale contradicting prose") stay
|
|
502
|
+
// agent-level.
|
|
503
|
+
|
|
504
|
+
export const GRILL_MARKER_PATTERN = /<!--\s*loop-grill:\s*.*?-->/iu;
|
|
505
|
+
|
|
506
|
+
/** Case-insensitive **section heading names** that embed grill material. */
|
|
507
|
+
export const GRILL_EMBED_HEADING_PATTERNS = Object.freeze([
|
|
508
|
+
/^grill\s+findings$/iu,
|
|
509
|
+
/^grill\s+transcript$/iu,
|
|
510
|
+
/^grill\s+synthesis$/iu,
|
|
511
|
+
/^grill\s+q&a$/iu,
|
|
512
|
+
/^grill\s+qa$/iu,
|
|
513
|
+
]);
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Detect the sanctioned `<!-- loop-grill: ... -->` marker. Pure predicate.
|
|
517
|
+
* @param {string} [body]
|
|
518
|
+
* @returns {boolean} true when the marker is present.
|
|
519
|
+
*/
|
|
520
|
+
export function detectGrillMarker(body = "") {
|
|
521
|
+
return typeof body === "string" && GRILL_MARKER_PATTERN.test(body);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Detect a grill transcript/synthesis/Q&A embed heading in the body. Pure
|
|
526
|
+
* predicate; returns the first offending heading name at any markdown level
|
|
527
|
+
* (# through ######) or null.
|
|
528
|
+
* @param {string} [body]
|
|
529
|
+
* @returns {string|null} the offending heading name, or null when none.
|
|
530
|
+
*/
|
|
531
|
+
export function detectGrillEmbedHeading(body = "") {
|
|
532
|
+
if (typeof body !== "string" || body.length === 0) return null;
|
|
533
|
+
for (const section of parseMarkdownSections(body)) {
|
|
534
|
+
for (const pattern of GRILL_EMBED_HEADING_PATTERNS) {
|
|
535
|
+
if (pattern.test(String(section.name))) {
|
|
536
|
+
return String(section.name);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
return null;
|
|
541
|
+
}
|
|
542
|
+
|
|
489
543
|
export function validatePrBodySpec({ body = "", expectedIssue = null, issueLess = false } = {}) {
|
|
490
544
|
if (issueLess && Number.isInteger(expectedIssue)) {
|
|
491
545
|
// Fail closed at the library boundary too (not just the CLI): the two modes
|
|
@@ -583,6 +637,46 @@ export function decideEnqueueRefinementGate({ artifact, targetIsPickup, auto = f
|
|
|
583
637
|
return { action: auto ? "divert" : "block", reason, missing };
|
|
584
638
|
}
|
|
585
639
|
|
|
640
|
+
/**
|
|
641
|
+
* Apply the pickup-column refinement gate to one issue: fetch the issue body,
|
|
642
|
+
* run `decideEnqueueRefinementGate`, and throw the canonical `GH_API_ERROR` /
|
|
643
|
+
* `MISSING_REFINEMENT_ARTIFACT` on failure. This is the single
|
|
644
|
+
* gate-application orchestration shared by `queue add` (enqueue-time) and
|
|
645
|
+
* `queue move` (move-time) — never a second copy. It returns the gate decision
|
|
646
|
+
* so add-only (divert/park) and move-only (refined-flag) handling stays with
|
|
647
|
+
* each caller.
|
|
648
|
+
*
|
|
649
|
+
* @param {{ issueNumber: number, repo: string, env: object, runChild: Function, auto?: boolean }} input
|
|
650
|
+
* @returns {Promise<{ action: "enqueue" } | { action: "divert"|"block", reason: string, missing: string[] }>}
|
|
651
|
+
*/
|
|
652
|
+
export async function runPickupRefinementGate({ issueNumber, repo, env, runChild, auto = false }) {
|
|
653
|
+
const bodyResult = await runChild(
|
|
654
|
+
"gh",
|
|
655
|
+
["issue", "view", String(issueNumber), "--repo", repo, "--json", "body"],
|
|
656
|
+
env,
|
|
657
|
+
);
|
|
658
|
+
if (bodyResult.code !== 0) {
|
|
659
|
+
const detail = bodyResult.stderr?.trim() || `exit code ${bodyResult.code}`;
|
|
660
|
+
throw Object.assign(new Error(`gh issue view failed: ${detail}`), { code: "GH_API_ERROR" });
|
|
661
|
+
}
|
|
662
|
+
let bodyPayload;
|
|
663
|
+
try {
|
|
664
|
+
bodyPayload = JSON.parse(bodyResult.stdout);
|
|
665
|
+
} catch {
|
|
666
|
+
throw new Error("Invalid JSON input");
|
|
667
|
+
}
|
|
668
|
+
const body = typeof bodyPayload?.body === "string" ? bodyPayload.body : "";
|
|
669
|
+
const artifact = detectIssueRefinementArtifact({ body, issueNumber });
|
|
670
|
+
const decision = decideEnqueueRefinementGate({ artifact, targetIsPickup: true, auto });
|
|
671
|
+
if (decision.action === "block") {
|
|
672
|
+
throw Object.assign(new Error(decision.reason), {
|
|
673
|
+
code: "MISSING_REFINEMENT_ARTIFACT",
|
|
674
|
+
missing: decision.missing,
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
return decision;
|
|
678
|
+
}
|
|
679
|
+
|
|
586
680
|
/**
|
|
587
681
|
* Map a draft-gate refinement check to the result surface consumed by
|
|
588
682
|
* `evaluatePrGateCoordination`. The mapping keeps the contract
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
* No imports so this file vendors into the `.claude/hooks/` bundle unchanged
|
|
25
25
|
* (vendored modules may only import `node:` builtins or relative paths).
|
|
26
26
|
*/
|
|
27
|
+
import path from "node:path";
|
|
27
28
|
|
|
28
29
|
/**
|
|
29
30
|
* Timeout (ms) for the `git worktree list` resolution step (the fetch-half budget;
|
|
@@ -56,3 +57,41 @@ export function buildMainCheckoutFastForwardCommand(mainCheckout) {
|
|
|
56
57
|
// wrong branch. No state change, no git switch.
|
|
57
58
|
return `git -C ${quoted} fetch origin main && [ "$(git -C ${quoted} rev-parse --abbrev-ref HEAD)" = main ] && git -C ${quoted} merge --ff-only origin/main`;
|
|
58
59
|
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Worktree-cleanup timeout (ms) for the post-merge `git worktree remove` half.
|
|
63
|
+
*/
|
|
64
|
+
export const WORKTREE_CLEANUP_TIMEOUT_MS = 60_000;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Build the best-effort post-merge worktree-removal command string (#1627).
|
|
68
|
+
*
|
|
69
|
+
* The dev-loop mandates removing the branch's worktree after merge, but neither
|
|
70
|
+
* the merge procedure nor the post-merge hooks performed it. This builds the
|
|
71
|
+
* shell command that runs the shared `cleanup-worktree.mjs` script FROM the main
|
|
72
|
+
* checkout (the hook's cwd can be inside the worktree being removed, which makes
|
|
73
|
+
* `git worktree remove` fail), and stays non-fatal: the script itself is fail-soft
|
|
74
|
+
* (refuses any path outside tmp/worktrees/dev-loops/, exits 0 on git errors), and
|
|
75
|
+
* the surrounding guard makes a consumer checkout without the script a silent no-op.
|
|
76
|
+
* `prNumber` is shell-escaped as a double-quoted argument; `mainCheckout` and the
|
|
77
|
+
* script path are POSIX single-quoted. Returns an empty string when no PR number
|
|
78
|
+
* (or no meaningful target) is available, so callers can skip cleanly.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} mainCheckout - Absolute path to the main (primary) git checkout.
|
|
81
|
+
* @param {string | number | undefined} prNumber - Merged PR number (drives `--pr`).
|
|
82
|
+
* @returns {string} the cleanup command, or "" when `prNumber` is absent.
|
|
83
|
+
*/
|
|
84
|
+
export function buildWorktreeCleanupCommand(mainCheckout, prNumber) {
|
|
85
|
+
const pr = String(prNumber ?? "").trim();
|
|
86
|
+
// Validate the PR number is a positive integer BEFORE embedding it into the
|
|
87
|
+
// shell string; a caller passing a non-numeric string (could carry command
|
|
88
|
+
// substitution) is refused by returning "" — defense-in-depth in a public helper.
|
|
89
|
+
if (!/^[0-9]+$/u.test(pr)) {
|
|
90
|
+
return "";
|
|
91
|
+
}
|
|
92
|
+
const quotedMain = shellQuotePath(mainCheckout);
|
|
93
|
+
const script = shellQuotePath(path.join(mainCheckout, "scripts", "loop", "cleanup-worktree.mjs"));
|
|
94
|
+
// Guard the script's existence (consumer no-op) and keep the whole thing
|
|
95
|
+
// non-fatal with `|| true` — removal must never break a merge-completion flow.
|
|
96
|
+
return `if [ -f ${script} ]; then node ${script} --repo-root ${quotedMain} --pr "${pr}"; fi || true`;
|
|
97
|
+
}
|