@dzhechkov/harness-core 0.3.144 → 0.3.145

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.
@@ -0,0 +1,760 @@
1
+ /**
2
+ * `dz guard promote` — lesson → guard-rule PROMOTION with a "win twice to promote" gate.
3
+ *
4
+ * The cost-of-detection ladder says: put every check on the strongest layer that can express it.
5
+ * `dz compounding` MEASURED (2026-07-29) that this repo's learned store is ~82% write-only while the
6
+ * rules that DID reach layer 1 collapsed their own violation rate (no-workspace-star 31→0,
7
+ * readme-first 49→4). This module is the elevator: it moves a lesson from layer 5 (agent memory) to
8
+ * layer 1 (a deterministic rule) — but only when real evidence earns it.
9
+ *
10
+ * Ported from rUv's `@claude-flow/guidance` ADR-G008 (optimizer-promotion-rule, ACCEPTED) +
11
+ * `src/optimizer.ts` / `src/ledger.ts` (`score = frequency * cost`, promotionTracker, two
12
+ * consecutive wins, one loss resets). IMPROVEMENT OVER SOURCE: ADR-G008's own Negative section
13
+ * admits its A/B uses hard-coded SIMULATED reduction percentages. Here a "win" is a REPLAY of the
14
+ * candidate's check over REAL commits — the firings are real or there is no win.
15
+ *
16
+ * PURE: zero imports, zero I/O, no wall clock. Callers inject lessons, existing rules, and the
17
+ * change history; this module only computes. Same facts ⇒ byte-identical report.
18
+ *
19
+ * WHAT THIS DELIBERATELY IS NOT: a rule SYNTHESISER. Rule code is never generated from lesson text —
20
+ * that is layer-4 model judgment wearing layer-1 clothing, and its failure mode is silent. The fixed
21
+ * template vocabulary below is the entire executable surface (ADR-002).
22
+ */
23
+ export const TEMPLATES = ['pairing-check', 'absence-check', 'format-match'];
24
+ /** Params are well-formed for their template (a hand-edited config cannot smuggle a half-rule in). */
25
+ export function validTemplateParams(template, params) {
26
+ if (typeof template !== 'string' || !TEMPLATES.includes(template))
27
+ return false;
28
+ if (!params || typeof params !== 'object' || Array.isArray(params))
29
+ return false;
30
+ const p = params;
31
+ const str = (k) => typeof p[k] === 'string' && p[k].length > 0 && p[k].length <= MAX_GLOB_LENGTH;
32
+ // Glob-valued params are additionally bounded in WILDCARD DEGREE, so a hand-edited `.dz/guard.json`
33
+ // cannot install a rule whose pattern makes the engine backtrack catastrophically. `mustMatch` is
34
+ // a literal substring, never compiled, so only its length is bounded.
35
+ const glob = (k) => str(k) && isSafeGlob(p[k]);
36
+ if (template === 'pairing-check')
37
+ return glob('when') && glob('requires');
38
+ if (template === 'absence-check')
39
+ return glob('forbid');
40
+ return glob('file') && str('mustMatch');
41
+ }
42
+ // ── Glob matching (tiny, anchored, injection-proof) ──────────────────────────────────────────────
43
+ /**
44
+ * `**` matches any run of characters (including `/`); `*` matches any run WITHOUT `/`. Every other
45
+ * character is regex-escaped, so a lesson-derived token can never become an expression. Anchored at
46
+ * both ends. Never throws.
47
+ *
48
+ * The leading `**​/` is OPTIONAL — `**​/package.json` matches BOTH `packages/a/package.json` and a
49
+ * root-level `package.json`. A naive `.*` + `/` made the segment mandatory, so every promoted rule
50
+ * silently missed root-level files: the shadow replay of a real 12-commit history scored 0 firings
51
+ * and the candidate WAITED forever, looking like an honest verdict. A false gate is only ever found
52
+ * by RUNNING it — the unit tests were green throughout.
53
+ */
54
+ /**
55
+ * The most wildcard groups a glob may contain. Our own classifier emits exactly ONE (`**​/<token>`),
56
+ * so 2 is already generous; the cap exists because a regex built from `**a**a**a…` backtracks
57
+ * catastrophically (Codex QE MEASURED >10 s on such a pattern). Collapsing adjacent `.*` does NOT
58
+ * fix that — `.*a.*a.*a` is polynomial in the number of groups, so degree is the thing to bound.
59
+ * Refusal is the right answer here: these params come from a classifier we control, and a glob
60
+ * beyond the cap is a hand-edited config, not a promotion.
61
+ */
62
+ export const MAX_GLOB_WILDCARDS = 2;
63
+ /** Longest path a glob is matched against; beyond this the input is not a repo path. */
64
+ export const MAX_GLOB_PATH_LENGTH = 4096;
65
+ /** Longest glob accepted. Mirrors the length bound in {@link validTemplateParams}. */
66
+ export const MAX_GLOB_LENGTH = 200;
67
+ /** Collapse `***`/`****`/… runs to `**`, so padding cannot inflate the wildcard count. */
68
+ export function normalizeGlob(glob) {
69
+ return glob.replace(/\*{2,}/g, '**');
70
+ }
71
+ /** How many wildcard groups (`**` or `*`) a NORMALIZED glob contains. */
72
+ export function globWildcardCount(glob) {
73
+ if (typeof glob !== 'string')
74
+ return 0;
75
+ return (normalizeGlob(glob).match(/\*\*|\*/g) ?? []).length;
76
+ }
77
+ /** A glob this module is willing to compile: bounded length AND bounded wildcard degree. */
78
+ export function isSafeGlob(glob) {
79
+ return typeof glob === 'string' && glob.length > 0 && glob.length <= MAX_GLOB_LENGTH && globWildcardCount(glob) <= MAX_GLOB_WILDCARDS;
80
+ }
81
+ export function globMatch(glob, path) {
82
+ if (typeof glob !== 'string' || typeof path !== 'string' || glob === '')
83
+ return false;
84
+ if (path.length > MAX_GLOB_PATH_LENGTH)
85
+ return false;
86
+ // REFUSE rather than compile: an unbounded-degree pattern is a denial of service, and a glob
87
+ // that never matches is the safe failure here (a promoted rule that reports nothing, not a hang).
88
+ if (!isSafeGlob(glob))
89
+ return false;
90
+ const g = normalizeGlob(glob);
91
+ let re = '';
92
+ for (let i = 0; i < g.length; i++) {
93
+ const c = g[i];
94
+ if (c === '*') {
95
+ if (g[i + 1] === '*') {
96
+ if (g[i + 2] === '/') {
97
+ re += '(?:.*/)?'; // `**/` spans zero or more directory segments
98
+ i += 2;
99
+ }
100
+ else {
101
+ re += '.*';
102
+ i += 1;
103
+ }
104
+ }
105
+ else {
106
+ re += '[^/]*';
107
+ }
108
+ continue;
109
+ }
110
+ re += c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
111
+ }
112
+ try {
113
+ return new RegExp(`^${re}$`).test(path);
114
+ }
115
+ catch {
116
+ return false;
117
+ }
118
+ }
119
+ /**
120
+ * Does this (template, params) fire on this change? ONE definition, used by BOTH the historical
121
+ * replay and `evaluateGuard`'s template checker — a second copy would let the promoter promise a
122
+ * rule the guard then enforces differently, silently.
123
+ *
124
+ * `undecidable` (not `fired:false`) when the evidence the template needs is absent: a
125
+ * `format-match` over a change whose contents were not fetched is NOT a clean change, and counting
126
+ * it as a non-firing would convert missing data into a LOSS (the INSUFFICIENT_DATA discipline).
127
+ */
128
+ export function templateFires(template, params, change) {
129
+ const files = Array.isArray(change?.files) ? change.files.filter((f) => typeof f === 'string') : [];
130
+ if (template === 'pairing-check') {
131
+ const armed = files.filter((f) => globMatch(params.when, f));
132
+ if (armed.length === 0)
133
+ return { fired: false };
134
+ if (files.some((f) => globMatch(params.requires, f)))
135
+ return { fired: false };
136
+ return { fired: true, detail: `${armed.slice(0, 3).join(', ')} changed without any ${params.requires}` };
137
+ }
138
+ if (template === 'absence-check') {
139
+ const hit = files.filter((f) => globMatch(params.forbid, f));
140
+ return hit.length === 0 ? { fired: false } : { fired: true, detail: `${hit.slice(0, 3).join(', ')} matches the forbidden pattern ${params.forbid}` };
141
+ }
142
+ // format-match
143
+ const targets = files.filter((f) => globMatch(params.file, f));
144
+ if (targets.length === 0)
145
+ return { fired: false };
146
+ const contents = change.contents;
147
+ if (!contents || typeof contents !== 'object')
148
+ return { undecidable: `no content for ${targets.length} file(s) matching ${params.file}` };
149
+ const missing = [];
150
+ for (const t of targets) {
151
+ const text = Object.hasOwn(contents, t) ? contents[t] : undefined;
152
+ if (typeof text !== 'string')
153
+ return { undecidable: `no content for ${t}` };
154
+ if (!text.includes(String(params.mustMatch)))
155
+ missing.push(t);
156
+ }
157
+ return missing.length === 0 ? { fired: false } : { fired: true, detail: `${missing.slice(0, 3).join(', ')} does not contain ${JSON.stringify(params.mustMatch)}` };
158
+ }
159
+ export function isClassified(x) {
160
+ return Object.hasOwn(x, 'template');
161
+ }
162
+ /** Extensions a token must carry to count as an artifact reference. Closed list on purpose. */
163
+ const ARTIFACT_EXT = ['md', 'json', 'ts', 'tsx', 'js', 'mjs', 'cjs', 'yaml', 'yml', 'toml', 'lock', 'txt'];
164
+ const ARTIFACT_RE = new RegExp(`\\b[\\w.@/-]*[\\w@-]\\.(?:${ARTIFACT_EXT.join('|')})\\b`, 'g');
165
+ const PAIRING_RE = /\b(?:without|in the same (?:commit|change|diff|pr|merge request)|must also|requires? a[n]? [\w-]*\s*refresh|alongside)\b/i;
166
+ const ABSENCE_RE = /\b(?:never|must not|do not|don't|no longer)\b[^.]{0,80}?\b(?:commit|publish|ship|include|contain|add|check in)\b/i;
167
+ const FORMAT_RE = /\b(?:must (?:match|agree|equal|contain|carry)|in sync with|consistent with|agree with)\b/i;
168
+ /** Repo-STATE phrasing — recognised only so the refusal can name WHY (ADR-002). */
169
+ const PRESENCE_RE = /\b(?:every|each|all)\b[^.]{0,80}?\b(?:must (?:have|carry|ship with|contain|include)|needs? an?)\b/i;
170
+ /**
171
+ * The discriminator between a repo-STATE predicate and a CHANGE predicate that happen to share the
172
+ * phrase *"must contain"*. Codex QE MED-4: *"Every package must contain X in package.json"* is a
173
+ * state predicate over repo entities and slipped through as `format-match`; *"Every CHANGED
174
+ * package.json must contain X"* scopes over the change set and is genuinely per-commit decidable.
175
+ * The word that scopes it is the whole difference, so it is the whole test.
176
+ */
177
+ const CHANGE_SCOPED_RE = /\b(?:changed|modified|touched|edited|updated|committed|staged)\b/i;
178
+ /** Backticked spans, in order — the highest-confidence token source. */
179
+ function backticked(text) {
180
+ const out = [];
181
+ const re = /`([^`\n]{1,120})`/g;
182
+ let m;
183
+ while ((m = re.exec(text)) !== null)
184
+ if (m[1] !== undefined)
185
+ out.push(m[1].trim());
186
+ return out;
187
+ }
188
+ /** Artifact tokens in TEXT ORDER, deduped. A token with no `/` becomes a basename glob. */
189
+ export function artifactTokens(text) {
190
+ const seen = new Set();
191
+ const out = [];
192
+ for (const raw of text.match(ARTIFACT_RE) ?? []) {
193
+ const t = raw.replace(/^[./]+/, '');
194
+ if (t === '' || seen.has(t))
195
+ continue;
196
+ seen.add(t);
197
+ out.push(t);
198
+ }
199
+ return out;
200
+ }
201
+ /** `README.md` → `**​/README.md`; `packages/x/README.md` → itself. */
202
+ export function tokenToGlob(token) {
203
+ return token.includes('/') ? token : `**/${token}`;
204
+ }
205
+ /**
206
+ * Reduce a lesson to a (template, params) pair, or refuse WITH A REASON.
207
+ *
208
+ * Conservative by construction and asymmetric by design: a false negative costs a missed promotion
209
+ * (the lesson stays exactly where it already was); a false positive is caught downstream by the
210
+ * win-twice gate and the duplicate refusal, and even a survivor lands SOFT + advisory.
211
+ */
212
+ export function classifyLesson(text) {
213
+ if (typeof text !== 'string' || text.trim() === '')
214
+ return { reason: 'not-promotable: empty lesson text' };
215
+ const tokens = artifactTokens(text);
216
+ // PAIRING first: it is the most specific shape and the corpus phrases it explicitly.
217
+ if (PAIRING_RE.test(text)) {
218
+ if (tokens.length < 2)
219
+ return { reason: `not-promotable: pairing-shaped but only ${tokens.length} artifact token(s) — cannot bind when/requires` };
220
+ if (tokens.length > 3)
221
+ return { reason: `not-promotable: pairing-shaped but ambiguous (${tokens.length} distinct artifact tokens; the classifier binds at most 2)` };
222
+ const when = tokens[0];
223
+ const requires = tokens[1];
224
+ return { template: 'pairing-check', params: { when: tokenToGlob(when), requires: tokenToGlob(requires) }, tokens: [when, requires] };
225
+ }
226
+ if (ABSENCE_RE.test(text)) {
227
+ if (tokens.length !== 1)
228
+ return { reason: `not-promotable: absence-shaped but ${tokens.length} artifact token(s) — absence-check binds exactly 1` };
229
+ return { template: 'absence-check', params: { forbid: tokenToGlob(tokens[0]) }, tokens: [tokens[0]] };
230
+ }
231
+ // PRESENCE IS TESTED BEFORE FORMAT (Codex QE MED-4). Both surface as "must contain", but only the
232
+ // change-scoped one is decidable per commit. A state predicate that reached `format-match` would be
233
+ // replayed against whatever files happened to change — an answer to a different question.
234
+ if (PRESENCE_RE.test(text) && !CHANGE_SCOPED_RE.test(text)) {
235
+ return {
236
+ reason: 'not-promotable: presence-shaped (a repo-STATE predicate over repo entities, not over a change). v1 has no shadow evaluator for state predicates — replaying one against today\'s tree returns the same answer every window and would MANUFACTURE two consecutive wins from one observation. Re-word it to scope over the CHANGE ("every CHANGED <file> must …") if that is what you mean (ADR-002)',
237
+ };
238
+ }
239
+ if (FORMAT_RE.test(text)) {
240
+ const literals = backticked(text).filter((s) => !ARTIFACT_RE.test(s) && s.length >= 3);
241
+ ARTIFACT_RE.lastIndex = 0; // the /g regex above is stateful — reset or the next call skips matches
242
+ if (tokens.length !== 1)
243
+ return { reason: `not-promotable: format-shaped but ${tokens.length} artifact token(s) — format-match binds exactly 1` };
244
+ if (literals.length !== 1)
245
+ return { reason: `not-promotable: format-shaped but ${literals.length} backticked literal(s) — format-match needs exactly 1 to match against` };
246
+ return { template: 'format-match', params: { file: tokenToGlob(tokens[0]), mustMatch: literals[0] }, tokens: [tokens[0]] };
247
+ }
248
+ return { reason: 'not-promotable: no template matched (the lesson is semantic, not a deterministic change predicate)' };
249
+ }
250
+ /**
251
+ * FNV-1a, 32-bit — a DISCRIMINATOR, not a security primitive, and labelled as one.
252
+ *
253
+ * It exists solely to keep two DIFFERENT rule bodies from claiming the same id after slug
254
+ * normalisation (`a.b.json` and `a-b.json` both slug to `a-b-json`). Nothing trusts it for
255
+ * integrity or authenticity; the key space is a few dozen self-generated rule bodies, so a
256
+ * non-cryptographic 32-bit mix is ample. Kept in-module because this file is deliberately pure with
257
+ * zero imports (NFR-1) — reaching for `node:crypto` here would buy nothing the threat model needs.
258
+ */
259
+ export function fnv1a32(s) {
260
+ let h = 0x811c9dc5;
261
+ for (let i = 0; i < s.length; i++) {
262
+ h ^= s.charCodeAt(i);
263
+ h = Math.imul(h, 0x01000193) >>> 0;
264
+ }
265
+ return h.toString(16).padStart(8, '0');
266
+ }
267
+ /**
268
+ * Stable rule id derived from the template + its bound params.
269
+ *
270
+ * The trailing hash is load-bearing (Codex QE MED-6): the slug lowercases and collapses every
271
+ * non-alphanumeric run, so `a.b.json` and `a-b.json` — two genuinely different rules — produced the
272
+ * SAME id and the second silently read as a duplicate of the first. The hash is taken over the
273
+ * template and the actual PARAMS (not the pre-normalisation tokens), so two rules collide only if
274
+ * they would enforce exactly the same thing.
275
+ */
276
+ export function derivedRuleId(c) {
277
+ const slug = c.tokens
278
+ .map((t) => t.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''))
279
+ .filter(Boolean)
280
+ .join('-');
281
+ const kind = c.template.replace('-check', '').replace('format-match', 'format');
282
+ const hash = fnv1a32(paramsKey(c.template, c.params)).slice(0, 6);
283
+ return `${`promoted-${kind}-${slug}`.slice(0, 72)}-${hash}`;
284
+ }
285
+ /**
286
+ * The character set a promoted rule id may use. Enforced wherever an id becomes part of a FILE PATH:
287
+ * an id is data that has round-tripped through `.dz/promotion-state.json`, and a path segment built
288
+ * from unvalidated data is an arbitrary-write primitive (Codex QE HIGH-1).
289
+ */
290
+ export function isSafeRuleId(id) {
291
+ return typeof id === 'string' && id.length > 0 && id.length <= 100 && /^promoted-[a-z0-9][a-z0-9-]*$/.test(id);
292
+ }
293
+ /** The one place mini-ADR paths are defined (POSIX-relative, forward slashes). */
294
+ export const PROMOTIONS_REL_DIR = 'features/guard-promotion/promotions';
295
+ /**
296
+ * DERIVE a mini-ADR path from a validated id + an integer sequence — the only way a promotion
297
+ * document path is ever produced (Codex QE HIGH-1). Returns `null` when either input fails
298
+ * validation, so a caller that gets `null` writes nothing rather than falling back to a raw string.
299
+ * The character set (`isSafeRuleId`) admits no `/`, no `.`, and no `..`, so the result cannot escape
300
+ * {@link PROMOTIONS_REL_DIR}; callers still assert containment after resolving, because a derivation
301
+ * that is correct today is not a substitute for checking the thing you are about to write.
302
+ */
303
+ export function promotionAdrRelPath(ruleId, seq) {
304
+ if (!isSafeRuleId(ruleId))
305
+ return null;
306
+ if (typeof seq !== 'number' || !Number.isInteger(seq) || seq < 0 || seq > 100_000)
307
+ return null;
308
+ return `${PROMOTIONS_REL_DIR}/${String(seq).padStart(3, '0')}-${ruleId}.md`;
309
+ }
310
+ // ── Coverage: what the built-in rules ALREADY do (ADR-002) ──────────────────────────────────────
311
+ /**
312
+ * Template-equivalents of the built-in guard rules that have one. Rule ids are plain literals — this
313
+ * module must not import `guard.ts` (guard.ts imports THIS one).
314
+ *
315
+ * DELIBERATELY PARTIAL. `no-secrets` (content regexes), `readme-consistency` (numeric parity),
316
+ * `no-skill-drift` (byte comparison) and `store-bloat-cap` (a counter) have no template equivalent
317
+ * and are simply absent. Partiality errs in the SAFE direction only because over-refusing costs a
318
+ * missed promotion while under-refusing ships a duplicate rule — so when in doubt, add an entry.
319
+ */
320
+ export const BUILTIN_COVERAGE = {
321
+ 'readme-first': { template: 'pairing-check', params: { when: '**/package.json', requires: '**/README.md' } },
322
+ 'lockfile-in-sync': { template: 'pairing-check', params: { when: '**/package.json', requires: '**/pnpm-lock.yaml' } },
323
+ };
324
+ /** Order-insensitive, whitespace-insensitive params key for equality. */
325
+ export function paramsKey(template, params) {
326
+ const p = params ?? {};
327
+ const parts = Object.keys(p)
328
+ .sort()
329
+ .map((k) => `${k}=${String(p[k]).trim()}`);
330
+ return `${template}|${parts.join('&')}`;
331
+ }
332
+ /** The id of the rule that already covers this candidate, or `null`. */
333
+ export function coveringRule(c, existing) {
334
+ const key = paramsKey(c.template, c.params);
335
+ for (const [id, cov] of Object.entries(BUILTIN_COVERAGE)) {
336
+ if (paramsKey(cov.template, cov.params) === key && existing.some((e) => e?.id === id))
337
+ return id;
338
+ }
339
+ for (const e of existing) {
340
+ if (!e || typeof e.id !== 'string')
341
+ continue;
342
+ if (e.template !== undefined && e.params !== undefined && paramsKey(e.template, e.params) === key)
343
+ return e.id;
344
+ }
345
+ return null;
346
+ }
347
+ // ── The win-twice gate (ADR-003) ────────────────────────────────────────────────────────────────
348
+ export const DEFAULT_WINDOW_DAYS = 7;
349
+ export const DEFAULT_PERIODS = 4;
350
+ /** Below this many changes a window carries no information — it is SKIPPED, never counted a loss. */
351
+ export const MIN_CHANGES_PER_PERIOD = 5;
352
+ export const WINS_TO_PROMOTE = 2;
353
+ /** Cap on `git show` fetches per run; over it, a format-match candidate is insufficient-data. */
354
+ export const MAX_CONTENT_FETCHES = 200;
355
+ const DAY_MS = 86_400_000;
356
+ /**
357
+ * Cut history into `periods` consecutive `windowDays` windows anchored at `nowMs`, walking BACKWARDS
358
+ * and returned oldest→newest. Wall-clock windows, NOT per-invocation and NOT per-commit-count: an
359
+ * operator's invocation frequency must never be an input to a safety gate (ADR-003 option A).
360
+ */
361
+ export function buildPeriods(changes, nowMs, windowDays = DEFAULT_WINDOW_DAYS, periods = DEFAULT_PERIODS) {
362
+ const w = Number.isFinite(windowDays) && windowDays >= 1 ? Math.min(Math.floor(windowDays), 365) : DEFAULT_WINDOW_DAYS;
363
+ const n = Number.isFinite(periods) && periods >= 1 ? Math.min(Math.floor(periods), 52) : DEFAULT_PERIODS;
364
+ const now = Number.isFinite(nowMs) ? nowMs : 0;
365
+ const timed = (Array.isArray(changes) ? changes : [])
366
+ .map((c) => ({ c, ms: Date.parse(c?.ts ?? '') }))
367
+ .filter((x) => Number.isFinite(x.ms));
368
+ const out = [];
369
+ for (let i = n - 1; i >= 0; i--) {
370
+ const end = now - i * w * DAY_MS;
371
+ const start = end - w * DAY_MS;
372
+ out.push({
373
+ start: new Date(start).toISOString(),
374
+ end: new Date(end).toISOString(),
375
+ changes: timed.filter((x) => x.ms > start && x.ms <= end).map((x) => x.c),
376
+ });
377
+ }
378
+ return out;
379
+ }
380
+ /**
381
+ * Replay the candidate over each period.
382
+ *
383
+ * A period below {@link MIN_CHANGES_PER_PERIOD} is SKIPPED — a one-commit week that happens not to
384
+ * touch package.json is NOT evidence the pairing rule is worthless, it is NO evidence, and absence
385
+ * of data must never be converted into a negative observation.
386
+ */
387
+ export function evaluateCandidate(c, periods, minChanges = MIN_CHANGES_PER_PERIOD) {
388
+ const floor = Number.isFinite(minChanges) && minChanges >= 1 ? Math.floor(minChanges) : MIN_CHANGES_PER_PERIOD;
389
+ const results = [];
390
+ let wins = 0;
391
+ let evaluated = 0;
392
+ let totalFirings = 0;
393
+ let undecidable;
394
+ for (const p of Array.isArray(periods) ? periods : []) {
395
+ const changes = Array.isArray(p?.changes) ? p.changes : [];
396
+ if (changes.length < floor) {
397
+ results.push({ start: p.start, end: p.end, changes: changes.length, firings: 0, outcome: 'skipped' });
398
+ continue;
399
+ }
400
+ let firings = 0;
401
+ let evidence;
402
+ for (const ch of changes) {
403
+ const r = templateFires(c.template, c.params, ch);
404
+ if (Object.hasOwn(r, 'undecidable')) {
405
+ undecidable = undecidable ?? r.undecidable;
406
+ continue;
407
+ }
408
+ if (r.fired) {
409
+ firings += 1;
410
+ evidence = evidence ?? `${ch.id}${r.detail ? `: ${r.detail}` : ''}`;
411
+ }
412
+ }
413
+ evaluated += 1;
414
+ totalFirings += firings;
415
+ // A win increments; a LOSS RESETS TO ZERO (ADR-G008's rule, kept verbatim).
416
+ if (firings > 0)
417
+ wins += 1;
418
+ else
419
+ wins = 0;
420
+ results.push({ start: p.start, end: p.end, changes: changes.length, firings, outcome: firings > 0 ? 'win' : 'loss', ...(evidence !== undefined ? { evidence } : {}) });
421
+ }
422
+ return { periods: results, evaluatedPeriods: evaluated, wins, totalFirings, ...(undecidable !== undefined ? { undecidable } : {}) };
423
+ }
424
+ /**
425
+ * A promotion also needs this many WINDOW-LENGTHS of REAL elapsed time since the candidate was first
426
+ * recorded — a defence Codex QE (MED-7) showed the window logic alone does not provide.
427
+ *
428
+ * The threat is not an attacker; it is ACCIDENTAL SELF-GAMING. Commit timestamps are author-supplied
429
+ * (`GIT_COMMITTER_DATE`, a rebase, an import, a clock skew), so a repo whose history is minted in one
430
+ * afternoon can present two full "windows" instantly, and the gate that is supposed to mean *"this
431
+ * recurred over two separate stretches of work"* would mean nothing.
432
+ *
433
+ * THE HONEST SPLIT, stated so it is not mistaken for more than it is:
434
+ * • committer dates are trusted for firing ATTRIBUTION — which commit a violation belongs to;
435
+ * • the LOCAL clock, journalled in state, gates ELAPSED time — how long we have been watching.
436
+ * This is not cryptographic and does not resist a determined forger (state is a local JSON file you
437
+ * can edit). It resists the realistic failure: history that only LOOKS like it spans two windows.
438
+ */
439
+ export const ELAPSED_WINDOWS_REQUIRED = 2;
440
+ export function promotedRuleObject(c, ruleId, lessonId) {
441
+ const what = c.template === 'pairing-check'
442
+ ? `a change touching ${c.params.when} must also touch ${c.params.requires}`
443
+ : c.template === 'absence-check'
444
+ ? `no change may touch ${c.params.forbid}`
445
+ : `every changed ${c.params.file} must contain ${JSON.stringify(c.params.mustMatch)}`;
446
+ return {
447
+ id: ruleId,
448
+ severity: 'soft',
449
+ ops: ['publish'],
450
+ enabled: true,
451
+ template: c.template,
452
+ params: c.params,
453
+ description: `${what} — promoted from lesson ${lessonId} after ${WINS_TO_PROMOTE} consecutive shadow wins (dz guard promote)`,
454
+ };
455
+ }
456
+ /**
457
+ * Rank every lesson and decide. Deterministic: the sort is (score desc, ruleId asc, lessonId asc), so
458
+ * ties never reorder between runs.
459
+ */
460
+ export function assembleCandidates(facts) {
461
+ const lessons = Array.isArray(facts?.lessons) ? facts.lessons : [];
462
+ const existing = Array.isArray(facts?.existingRules) ? facts.existingRules : [];
463
+ const nowMs = Date.parse(facts?.nowTs ?? '');
464
+ const windowDays = Number.isFinite(facts?.windowDays) ? facts.windowDays : DEFAULT_WINDOW_DAYS;
465
+ const periodCount = Number.isFinite(facts?.periods) ? facts.periods : DEFAULT_PERIODS;
466
+ const periods = buildPeriods(facts?.changes ?? [], Number.isFinite(nowMs) ? nowMs : 0, windowDays, periodCount);
467
+ // The REAL-elapsed requirement (MED-7), derived from the same window length the replay uses.
468
+ const windowMs = (Number.isFinite(windowDays) && windowDays >= 1 ? Math.min(Math.floor(windowDays), 365) : DEFAULT_WINDOW_DAYS) * DAY_MS;
469
+ const elapsedRequiredMs = ELAPSED_WINDOWS_REQUIRED * windowMs;
470
+ const firstSeenMap = facts?.firstSeen && typeof facts.firstSeen === 'object' ? facts.firstSeen : {};
471
+ const out = [];
472
+ let quarantinedSkipped = 0;
473
+ for (const l of lessons) {
474
+ if (!l || typeof l.dzId !== 'string')
475
+ continue;
476
+ const uses = Number.isFinite(l.uses) && l.uses >= 0 ? Math.floor(l.uses) : 0;
477
+ const cost = 1 + uses;
478
+ const base = { lessonId: l.dzId, lessonText: typeof l.text === 'string' ? l.text : '', cost, ruleId: null, template: null, params: null, score: 0, firings: 0, wins: 0, evaluatedPeriods: 0, periods: [], proposedRule: null, firstSeenTs: null, elapsedMs: 0, elapsedRequiredMs };
479
+ if (l.quarantined === true)
480
+ quarantinedSkipped += 1;
481
+ // (b) CHECKABILITY runs FIRST — not because it outranks trust, but because a refusal that names
482
+ // WHAT the lesson would become is a roadmap, and one that just says "quarantined" is a
483
+ // shrug. The trust gate below still decides the verdict; classification only informs it.
484
+ const cls = classifyLesson(l.text);
485
+ if (!isClassified(cls)) {
486
+ // For an unclassifiable lesson, quarantine is moot — the deeper fact is that no deterministic
487
+ // check can express it, and that stays true however reinforced it becomes.
488
+ out.push({ ...base, verdict: 'not-promotable', reason: cls.reason });
489
+ continue;
490
+ }
491
+ const ruleId = derivedRuleId(cls);
492
+ const covering = coveringRule(cls, existing);
493
+ // LOCAL-clock first observation. Absent (or unparseable) ⇒ this run IS the first observation, so
494
+ // elapsed is 0 and nothing can promote — the clock starts when the candidate is first RECORDED,
495
+ // which `--dry-run` deliberately never does.
496
+ const firstSeenRaw = Object.hasOwn(firstSeenMap, ruleId) ? firstSeenMap[ruleId] : undefined;
497
+ const firstSeenMs = typeof firstSeenRaw === 'string' ? Date.parse(firstSeenRaw) : Number.NaN;
498
+ const firstSeenTs = Number.isFinite(firstSeenMs) ? firstSeenRaw : null;
499
+ // A FUTURE firstSeen (clock skew, hand-edited state) must not mint elapsed time: clamp at 0.
500
+ const elapsedMs = Number.isFinite(firstSeenMs) && Number.isFinite(nowMs) ? Math.max(0, nowMs - firstSeenMs) : 0;
501
+ const shaped = { ...base, ruleId, template: cls.template, params: cls.params, firstSeenTs, elapsedMs };
502
+ // (a) TRUST — a fresh lesson is a hypothesis (lesson-quarantine ADR). Enforcement is the LAST
503
+ // thing an unproven hypothesis should earn. This gate BINDS, whatever the classification says.
504
+ if (l.quarantined === true) {
505
+ out.push({
506
+ ...shaped,
507
+ verdict: 'not-promotable',
508
+ reason: `not-promotable: quarantined (an unproven hypothesis must not become an enforced rule) — it WOULD classify as ${cls.template}${covering !== null ? `, and is already covered by '${covering}'` : ''}; confirm it with \`dz teach --reinforce\` to make it eligible`,
509
+ });
510
+ continue;
511
+ }
512
+ if (covering !== null) {
513
+ out.push({ ...shaped, verdict: 'duplicate', reason: `duplicate: already covered by the existing rule '${covering}'` });
514
+ continue;
515
+ }
516
+ const ev = evaluateCandidate(cls, periods);
517
+ const score = ev.totalFirings * cost;
518
+ const common = { ...shaped, score, firings: ev.totalFirings, wins: ev.wins, evaluatedPeriods: ev.evaluatedPeriods, periods: ev.periods };
519
+ if (ev.undecidable !== undefined && ev.totalFirings === 0) {
520
+ out.push({ ...common, verdict: 'insufficient-data', reason: `insufficient-data: ${ev.undecidable}` });
521
+ continue;
522
+ }
523
+ if (ev.evaluatedPeriods < 2) {
524
+ out.push({ ...common, verdict: 'insufficient-data', reason: `insufficient-data: only ${ev.evaluatedPeriods} period(s) had >= ${MIN_CHANGES_PER_PERIOD} changes — WAITING (thin evidence never promotes and never rejects)` });
525
+ continue;
526
+ }
527
+ if (ev.wins >= WINS_TO_PROMOTE) {
528
+ // The SECOND clock (MED-7). Two "windows" of committer dates can be minted in one afternoon;
529
+ // real elapsed time since the candidate was first RECORDED cannot. Both must pass.
530
+ if (elapsedMs < elapsedRequiredMs) {
531
+ const days = (ms) => (ms / DAY_MS).toFixed(1);
532
+ out.push({
533
+ ...common,
534
+ verdict: 'wait',
535
+ reason: `wait: ${ev.wins} consecutive shadow win(s), but only ${days(elapsedMs)}d of the ${days(elapsedRequiredMs)}d REAL elapsed time required since first observation` +
536
+ (firstSeenTs === null
537
+ ? ' — this run is the first observation; commit dates are author-supplied, so elapsed time is measured by the local clock recorded in .dz/promotion-state.json (a --dry-run never starts that clock)'
538
+ : ` (first seen ${firstSeenTs})`),
539
+ });
540
+ continue;
541
+ }
542
+ out.push({ ...common, verdict: 'promote', reason: `promote: ${ev.wins} consecutive shadow win(s) over ${ev.evaluatedPeriods} evaluated period(s), ${ev.totalFirings} real firing(s), and ${(elapsedMs / DAY_MS).toFixed(1)}d of real elapsed time since first observation`, proposedRule: promotedRuleObject(cls, ruleId, l.dzId) });
543
+ continue;
544
+ }
545
+ out.push({ ...common, verdict: 'wait', reason: `wait: ${ev.wins}/${WINS_TO_PROMOTE} consecutive shadow win(s) over ${ev.evaluatedPeriods} evaluated period(s)` });
546
+ }
547
+ out.sort((a, b) => b.score - a.score || (a.ruleId ?? '').localeCompare(b.ruleId ?? '') || a.lessonId.localeCompare(b.lessonId));
548
+ const n = (v) => out.filter((c) => c.verdict === v).length;
549
+ const verdict = `${out.length} lesson(s) · promote ${n('promote')} · wait ${n('wait')} · insufficient-data ${n('insufficient-data')} · duplicate ${n('duplicate')} · not-promotable ${n('not-promotable')}`;
550
+ return {
551
+ candidates: out,
552
+ totalLessons: lessons.length,
553
+ quarantinedSkipped,
554
+ windowDays: periods.length > 0 ? windowDays : DEFAULT_WINDOW_DAYS,
555
+ periodCount: periods.length,
556
+ totalChanges: Array.isArray(facts?.changes) ? facts.changes.length : 0,
557
+ verdict,
558
+ };
559
+ }
560
+ export const EMPTY_PROMOTION_STATE = { version: 1, nextAdrSeq: 1, entries: {} };
561
+ /**
562
+ * Keys that must never become an entry name. `Object.hasOwn` stops a polluted JSON from being READ
563
+ * through the prototype, but it does not stop `entries[key] = …` from WRITING through it: `JSON.parse`
564
+ * gives `__proto__` as an own property, and a plain assignment with that key sets the object's
565
+ * prototype instead of adding a member — so `state.entries.ruleId` then resolves to the attacker's
566
+ * value. (Found by this feature's own hostile-input test, not by review.)
567
+ */
568
+ const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
569
+ /**
570
+ * Read state defensively. `Object.hasOwn` (never `in`) so a prototype-polluted JSON cannot conjure an
571
+ * entry; `Number.isInteger` on every counter because `1e400` parses to `Infinity`, passes `> 0`, and
572
+ * this repo has already been bitten by exactly that twice (storeCap, auto-cost).
573
+ */
574
+ export function normalizePromotionState(raw) {
575
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
576
+ return EMPTY_PROMOTION_STATE;
577
+ const o = raw;
578
+ if (!Object.hasOwn(o, 'version') || o['version'] !== 1)
579
+ return EMPTY_PROMOTION_STATE;
580
+ const seqRaw = o['nextAdrSeq'];
581
+ const nextAdrSeq = typeof seqRaw === 'number' && Number.isInteger(seqRaw) && seqRaw >= 1 && seqRaw <= 100_000 ? seqRaw : 1;
582
+ const entries = {};
583
+ const rawEntries = o['entries'];
584
+ if (rawEntries && typeof rawEntries === 'object' && !Array.isArray(rawEntries)) {
585
+ for (const key of Object.keys(rawEntries)) {
586
+ if (!Object.hasOwn(rawEntries, key) || UNSAFE_KEYS.has(key))
587
+ continue;
588
+ const e = rawEntries[key];
589
+ if (!e || typeof e !== 'object' || Array.isArray(e))
590
+ continue;
591
+ const r = e;
592
+ const str = (k) => (typeof r[k] === 'string' ? r[k] : undefined);
593
+ const int = (k) => (typeof r[k] === 'number' && Number.isInteger(r[k]) && r[k] >= 0 ? r[k] : 0);
594
+ const ruleId = str('ruleId');
595
+ const lessonId = str('lessonId');
596
+ if (ruleId === undefined || lessonId === undefined)
597
+ continue;
598
+ // The KEY is used to derive a file path, so it must satisfy the id whitelist — and it must
599
+ // agree with the entry's own ruleId, so a well-named key cannot smuggle a hostile body.
600
+ if (!isSafeRuleId(key) || key !== ruleId)
601
+ continue;
602
+ const seqRawE = r['adrSeq'];
603
+ const adrSeq = typeof seqRawE === 'number' && Number.isInteger(seqRawE) && seqRawE >= 0 && seqRawE <= 100_000 ? seqRawE : undefined;
604
+ entries[key] = {
605
+ ruleId,
606
+ lessonId,
607
+ // A malformed-but-nonempty firstSeenTs would WEDGE the elapsed clock forever (now − NaN is
608
+ // never ≥ anything) — an unparseable timestamp RESTARTS the clock instead (Codex re-QE LOW).
609
+ firstSeenTs: (() => { const v = str('firstSeenTs') ?? ''; return v !== '' && Number.isFinite(Date.parse(v)) ? v : ''; })(),
610
+ lastRunTs: str('lastRunTs') ?? '',
611
+ wins: int('wins'),
612
+ evaluatedPeriods: int('evaluatedPeriods'),
613
+ verdict: ['promote', 'wait', 'insufficient-data', 'duplicate', 'not-promotable'].includes(str('verdict')) ? str('verdict') : 'wait',
614
+ ...(adrSeq !== undefined ? { adrSeq } : {}),
615
+ ...(str('appliedTs') !== undefined ? { appliedTs: str('appliedTs') } : {}),
616
+ };
617
+ }
618
+ }
619
+ return { version: 1, nextAdrSeq, entries };
620
+ }
621
+ /**
622
+ * Fold a report into the state journal.
623
+ *
624
+ * THE ANTI-GAMING PROPERTY (SP-3): `wins` is OVERWRITTEN with the freshly recomputed value — it is
625
+ * never `prev.wins + …`. The state is a JOURNAL, not the source of truth, so running the promoter
626
+ * ten times over unchanged history leaves the counter exactly where one run leaves it. (Recalled
627
+ * lesson: "a learning loop's write path can promote by EXPOSURE without anyone noticing.")
628
+ */
629
+ export function nextPromotionState(prev, report, nowTs, adrSeqs = {}, newlyAllocated) {
630
+ const base = normalizePromotionState(prev);
631
+ const entries = { ...base.entries };
632
+ for (const c of report.candidates) {
633
+ // Unclassifiable lessons get no journal entry (they have no rule identity); an id that fails the
634
+ // whitelist gets none either, because the key is later turned into a file path.
635
+ if (c.ruleId === null || UNSAFE_KEYS.has(c.ruleId) || !isSafeRuleId(c.ruleId))
636
+ continue;
637
+ const old = Object.hasOwn(entries, c.ruleId) ? entries[c.ruleId] : undefined;
638
+ entries[c.ruleId] = {
639
+ ruleId: c.ruleId,
640
+ lessonId: c.lessonId,
641
+ firstSeenTs: old?.firstSeenTs && old.firstSeenTs !== '' ? old.firstSeenTs : nowTs,
642
+ lastRunTs: nowTs,
643
+ wins: c.wins, // OVERWRITE, never accumulate — SP-3
644
+ evaluatedPeriods: c.evaluatedPeriods,
645
+ verdict: c.verdict,
646
+ ...(Object.hasOwn(adrSeqs, c.ruleId) ? { adrSeq: adrSeqs[c.ruleId] } : old?.adrSeq !== undefined ? { adrSeq: old.adrSeq } : {}),
647
+ ...(old?.appliedTs !== undefined ? { appliedTs: old.appliedTs } : {}),
648
+ };
649
+ }
650
+ // Only NEWLY allocated documents advance the sequence — a re-refused candidate rewrites its own
651
+ // file, so counting every path would leave permanent gaps in the numbering.
652
+ const seq = Number.isInteger(newlyAllocated) && newlyAllocated >= 0 ? newlyAllocated : Object.keys(adrSeqs).length;
653
+ return { version: 1, nextAdrSeq: Math.min(100_000, base.nextAdrSeq + seq), entries };
654
+ }
655
+ // ── Rendering ───────────────────────────────────────────────────────────────────────────────────
656
+ const GLYPH = {
657
+ promote: '★',
658
+ wait: '·',
659
+ 'insufficient-data': '?',
660
+ duplicate: '=',
661
+ 'not-promotable': '✗',
662
+ };
663
+ export function renderPromotionReport(r, limit = 15) {
664
+ const out = [];
665
+ out.push('dz guard promote — lesson → guard-rule promotion (two consecutive shadow wins required)');
666
+ out.push('');
667
+ out.push(` corpus: ${r.totalLessons} lesson(s) · ${r.quarantinedSkipped} quarantined · ${r.totalChanges} change(s) over ${r.periodCount} × ${r.windowDays}d window(s)`);
668
+ out.push('');
669
+ // A lesson that REDUCED to a template is shown even when refused — that is the interesting half of
670
+ // the report. Only the unclassifiable ones collapse into the histogram below.
671
+ const ranked = r.candidates.filter((c) => c.ruleId !== null);
672
+ if (ranked.length === 0) {
673
+ out.push(' RANKED CANDIDATES: none — no lesson in the store reduces to a v1 rule template');
674
+ }
675
+ else {
676
+ out.push(' RANKED CANDIDATES (score = firings × cost, cost = 1 + lesson uses — cost is a PROXY, not a token figure):');
677
+ for (const c of ranked.slice(0, limit)) {
678
+ out.push(` ${GLYPH[c.verdict]} [${String(c.score).padStart(4)}] ${c.ruleId ?? '(unclassified)'} ${c.verdict.toUpperCase()}`);
679
+ out.push(` ${c.reason}`);
680
+ if (c.periods.length > 0) {
681
+ out.push(` periods (oldest→newest): ${c.periods.map((p) => `${p.outcome === 'win' ? 'W' : p.outcome === 'loss' ? 'L' : '–'}${p.firings}/${p.changes}`).join(' ')}`);
682
+ const ev = c.periods.find((p) => p.evidence !== undefined);
683
+ if (ev?.evidence !== undefined)
684
+ out.push(` evidence: ${ev.evidence}`);
685
+ }
686
+ }
687
+ if (ranked.length > limit)
688
+ out.push(` … ${ranked.length - limit} more`);
689
+ }
690
+ const refused = r.candidates.filter((c) => c.verdict === 'not-promotable' && c.ruleId === null);
691
+ if (refused.length > 0) {
692
+ out.push('');
693
+ out.push(` NOT PROMOTABLE — no template matched (${refused.length}), by reason:`);
694
+ const byReason = new Map();
695
+ for (const c of refused) {
696
+ const short = c.reason.replace(/^not-promotable: /, '').split(' —')[0].split(' (the classifier')[0];
697
+ byReason.set(short, (byReason.get(short) ?? 0) + 1);
698
+ }
699
+ for (const [reason, n] of [...byReason.entries()].sort((a, b) => b[1] - a[1]))
700
+ out.push(` ${String(n).padStart(4)} × ${reason}`);
701
+ }
702
+ out.push('');
703
+ out.push(` VERDICT: ${r.verdict}`);
704
+ return out.join('\n');
705
+ }
706
+ /**
707
+ * The mini-ADR for one decision. Written for PROMOTIONS and REJECTIONS alike (ADR-G008 requires
708
+ * both) — a refusal is a decision about the harness's own capability, and it is what turns the
709
+ * "not promotable" list into a roadmap instead of a shrug. `wait` / `insufficient-data` get NO
710
+ * document: they are not decisions yet, and one per run would bury the real ones.
711
+ */
712
+ export function renderPromotionAdr(c, seq, nowTs) {
713
+ const decision = c.verdict === 'promote' ? 'PROMOTED (proposal)' : 'REFUSED';
714
+ const out = [];
715
+ out.push(`# ${String(seq).padStart(3, '0')} — ${c.ruleId ?? c.lessonId}`);
716
+ out.push('');
717
+ out.push(`**Decision:** ${decision}`);
718
+ out.push(`**Date:** ${nowTs}`);
719
+ out.push(`**Lesson:** \`${c.lessonId}\``);
720
+ out.push('');
721
+ out.push('## Lesson');
722
+ out.push('');
723
+ out.push('> ' + c.lessonText.replace(/\n/g, '\n> '));
724
+ out.push('');
725
+ out.push('## Classification');
726
+ out.push('');
727
+ out.push(`- template: \`${c.template ?? '(none)'}\``);
728
+ out.push(`- params: \`${JSON.stringify(c.params ?? {})}\``);
729
+ out.push('');
730
+ out.push('## Evidence');
731
+ out.push('');
732
+ out.push(`- score: **${c.score}** = ${c.firings} firing(s) × cost ${c.cost} (cost = 1 + lesson uses — a named PROXY, not a token/dollar figure)`);
733
+ out.push(`- consecutive shadow wins: **${c.wins}** / ${WINS_TO_PROMOTE} required`);
734
+ out.push(`- evaluated periods: ${c.evaluatedPeriods} (a period with < ${MIN_CHANGES_PER_PERIOD} changes is SKIPPED, never counted a loss)`);
735
+ if (c.periods.length > 0) {
736
+ out.push('');
737
+ out.push('| window start | window end | changes | firings | outcome | evidence |');
738
+ out.push('|---|---|---|---|---|---|');
739
+ for (const p of c.periods)
740
+ out.push(`| ${p.start} | ${p.end} | ${p.changes} | ${p.firings} | ${p.outcome} | ${p.evidence ?? '—'} |`);
741
+ }
742
+ out.push('');
743
+ out.push('## Reason');
744
+ out.push('');
745
+ out.push(c.reason);
746
+ out.push('');
747
+ if (c.proposedRule !== null) {
748
+ out.push('## The rule `--apply` would write into `.dz/guard.json`');
749
+ out.push('');
750
+ out.push('```json');
751
+ out.push(JSON.stringify(c.proposedRule, null, 2));
752
+ out.push('```');
753
+ out.push('');
754
+ out.push('Severity is `soft` and cannot be raised: `resolveRules` forces SOFT for every');
755
+ out.push('template-backed rule, so a hand-edited `"severity": "hard"` in the config is ignored');
756
+ out.push('(ADR-004 / the `lockfile-in-sync` precedent).');
757
+ }
758
+ return out.join('\n') + '\n';
759
+ }
760
+ //# sourceMappingURL=guard-promotion.js.map