@dzhechkov/harness-core 0.7.12 → 0.8.2

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 (105) hide show
  1. package/.dz-manifest.json +210 -70
  2. package/README.md +34 -1
  3. package/dist/book-kb.d.ts.map +1 -1
  4. package/dist/book-kb.js +17 -0
  5. package/dist/book-kb.js.map +1 -1
  6. package/dist/feature-adr-checkpoints.d.ts +61 -0
  7. package/dist/feature-adr-checkpoints.d.ts.map +1 -1
  8. package/dist/feature-adr-checkpoints.js +116 -2
  9. package/dist/feature-adr-checkpoints.js.map +1 -1
  10. package/dist/index.d.ts +16 -2
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +13 -1
  13. package/dist/index.js.map +1 -1
  14. package/dist/loop-blobs.generated.js +2 -2
  15. package/dist/loop-blobs.generated.js.map +1 -1
  16. package/dist/mutation-gate.d.ts +31 -8
  17. package/dist/mutation-gate.d.ts.map +1 -1
  18. package/dist/mutation-gate.js +57 -16
  19. package/dist/mutation-gate.js.map +1 -1
  20. package/dist/named-lock.d.ts.map +1 -1
  21. package/dist/named-lock.js +11 -9
  22. package/dist/named-lock.js.map +1 -1
  23. package/dist/native-dep-probe.d.ts +11 -0
  24. package/dist/native-dep-probe.d.ts.map +1 -0
  25. package/dist/native-dep-probe.js +100 -0
  26. package/dist/native-dep-probe.js.map +1 -0
  27. package/dist/operations.d.ts.map +1 -1
  28. package/dist/operations.js +82 -1
  29. package/dist/operations.js.map +1 -1
  30. package/dist/parser-safe-region.d.ts +47 -0
  31. package/dist/parser-safe-region.d.ts.map +1 -0
  32. package/dist/parser-safe-region.js +47 -0
  33. package/dist/parser-safe-region.js.map +1 -0
  34. package/dist/patterns.d.ts.map +1 -1
  35. package/dist/patterns.js +10 -1
  36. package/dist/patterns.js.map +1 -1
  37. package/dist/profile.d.ts +202 -0
  38. package/dist/profile.d.ts.map +1 -0
  39. package/dist/profile.js +536 -0
  40. package/dist/profile.js.map +1 -0
  41. package/dist/publish.d.ts +5 -3
  42. package/dist/publish.d.ts.map +1 -1
  43. package/dist/publish.js +46 -8
  44. package/dist/publish.js.map +1 -1
  45. package/dist/qe-rounds.d.ts +75 -0
  46. package/dist/qe-rounds.d.ts.map +1 -0
  47. package/dist/qe-rounds.js +174 -0
  48. package/dist/qe-rounds.js.map +1 -0
  49. package/dist/registry.d.ts.map +1 -1
  50. package/dist/registry.js +5 -1
  51. package/dist/registry.js.map +1 -1
  52. package/dist/run-records.d.ts.map +1 -1
  53. package/dist/run-records.js +8 -1
  54. package/dist/run-records.js.map +1 -1
  55. package/dist/setup.d.ts +35 -4
  56. package/dist/setup.d.ts.map +1 -1
  57. package/dist/setup.js +86 -23
  58. package/dist/setup.js.map +1 -1
  59. package/dist/sign.d.ts +29 -24
  60. package/dist/sign.d.ts.map +1 -1
  61. package/dist/sign.js +183 -61
  62. package/dist/sign.js.map +1 -1
  63. package/dist/store-location.d.ts +56 -0
  64. package/dist/store-location.d.ts.map +1 -0
  65. package/dist/store-location.js +56 -0
  66. package/dist/store-location.js.map +1 -0
  67. package/dist/store-lock.d.ts +1 -0
  68. package/dist/store-lock.d.ts.map +1 -1
  69. package/dist/store-lock.js +7 -1
  70. package/dist/store-lock.js.map +1 -1
  71. package/dist/store-merge.d.ts +55 -0
  72. package/dist/store-merge.d.ts.map +1 -0
  73. package/dist/store-merge.js +77 -0
  74. package/dist/store-merge.js.map +1 -0
  75. package/dist/teach-target.d.ts +61 -0
  76. package/dist/teach-target.d.ts.map +1 -0
  77. package/dist/teach-target.js +105 -0
  78. package/dist/teach-target.js.map +1 -0
  79. package/dist/vector-tier.d.ts.map +1 -1
  80. package/dist/vector-tier.js +63 -20
  81. package/dist/vector-tier.js.map +1 -1
  82. package/package.json +13 -13
  83. package/sbom.json +424 -70
  84. package/src/book-kb.ts +17 -0
  85. package/src/feature-adr-checkpoints.ts +111 -2
  86. package/src/index.ts +39 -1
  87. package/src/loop-blobs.generated.ts +2 -2
  88. package/src/mutation-gate.ts +74 -17
  89. package/src/named-lock.ts +10 -8
  90. package/src/native-dep-probe.ts +118 -0
  91. package/src/operations.ts +77 -1
  92. package/src/parser-safe-region.ts +65 -0
  93. package/src/patterns.ts +10 -1
  94. package/src/profile.ts +593 -0
  95. package/src/publish.ts +43 -7
  96. package/src/qe-rounds.ts +226 -0
  97. package/src/registry.ts +5 -1
  98. package/src/run-records.ts +9 -1
  99. package/src/setup.ts +87 -23
  100. package/src/sign.ts +180 -60
  101. package/src/store-location.ts +76 -0
  102. package/src/store-lock.ts +8 -1
  103. package/src/store-merge.ts +91 -0
  104. package/src/teach-target.ts +126 -0
  105. package/src/vector-tier.ts +58 -9
package/src/book-kb.ts CHANGED
@@ -16,6 +16,8 @@ import { existsSync, mkdirSync } from 'node:fs';
16
16
  import { pathToFileURL } from 'node:url';
17
17
  import { createRequire } from 'node:module';
18
18
 
19
+ import { describeNativeDep, exerciseSqliteOpen, probeNativeDep } from './native-dep-probe.js';
20
+
19
21
  /** A digitized Knowledge Unit as stored in the book KB. */
20
22
  export interface BookKU {
21
23
  readonly book: string; // ISBN or book slug (the immutable upstream key)
@@ -64,6 +66,11 @@ const SCHEMA = `CREATE VIRTUAL TABLE IF NOT EXISTS book_knowledge USING fts5(
64
66
  );`;
65
67
 
66
68
  async function openDb(projectRoot: string, dbPath?: string): Promise<NativeDb | { error: string }> {
69
+ const verdict = probeNativeDep(projectRoot, 'better-sqlite3', exerciseSqliteOpen, 'sqlite-open');
70
+ if (verdict.state === 'absent') {
71
+ return { error: 'better-sqlite3 not installed in project (run: dz setup --memory agentdb)' };
72
+ }
73
+ if (verdict.state === 'unusable') return { error: describeNativeDep(verdict) };
67
74
  let sqliteUrl: string;
68
75
  try {
69
76
  const req = createRequire(join(projectRoot, 'package.json'));
@@ -71,6 +78,16 @@ async function openDb(projectRoot: string, dbPath?: string): Promise<NativeDb |
71
78
  } catch {
72
79
  return { error: 'better-sqlite3 not installed in project (run: dz setup --memory agentdb)' };
73
80
  }
81
+ // The probe above absorbs the DEPENDENCY failures — absent, or present-but-unloadable — which is
82
+ // what cross-family review (F2) required: those are instrument failures and must be RETURNED.
83
+ //
84
+ // What follows must still THROW, and that is deliberate. `dz recall --books` distinguishes two
85
+ // states by throw-vs-return, and collapsing them loses a shipped honesty contract (measured
86
+ // 2026-08-27, `books-recall-honesty.test.ts`):
87
+ // throws -> the STORE ITSELF is unreadable -> exit 1, naming the path and a cure
88
+ // {error} -> the INSTRUMENT did not run -> exit 3 under --semantic, never a clean 0-hit
89
+ // A corrupted `books.sqlite` is the first state. Wrapping it into `{error}` made a corrupt store
90
+ // read as a missing instrument and exit 0 — a false clean pass over unreadable data.
74
91
  const path = dbPath ?? bookKbPath(projectRoot);
75
92
  mkdirSync(dirname(path), { recursive: true });
76
93
  const { default: Database } = (await import(sqliteUrl)) as { default: new (p: string) => NativeDb };
@@ -497,6 +497,113 @@ export const TRAINPAIR_PRIVACY_NOTE =
497
497
  /** Coerce a stage input/output to text: strings pass through; objects serialize to JSON;
498
498
  * an unserializable value degrades to String(v) — buildTrainingPair NEVER throws (capture
499
499
  * is non-blocking by contract). */
500
+ /** Marker pair of the operator-profile block (feature operator-profile, ADR-001 Decision 5).
501
+ * DELIBERATE LOCAL COPIES of profile.ts's PROFILE_MARKER_START/END: this module stays import-free
502
+ * so the workflow can mirror it inline. `test/profile-redaction.test.ts` pins the pairs equal. */
503
+ export const TP_PROFILE_MARKER_START = '<!-- dz:profile:start -->';
504
+ export const TP_PROFILE_MARKER_END = '<!-- dz:profile:end -->';
505
+
506
+ /** What a redacted block is replaced with — visible in the dataset, so a missing profile is
507
+ * distinguishable from a never-present one. */
508
+ export const TP_PROFILE_REDACTED = '[dz:profile REDACTED]';
509
+
510
+ /**
511
+ * Strip every operator-profile block from `text` BEFORE a training pair is persisted.
512
+ *
513
+ * Why here and not "the guard already says never write the profile into a project": training-pair
514
+ * capture records the FULL prompt as the model received it into `.dz/fa-training/`, which is
515
+ * deliberately NOT gitignored — so a profile injected into context would reach a committable
516
+ * directory, and the never-in-a-project guard would be defeated through this path (ADR-001
517
+ * Decision 5, exit 3). Redaction at the single assembly seam closes it for capture AND backfill.
518
+ *
519
+ * Semantics: every complete `start…end` span is replaced (markers included) with
520
+ * {@link TP_PROFILE_REDACTED}. A START marker with no matching END fails CLOSED — everything from
521
+ * the marker to the end of the text is dropped (over-redaction is a lost training sample;
522
+ * under-redaction is a personal-data leak). Text without markers passes through byte-identical.
523
+ * Works on JSON-stringified payloads too: the marker literals contain no characters JSON escapes.
524
+ */
525
+ export function redactProfileBlock(text: string): string {
526
+ if (typeof text !== 'string' || text === '') return typeof text === 'string' ? text : '';
527
+ let out = '';
528
+ let rest = text;
529
+ for (;;) {
530
+ const start = rest.indexOf(TP_PROFILE_MARKER_START);
531
+ if (start === -1) return out + rest;
532
+ out += rest.slice(0, start) + TP_PROFILE_REDACTED;
533
+ const end = rest.indexOf(TP_PROFILE_MARKER_END, start + TP_PROFILE_MARKER_START.length);
534
+ if (end === -1) return out; // unterminated block: fail closed, drop the tail
535
+ rest = rest.slice(end + TP_PROFILE_MARKER_END.length);
536
+ }
537
+ }
538
+
539
+ /**
540
+ * Deep redaction over an already-PARSED training-pair payload — the PERSIST-SIDE half of CF-6.
541
+ *
542
+ * Why a second entry point next to {@link redactProfileBlock}: the DEFAULT-ON capture in the
543
+ * canonical workflow builds its pair with an INLINE mirror of buildTrainingPair and hands the
544
+ * serialised JSON to `dz feature-adr-record` — a path that never passes through the core builder.
545
+ * Redacting at the builder alone therefore guarded the path that does NOT run (Codex cross-family
546
+ * finding, 2026-08-28: guard 1 defeated through guard 3, one seam further down — the exact shape
547
+ * ADR-001 Decision 5 names). This function runs inside `decideRecordWrite` (run-records.ts), the
548
+ * one decision every witnessed training-pair write funnels through, so a FUTURE pair builder is
549
+ * covered without patching its caller.
550
+ *
551
+ * Semantics: every string leaf (keys included) goes through {@link redactProfileBlock} — same
552
+ * fail-closed rule on an unterminated block; arrays and plain objects are walked; numbers,
553
+ * booleans and null pass through untouched. A payload with no markers anywhere round-trips to a
554
+ * deep-equal value.
555
+ *
556
+ * Two mechanics, both cross-family findings (2026-08-28), both load-bearing:
557
+ *
558
+ * - Rebuilt objects have a NULL prototype, so every JSON key — `__proto__` included — lands as an
559
+ * OWN property. The previous `{}` + assignment invoked the inherited `__proto__` SETTER for a
560
+ * payload like `{"slug":"s","stage":"code","__proto__":{"input":"i","output":"o"}}`: the result
561
+ * then INHERITED input/output (the shape check passed) while serialization dropped them — an
562
+ * invalid pair reported `written`. With own-key reconstruction the JSON keys round-trip exactly
563
+ * and that payload fails the shape check honestly.
564
+ * - The walk is ITERATIVE (explicit stack), not recursive: ~5000 nested arrays is a ~10 KB payload
565
+ * that passes JSON.parse and sits under the line cap, but a recursive map hit RangeError before
566
+ * any size guard — a throw escaping a seam whose callers promise non-blocking verdicts. The
567
+ * iterative walk chose over a caught-RangeError→`refused` wrapper because it keeps the honest
568
+ * outcome for deep-but-valid payloads (they get redacted and judged on their merits) instead of
569
+ * refusing them at an arbitrary engine-dependent depth. A repeated container is walked once and
570
+ * reused (WeakMap), so a shared or cyclic reference can never loop the walk either.
571
+ */
572
+ export function redactTrainingPayload(v: unknown): unknown {
573
+ if (typeof v === 'string') return redactProfileBlock(v);
574
+ if (v === null || typeof v !== 'object') return v;
575
+ const makeDst = (src: object): Record<string, unknown> | unknown[] =>
576
+ Array.isArray(src) ? new Array(src.length) : (Object.create(null) as Record<string, unknown>);
577
+ const dstOf = new WeakMap<object, Record<string, unknown> | unknown[]>();
578
+ const root = makeDst(v);
579
+ dstOf.set(v, root);
580
+ const stack: object[] = [v];
581
+ const walkChild = (child: unknown): unknown => {
582
+ if (typeof child === 'string') return redactProfileBlock(child);
583
+ if (child === null || typeof child !== 'object') return child;
584
+ let dst = dstOf.get(child);
585
+ if (dst === undefined) {
586
+ dst = makeDst(child);
587
+ dstOf.set(child, dst);
588
+ stack.push(child);
589
+ }
590
+ return dst;
591
+ };
592
+ while (stack.length > 0) {
593
+ const src = stack.pop()!;
594
+ const dst = dstOf.get(src)!;
595
+ if (Array.isArray(src)) {
596
+ const arr = dst as unknown[];
597
+ for (let i = 0; i < src.length; i++) arr[i] = walkChild(src[i]);
598
+ } else {
599
+ const obj = dst as Record<string, unknown>;
600
+ // null prototype ⇒ this assignment defines an OWN property even for the key '__proto__'
601
+ for (const [key, value] of Object.entries(src)) obj[redactProfileBlock(key)] = walkChild(value);
602
+ }
603
+ }
604
+ return root;
605
+ }
606
+
500
607
  function coerceText(v: unknown): string {
501
608
  if (typeof v === 'string') return v;
502
609
  if (v === null || v === undefined) return '';
@@ -526,8 +633,10 @@ export function buildTrainingPair(opts: {
526
633
  captureMode?: unknown;
527
634
  resumed?: unknown;
528
635
  }): TrainingPair {
529
- let input = coerceText(opts.input);
530
- let output = coerceText(opts.output);
636
+ // Operator-profile redaction FIRST — before the oversize guard, so the truncation hashes are
637
+ // hashes of the redacted text and the full-text fnv1a64 never fingerprints personal data.
638
+ let input = redactProfileBlock(coerceText(opts.input));
639
+ let output = redactProfileBlock(coerceText(opts.output));
531
640
  let truncated: TrainingPairTruncation | null = null;
532
641
  if (input.length + output.length > TRAINPAIR_MAX_IO_CHARS) {
533
642
  truncated = { inputChars: input.length, outputChars: output.length, inputHash: fnv1a64(input), outputHash: fnv1a64(output) };
package/src/index.ts CHANGED
@@ -182,7 +182,8 @@ export type {
182
182
  ReindexVectorReport,
183
183
  TeachGuardResult,
184
184
  } from './vector-tier.js';
185
- export { runSetup, generateHooksConfig, generateAgentdbWriter, writerVersionOf, AGENTDB_WRITER_VERSION } from './setup.js';
185
+ export { runSetup, generateHooksConfig, generateAgentdbWriter, writerVersionOf, AGENTDB_WRITER_VERSION,
186
+ agentdbStorePath, agentdbMcpStorePath, agentdbStoreSeparationProblem } from './setup.js';
186
187
  export { statuslineData, readFeatureAdrState, writeFeatureAdrState, featureAdrStateDir, featureAdrStatePath } from './statusline.js';
187
188
  export type { StatuslineData, FeatureAdrState, WriteFeatureAdrStateInput } from './statusline.js';
188
189
  export { indexPatternsToAgentdb, resolveAgentdbPath, searchAgentdbPatterns, listAgentdbDzIds, resolveAgentdbEmbedder, cosineSimilarity, importVectorsToAgentdb, reindexAgentdbRows, bumpAgentdbUses, clearAgentdbQuarantine, deleteAgentdbByDzIds, readAgentdbRowsByTaskType, DZ_OWNED_TASK_TYPES } from './agentdb-index.js';
@@ -251,6 +252,11 @@ export {
251
252
  serializeTrainingPair,
252
253
  trainingPairPath,
253
254
  trainingPairAppendCmd,
255
+ // operator-profile (ADR-001 Decision 5): the profile block never reaches .dz/fa-training/.
256
+ redactProfileBlock,
257
+ TP_PROFILE_MARKER_START,
258
+ TP_PROFILE_MARKER_END,
259
+ TP_PROFILE_REDACTED,
254
260
  // wave1-instrument-repair (ADR-003 Condition 3): the persist allowlist + composite shape check.
255
261
  codeCheckpointPersistAllowed,
256
262
  codeStageResultShapeValid,
@@ -691,6 +697,12 @@ export type {
691
697
  CodexQeDeclineDetail,
692
698
  CodexReviewResult,
693
699
  } from './feature-adr-routing.js';
700
+ export {
701
+ PARSER_SAFE_REGION_START,
702
+ PARSER_SAFE_REGION_END,
703
+ checkParserSafeRegion,
704
+ } from './parser-safe-region.js';
705
+ export type { ParserSafeRegionVerdict, ParserSafeRegionRefusal } from './parser-safe-region.js';
694
706
  export {
695
707
  CLAUDE_USAGE_MODELS,
696
708
  computeUsage,
@@ -858,3 +870,29 @@ export { quiescenceProbeScript, decideWriterQuiescence, WQ_WINDOW_SECONDS, WQ_MA
858
870
  export type { WriterQuiescenceDecision } from './writer-quiescence.js';
859
871
  export { decideCadenceWindow, isoWeekOf, weeklyBuckets, guardRepeatDecay, buildCadenceReport, CADENCE_WINDOW_DAYS } from './cadence.js';
860
872
  export type { CadenceWindow, CadenceReport, CadenceWindowDecision } from './cadence.js';
873
+ export { readQeRounds, countQeRounds, QE_ROUNDS_DEFAULT_CEILING } from './qe-rounds.js';
874
+ export type { QeRound, QeFailedAttempt, QeRoundsReport, QeRoundsStatus } from './qe-rounds.js';
875
+ export { describeStoreLocation, storeLocationLine } from './store-location.js';
876
+ export type { StoreLocation, StoreOrigin } from './store-location.js';
877
+ export { mergeStoreHits, sameStore, globalStoreRoot, storeCountLabel } from './store-merge.js';
878
+ export type { HitOrigin, OriginTagged } from './store-merge.js';
879
+ export { resolveTeachTarget, teachReasonPhrase, readTeachToConfig, TeachTargetError, TEACH_STORES } from './teach-target.js';
880
+ export type { TeachStore, TeachReason, TeachTarget } from './teach-target.js';
881
+ export { probeNativeDep, describeNativeDep, exerciseSqliteOpen } from './native-dep-probe.js';
882
+ export type { NativeDepState, NativeDepVerdict } from './native-dep-probe.js';
883
+
884
+ // Operator profile (feature operator-profile, ADR-001) — WHO the assistant is talking to: a
885
+ // per-user 0600 store under homedir(), rendered into a marked block in ~/.claude/CLAUDE.md
886
+ // (layer 2 — loaded in every project, no dz required). The register changes FORM, never FACTS.
887
+ export {
888
+ renderProfileBlock, readProfile, writeProfile, validateProfile,
889
+ profileStorePath, claudeMdPath, syncProfileBlock, checkProfileDrift,
890
+ mergeProfileBlock, extractProfileBlock, wrapProfileBlock,
891
+ parseRegister, registerOwnerWord, profileAgeDays,
892
+ parseDomainList, domainListText, parseYesNo,
893
+ PROFILE_MARKER_START, PROFILE_MARKER_END, REGISTERS,
894
+ } from './profile.js';
895
+ export type {
896
+ Register, Domain, OperatorProfile, ProfileReadResult, ProfileSyncResult,
897
+ ProfileDriftVerdict, ProfileDriftResult,
898
+ } from './profile.js';
@@ -52,11 +52,11 @@ export const BLOBS: Record<string, LoopBlob> = {
52
52
  "training-pairs": {
53
53
  name: "training-pairs",
54
54
  version: "1.1.0",
55
- contentHash: "740b733d3d995e7031590f1707b442d0f0f9d7d585ada6f37bbf08c5db3351e9",
55
+ contentHash: "199eaaa8ce5dba4f54744b4a4f34eea32a3c33d4de8dbbddd3d119b70459361d",
56
56
  sourcePath: "packages/@dzhechkov/harness-core/src/feature-adr-checkpoints.ts",
57
57
  requires: ["checkpoints"],
58
58
  exports: ["TRAINPAIR_SCHEMA_VERSION","TRAINPAIR_MAX_IO_CHARS","trainingPairFamily","trainingPairPath","TRAINPAIR_PRIVACY_NOTE","buildTrainingPair","serializeTrainingPair","trainingPairAppendCmd","decideCaptureMode","captureFailureRecord","trainingPairBackfillCmd","TP_BACKFILL_OK","TP_BACKFILL_SKIP"],
59
- code: "function decideCaptureMode(opts) {\n if (!opts.enabled)\n return 'skip-disabled';\n if (!Number.isInteger(opts.recordCount) || opts.recordCount <= 0)\n return 'skip-empty';\n return opts.resumed ? 'backfill' : 'capture';\n}\nfunction captureFailureRecord(stage, mode, reason, detail) {\n const normalizedStage = typeof stage === 'string' && stage.trim() !== '' ? stage : 'unknown';\n const normalizedMode = mode === 'capture' || mode === 'backfill' || mode === 'skip-disabled' || mode === 'skip-empty'\n ? mode\n : null;\n const normalizedReason = reason === 'threw' || reason === 'unserializable' || reason === 'unverified' || reason === 'backfill-unverified' || reason === 'empty-output'\n ? reason\n : 'threw';\n let normalizedDetail = null;\n if (detail !== null && detail !== undefined) {\n try {\n const text = String(detail);\n if (text !== '')\n normalizedDetail = text.length > 500 ? text.slice(0, 500) + '…' : text;\n }\n catch {\n normalizedDetail = null;\n }\n }\n return { stage: normalizedStage, mode: normalizedMode, reason: normalizedReason, detail: normalizedDetail };\n}\nconst TRAINPAIR_SCHEMA_VERSION = 'fa-trainpair-2';\nconst TRAINPAIR_MAX_IO_CHARS = 48000;\nfunction trainingPairFamily(spec) {\n return /codex|gpt|openai/i.test(String(spec ?? '')) ? 'codex' : 'claude';\n}\nfunction trainingPairPath(slug, stage) {\n return '.dz/fa-training/' + slug + '/' + stage + '.jsonl';\n}\nconst TRAINPAIR_PRIVACY_NOTE = \"feature-adr TRAINING PAIRS (backlog 70e0f083): per-stage SFT records - STAGE INPUT (full prompt/context) -> STAGE OUTPUT (artifact/result) -> EVALUATION (QE grade + injected lessons) with model+family provenance; one JSONL file per stage per slug. PRIVACY: pairs may contain TARGET-REPO CODE and full prompts. This directory is NOT gitignored yet by explicit owner decision - review contents before sharing or publishing anything that embeds it. ts is the CAPTURE time. On a record with captureMode: 'backfill' that is the RECONSTRUCTION time, NOT the stage's observation time — the original stage's timing lives in that run's .fa-state checkpoint.\";\nfunction coerceText(v) {\n if (typeof v === 'string')\n return v;\n if (v === null || v === undefined)\n return '';\n try {\n const s = JSON.stringify(v);\n return typeof s === 'string' ? s : String(v);\n }\n catch {\n return String(v);\n }\n}\nfunction buildTrainingPair(opts) {\n let input = coerceText(opts.input);\n let output = coerceText(opts.output);\n let truncated = null;\n if (input.length + output.length > TRAINPAIR_MAX_IO_CHARS) {\n truncated = { inputChars: input.length, outputChars: output.length, inputHash: fnv1a64(input), outputHash: fnv1a64(output) };\n const half = Math.floor(TRAINPAIR_MAX_IO_CHARS / 2);\n let inKeep = input.length;\n let outKeep = output.length;\n if (outKeep <= half)\n inKeep = TRAINPAIR_MAX_IO_CHARS - outKeep;\n else if (inKeep <= half)\n outKeep = TRAINPAIR_MAX_IO_CHARS - inKeep;\n else {\n inKeep = half;\n outKeep = TRAINPAIR_MAX_IO_CHARS - half;\n }\n if (inKeep < input.length)\n input = input.slice(0, inKeep) + '\\n…[TRUNCATED ' + (truncated.inputChars - inKeep) + ' chars — full-text fnv1a64=' + truncated.inputHash + ']';\n if (outKeep < output.length)\n output = output.slice(0, outKeep) + '\\n…[TRUNCATED ' + (truncated.outputChars - outKeep) + ' chars — full-text fnv1a64=' + truncated.outputHash + ']';\n }\n const ev = opts.evaluation || {};\n const pv = opts.provenance || {};\n return {\n schema: TRAINPAIR_SCHEMA_VERSION,\n slug: opts.slug,\n stage: opts.stage,\n ts: opts.ts === undefined ? null : opts.ts,\n input,\n output,\n evaluation: {\n grade: typeof ev.grade === 'string' && ev.grade.trim() !== '' ? ev.grade : null,\n gradedBy: typeof ev.gradedBy === 'string' && ev.gradedBy !== '' ? ev.gradedBy : null,\n lessonsInjected: Array.isArray(ev.lessonsInjected) ? ev.lessonsInjected.filter((s) => typeof s === 'string' && s !== '') : [],\n },\n provenance: {\n model: typeof pv.model === 'string' && pv.model !== '' ? pv.model : 'unknown',\n family: pv.family === 'claude' || pv.family === 'codex' ? pv.family : trainingPairFamily(pv.model),\n role: typeof pv.role === 'string' && pv.role !== '' ? pv.role : 'unknown',\n tokens: typeof pv.tokens === 'number' && Number.isFinite(pv.tokens) ? pv.tokens : null,\n minutes: typeof pv.minutes === 'number' && Number.isFinite(pv.minutes) ? pv.minutes : null,\n },\n truncated,\n captureMode: opts.captureMode === 'backfill' ? 'backfill' : 'capture',\n resumed: opts.resumed === true,\n };\n}\nfunction serializeTrainingPair(pair) {\n try {\n const line = JSON.stringify(pair);\n return typeof line === 'string' ? line : null;\n }\n catch {\n return null;\n }\n}\nfunction trainingPairAppendCmd(repoAbs, slug, stage, line) {\n const dirAbs = repoAbs + '/.dz/fa-training/' + slug;\n const readmeAbs = repoAbs + '/.dz/fa-training/README.md';\n const fileAbs = dirAbs + '/' + stage + '.jsonl';\n return ('mkdir -p ' + shellQuote(dirAbs) +\n ' && { [ -f ' + shellQuote(readmeAbs) + ' ] || printf \\'%s\\\\n\\' ' + shellQuote(TRAINPAIR_PRIVACY_NOTE) + ' > ' + shellQuote(readmeAbs) + '; }' +\n \" && printf '%s\\\\n' \" + shellQuote(line) + ' >> ' + shellQuote(fileAbs));\n}\nconst TP_BACKFILL_OK = 'TP-BACKFILL-OK';\nconst TP_BACKFILL_SKIP = 'TP-BACKFILL-SKIP';\nconst TP_BACKFILL_DUP = 'TP-BACKFILL-DUP';\nfunction trainingPairBackfillCmd(repoAbs, slug, stage, lines, markKey) {\n if (typeof repoAbs !== 'string' || repoAbs === '')\n return null;\n if (typeof slug !== 'string' || slug === '')\n return null;\n if (typeof stage !== 'string' || stage === '')\n return null;\n if (!Array.isArray(lines) || lines.length === 0 || !lines.every(line => typeof line === 'string' && line !== ''))\n return null;\n const dirAbs = repoAbs + '/.dz/fa-training/' + slug;\n const readmeAbs = repoAbs + '/.dz/fa-training/README.md';\n const fileAbs = dirAbs + '/' + stage + '.jsonl';\n const markDir = repoAbs + '/.dz/fa-training/.backfill-marks';\n const markStage = stage.replace(/\\.\\./g, '_').replace(/\\//g, '_');\n const resolvedMarkKey = markKey === undefined ? fnv1a64(stage + '\\0' + lines.join('\\n')) : markKey;\n const markPath = markDir + '/' + markStage + '-' + resolvedMarkKey;\n const appends = lines\n .map(line => \"printf '%s\\\\n' \" + shellQuote(line) + ' >> ' + shellQuote(fileAbs))\n .join(' && ');\n return ('mkdir -p ' + shellQuote(dirAbs) +\n ' && { [ -f ' + shellQuote(readmeAbs) + ' ] || printf \\'%s\\\\n\\' ' + shellQuote(TRAINPAIR_PRIVACY_NOTE) + ' > ' + shellQuote(readmeAbs) + '; }' +\n ' && mkdir -p ' + shellQuote(markDir) +\n ' && if mkdir ' + shellQuote(markPath) + ' 2>/dev/null; then ' +\n 'if [ -f ' + shellQuote(fileAbs) + ' ]; then echo ' + shellQuote(TP_BACKFILL_SKIP) +\n '; else { ' + appends + ' && echo ' + shellQuote(TP_BACKFILL_OK) + '; } || { rmdir ' + shellQuote(markPath) + ' 2>/dev/null; false; }; fi' +\n '; else echo ' + shellQuote(TP_BACKFILL_DUP) + '; fi');\n}",
59
+ code: "function decideCaptureMode(opts) {\n if (!opts.enabled)\n return 'skip-disabled';\n if (!Number.isInteger(opts.recordCount) || opts.recordCount <= 0)\n return 'skip-empty';\n return opts.resumed ? 'backfill' : 'capture';\n}\nfunction captureFailureRecord(stage, mode, reason, detail) {\n const normalizedStage = typeof stage === 'string' && stage.trim() !== '' ? stage : 'unknown';\n const normalizedMode = mode === 'capture' || mode === 'backfill' || mode === 'skip-disabled' || mode === 'skip-empty'\n ? mode\n : null;\n const normalizedReason = reason === 'threw' || reason === 'unserializable' || reason === 'unverified' || reason === 'backfill-unverified' || reason === 'empty-output'\n ? reason\n : 'threw';\n let normalizedDetail = null;\n if (detail !== null && detail !== undefined) {\n try {\n const text = String(detail);\n if (text !== '')\n normalizedDetail = text.length > 500 ? text.slice(0, 500) + '…' : text;\n }\n catch {\n normalizedDetail = null;\n }\n }\n return { stage: normalizedStage, mode: normalizedMode, reason: normalizedReason, detail: normalizedDetail };\n}\nconst TRAINPAIR_SCHEMA_VERSION = 'fa-trainpair-2';\nconst TRAINPAIR_MAX_IO_CHARS = 48000;\nfunction trainingPairFamily(spec) {\n return /codex|gpt|openai/i.test(String(spec ?? '')) ? 'codex' : 'claude';\n}\nfunction trainingPairPath(slug, stage) {\n return '.dz/fa-training/' + slug + '/' + stage + '.jsonl';\n}\nconst TRAINPAIR_PRIVACY_NOTE = \"feature-adr TRAINING PAIRS (backlog 70e0f083): per-stage SFT records - STAGE INPUT (full prompt/context) -> STAGE OUTPUT (artifact/result) -> EVALUATION (QE grade + injected lessons) with model+family provenance; one JSONL file per stage per slug. PRIVACY: pairs may contain TARGET-REPO CODE and full prompts. This directory is NOT gitignored yet by explicit owner decision - review contents before sharing or publishing anything that embeds it. ts is the CAPTURE time. On a record with captureMode: 'backfill' that is the RECONSTRUCTION time, NOT the stage's observation time — the original stage's timing lives in that run's .fa-state checkpoint.\";\nconst TP_PROFILE_MARKER_START = '<!-- dz:profile:start -->';\nconst TP_PROFILE_MARKER_END = '<!-- dz:profile:end -->';\nconst TP_PROFILE_REDACTED = '[dz:profile REDACTED]';\nfunction redactProfileBlock(text) {\n if (typeof text !== 'string' || text === '')\n return typeof text === 'string' ? text : '';\n let out = '';\n let rest = text;\n for (;;) {\n const start = rest.indexOf(TP_PROFILE_MARKER_START);\n if (start === -1)\n return out + rest;\n out += rest.slice(0, start) + TP_PROFILE_REDACTED;\n const end = rest.indexOf(TP_PROFILE_MARKER_END, start + TP_PROFILE_MARKER_START.length);\n if (end === -1)\n return out;\n rest = rest.slice(end + TP_PROFILE_MARKER_END.length);\n }\n}\nfunction coerceText(v) {\n if (typeof v === 'string')\n return v;\n if (v === null || v === undefined)\n return '';\n try {\n const s = JSON.stringify(v);\n return typeof s === 'string' ? s : String(v);\n }\n catch {\n return String(v);\n }\n}\nfunction buildTrainingPair(opts) {\n let input = redactProfileBlock(coerceText(opts.input));\n let output = redactProfileBlock(coerceText(opts.output));\n let truncated = null;\n if (input.length + output.length > TRAINPAIR_MAX_IO_CHARS) {\n truncated = { inputChars: input.length, outputChars: output.length, inputHash: fnv1a64(input), outputHash: fnv1a64(output) };\n const half = Math.floor(TRAINPAIR_MAX_IO_CHARS / 2);\n let inKeep = input.length;\n let outKeep = output.length;\n if (outKeep <= half)\n inKeep = TRAINPAIR_MAX_IO_CHARS - outKeep;\n else if (inKeep <= half)\n outKeep = TRAINPAIR_MAX_IO_CHARS - inKeep;\n else {\n inKeep = half;\n outKeep = TRAINPAIR_MAX_IO_CHARS - half;\n }\n if (inKeep < input.length)\n input = input.slice(0, inKeep) + '\\n…[TRUNCATED ' + (truncated.inputChars - inKeep) + ' chars — full-text fnv1a64=' + truncated.inputHash + ']';\n if (outKeep < output.length)\n output = output.slice(0, outKeep) + '\\n…[TRUNCATED ' + (truncated.outputChars - outKeep) + ' chars — full-text fnv1a64=' + truncated.outputHash + ']';\n }\n const ev = opts.evaluation || {};\n const pv = opts.provenance || {};\n return {\n schema: TRAINPAIR_SCHEMA_VERSION,\n slug: opts.slug,\n stage: opts.stage,\n ts: opts.ts === undefined ? null : opts.ts,\n input,\n output,\n evaluation: {\n grade: typeof ev.grade === 'string' && ev.grade.trim() !== '' ? ev.grade : null,\n gradedBy: typeof ev.gradedBy === 'string' && ev.gradedBy !== '' ? ev.gradedBy : null,\n lessonsInjected: Array.isArray(ev.lessonsInjected) ? ev.lessonsInjected.filter((s) => typeof s === 'string' && s !== '') : [],\n },\n provenance: {\n model: typeof pv.model === 'string' && pv.model !== '' ? pv.model : 'unknown',\n family: pv.family === 'claude' || pv.family === 'codex' ? pv.family : trainingPairFamily(pv.model),\n role: typeof pv.role === 'string' && pv.role !== '' ? pv.role : 'unknown',\n tokens: typeof pv.tokens === 'number' && Number.isFinite(pv.tokens) ? pv.tokens : null,\n minutes: typeof pv.minutes === 'number' && Number.isFinite(pv.minutes) ? pv.minutes : null,\n },\n truncated,\n captureMode: opts.captureMode === 'backfill' ? 'backfill' : 'capture',\n resumed: opts.resumed === true,\n };\n}\nfunction serializeTrainingPair(pair) {\n try {\n const line = JSON.stringify(pair);\n return typeof line === 'string' ? line : null;\n }\n catch {\n return null;\n }\n}\nfunction trainingPairAppendCmd(repoAbs, slug, stage, line) {\n const dirAbs = repoAbs + '/.dz/fa-training/' + slug;\n const readmeAbs = repoAbs + '/.dz/fa-training/README.md';\n const fileAbs = dirAbs + '/' + stage + '.jsonl';\n return ('mkdir -p ' + shellQuote(dirAbs) +\n ' && { [ -f ' + shellQuote(readmeAbs) + ' ] || printf \\'%s\\\\n\\' ' + shellQuote(TRAINPAIR_PRIVACY_NOTE) + ' > ' + shellQuote(readmeAbs) + '; }' +\n \" && printf '%s\\\\n' \" + shellQuote(line) + ' >> ' + shellQuote(fileAbs));\n}\nconst TP_BACKFILL_OK = 'TP-BACKFILL-OK';\nconst TP_BACKFILL_SKIP = 'TP-BACKFILL-SKIP';\nconst TP_BACKFILL_DUP = 'TP-BACKFILL-DUP';\nfunction trainingPairBackfillCmd(repoAbs, slug, stage, lines, markKey) {\n if (typeof repoAbs !== 'string' || repoAbs === '')\n return null;\n if (typeof slug !== 'string' || slug === '')\n return null;\n if (typeof stage !== 'string' || stage === '')\n return null;\n if (!Array.isArray(lines) || lines.length === 0 || !lines.every(line => typeof line === 'string' && line !== ''))\n return null;\n const dirAbs = repoAbs + '/.dz/fa-training/' + slug;\n const readmeAbs = repoAbs + '/.dz/fa-training/README.md';\n const fileAbs = dirAbs + '/' + stage + '.jsonl';\n const markDir = repoAbs + '/.dz/fa-training/.backfill-marks';\n const markStage = stage.replace(/\\.\\./g, '_').replace(/\\//g, '_');\n const resolvedMarkKey = markKey === undefined ? fnv1a64(stage + '\\0' + lines.join('\\n')) : markKey;\n const markPath = markDir + '/' + markStage + '-' + resolvedMarkKey;\n const appends = lines\n .map(line => \"printf '%s\\\\n' \" + shellQuote(line) + ' >> ' + shellQuote(fileAbs))\n .join(' && ');\n return ('mkdir -p ' + shellQuote(dirAbs) +\n ' && { [ -f ' + shellQuote(readmeAbs) + ' ] || printf \\'%s\\\\n\\' ' + shellQuote(TRAINPAIR_PRIVACY_NOTE) + ' > ' + shellQuote(readmeAbs) + '; }' +\n ' && mkdir -p ' + shellQuote(markDir) +\n ' && if mkdir ' + shellQuote(markPath) + ' 2>/dev/null; then ' +\n 'if [ -f ' + shellQuote(fileAbs) + ' ]; then echo ' + shellQuote(TP_BACKFILL_SKIP) +\n '; else { ' + appends + ' && echo ' + shellQuote(TP_BACKFILL_OK) + '; } || { rmdir ' + shellQuote(markPath) + ' 2>/dev/null; false; }; fi' +\n '; else echo ' + shellQuote(TP_BACKFILL_DUP) + '; fi');\n}",
60
60
  },
61
61
  "model-resolver": {
62
62
  name: "model-resolver",
@@ -45,12 +45,15 @@ export interface MutationRegistryEntry {
45
45
  export interface MutationRegistry {
46
46
  /** optional suite command override for the whole registry (default `npm test`). */
47
47
  readonly testCommand?: string;
48
+ /** opt-in proof that the suite harness reached its clean completion path. */
49
+ readonly requireCompletionReceipt?: boolean;
48
50
  readonly entries: readonly MutationRegistryEntry[];
49
51
  }
50
52
 
51
53
  export type MutationVerdict =
52
54
  | 'PROVEN' // mutation applied, suite went red, failing count (when known) >= minFailing
53
55
  | 'UNDEFENDED' // mutation applied, suite stayed GREEN — the property has no discriminating test
56
+ | '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
54
57
  | 'NOT_APPLIED' // `find` occurred 0 or >1 times — code drifted; nothing was tested (rule 1)
55
58
  | 'BELOW_MIN' // suite red, but a RELIABLE count came in under the entry's own minFailing contract
56
59
  | 'MUTATION_UNPARSEABLE' // the MUTATED file no longer parses — a registry/setup error: whole-suite redness from a load failure proves nothing (route a)
@@ -89,12 +92,22 @@ export interface MutationObservation {
89
92
  * failure), NEVER PROVEN: redness that cannot be attributed proves nothing.
90
93
  */
91
94
  readonly outputUnrecognised?: string;
95
+ /**
96
+ * set to the marker text when the suite harness declares its own execution-receipt contract
97
+ * violated, or to the missing-receipt detail for an opted-in registry. Either condition
98
+ * invalidates both green and count-based readings of the same run.
99
+ */
100
+ readonly receiptMismatch?: string;
101
+ /** named reason from spawnSync when no exit code exists (timeout, signal, maxBuffer, spawn). */
102
+ readonly runFailureReason?: string;
92
103
  /**
93
104
  * exit code of the suite re-run on the RESTORED tree after this entry (the attribution check
94
105
  * for flaky neighbours). undefined = not run; 0 = reproducibly green; any other value (or null)
95
106
  * = the "green" baseline is not reproducible, so the red under mutation is not attributable.
96
107
  */
97
108
  readonly rebaselineExitCode?: number | null;
109
+ /** named no-exit reason for the restored-tree attribution run, when it produced none. */
110
+ readonly rebaselineFailureReason?: string;
98
111
  }
99
112
 
100
113
  export interface MutationEntryResult {
@@ -135,7 +148,7 @@ export interface ParsedRegistry {
135
148
  readonly errors: readonly string[];
136
149
  }
137
150
 
138
- /** Parse + validate a registry JSON text. Accepts a bare array of entries or `{testCommand, entries}`. */
151
+ /** Parse + validate a registry JSON text. Accepts a bare array or `{testCommand?, requireCompletionReceipt?, entries}`. */
139
152
  export function parseMutationRegistry(text: string): ParsedRegistry {
140
153
  let raw: unknown;
141
154
  try {
@@ -146,10 +159,11 @@ export function parseMutationRegistry(text: string): ParsedRegistry {
146
159
 
147
160
  let entriesRaw: unknown;
148
161
  let testCommand: string | undefined;
162
+ let requireCompletionReceipt: boolean | undefined;
149
163
  if (Array.isArray(raw)) {
150
164
  entriesRaw = raw;
151
165
  } else if (raw && typeof raw === 'object') {
152
- const obj = raw as { testCommand?: unknown; entries?: unknown };
166
+ const obj = raw as { testCommand?: unknown; requireCompletionReceipt?: unknown; entries?: unknown };
153
167
  entriesRaw = obj.entries;
154
168
  if (obj.testCommand !== undefined) {
155
169
  if (typeof obj.testCommand !== 'string' || obj.testCommand.trim() === '') {
@@ -157,9 +171,15 @@ export function parseMutationRegistry(text: string): ParsedRegistry {
157
171
  }
158
172
  testCommand = obj.testCommand.trim();
159
173
  }
174
+ if (obj.requireCompletionReceipt !== undefined) {
175
+ if (typeof obj.requireCompletionReceipt !== 'boolean') {
176
+ return { registry: null, errors: ['requireCompletionReceipt must be a boolean when present'] };
177
+ }
178
+ requireCompletionReceipt = obj.requireCompletionReceipt;
179
+ }
160
180
  }
161
181
  if (!Array.isArray(entriesRaw)) {
162
- return { registry: null, errors: ['registry must be an array of entries or {testCommand?, entries: [...]}'] };
182
+ return { registry: null, errors: ['registry must be an array of entries or {testCommand?, requireCompletionReceipt?, entries: [...]}'] };
163
183
  }
164
184
  if (entriesRaw.length === 0) {
165
185
  // An empty registry "passes" by testing nothing — the same silent hole as a skipped mutation.
@@ -237,9 +257,14 @@ export function parseMutationRegistry(text: string): ParsedRegistry {
237
257
  });
238
258
 
239
259
  if (errors.length > 0) return { registry: null, errors };
240
- return testCommand !== undefined
241
- ? { registry: { testCommand, entries }, errors: [] }
242
- : { registry: { entries }, errors: [] };
260
+ return {
261
+ registry: {
262
+ ...(testCommand !== undefined ? { testCommand } : {}),
263
+ ...(requireCompletionReceipt !== undefined ? { requireCompletionReceipt } : {}),
264
+ entries,
265
+ },
266
+ errors: [],
267
+ };
243
268
  }
244
269
 
245
270
  // ── Mutation application — exact text surgery, exactly once (rule 1) ──────────────────────────
@@ -315,6 +340,21 @@ function stripSgr(s: string): string {
315
340
  return s.replace(SGR, '');
316
341
  }
317
342
 
343
+ /** Return the first suite-harness receipt error at column 0, with SGR removed. */
344
+ export function detectSuiteReceiptMismatch(rawOutput: string): string | undefined {
345
+ const match = /^mutation-suite-receipt-error:[^\S\r\n]*(.*)$/m.exec(stripSgr(rawOutput));
346
+ return match === null ? undefined : (match[1] ?? '').trim();
347
+ }
348
+
349
+ /** Return the first suite-harness clean-completion receipt at column 0, with SGR removed. */
350
+ export function detectSuiteCompletionReceipt(rawOutput: string): { lanes: number; names: number } | undefined {
351
+ const match = /^mutation-suite-receipt-ok:[^\S\r\n]*lanes=(\d+)[^\S\r\n]+names=(\d+)[^\S\r\n]*$/m.exec(stripSgr(rawOutput));
352
+ if (match === null) return undefined;
353
+ const lanes = Number(match[1]);
354
+ const names = Number(match[2]);
355
+ return Number.isSafeInteger(lanes) && Number.isSafeInteger(names) ? { lanes, names } : undefined;
356
+ }
357
+
318
358
  export type RunnerKind = 'node-test' | 'vitest' | 'unknown';
319
359
 
320
360
  /**
@@ -464,10 +504,10 @@ export interface BaselineResult {
464
504
  * A RED baseline in the scratch copy is a SETUP error, never a mutation result: every subsequent
465
505
  * "red under mutation" would be noise, and every "green" a lie about an unrunnable copy.
466
506
  */
467
- export function classifyBaseline(exitCode: number | null): BaselineResult {
507
+ export function classifyBaseline(exitCode: number | null, runFailureReason?: string): BaselineResult {
468
508
  if (exitCode === 0) return { ok: true, detail: 'baseline suite green in the scratch copy' };
469
509
  if (exitCode === null) {
470
- return { ok: false, detail: 'baseline suite produced no exit code (timeout/spawn failure) — the copy is not runnable; fix the copy, do not read this as a mutation result' };
510
+ return { ok: false, detail: `baseline suite produced no exit code (${runFailureReason ?? 'unknown timeout/spawn failure'}) — the copy is not runnable; fix the copy, do not read this as a mutation result` };
471
511
  }
472
512
  return { ok: false, detail: `baseline suite RED (exit ${exitCode}) in the UNMUTATED scratch copy — a broken copy cannot prove anything; fix the copy (node_modules link? path-dependent test?) before trusting any verdict` };
473
513
  }
@@ -489,13 +529,16 @@ export function classifyBaseline(exitCode: number | null): BaselineResult {
489
529
  * the flaky-rebaseline check (a structurally-broken run needs no
490
530
  * attribution analysis);
491
531
  * 4. INCONCLUSIVE (no exit) — the run produced nothing to classify at all;
492
- * 5. UNDEFENDED (exit 0) — the disjoint GREEN arm: mutually exclusive with every red-based
532
+ * 5. RECEIPT_MISMATCH — the harness declared its own receipt contract broken, which
533
+ * invalidates BOTH the green reading and every count-based reading
534
+ * of the same run;
535
+ * 6. UNDEFENDED (exit 0) — the disjoint GREEN arm: mutually exclusive with every red-based
493
536
  * verdict below;
494
- * 6. INCONCLUSIVE (unrecognised output) — red, but the shape is unreadable: counts parsed out of
537
+ * 7. INCONCLUSIVE (unrecognised output) — red, but the shape is unreadable: counts parsed out of
495
538
  * unrecognised output must not reach BELOW_MIN/OVER_FAILING/PROVEN;
496
- * 7. INCONCLUSIVE (flaky rebaseline) — red, readable, but not attributable;
497
- * 8. BELOW_MIN → 9. OVER_FAILING — reliable-count contract checks, both failing;
498
- * 10. PROVEN — applied, red, behavioural, attributable, within bounds.
539
+ * 8. INCONCLUSIVE (flaky rebaseline) — red, readable, but not attributable;
540
+ * 9. BELOW_MIN → 10. OVER_FAILING — reliable-count contract checks, both failing;
541
+ * 11. PROVEN — applied, red, behavioural, attributable, within bounds.
499
542
  *
500
543
  * THE DROP DECISION (SPEC §Reporting, decided here + justified): `failing < observed` but still
501
544
  * `>= minFailing` is a LOUD WARNING, not a failure. Two reasons, both load-bearing:
@@ -568,7 +611,18 @@ export function classifyMutationOutcome(obs: MutationObservation): MutationEntry
568
611
  applied: true,
569
612
  verdict: 'INCONCLUSIVE',
570
613
  drop: false,
571
- detail: 'suite produced NO exit code under the mutation (timeout / spawn failure) — inconclusive is a FAILURE, never a pass',
614
+ detail: `suite produced NO exit code under the mutation (${obs.runFailureReason ?? 'unknown timeout / spawn failure'}) — inconclusive is a FAILURE, never a pass`,
615
+ };
616
+ }
617
+
618
+ if (obs.receiptMismatch !== undefined) {
619
+ const boundedReceipt = Array.from(obs.receiptMismatch).slice(0, 220).join('');
620
+ return {
621
+ ...base,
622
+ applied: true,
623
+ verdict: 'RECEIPT_MISMATCH',
624
+ drop: false,
625
+ detail: `the suite harness declared its own receipt contract violated (${boundedReceipt}) — neither this run's exit code nor its failing count may be read as discrimination; RECEIPT_MISMATCH is a FAILURE, never PROVEN`,
572
626
  };
573
627
  }
574
628
 
@@ -604,7 +658,7 @@ export function classifyMutationOutcome(obs: MutationObservation): MutationEntry
604
658
  applied: true,
605
659
  verdict: 'INCONCLUSIVE',
606
660
  drop: false,
607
- detail: `suite red under the mutation BUT the restored baseline did not reproduce green (${obs.rebaselineExitCode === null ? 'no exit code' : `exit ${obs.rebaselineExitCode}`}) — the suite is flaky; the redness is not attributable to the protection and may be an unrelated neighbour`,
661
+ detail: `suite red under the mutation BUT the restored baseline did not reproduce green (${obs.rebaselineExitCode === null ? `no exit code: ${obs.rebaselineFailureReason ?? 'unknown timeout / spawn failure'}` : `exit ${obs.rebaselineExitCode}`}) — the suite is flaky; the redness is not attributable to the protection and may be an unrelated neighbour`,
608
662
  };
609
663
  }
610
664
 
@@ -645,7 +699,7 @@ export function classifyMutationOutcome(obs: MutationObservation): MutationEntry
645
699
  }
646
700
 
647
701
  /** Verdicts that fail the gate. INCONCLUSIVE and NOT_APPLIED fail (inconclusive ≠ pass). */
648
- const FAILING_VERDICTS: ReadonlySet<MutationVerdict> = new Set(['UNDEFENDED', 'NOT_APPLIED', 'BELOW_MIN', 'MUTATION_UNPARSEABLE', 'MUTATION_LOAD_FATAL', 'OVER_FAILING', 'INCONCLUSIVE']);
702
+ const FAILING_VERDICTS: ReadonlySet<MutationVerdict> = new Set(['UNDEFENDED', 'RECEIPT_MISMATCH', 'NOT_APPLIED', 'BELOW_MIN', 'MUTATION_UNPARSEABLE', 'MUTATION_LOAD_FATAL', 'OVER_FAILING', 'INCONCLUSIVE']);
649
703
 
650
704
  /** Exit contract: 0 all proven · 1 any entry failed (or red baseline) · (2 = usage/setup, CLI-side). */
651
705
  export function mutationGateExitCode(results: readonly MutationEntryResult[], baselineOk: boolean): number {
@@ -660,6 +714,7 @@ export interface MutationGateSummary {
660
714
  readonly total: number;
661
715
  readonly proven: number;
662
716
  readonly undefended: number;
717
+ readonly receiptMismatch: number;
663
718
  readonly notApplied: number;
664
719
  readonly belowMin: number;
665
720
  readonly unparseable: number;
@@ -678,6 +733,7 @@ export function summarizeMutationResults(results: readonly MutationEntryResult[]
678
733
  total: results.length,
679
734
  proven: results.filter((r) => r.verdict === 'PROVEN').length,
680
735
  undefended: results.filter((r) => r.verdict === 'UNDEFENDED').length,
736
+ receiptMismatch: results.filter((r) => r.verdict === 'RECEIPT_MISMATCH').length,
681
737
  notApplied: results.filter((r) => r.verdict === 'NOT_APPLIED').length,
682
738
  belowMin: results.filter((r) => r.verdict === 'BELOW_MIN').length,
683
739
  unparseable: results.filter((r) => r.verdict === 'MUTATION_UNPARSEABLE').length,
@@ -692,6 +748,7 @@ export function summarizeMutationResults(results: readonly MutationEntryResult[]
692
748
  const VERDICT_MARK: Record<MutationVerdict, string> = {
693
749
  PROVEN: '✓',
694
750
  UNDEFENDED: '✗',
751
+ RECEIPT_MISMATCH: '✗',
695
752
  NOT_APPLIED: '✗',
696
753
  BELOW_MIN: '✗',
697
754
  MUTATION_UNPARSEABLE: '✗',
@@ -715,7 +772,7 @@ export function renderMutationReport(
715
772
  if (r.verdict !== 'PROVEN' || r.drop) lines.push(` ${r.detail}`);
716
773
  }
717
774
  const s = summarizeMutationResults(results);
718
- lines.push(` summary: ${s.proven}/${s.total} proven · ${s.undefended} undefended · ${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)`);
775
+ 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)`);
719
776
  lines.push(mutationGateExitCode(results, baseline.ok) === 0
720
777
  ? ' verdict: PASS — every named protection has a test that goes red when the protection is deleted'
721
778
  : ' verdict: FAIL — at least one named protection is undefended, unmutable, or unproven');
package/src/named-lock.ts CHANGED
@@ -33,7 +33,7 @@ import { createRequire } from 'node:module';
33
33
  import { join, resolve } from 'node:path';
34
34
  import { lockSync } from 'proper-lockfile';
35
35
 
36
- import { LOCK_TIMEOUT_MS, STALE_LOCK_MS } from './store-lock.js';
36
+ import { LOCK_TIMEOUT_MS, resolveStaleMs } from './store-lock.js';
37
37
  import type { StoreLockOptions } from './store-lock.js';
38
38
 
39
39
  /** `proper-lockfile` silently clamps `stale` up to this minimum. */
@@ -99,7 +99,7 @@ const sleepSync = (ms: number): void => {
99
99
 
100
100
  function resolveOpts(opts: StoreLockOptions): { staleMs: number; timeoutMs: number; pollMs: number } {
101
101
  return {
102
- staleMs: Math.max(opts.staleMs ?? STALE_LOCK_MS, MIN_STALE_MS),
102
+ staleMs: Math.max(opts.staleMs ?? resolveStaleMs(), MIN_STALE_MS),
103
103
  timeoutMs: opts.timeoutMs ?? defaultTimeoutMs(),
104
104
  pollMs: opts.pollMs ?? 25,
105
105
  };
@@ -206,12 +206,14 @@ function tryAcquire(
206
206
  // NEVER remove a lock directory we cannot prove is ours — including at process exit.
207
207
  return disarmExitRemoval(resourceKey) ? 'stolen' : 'stolen-undisarmed';
208
208
  }
209
- try {
210
- release();
211
- } catch { /* ERELEASED after a compromise — the lock is no longer ours to remove */ }
209
+ // Load-bearing order: remove our marker while the directory still excludes waiters. If we
210
+ // unlock first, a successor can acquire and publish its marker before our cleanup runs.
212
211
  try {
213
212
  rmSync(ownerMarkerPath(lockPath), { force: true });
214
213
  } catch { /* best effort: a stale marker is overwritten by the next holder anyway */ }
214
+ try {
215
+ release();
216
+ } catch { /* ERELEASED after a compromise — the lock is no longer ours to remove */ }
215
217
  return 'released';
216
218
  },
217
219
  };
@@ -257,9 +259,9 @@ export function withNamedLockSync<T>(
257
259
  lockPath,
258
260
  compromised ??
259
261
  new Error(
260
- 'the lock directory no longer carries this holder\u2019s ownership token a waiter broke it as stale ' +
261
- 'while the critical section was still running (a synchronous body cannot heartbeat). The successor\u2019s ' +
262
- 'lock was left untouched; THIS run must be treated as having raced and retried.' +
262
+ 'the lock directory no longer carries this holder\u2019s ownership token; a waiter may have broken it ' +
263
+ 'as stale, or another holder\u2019s release may have removed it. The current holder left the observed ' +
264
+ 'lock untouched; THIS run must be treated as having raced and retried.' +
263
265
  (outcome === 'stolen-undisarmed'
264
266
  ? ' WARNING: proper-lockfile\u2019s exit-time lock removal could NOT be disarmed (its getLocks() accessor ' +
265
267
  'is unavailable in this version), so this process may still remove the successor\u2019s lock when it exits.'