@dev-loops/core 1.0.2 → 1.0.4-pre.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.
- package/package.json +10 -1
- package/src/analysis/change-classifier.mjs +35 -0
- package/src/analysis/diff-analyzer.mjs +89 -16
- package/src/claude/asset-generation.mjs +64 -3
- package/src/claude/hook-decisions.mjs +213 -65
- package/src/config/config.mjs +473 -43
- package/src/config/extension-defaults.yaml +48 -0
- package/src/github/copilot-helpers.mjs +79 -1
- package/src/github/issue-ops.mjs +4 -0
- package/src/github/repo-slug.mjs +25 -4
- package/src/github/test-mode-write-guard.mjs +81 -0
- package/src/loop/bash-command-classify.mjs +396 -42
- package/src/loop/child-launch-bound.mjs +152 -0
- package/src/loop/copilot-ci-status.mjs +116 -6
- package/src/loop/copilot-loop-state.mjs +20 -4
- package/src/loop/execution-record.mjs +412 -0
- package/src/loop/finding-cluster.mjs +296 -0
- package/src/loop/fixer-disposition.mjs +200 -0
- package/src/loop/gate-carry-forward.mjs +39 -6
- package/src/loop/gate-fanin.mjs +82 -3
- package/src/loop/issue-refinement-artifact.mjs +117 -9
- package/src/loop/merge-approval.mjs +399 -0
- package/src/loop/pr-gate-coordination.mjs +123 -12
- package/src/loop/queue-board-sync.mjs +6 -3
- package/src/loop/reviewer-unit-bound.mjs +308 -0
- package/src/loop/role-budget-bound.mjs +242 -0
- package/src/loop/run-inspection.mjs +6 -0
- package/src/loop/size-budget-merge-gate.mjs +48 -12
- package/src/loop/spec-authority.mjs +19 -6
- package/src/loop/ui-e2e-scoping.mjs +1 -0
- package/src/loop/watcher-exclusivity.mjs +302 -0
- package/src/security/secret-scan.mjs +13 -0
|
@@ -98,6 +98,17 @@ export const SPEC_AUTHORITY_OUTCOME_VALUES = Object.freeze(
|
|
|
98
98
|
* three resolve autonomously. Exported so no consumer re-hardcodes the set. */
|
|
99
99
|
export const HUMAN_SPEC_DECISION_OUTCOME = SPEC_AUTHORITY_OUTCOMES.SPEC_CANNOT_DECIDE;
|
|
100
100
|
|
|
101
|
+
/** The two outcomes that REQUIRE explicit conflict evidence: a non-empty
|
|
102
|
+
* `conflictingCriteria` array of criterion ids (SPEC-AUTHORITY-CONFLICT-EVIDENCE).
|
|
103
|
+
* The enforcer and the divergence guard bind to this set directly; the guard
|
|
104
|
+
* also pins the producer contract prose (`agents/judge.agent.md`) to it, so
|
|
105
|
+
* producer and enforcer cannot silently diverge. SPEC-AUTHORITY-CONFLICT-EVIDENCE
|
|
106
|
+
* in this module is the governing rule. */
|
|
107
|
+
export const SPEC_AUTHORITY_CONFLICT_OUTCOMES = Object.freeze([
|
|
108
|
+
SPEC_AUTHORITY_OUTCOMES.FINDING_CONFLICTS,
|
|
109
|
+
SPEC_AUTHORITY_OUTCOMES.REMEDIATION_CONFLICTS,
|
|
110
|
+
]);
|
|
111
|
+
|
|
101
112
|
/**
|
|
102
113
|
* Does an outcome require the loop to stop at the human-spec-decision state?
|
|
103
114
|
* Only `spec_cannot_decide` does — a finding/remediation conflict alone never
|
|
@@ -364,15 +375,17 @@ export function validateSpecAuthorityDecision(decision, { specDigest, headSha, c
|
|
|
364
375
|
// The two conflict outcomes require explicit conflict evidence: a non-empty
|
|
365
376
|
// conflictingCriteria drawn from the spec. Autonomous rejection is only
|
|
366
377
|
// legitimate when it names what the finding/remedy conflicts with.
|
|
367
|
-
const isConflict =
|
|
368
|
-
d.outcome === SPEC_AUTHORITY_OUTCOMES.FINDING_CONFLICTS ||
|
|
369
|
-
d.outcome === SPEC_AUTHORITY_OUTCOMES.REMEDIATION_CONFLICTS;
|
|
378
|
+
const isConflict = SPEC_AUTHORITY_CONFLICT_OUTCOMES.includes(d.outcome);
|
|
370
379
|
let conflictingCriteria = [];
|
|
371
380
|
if (isConflict) {
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
381
|
+
// Absent, non-array, and empty all mean "no conflict evidence". Route them
|
|
382
|
+
// to the named rule (not the generic id-set shape error) so a human hitting
|
|
383
|
+
// this is pointed at the fix — populate the field — rather than a hand-edit;
|
|
384
|
+
// naming the criteria in `rationale` prose alone does not satisfy it.
|
|
385
|
+
if (!Array.isArray(d.conflictingCriteria) || d.conflictingCriteria.length === 0) {
|
|
386
|
+
throw new Error(`SPEC-AUTHORITY-CONFLICT-EVIDENCE: ${d.outcome} decision requires a non-empty conflictingCriteria array of criterion ids (explicit conflict evidence; naming them in rationale prose alone does not satisfy it; fail closed)`);
|
|
375
387
|
}
|
|
388
|
+
const conflicts = normalizeIdSet(d.conflictingCriteria, "decision.conflictingCriteria");
|
|
376
389
|
const unknownConflicts = [...conflicts].filter((id) => !fullCriteria.has(id));
|
|
377
390
|
if (unknownConflicts.length > 0) {
|
|
378
391
|
throw new Error(`spec-authority decision.conflictingCriteria names unknown criterion id(s): ${unknownConflicts.join(", ")}`);
|
|
@@ -40,6 +40,7 @@ export const REGISTERED_ARTIFACT_PATHS = Object.freeze([
|
|
|
40
40
|
"docs/presentations/dev-loops-deep-dive.html",
|
|
41
41
|
"docs/presentations/how-dev-loops-decided-itself.html",
|
|
42
42
|
"docs/presentations/state-graph-surface.html",
|
|
43
|
+
"docs/presentations/finding-the-flow.html",
|
|
43
44
|
"docs/articles/introducing-dev-loops.html",
|
|
44
45
|
"docs/articles/dev-loops-deep-dive.html",
|
|
45
46
|
"docs/articles/how-dev-loops-decided-itself.html",
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* watcher-exclusivity.mjs — dev-loop execution-cap watcher-exclusivity
|
|
3
|
+
* resolver. Mirrors ./reviewer-unit-bound.mjs and ./role-budget-bound.mjs's
|
|
4
|
+
* style: a bounded, deterministic primitive that resolves the current
|
|
5
|
+
* watch-owner verdict for a (target, head, wait-kind) boundary from
|
|
6
|
+
* caller-supplied lease/transition evidence, and always fails closed
|
|
7
|
+
* (blocked, never a silent pass) on any owner mismatch, stale lease, or
|
|
8
|
+
* malformed transition.
|
|
9
|
+
*
|
|
10
|
+
* The whole point of this primitive: the coordinator never becomes a SECOND
|
|
11
|
+
* observer of an in-flight external wait (Copilot review, CI, workflow run).
|
|
12
|
+
* `secondObserverAuthorized` is `false` in every branch of
|
|
13
|
+
* `resolveWatchOwnership` — there is no verdict shape that authorizes the
|
|
14
|
+
* caller to start its own competing watch/probe loop. The owner and
|
|
15
|
+
* transition evidence each carry a `target` field, compared against
|
|
16
|
+
* `boundary.target` alongside `head` and `waitKind`, so the resolver
|
|
17
|
+
* genuinely keys on the full (target, head, wait-kind) triple instead of
|
|
18
|
+
* accepting evidence for a different target.
|
|
19
|
+
*
|
|
20
|
+
* This resolver is caller-AGNOSTIC: it reports the single-owner verdict over
|
|
21
|
+
* SUPPLIED evidence and does not authenticate the calling runner. It cannot
|
|
22
|
+
* tell whether the process invoking it IS the recorded lease owner — that
|
|
23
|
+
* caller-identity check (verifying the calling runner matches
|
|
24
|
+
* `evidence.owner`, and gating the wait before it starts) is the consumer's
|
|
25
|
+
* responsibility and is deferred to the slice-b live wiring.
|
|
26
|
+
*
|
|
27
|
+
* Pure and offline: no runtime/harness adapter import, no file reads, no
|
|
28
|
+
* network, no state held across calls. This primitive is a post-hoc verdict
|
|
29
|
+
* over caller-supplied `evidence` — it does not read or write any lease
|
|
30
|
+
* file itself; that stays the caller's concern (the existing
|
|
31
|
+
* runner-coordination lease read/write path).
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { EXTERNAL_HEALTHY_WAIT_TIMEOUT_POLICY } from "./timeout-policy.mjs";
|
|
35
|
+
|
|
36
|
+
/** Wait kinds this primitive recognizes. */
|
|
37
|
+
export const WATCH_KINDS = Object.freeze(["copilot_review", "ci", "workflow_run"]);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Coordinator operation kinds that are always prohibited under watcher
|
|
41
|
+
* exclusivity: every one of these would make the coordinator a second
|
|
42
|
+
* observer of an in-flight external wait instead of the sole lease owner.
|
|
43
|
+
*
|
|
44
|
+
* Exported as a frozen ARRAY, not a Set: `Object.freeze(new Set(...))`
|
|
45
|
+
* freezes only the Set's own properties, not its contents — `.add`/
|
|
46
|
+
* `.delete`/`.clear` still work on a frozen Set and the mutation persists on
|
|
47
|
+
* this module-singleton export. A frozen array has no such escape hatch.
|
|
48
|
+
*/
|
|
49
|
+
export const PROHIBITED_COORDINATOR_OBSERVER_OPERATIONS = Object.freeze([
|
|
50
|
+
"direct_probe",
|
|
51
|
+
"start_watcher",
|
|
52
|
+
"sleep_retry",
|
|
53
|
+
"second_watch_loop",
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
const PROHIBITED_COORDINATOR_OBSERVER_OPERATIONS_SET = new Set(PROHIBITED_COORDINATOR_OBSERVER_OPERATIONS);
|
|
57
|
+
|
|
58
|
+
/** Transition statuses that authorize a phase advance once owner+transition match the boundary. */
|
|
59
|
+
const ADVANCING_TRANSITION_STATUSES = new Set(["changed", "completed"]);
|
|
60
|
+
/** Transition statuses that keep a matching owner in a healthy wait (never advance). */
|
|
61
|
+
const WAITING_TRANSITION_STATUSES = new Set(["timeout", "idle", "pending"]);
|
|
62
|
+
|
|
63
|
+
/** @param {unknown} value @returns {boolean} */
|
|
64
|
+
function isNonEmptyString(value) {
|
|
65
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Recursively freeze a plain object/array value's own nested plain
|
|
70
|
+
* objects/arrays. A shallow Object.freeze leaves nested values mutable.
|
|
71
|
+
* Recurses into children even when the current container is already frozen.
|
|
72
|
+
* A WeakSet cycle guard prevents infinite recursion on a cyclic object graph.
|
|
73
|
+
* @param {unknown} value
|
|
74
|
+
* @param {WeakSet<object>} [seen]
|
|
75
|
+
* @returns {unknown} the same value, deep-frozen.
|
|
76
|
+
*/
|
|
77
|
+
function deepFreeze(value, seen = new WeakSet()) {
|
|
78
|
+
if (value === null || typeof value !== "object" || seen.has(value)) {
|
|
79
|
+
return value;
|
|
80
|
+
}
|
|
81
|
+
seen.add(value);
|
|
82
|
+
for (const key of Object.keys(value)) {
|
|
83
|
+
deepFreeze(value[key], seen);
|
|
84
|
+
}
|
|
85
|
+
return Object.freeze(value);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Validate + normalize the trust-boundary identity at the boundary. Fails
|
|
90
|
+
* closed (TypeError naming the violation) on any malformed field.
|
|
91
|
+
* @param {{target:string, head:string, waitKind:string}} boundary
|
|
92
|
+
* @returns {{target:string, head:string, waitKind:string}}
|
|
93
|
+
*/
|
|
94
|
+
function validateBoundary(boundary) {
|
|
95
|
+
if (!boundary || typeof boundary !== "object") {
|
|
96
|
+
throw new TypeError("resolveWatchOwnership requires boundary to be an object");
|
|
97
|
+
}
|
|
98
|
+
if (!isNonEmptyString(boundary.target)) {
|
|
99
|
+
throw new TypeError("boundary.target must be a non-empty string");
|
|
100
|
+
}
|
|
101
|
+
if (!isNonEmptyString(boundary.head)) {
|
|
102
|
+
throw new TypeError("boundary.head must be a non-empty string");
|
|
103
|
+
}
|
|
104
|
+
if (!WATCH_KINDS.includes(boundary.waitKind)) {
|
|
105
|
+
throw new TypeError(`boundary.waitKind must be one of ${WATCH_KINDS.join(", ")}, got ${JSON.stringify(boundary.waitKind)}`);
|
|
106
|
+
}
|
|
107
|
+
return { target: boundary.target.trim(), head: boundary.head.trim(), waitKind: boundary.waitKind };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Validate + normalize `evidence.owner`. `null` (no active owner) is valid;
|
|
112
|
+
* a present owner must carry every required field.
|
|
113
|
+
* @param {unknown} owner
|
|
114
|
+
* @returns {{runId:string, target:string, head:string, waitKind:string, updatedAt:string}|null}
|
|
115
|
+
*/
|
|
116
|
+
function validateOwner(owner) {
|
|
117
|
+
if (owner === null || owner === undefined) return null;
|
|
118
|
+
if (typeof owner !== "object") {
|
|
119
|
+
throw new TypeError("evidence.owner must be null or an object");
|
|
120
|
+
}
|
|
121
|
+
if (!isNonEmptyString(owner.runId)) {
|
|
122
|
+
throw new TypeError("evidence.owner.runId must be a non-empty string");
|
|
123
|
+
}
|
|
124
|
+
if (!isNonEmptyString(owner.target)) {
|
|
125
|
+
throw new TypeError("evidence.owner.target must be a non-empty string");
|
|
126
|
+
}
|
|
127
|
+
if (!isNonEmptyString(owner.head)) {
|
|
128
|
+
throw new TypeError("evidence.owner.head must be a non-empty string");
|
|
129
|
+
}
|
|
130
|
+
if (!WATCH_KINDS.includes(owner.waitKind)) {
|
|
131
|
+
throw new TypeError(`evidence.owner.waitKind must be one of ${WATCH_KINDS.join(", ")}, got ${JSON.stringify(owner.waitKind)}`);
|
|
132
|
+
}
|
|
133
|
+
if (!isNonEmptyString(owner.updatedAt) || Number.isNaN(Date.parse(owner.updatedAt))) {
|
|
134
|
+
throw new TypeError("evidence.owner.updatedAt must be a non-empty, Date.parse-able timestamp string");
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
runId: owner.runId.trim(),
|
|
138
|
+
target: owner.target.trim(),
|
|
139
|
+
head: owner.head.trim(),
|
|
140
|
+
waitKind: owner.waitKind,
|
|
141
|
+
updatedAt: owner.updatedAt.trim(),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Validate + normalize `evidence.transition`. `null`/absent (no transition
|
|
147
|
+
* observed yet) is valid; a present transition must carry every required
|
|
148
|
+
* field.
|
|
149
|
+
* @param {unknown} transition
|
|
150
|
+
* @returns {{target:string, head:string, waitKind:string, status:string}|null}
|
|
151
|
+
*/
|
|
152
|
+
function validateTransition(transition) {
|
|
153
|
+
if (transition === null || transition === undefined) return null;
|
|
154
|
+
if (typeof transition !== "object") {
|
|
155
|
+
throw new TypeError("evidence.transition must be null or an object");
|
|
156
|
+
}
|
|
157
|
+
if (!isNonEmptyString(transition.target)) {
|
|
158
|
+
throw new TypeError("evidence.transition.target must be a non-empty string");
|
|
159
|
+
}
|
|
160
|
+
if (!isNonEmptyString(transition.head)) {
|
|
161
|
+
throw new TypeError("evidence.transition.head must be a non-empty string");
|
|
162
|
+
}
|
|
163
|
+
if (!WATCH_KINDS.includes(transition.waitKind)) {
|
|
164
|
+
throw new TypeError(`evidence.transition.waitKind must be one of ${WATCH_KINDS.join(", ")}, got ${JSON.stringify(transition.waitKind)}`);
|
|
165
|
+
}
|
|
166
|
+
if (!isNonEmptyString(transition.status)) {
|
|
167
|
+
throw new TypeError("evidence.transition.status must be a non-empty string");
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
target: transition.target.trim(),
|
|
171
|
+
head: transition.head.trim(),
|
|
172
|
+
waitKind: transition.waitKind,
|
|
173
|
+
status: transition.status.trim(),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** @param {string} a @param {string} b @returns {boolean} case-insensitive, trimmed equality. */
|
|
178
|
+
function sameNormalized(a, b) {
|
|
179
|
+
return a.trim().toLowerCase() === b.trim().toLowerCase();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Resolve the current watch-owner verdict for one (target, head, wait-kind)
|
|
184
|
+
* boundary from caller-supplied lease/transition evidence. Validates every
|
|
185
|
+
* input at the trust boundary and fails closed on malformed input
|
|
186
|
+
* (TypeError) or on any owner/transition mismatch (a "blocked" verdict —
|
|
187
|
+
* never a silent pass).
|
|
188
|
+
*
|
|
189
|
+
* `secondObserverAuthorized` is `false` in every returned verdict: there is
|
|
190
|
+
* no shape this function returns that authorizes the caller to start its
|
|
191
|
+
* own competing watch/probe loop.
|
|
192
|
+
*
|
|
193
|
+
* @param {object} options
|
|
194
|
+
* @param {{target:string, head:string, waitKind:"copilot_review"|"ci"|"workflow_run"}} options.boundary
|
|
195
|
+
* @param {{owner: object|null, transition?: object|null}} options.evidence
|
|
196
|
+
* @param {number} options.now non-negative integer ms epoch.
|
|
197
|
+
* @param {number} options.staleAfterMs positive integer.
|
|
198
|
+
* @returns {object} a frozen verdict; see module header for shapes.
|
|
199
|
+
*/
|
|
200
|
+
export function resolveWatchOwnership({ boundary, evidence, now, staleAfterMs } = {}) {
|
|
201
|
+
const normalizedBoundary = validateBoundary(boundary);
|
|
202
|
+
if (!Number.isInteger(now) || now < 0) {
|
|
203
|
+
throw new TypeError("resolveWatchOwnership requires now to be a non-negative integer");
|
|
204
|
+
}
|
|
205
|
+
if (!Number.isInteger(staleAfterMs) || staleAfterMs <= 0) {
|
|
206
|
+
throw new TypeError("resolveWatchOwnership requires staleAfterMs to be a positive integer");
|
|
207
|
+
}
|
|
208
|
+
if (!evidence || typeof evidence !== "object") {
|
|
209
|
+
throw new TypeError("resolveWatchOwnership requires evidence to be an object with an owner field");
|
|
210
|
+
}
|
|
211
|
+
const owner = validateOwner(evidence.owner);
|
|
212
|
+
const transition = validateTransition(evidence.transition);
|
|
213
|
+
|
|
214
|
+
const blocked = (reason) => deepFreeze({
|
|
215
|
+
ok: false,
|
|
216
|
+
verdict: "blocked",
|
|
217
|
+
reason,
|
|
218
|
+
boundary: normalizedBoundary,
|
|
219
|
+
secondObserverAuthorized: false,
|
|
220
|
+
advancePhaseAuthorized: false,
|
|
221
|
+
probeAuthorized: false,
|
|
222
|
+
waitTimeoutPolicy: EXTERNAL_HEALTHY_WAIT_TIMEOUT_POLICY,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
if (owner === null) {
|
|
226
|
+
return blocked("no_active_owner");
|
|
227
|
+
}
|
|
228
|
+
if (!sameNormalized(owner.target, normalizedBoundary.target)) {
|
|
229
|
+
return blocked("owner_target_mismatch");
|
|
230
|
+
}
|
|
231
|
+
if (!sameNormalized(owner.head, normalizedBoundary.head)) {
|
|
232
|
+
return blocked("owner_head_mismatch");
|
|
233
|
+
}
|
|
234
|
+
if (owner.waitKind !== normalizedBoundary.waitKind) {
|
|
235
|
+
return blocked("owner_wait_kind_mismatch");
|
|
236
|
+
}
|
|
237
|
+
if (now - Date.parse(owner.updatedAt) > staleAfterMs) {
|
|
238
|
+
return blocked("owner_lease_stale");
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const ownedWaiting = () => deepFreeze({
|
|
242
|
+
ok: true,
|
|
243
|
+
status: "owned_waiting",
|
|
244
|
+
owner,
|
|
245
|
+
advancePhaseAuthorized: false,
|
|
246
|
+
probeAuthorized: false,
|
|
247
|
+
secondObserverAuthorized: false,
|
|
248
|
+
waitTimeoutPolicy: EXTERNAL_HEALTHY_WAIT_TIMEOUT_POLICY,
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
if (transition === null) {
|
|
252
|
+
return ownedWaiting();
|
|
253
|
+
}
|
|
254
|
+
if (
|
|
255
|
+
!sameNormalized(transition.target, normalizedBoundary.target) ||
|
|
256
|
+
!sameNormalized(transition.head, normalizedBoundary.head) ||
|
|
257
|
+
transition.waitKind !== normalizedBoundary.waitKind
|
|
258
|
+
) {
|
|
259
|
+
return blocked("stale_or_malformed_transition");
|
|
260
|
+
}
|
|
261
|
+
if (ADVANCING_TRANSITION_STATUSES.has(transition.status)) {
|
|
262
|
+
return deepFreeze({
|
|
263
|
+
ok: true,
|
|
264
|
+
status: "transition_ready",
|
|
265
|
+
owner,
|
|
266
|
+
transition,
|
|
267
|
+
advancePhaseAuthorized: true,
|
|
268
|
+
probeAuthorized: false,
|
|
269
|
+
secondObserverAuthorized: false,
|
|
270
|
+
waitTimeoutPolicy: EXTERNAL_HEALTHY_WAIT_TIMEOUT_POLICY,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
if (WAITING_TRANSITION_STATUSES.has(transition.status)) {
|
|
274
|
+
// A timeout does not advance the phase — the existing timeout policy
|
|
275
|
+
// governs escalation, this resolver only reports the healthy wait.
|
|
276
|
+
return ownedWaiting();
|
|
277
|
+
}
|
|
278
|
+
return blocked("stale_or_malformed_transition");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Pure default-deny guard: the coordinator has no sanctioned observer
|
|
283
|
+
* operation while watcher exclusivity holds — the lease owner is the sole
|
|
284
|
+
* observer. Every explicitly prohibited kind throws a named prohibition
|
|
285
|
+
* error; every OTHER kind (there is no allow-list) throws
|
|
286
|
+
* unknown_coordinator_observer_operation. This mirrors
|
|
287
|
+
* assertReviewerOperationAllowed's default-deny posture, but with an empty
|
|
288
|
+
* allow-list: there is nothing a coordinator may do here except wait for the
|
|
289
|
+
* lease owner's evidence to change.
|
|
290
|
+
* @param {{kind:string}} operation
|
|
291
|
+
* @returns {never}
|
|
292
|
+
*/
|
|
293
|
+
export function assertNoOverlappingObserver(operation) {
|
|
294
|
+
if (!operation || typeof operation !== "object" || !isNonEmptyString(operation.kind)) {
|
|
295
|
+
throw new TypeError("assertNoOverlappingObserver requires operation.kind to be a non-empty string");
|
|
296
|
+
}
|
|
297
|
+
const { kind } = operation;
|
|
298
|
+
if (PROHIBITED_COORDINATOR_OBSERVER_OPERATIONS_SET.has(kind)) {
|
|
299
|
+
throw new Error(`coordinator observer operation prohibited under watcher exclusivity: ${kind}`);
|
|
300
|
+
}
|
|
301
|
+
throw new Error(`unknown_coordinator_observer_operation: ${kind}`);
|
|
302
|
+
}
|
|
@@ -319,6 +319,17 @@ export function parseAddedLines(diffText) {
|
|
|
319
319
|
* @param {string} diffText
|
|
320
320
|
* @returns {{ ok: boolean, findings: { file: string, line: number, detectorClass: string, reason: string }[] }}
|
|
321
321
|
*/
|
|
322
|
+
// The committed `.claude/package-lock.json` is, like `bun.lock`, a fully
|
|
323
|
+
// machine-generated lockfile whose every token derives from public npm registry metadata
|
|
324
|
+
// (Subresource Integrity digests, `resolved` tarball URLs, and long hyphenated per-platform
|
|
325
|
+
// package names from its real transitive tree) — none of it is a secret by construction.
|
|
326
|
+
// Unlike bun.lock's narrow tuple shape, an npm v3 lock spreads those long tokens across many
|
|
327
|
+
// distinct field shapes, so rather than enumerate every
|
|
328
|
+
// field individually the HIGH_ENTROPY detector is skipped for this one generated file; the
|
|
329
|
+
// literal-credential and sink-pattern detectors still run over every line unchanged, so a real
|
|
330
|
+
// credential shape landing here is still caught.
|
|
331
|
+
const HIGH_ENTROPY_EXEMPT_FILES = new Set([".claude/package-lock.json"]);
|
|
332
|
+
|
|
322
333
|
export function scanDiffText(diffText) {
|
|
323
334
|
const findings = [];
|
|
324
335
|
for (const entry of parseAddedLines(diffText)) {
|
|
@@ -334,7 +345,9 @@ export function scanDiffText(diffText) {
|
|
|
334
345
|
// names cross the generic entropy threshold despite containing no value.
|
|
335
346
|
.replace(/@mariozechner\/clipboard-[a-z0-9-]+/gu, "<clipboard-platform-package>")
|
|
336
347
|
: entry.text;
|
|
348
|
+
const skipHighEntropy = HIGH_ENTROPY_EXEMPT_FILES.has(entry.file ?? "");
|
|
337
349
|
for (const hit of scanLineText(text)) {
|
|
350
|
+
if (skipHighEntropy && hit.detectorClass === DETECTOR_CLASSES.HIGH_ENTROPY) continue;
|
|
338
351
|
findings.push({ file: entry.file, line: entry.line, ...hit });
|
|
339
352
|
}
|
|
340
353
|
}
|