@dzhechkov/harness-core 0.8.21 → 0.8.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/.dz-manifest.json +68 -48
  2. package/README.md +163 -0
  3. package/dist/amendment-trace.d.ts.map +1 -1
  4. package/dist/amendment-trace.js +5 -140
  5. package/dist/amendment-trace.js.map +1 -1
  6. package/dist/claude-hooks-assets.d.ts +3 -3
  7. package/dist/claude-hooks-assets.d.ts.map +1 -1
  8. package/dist/claude-hooks-assets.js +24 -11
  9. package/dist/claude-hooks-assets.js.map +1 -1
  10. package/dist/discrimination-gate.d.ts.map +1 -1
  11. package/dist/discrimination-gate.js +2 -1
  12. package/dist/discrimination-gate.js.map +1 -1
  13. package/dist/feature-adr-routing.d.ts +3 -1
  14. package/dist/feature-adr-routing.d.ts.map +1 -1
  15. package/dist/feature-adr-routing.js +17 -10
  16. package/dist/feature-adr-routing.js.map +1 -1
  17. package/dist/index.d.ts +1 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +1 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/loop-blobs.generated.js +2 -2
  22. package/dist/loop-blobs.generated.js.map +1 -1
  23. package/dist/markdown-masker.d.ts +21 -0
  24. package/dist/markdown-masker.d.ts.map +1 -0
  25. package/dist/markdown-masker.js +119 -0
  26. package/dist/markdown-masker.js.map +1 -0
  27. package/dist/mutation-gate.d.ts +16 -7
  28. package/dist/mutation-gate.d.ts.map +1 -1
  29. package/dist/mutation-gate.js +141 -30
  30. package/dist/mutation-gate.js.map +1 -1
  31. package/dist/statusline.d.ts +14 -0
  32. package/dist/statusline.d.ts.map +1 -1
  33. package/dist/statusline.js +24 -1
  34. package/dist/statusline.js.map +1 -1
  35. package/dist/store-counts.d.ts +12 -0
  36. package/dist/store-counts.d.ts.map +1 -1
  37. package/dist/store-counts.js +30 -12
  38. package/dist/store-counts.js.map +1 -1
  39. package/dist/swarm-brief.d.ts.map +1 -1
  40. package/dist/swarm-brief.js +13 -144
  41. package/dist/swarm-brief.js.map +1 -1
  42. package/package.json +6 -6
  43. package/sbom.json +97 -47
  44. package/src/amendment-trace.ts +6 -123
  45. package/src/claude-hooks-assets.ts +24 -11
  46. package/src/discrimination-gate.ts +2 -1
  47. package/src/feature-adr-routing.ts +21 -11
  48. package/src/index.ts +2 -0
  49. package/src/loop-blobs.generated.ts +2 -2
  50. package/src/markdown-masker.ts +109 -0
  51. package/src/mutation-gate.ts +167 -31
  52. package/src/statusline.ts +35 -1
  53. package/src/store-counts.ts +44 -13
  54. package/src/swarm-brief.ts +13 -104
@@ -52,6 +52,8 @@ export interface MutationRegistry {
52
52
 
53
53
  export type MutationVerdict =
54
54
  | 'PROVEN' // mutation applied, suite went red, failing count (when known) >= minFailing
55
+ | 'ENTRY_INVALID' // this registry entry is malformed; valid neighbours still run, but the gate fails
56
+ | 'COVERAGE_GAP' // author-declared, reason-bearing gap; no mutation ran and the gate fails
55
57
  | 'UNDEFENDED' // mutation applied, suite stayed GREEN — the property has no discriminating test
56
58
  | 'RECEIPT_MISMATCH' // the suite harness declared a receipt error, or an opted-in completion receipt was absent — neither the exit code nor failing count may be read as discrimination
57
59
  | 'NOT_APPLIED' // `find` occurred 0 or >1 times — code drifted; nothing was tested (rule 1)
@@ -201,18 +203,50 @@ const UNSAFE_FILE = /(^\/)|(^[A-Za-z]:)|(^~)|(^-)|(\/-)|(\.\.(\/|\\|$))|[\0`$;&|
201
203
 
202
204
  export interface ParsedRegistry {
203
205
  readonly registry: MutationRegistry | null;
204
- /** every defect found ANY error makes the registry unusable (a half-valid registry that runs
205
- * its valid half reports a partial scan as a full one). */
206
+ /** Per-entry outcomes that need no mutation run: malformed entries and declared gaps. */
207
+ readonly entryResults: readonly MutationEntryResult[];
208
+ /** Every defect found. Envelope/JSON defects make registry null; entry defects are also
209
+ * represented as ENTRY_INVALID while valid neighbours remain executable. */
206
210
  readonly errors: readonly string[];
207
211
  }
208
212
 
213
+ function preclassifiedEntryResult(
214
+ index: number,
215
+ raw: Record<string, unknown>,
216
+ verdict: 'ENTRY_INVALID' | 'COVERAGE_GAP',
217
+ detail: string,
218
+ idOverride?: string,
219
+ ): MutationEntryResult {
220
+ const rawId = typeof raw['id'] === 'string' ? raw['id'] : '';
221
+ const id = idOverride ?? (SAFE_ID.test(rawId) ? rawId : `entry-${index + 1}-invalid`);
222
+ const property = typeof raw['property'] === 'string' && raw['property'].trim() !== ''
223
+ ? raw['property'].trim()
224
+ : `Registry entry ${index + 1}`;
225
+ const file = typeof raw['file'] === 'string' && raw['file'].trim() !== ''
226
+ ? raw['file'].trim()
227
+ : '<not provided>';
228
+ return {
229
+ id,
230
+ property,
231
+ file,
232
+ applied: false,
233
+ occurrences: 0,
234
+ exitCode: null,
235
+ failingCount: null,
236
+ verdict,
237
+ drop: false,
238
+ dropComparable: false,
239
+ detail,
240
+ };
241
+ }
242
+
209
243
  /** Parse + validate a registry JSON text. Accepts a bare array or `{testCommand?, requireCompletionReceipt?, entries}`. */
210
244
  export function parseMutationRegistry(text: string): ParsedRegistry {
211
245
  let raw: unknown;
212
246
  try {
213
247
  raw = JSON.parse(text);
214
248
  } catch (e) {
215
- return { registry: null, errors: [`registry is not valid JSON: ${String((e as Error).message).slice(0, 120)}`] };
249
+ return { registry: null, entryResults: [], errors: [`registry is not valid JSON: ${String((e as Error).message).slice(0, 120)}`] };
216
250
  }
217
251
 
218
252
  let entriesRaw: unknown;
@@ -225,39 +259,69 @@ export function parseMutationRegistry(text: string): ParsedRegistry {
225
259
  entriesRaw = obj.entries;
226
260
  if (obj.testCommand !== undefined) {
227
261
  if (typeof obj.testCommand !== 'string' || obj.testCommand.trim() === '') {
228
- return { registry: null, errors: ['testCommand must be a non-empty string when present'] };
262
+ return { registry: null, entryResults: [], errors: ['testCommand must be a non-empty string when present'] };
229
263
  }
230
264
  testCommand = obj.testCommand.trim();
231
265
  }
232
266
  if (obj.requireCompletionReceipt !== undefined) {
233
267
  if (typeof obj.requireCompletionReceipt !== 'boolean') {
234
- return { registry: null, errors: ['requireCompletionReceipt must be a boolean when present'] };
268
+ return { registry: null, entryResults: [], errors: ['requireCompletionReceipt must be a boolean when present'] };
235
269
  }
236
270
  requireCompletionReceipt = obj.requireCompletionReceipt;
237
271
  }
238
272
  }
239
273
  if (!Array.isArray(entriesRaw)) {
240
- return { registry: null, errors: ['registry must be an array of entries or {testCommand?, requireCompletionReceipt?, entries: [...]}'] };
274
+ return { registry: null, entryResults: [], errors: ['registry must be an array of entries or {testCommand?, requireCompletionReceipt?, entries: [...]}'] };
241
275
  }
242
276
  if (entriesRaw.length === 0) {
243
277
  // An empty registry "passes" by testing nothing — the same silent hole as a skipped mutation.
244
- return { registry: null, errors: ['registry has no entries — an empty registry proves nothing and is refused'] };
278
+ return { registry: null, entryResults: [], errors: ['registry has no entries — an empty registry proves nothing and is refused'] };
245
279
  }
246
280
 
247
281
  const errors: string[] = [];
248
282
  const entries: MutationRegistryEntry[] = [];
283
+ const entryResults: MutationEntryResult[] = [];
249
284
  const seen = new Set<string>();
250
285
  entriesRaw.forEach((e: unknown, i: number) => {
251
286
  const at = `entries[${i}]`;
252
- if (!e || typeof e !== 'object') { errors.push(`${at}: not an object`); return; }
287
+ const rawForResult = e && typeof e === 'object' ? e as Record<string, unknown> : {};
288
+ const reject = (detail: string, idOverride?: string): void => {
289
+ errors.push(detail);
290
+ entryResults.push(preclassifiedEntryResult(i, rawForResult, 'ENTRY_INVALID', detail, idOverride));
291
+ };
292
+ if (!e || typeof e !== 'object') { reject(`${at}: not an object`); return; }
253
293
  const o = e as Record<string, unknown>;
254
294
  const id = typeof o['id'] === 'string' ? o['id'] : '';
255
- if (!SAFE_ID.test(id)) { errors.push(`${at}: id must be kebab-case [a-z0-9-], got ${JSON.stringify(o['id'])}`); return; }
256
- if (seen.has(id)) { errors.push(`${at}: duplicate id '${id}'`); return; }
295
+ if (!SAFE_ID.test(id)) { reject(`${at}: id must be kebab-case [a-z0-9-], got ${JSON.stringify(o['id'])}`); return; }
296
+ if (seen.has(id)) { reject(`${at}: duplicate id '${id}'`, `entry-${i + 1}-invalid`); return; }
257
297
  seen.add(id);
258
- if (typeof o['property'] !== 'string' || o['property'].trim() === '') { errors.push(`${id}: property (the claimed sentence) is required`); return; }
298
+ if (typeof o['property'] !== 'string' || o['property'].trim() === '') { reject(`${id}: property (the claimed sentence) is required`); return; }
299
+
300
+ if (o['uncoverable'] !== undefined && o['uncoverable'] !== true) {
301
+ reject(`${id}: uncoverable must be true when present`);
302
+ return;
303
+ }
304
+ if (o['uncoverable'] === true) {
305
+ const reason = typeof o['reason'] === 'string' ? o['reason'].trim() : '';
306
+ if (reason === '') {
307
+ reject(`${id}: reason is required when uncoverable is true`);
308
+ return;
309
+ }
310
+ const declaredFile = typeof o['file'] === 'string' ? o['file'].trim() : '';
311
+ if (declaredFile === '' || /[\u0000-\u001f\u007f]/.test(declaredFile)) {
312
+ reject(`${id}: file must be a non-empty single-line path for an uncoverable declaration`);
313
+ return;
314
+ }
315
+ entryResults.push(preclassifiedEntryResult(
316
+ i,
317
+ o,
318
+ 'COVERAGE_GAP',
319
+ `declared uncoverable: ${reason} — author declaration, not a measurement; no mutation ran`,
320
+ ));
321
+ return;
322
+ }
259
323
  const file = typeof o['file'] === 'string' ? o['file'] : '';
260
- if (file === '' || UNSAFE_FILE.test(file)) { errors.push(`${id}: file must be a plain package-relative path, got ${JSON.stringify(o['file'])}`); return; }
324
+ if (file === '' || UNSAFE_FILE.test(file)) { reject(`${id}: file must be a plain package-relative path, got ${JSON.stringify(o['file'])}`); return; }
261
325
  // A registry file under node_modules/ is refused OUTRIGHT (F-2): the registry names protections
262
326
  // in the package's OWN code — a dependency's file is not this package's protection — and the
263
327
  // scratch copy intentionally SHARES node_modules with the real tree (symlinked for
@@ -265,19 +329,19 @@ export function parseMutationRegistry(text: string): ParsedRegistry {
265
329
  // the real working tree (SPEC rule 3). The executor's realpath containment is the belt; this
266
330
  // is the cheaper layer-1 refusal for the case that is always a mistake.
267
331
  if (file.split('/').includes('node_modules')) {
268
- errors.push(`${id}: file targets node_modules/ (${JSON.stringify(file)}) — refused: a dependency file is not this package's protection, and the scratch copy shares node_modules with the REAL tree (rule 3: never mutate the working tree)`);
332
+ reject(`${id}: file targets node_modules/ (${JSON.stringify(file)}) — refused: a dependency file is not this package's protection, and the scratch copy shares node_modules with the REAL tree (rule 3: never mutate the working tree)`);
269
333
  return;
270
334
  }
271
335
  const mut = o['mutation'] as { find?: unknown; replace?: unknown } | undefined;
272
336
  if (!mut || typeof mut !== 'object' || typeof mut.find !== 'string' || mut.find.length === 0 || typeof mut.replace !== 'string') {
273
- errors.push(`${id}: mutation must be {find: <non-empty string>, replace: <string>}`);
337
+ reject(`${id}: mutation must be {find: <non-empty string>, replace: <string>}`);
274
338
  return;
275
339
  }
276
- if (mut.find === mut.replace) { errors.push(`${id}: mutation.replace equals mutation.find — a no-op mutation tests nothing`); return; }
340
+ if (mut.find === mut.replace) { reject(`${id}: mutation.replace equals mutation.find — a no-op mutation tests nothing`); return; }
277
341
  let minFailing = 1;
278
342
  if (o['minFailing'] !== undefined) {
279
343
  if (typeof o['minFailing'] !== 'number' || !Number.isInteger(o['minFailing']) || o['minFailing'] < 1) {
280
- errors.push(`${id}: minFailing must be a positive integer`);
344
+ reject(`${id}: minFailing must be a positive integer`);
281
345
  return;
282
346
  }
283
347
  minFailing = o['minFailing'];
@@ -285,7 +349,7 @@ export function parseMutationRegistry(text: string): ParsedRegistry {
285
349
  let observed: number | undefined;
286
350
  if (o['observed'] !== undefined) {
287
351
  if (typeof o['observed'] !== 'number' || !Number.isInteger(o['observed']) || o['observed'] < 1) {
288
- errors.push(`${id}: observed must be a positive integer when present`);
352
+ reject(`${id}: observed must be a positive integer when present`);
289
353
  return;
290
354
  }
291
355
  observed = o['observed'];
@@ -293,11 +357,11 @@ export function parseMutationRegistry(text: string): ParsedRegistry {
293
357
  let maxFailing: number | undefined;
294
358
  if (o['maxFailing'] !== undefined) {
295
359
  if (typeof o['maxFailing'] !== 'number' || !Number.isInteger(o['maxFailing']) || o['maxFailing'] < 1) {
296
- errors.push(`${id}: maxFailing must be a positive integer when present`);
360
+ reject(`${id}: maxFailing must be a positive integer when present`);
297
361
  return;
298
362
  }
299
363
  if (o['maxFailing'] < minFailing) {
300
- errors.push(`${id}: maxFailing (${o['maxFailing']}) must be >= minFailing (${minFailing}) — a contradictory bound can never pass`);
364
+ reject(`${id}: maxFailing (${o['maxFailing']}) must be >= minFailing (${minFailing}) — a contradictory bound can never pass`);
301
365
  return;
302
366
  }
303
367
  maxFailing = o['maxFailing'];
@@ -314,14 +378,14 @@ export function parseMutationRegistry(text: string): ParsedRegistry {
314
378
  entries.push(entry);
315
379
  });
316
380
 
317
- if (errors.length > 0) return { registry: null, errors };
318
381
  return {
319
382
  registry: {
320
383
  ...(testCommand !== undefined ? { testCommand } : {}),
321
384
  ...(requireCompletionReceipt !== undefined ? { requireCompletionReceipt } : {}),
322
385
  entries,
323
386
  },
324
- errors: [],
387
+ entryResults,
388
+ errors,
325
389
  };
326
390
  }
327
391
 
@@ -385,6 +449,18 @@ export function countFailingTests(rawOutput: string): number | null {
385
449
  if (jest && jest[1] !== undefined) return Number(jest[1]);
386
450
  const notOk = output.match(/^not ok\b/gm);
387
451
  if (notOk !== null && notOk.length > 0) return notOk.length;
452
+ // Zero requires a complete, recognised vitest summary, never the absence of failure text.
453
+ // Keep TAP and every existing positive-count path above this additional fallback.
454
+ if (detectRunnerKind(output) === 'vitest') {
455
+ const zero = /^[ \t]*Tests[ \t]+(\d+[ \t]+(?:passed|skipped|todo)(?:[ \t]*\|[ \t]*\d+[ \t]+(?:passed|skipped|todo))*)[ \t]+\((\d+)\)[ \t]*\r?$/m.exec(output);
456
+ if (zero !== null) {
457
+ const counts = [...zero[1]!.matchAll(/(\d+)[ \t]+(?:passed|skipped|todo)/g)]
458
+ .map((match) => Number(match[1]));
459
+ const total = Number(zero[2]);
460
+ if (Number.isSafeInteger(total) && total > 0 && counts.every(Number.isSafeInteger)
461
+ && counts.reduce((sum, count) => sum + count, 0) === total) return 0;
462
+ }
463
+ }
388
464
  return null;
389
465
  }
390
466
 
@@ -447,8 +523,10 @@ export interface RunFailureClassification {
447
523
  * carried no classifiable failure): a runner-coverage gap of this tool — the
448
524
  * verdict must be INCONCLUSIVE, never PROVEN.
449
525
  */
450
- readonly kind: 'file-load' | 'assertions' | 'unrecognised';
451
- /** for 'file-load': the evidence line; for 'unrecognised': what could not be classified. */
526
+ readonly kind: 'file-load' | 'assertions' | 'runner-infrastructure' | 'unrecognised';
527
+ /** Closed reason set for an identified runner failure with zero failing tests. */
528
+ readonly reason?: 'worker-rpc-timeout';
529
+ /** Evidence of the load/infrastructure failure, or what could not be classified. */
452
530
  readonly evidence?: string;
453
531
  }
454
532
 
@@ -541,6 +619,15 @@ export function classifyRunFailure(rawOutput: string): RunFailureClassification
541
619
  if (/^\s*Tests\s+[^|\n]*?\d+\s+failed/m.test(output) || /\bFailed Tests\s+\d+\b/.test(output)) {
542
620
  return { runner: 'vitest', kind: 'assertions' };
543
621
  }
622
+ const workerTimeout = /\[vitest-worker\]: Timeout calling "([^"\r\n]+)"/.exec(output);
623
+ if (countFailingTests(output) === 0 && workerTimeout !== null) {
624
+ return {
625
+ runner: 'vitest',
626
+ kind: 'runner-infrastructure',
627
+ reason: 'worker-rpc-timeout',
628
+ evidence: `vitest worker RPC timeout (${workerTimeout[1]}), 0 failing tests`,
629
+ };
630
+ }
544
631
  return { runner: 'vitest', kind: 'unrecognised', evidence: 'red vitest run with neither Failed Suites nor failed tests in the output — the redness has no classifiable source (unhandled error outside any test?)' };
545
632
  }
546
633
 
@@ -557,6 +644,8 @@ export type BaselineAttributionSource = 'node-test' | 'vitest' | 'unparseable';
557
644
 
558
645
  export interface BaselineAttribution {
559
646
  readonly parsedFrom: BaselineAttributionSource;
647
+ /** Identified infrastructure failure from the same output; absent for all existing outcomes. */
648
+ readonly infrastructureFailure?: RunFailureClassification;
560
649
  /** Package-relative failing paths, in first-seen order. */
561
650
  readonly failingFiles: readonly string[];
562
651
  /** Failing paths that match a registry file exactly (or by package-relative suffix). */
@@ -599,6 +688,10 @@ export function attributeBaselineRedness(
599
688
  ? 'unparseable'
600
689
  : vitestMatches.length > 0 ? 'vitest' : 'node-test';
601
690
  if (parsedFrom === 'unparseable') {
691
+ const failure = classifyRunFailure(output);
692
+ if (failure.kind === 'runner-infrastructure') {
693
+ return { parsedFrom, failingFiles: [], covered: [], extraneous: [], infrastructureFailure: failure };
694
+ }
602
695
  return { parsedFrom, failingFiles: [], covered: [], extraneous: [] };
603
696
  }
604
697
 
@@ -611,6 +704,7 @@ export function attributeBaselineRedness(
611
704
  }
612
705
 
613
706
  export type BaselineFailureReason =
707
+ | 'worker-rpc-timeout'
614
708
  | 'runner-internal-error'
615
709
  | 'runner-no-exit'
616
710
  | 'extraneous-red-in-allowlist'
@@ -641,6 +735,14 @@ export function classifyBaseline(
641
735
  detail: `baseline INCONCLUSIVE — suite produced no exit code (${runFailureReason ?? 'unknown timeout/spawn failure'}) — the copy is not runnable; do not read this as a mutation result`,
642
736
  };
643
737
  }
738
+ const infrastructure = attribution?.infrastructureFailure;
739
+ if (infrastructure?.kind === 'runner-infrastructure') {
740
+ return {
741
+ ok: false,
742
+ ...(infrastructure.reason !== undefined ? { reason: infrastructure.reason } : {}),
743
+ detail: `baseline suite RED (exit ${exitCode}) in the UNMUTATED scratch copy — runner infrastructure failure: ${infrastructure.reason} — ${infrastructure.evidence}; the broken copy cannot prove anything`,
744
+ };
745
+ }
644
746
  if (attribution === undefined || attribution.parsedFrom === 'unparseable') {
645
747
  return {
646
748
  ok: false,
@@ -804,7 +906,10 @@ function classifyMutationOutcomeWithoutAttemptLog(obs: MutationObservation): Mut
804
906
  // not come back green, so the suite is flaky and an unrelated neighbour may be what went red.
805
907
  // Not attributable ⇒ INCONCLUSIVE (a failure, never a pass).
806
908
  if (obs.rebaselineExitCode !== undefined && obs.rebaselineExitCode !== 0) {
807
- const failing = obs.rebaselineAttribution === undefined || obs.rebaselineAttribution.parsedFrom === 'unparseable'
909
+ const infrastructure = obs.rebaselineAttribution?.infrastructureFailure;
910
+ const failing = infrastructure?.kind === 'runner-infrastructure'
911
+ ? `runner infrastructure failure: ${infrastructure.reason} — ${infrastructure.evidence}`
912
+ : obs.rebaselineAttribution === undefined || obs.rebaselineAttribution.parsedFrom === 'unparseable'
808
913
  ? 'failing files: unparseable from runner output'
809
914
  : `failing files: ${obs.rebaselineAttribution.failingFiles.join(', ')}`;
810
915
  return {
@@ -858,13 +963,36 @@ export function classifyMutationOutcome(obs: MutationObservation): MutationEntry
858
963
  return { ...result, detail: `${result.detail}; ${obs.internalAttemptLog}` };
859
964
  }
860
965
 
861
- /** Verdicts that fail the gate. INCONCLUSIVE and NOT_APPLIED fail (inconclusive ≠ pass). */
862
- const FAILING_VERDICTS: ReadonlySet<MutationVerdict> = new Set(['UNDEFENDED', 'RECEIPT_MISMATCH', 'NOT_APPLIED', 'BELOW_MIN', 'MUTATION_UNPARSEABLE', 'MUTATION_LOAD_FATAL', 'OVER_FAILING', 'INCONCLUSIVE']);
863
-
864
- /** Exit contract: 0 all proven · 1 any entry failed (or red baseline) · (2 = usage/setup, CLI-side). */
966
+ /**
967
+ * Verdicts that fail the gate. INCONCLUSIVE and NOT_APPLIED fail (inconclusive pass).
968
+ *
969
+ * COVERAGE_GAP is deliberately NOT here (owner decision 2026-09-09, option A). A declared gap is a
970
+ * DEBT, not a breakage: the orchestrator script is uncoverable by construction, so failing on gaps
971
+ * would make this package's gate red FOREVER — and a lamp that is always on is read exactly like a
972
+ * lamp that is off. What the mechanism owes is COUNTABILITY, and the summary delivers it: gaps are
973
+ * counted on their own line, carry their own per-entry verdict, and can never be mistaken for a
974
+ * proven protection. ENTRY_INVALID stays failing — a malformed entry is a broken claim, not a
975
+ * declared one, and it has an author who can fix it today.
976
+ */
977
+ const FAILING_VERDICTS: ReadonlySet<MutationVerdict> = new Set([
978
+ 'ENTRY_INVALID',
979
+ 'UNDEFENDED',
980
+ 'RECEIPT_MISMATCH',
981
+ 'NOT_APPLIED',
982
+ 'BELOW_MIN',
983
+ 'MUTATION_UNPARSEABLE',
984
+ 'MUTATION_LOAD_FATAL',
985
+ 'OVER_FAILING',
986
+ 'INCONCLUSIVE',
987
+ ]);
988
+
989
+ /** Exit contract: 0 all runnable entries proven · 1 a runnable entry failed (or red baseline) ·
990
+ * 2 no mutation-eligible entry exists, so the registry/selection is unusable as a run. */
865
991
  export function mutationGateExitCode(results: readonly MutationEntryResult[], baselineOk: boolean): number {
992
+ const hasRunnableEntry = results.some((result) =>
993
+ result.verdict !== 'ENTRY_INVALID' && result.verdict !== 'COVERAGE_GAP');
994
+ if (!hasRunnableEntry) return 2;
866
995
  if (!baselineOk) return 1;
867
- if (results.length === 0) return 1; // nothing ran ⇒ nothing proven
868
996
  return results.some((r) => FAILING_VERDICTS.has(r.verdict)) ? 1 : 0;
869
997
  }
870
998
 
@@ -873,6 +1001,8 @@ export function mutationGateExitCode(results: readonly MutationEntryResult[], ba
873
1001
  export interface MutationGateSummary {
874
1002
  readonly total: number;
875
1003
  readonly proven: number;
1004
+ readonly entryInvalid: number;
1005
+ readonly coverageGaps: number;
876
1006
  readonly undefended: number;
877
1007
  readonly receiptMismatch: number;
878
1008
  readonly notApplied: number;
@@ -892,6 +1022,8 @@ export function summarizeMutationResults(results: readonly MutationEntryResult[]
892
1022
  return {
893
1023
  total: results.length,
894
1024
  proven: results.filter((r) => r.verdict === 'PROVEN').length,
1025
+ entryInvalid: results.filter((r) => r.verdict === 'ENTRY_INVALID').length,
1026
+ coverageGaps: results.filter((r) => r.verdict === 'COVERAGE_GAP').length,
895
1027
  undefended: results.filter((r) => r.verdict === 'UNDEFENDED').length,
896
1028
  receiptMismatch: results.filter((r) => r.verdict === 'RECEIPT_MISMATCH').length,
897
1029
  notApplied: results.filter((r) => r.verdict === 'NOT_APPLIED').length,
@@ -907,6 +1039,10 @@ export function summarizeMutationResults(results: readonly MutationEntryResult[]
907
1039
 
908
1040
  const VERDICT_MARK: Record<MutationVerdict, string> = {
909
1041
  PROVEN: '✓',
1042
+ ENTRY_INVALID: '✗',
1043
+ // Пробел — объявленный ДОЛГ, а не отказ (вариант А владельца 2026-09-09): свой значок, чтобы
1044
+ // строку нельзя было прочитать как провал защиты.
1045
+ COVERAGE_GAP: '⚠',
910
1046
  UNDEFENDED: '✗',
911
1047
  RECEIPT_MISMATCH: '✗',
912
1048
  NOT_APPLIED: '✗',
@@ -932,9 +1068,9 @@ export function renderMutationReport(
932
1068
  if (r.verdict !== 'PROVEN' || r.drop) lines.push(` ${r.detail}`);
933
1069
  }
934
1070
  const s = summarizeMutationResults(results);
935
- lines.push(` summary: ${s.proven}/${s.total} proven · ${s.undefended} undefended · ${s.receiptMismatch} receipt-mismatch · ${s.notApplied} not-applied · ${s.belowMin} below-min · ${s.unparseable} unparseable · ${s.loadFatal} load-fatal · ${s.overFailing} over-failing · ${s.inconclusive} inconclusive · ${s.drops} coverage drop(s) among ${s.dropComparable}/${s.total} observed-anchored entries (a drop is undetectable without an \`observed\` anchor)`);
1071
+ lines.push(` summary: ${s.proven}/${s.total} proven · ${s.entryInvalid} entry-invalid · ${s.coverageGaps} coverage-gap · ${s.undefended} undefended · ${s.receiptMismatch} receipt-mismatch · ${s.notApplied} not-applied · ${s.belowMin} below-min · ${s.unparseable} unparseable · ${s.loadFatal} load-fatal · ${s.overFailing} over-failing · ${s.inconclusive} inconclusive · ${s.drops} coverage drop(s) among ${s.dropComparable}/${s.total} observed-anchored entries (a drop is undetectable without an \`observed\` anchor)`);
936
1072
  lines.push(mutationGateExitCode(results, baseline.ok) === 0
937
1073
  ? ' verdict: PASS — every named protection has a test that goes red when the protection is deleted'
938
- : ' verdict: FAIL — at least one named protection is undefended, unmutable, or unproven');
1074
+ : ' verdict: FAIL — at least one named protection is invalid, undefended, unmutable, or unproven');
939
1075
  return lines.join('\n');
940
1076
  }
package/src/statusline.ts CHANGED
@@ -68,6 +68,10 @@ export interface FeatureAdrState {
68
68
  export interface StatuslineData {
69
69
  /** Count of learned patterns in the project's unified memory store. */
70
70
  readonly patterns: number;
71
+ /** Absent on parity; missing/unreadable mirror is explicitly unavailable. */
72
+ readonly patternMirror?:
73
+ | { readonly state: 'different'; readonly lexical: number; readonly vector: number }
74
+ | { readonly state: 'unavailable' };
71
75
  /** Exact lexical-tier availability split; omitted when the enhanced readonly count cannot be established. */
72
76
  readonly patternBreakdown?: {
73
77
  readonly source: 'lexical';
@@ -82,6 +86,12 @@ export interface StatuslineData {
82
86
  readonly usedPatterns?: number;
83
87
  /** Number of sources registered in the durable cross-project knowledge brain. */
84
88
  readonly brainSources: number;
89
+ /**
90
+ * KU-объём КАЖДОГО источника, в том же порядке, что их перечисляет brain. Владелец 2026-09-09:
91
+ * одно число источников не говорит, велик ли корпус и не пуст ли какой-то из них.
92
+ * Пустой массив означает «перечислить не удалось», а не «источников нет» — их число рядом.
93
+ */
94
+ readonly brainKuCounts: readonly number[];
85
95
  /** Hours since the last `dz consolidate` run, if a watermark is present. */
86
96
  readonly consolidatedAgeH?: number;
87
97
  /** Live `/feature-adr` learning state — present ONLY when a fresh run is in flight. */
@@ -640,6 +650,24 @@ export function statuslineData(projectRoot: string, now: number = Date.now()): S
640
650
  storeHealth = undefined;
641
651
  }
642
652
 
653
+ // Сравнивать можно только сравнимое: слева уроки лексического слоя, справа уроки зеркала.
654
+ // Полный объём зеркала (`vectorRows`) для этого не годится — в него входят идеи бэклога и
655
+ // книжные единицы, и показатель на нём горел бы всегда. Неизвестное число уроков — это
656
+ // ТРЕТЬЕ состояние «не читается», а не молчание: молчание означает «величины сошлись».
657
+ // ОТСУТСТВИЕ зеркала и НЕЧИТАЕМОСТЬ зеркала — разные положения (решение владельца 2026-09-09).
658
+ // Зеркала нет вовсе: сравнивать не с чем, показатель молчит — иначе у любого проекта, который
659
+ // зеркалом не пользуется, он горел бы всегда, а вечно горящий показатель не несёт сведений.
660
+ // Зеркало ЕСТЬ, но прочитать или разложить его не удалось: это отказ инструмента, и он горит.
661
+ const mirrorLessons = storeRows?.vectorLessonRows;
662
+ const mirrorAbsent = storeRows !== undefined && storeRows.vectorSourcePath === undefined;
663
+ const patternMirror: StatuslineData['patternMirror'] = mirrorAbsent
664
+ ? undefined
665
+ : storeRows === undefined || storeRows.vectorRows === 'unreadable' || mirrorLessons === undefined
666
+ ? { state: 'unavailable' }
667
+ : mirrorLessons !== patterns
668
+ ? { state: 'different', lexical: patterns, vector: mirrorLessons }
669
+ : undefined;
670
+
643
671
  let patternBreakdown: StatuslineData['patternBreakdown'];
644
672
  try {
645
673
  if (storeRows !== undefined
@@ -662,10 +690,14 @@ export function statuslineData(projectRoot: string, now: number = Date.now()): S
662
690
  }
663
691
 
664
692
  let brainSources = 0;
693
+ let brainKuCounts: readonly number[] = [];
665
694
  try {
666
- brainSources = listBrain().length;
695
+ const sources = listBrain();
696
+ brainSources = sources.length;
697
+ brainKuCounts = sources.map((s) => (typeof s.kuCount === 'number' ? s.kuCount : 0));
667
698
  } catch {
668
699
  brainSources = 0;
700
+ brainKuCounts = [];
669
701
  }
670
702
 
671
703
  let usedPatterns: number | undefined;
@@ -687,9 +719,11 @@ export function statuslineData(projectRoot: string, now: number = Date.now()): S
687
719
 
688
720
  return {
689
721
  patterns,
722
+ ...(patternMirror !== undefined ? { patternMirror } : {}),
690
723
  ...(patternBreakdown !== undefined ? { patternBreakdown } : {}),
691
724
  ...(usedPatterns !== undefined ? { usedPatterns } : {}),
692
725
  brainSources,
726
+ brainKuCounts,
693
727
  ...(ageH !== undefined ? { consolidatedAgeH: ageH } : {}),
694
728
  ...(featureAdr !== undefined ? { featureAdr } : {}),
695
729
  ...(storeHealth !== undefined ? { storeHealth } : {}),
@@ -20,7 +20,19 @@ export interface LearningStoreRowCounts {
20
20
  /** Coexisting JSONL is deliberately excluded from the selected SQLite population. */
21
21
  readonly lexicalIgnoredRows?: number | 'unreadable';
22
22
  readonly lexicalIgnoredSourcePath?: string;
23
+ /**
24
+ * ВСЕ строки зеркала, включая идеи бэклога и книжные единицы. Смысл поля НЕ сужен намеренно:
25
+ * его читает страж стора как признак целостности (отметка высшей точки `vectorMax`), и сужение
26
+ * до уроков уронило бы наблюдение с 1774 до 631 — страж прочитал бы это как обвал стора.
27
+ * Для показателя зеркала в панели есть отдельное поле `vectorLessonRows`.
28
+ */
23
29
  readonly vectorRows: number | 'unreadable';
30
+ /**
31
+ * Только зеркальные УРОКИ (task_type dz-teach/dz-learning) — величина, сравнимая с `lexicalRows`.
32
+ * Отсутствует, когда зеркала нет или разложить его по родам не удалось: тогда показатель обязан
33
+ * сказать «не читается», а не молчать, будто величины сошлись.
34
+ */
35
+ readonly vectorLessonRows?: number;
24
36
  /** Exact active quarantine labels on mirrored lesson rows; absent when the vector tier is missing/unreadable. */
25
37
  readonly vectorQuarantinedRows?: number;
26
38
  /** Present when the vector store file exists, including when its count is unreadable. */
@@ -60,6 +72,8 @@ export function countSqliteRowsReadonly(
60
72
  interface SqliteRowsWithQuarantine {
61
73
  readonly rows: number;
62
74
  readonly quarantinedRows?: number;
75
+ /** Только для зеркала: подсчёт уроков внутри общего объёма. */
76
+ readonly lessonRows?: number;
63
77
  }
64
78
 
65
79
  /** One aggregate query on the healthy path; an unsupported metadata shape falls back to total-only. */
@@ -82,22 +96,38 @@ function countSqliteRowsWithQuarantineReadonly(
82
96
  COUNT(*) AS n
83
97
  FROM memory_records GROUP BY 1
84
98
  )`
85
- : `WITH lesson_statuses AS (
86
- SELECT CASE WHEN json_valid(metadata) THEN json_extract(metadata, '$.qStatus') END AS q_status,
87
- COUNT(*) AS n
88
- FROM reasoning_patterns
89
- WHERE task_type IN ('dz-teach', 'dz-learning')
90
- GROUP BY 1
91
- )
92
- SELECT (SELECT COUNT(*) FROM reasoning_patterns) AS cnt,
93
- COALESCE(SUM(CASE WHEN q_status = 'quarantined' THEN n ELSE 0 END), 0) AS quarantined
94
- FROM lesson_statuses`;
95
- const row = db.prepare(sql).get() as { cnt?: unknown; quarantined?: unknown };
99
+ : `SELECT COUNT(*) AS cnt,
100
+ COALESCE(SUM(CASE WHEN task_type IN ('dz-teach', 'dz-learning') THEN 1 ELSE 0 END), 0) AS lessons,
101
+ COALESCE(SUM(CASE WHEN task_type IN ('dz-teach', 'dz-learning')
102
+ AND json_valid(metadata)
103
+ AND json_extract(metadata, '$.qStatus') = 'quarantined' THEN 1 ELSE 0 END), 0) AS quarantined
104
+ FROM reasoning_patterns`;
105
+ const row = db.prepare(sql).get() as { cnt?: unknown; quarantined?: unknown; lessons?: unknown };
96
106
  if (typeof row?.cnt !== 'number' || typeof row.quarantined !== 'number') return undefined;
97
- return { rows: row.cnt, quarantinedRows: row.quarantined };
107
+ return {
108
+ rows: row.cnt,
109
+ quarantinedRows: row.quarantined,
110
+ ...(typeof row.lessons === 'number' ? { lessonRows: row.lessons } : {}),
111
+ };
98
112
  } catch {
113
+ // Запасной путь: общий объём берём всегда, а разложение по родам пробуем ОТДЕЛЬНО —
114
+ // схема без `metadata`, но с `task_type` уроки различает, и терять это не за что.
115
+ // Если не различает и её — lessonRows остаётся неизвестным, и показатель обязан сказать
116
+ // «не читается» вместо мнимого совпадения.
99
117
  const row = db.prepare(`SELECT COUNT(*) AS cnt FROM ${table}`).get() as { cnt?: unknown };
100
- return typeof row?.cnt === 'number' ? { rows: row.cnt } : undefined;
118
+ if (typeof row?.cnt !== 'number') return undefined;
119
+ if (table !== 'reasoning_patterns') return { rows: row.cnt };
120
+ try {
121
+ const lesson = db.prepare(
122
+ `SELECT COUNT(*) AS cnt FROM reasoning_patterns
123
+ WHERE task_type IN ('dz-teach', 'dz-learning')`,
124
+ ).get() as { cnt?: unknown };
125
+ return typeof lesson?.cnt === 'number'
126
+ ? { rows: row.cnt, lessonRows: lesson.cnt }
127
+ : { rows: row.cnt };
128
+ } catch {
129
+ return { rows: row.cnt };
130
+ }
101
131
  }
102
132
  } finally {
103
133
  db.close();
@@ -146,6 +176,7 @@ export function countLearningStoreRowsReadonly(projectRoot: string): LearningSto
146
176
  lexicalIgnoredSourcePath: jsonlPath,
147
177
  }),
148
178
  vectorRows: vectorExists ? (vectorSqlite?.rows ?? 'unreadable') : 0,
179
+ ...(vectorSqlite?.lessonRows === undefined ? {} : { vectorLessonRows: vectorSqlite.lessonRows }),
149
180
  ...(vectorSqlite?.quarantinedRows === undefined ? {} : {
150
181
  vectorQuarantinedRows: vectorSqlite.quarantinedRows,
151
182
  }),