@dzhechkov/harness-core 0.7.11 → 0.8.1

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 (137) hide show
  1. package/.dz-manifest.json +287 -87
  2. package/README.md +60 -1
  3. package/dist/amendment-trace.d.ts +12 -1
  4. package/dist/amendment-trace.d.ts.map +1 -1
  5. package/dist/amendment-trace.js +22 -4
  6. package/dist/amendment-trace.js.map +1 -1
  7. package/dist/book-kb.d.ts.map +1 -1
  8. package/dist/book-kb.js +17 -0
  9. package/dist/book-kb.js.map +1 -1
  10. package/dist/feature-adr-checkpoints.d.ts +61 -0
  11. package/dist/feature-adr-checkpoints.d.ts.map +1 -1
  12. package/dist/feature-adr-checkpoints.js +116 -2
  13. package/dist/feature-adr-checkpoints.js.map +1 -1
  14. package/dist/feature-adr-routing.d.ts +69 -12
  15. package/dist/feature-adr-routing.d.ts.map +1 -1
  16. package/dist/feature-adr-routing.js +117 -7
  17. package/dist/feature-adr-routing.js.map +1 -1
  18. package/dist/index.d.ts +23 -6
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +23 -4
  21. package/dist/index.js.map +1 -1
  22. package/dist/learning-backend.d.ts +39 -0
  23. package/dist/learning-backend.d.ts.map +1 -1
  24. package/dist/learning-backend.js +31 -11
  25. package/dist/learning-backend.js.map +1 -1
  26. package/dist/lesson-bandit.d.ts +116 -0
  27. package/dist/lesson-bandit.d.ts.map +1 -0
  28. package/dist/lesson-bandit.js +235 -0
  29. package/dist/lesson-bandit.js.map +1 -0
  30. package/dist/lesson-payoff.d.ts +260 -0
  31. package/dist/lesson-payoff.d.ts.map +1 -0
  32. package/dist/lesson-payoff.js +597 -0
  33. package/dist/lesson-payoff.js.map +1 -0
  34. package/dist/loop-blobs.generated.js +2 -2
  35. package/dist/loop-blobs.generated.js.map +1 -1
  36. package/dist/mutation-gate.d.ts +31 -8
  37. package/dist/mutation-gate.d.ts.map +1 -1
  38. package/dist/mutation-gate.js +57 -16
  39. package/dist/mutation-gate.js.map +1 -1
  40. package/dist/named-lock.d.ts.map +1 -1
  41. package/dist/named-lock.js +11 -9
  42. package/dist/named-lock.js.map +1 -1
  43. package/dist/native-dep-probe.d.ts +11 -0
  44. package/dist/native-dep-probe.d.ts.map +1 -0
  45. package/dist/native-dep-probe.js +100 -0
  46. package/dist/native-dep-probe.js.map +1 -0
  47. package/dist/operations.d.ts.map +1 -1
  48. package/dist/operations.js +82 -1
  49. package/dist/operations.js.map +1 -1
  50. package/dist/parser-safe-region.d.ts +47 -0
  51. package/dist/parser-safe-region.d.ts.map +1 -0
  52. package/dist/parser-safe-region.js +47 -0
  53. package/dist/parser-safe-region.js.map +1 -0
  54. package/dist/patterns.d.ts +21 -0
  55. package/dist/patterns.d.ts.map +1 -1
  56. package/dist/patterns.js +52 -4
  57. package/dist/patterns.js.map +1 -1
  58. package/dist/profile.d.ts +202 -0
  59. package/dist/profile.d.ts.map +1 -0
  60. package/dist/profile.js +536 -0
  61. package/dist/profile.js.map +1 -0
  62. package/dist/project-skills-root.d.ts +44 -0
  63. package/dist/project-skills-root.d.ts.map +1 -0
  64. package/dist/project-skills-root.js +62 -0
  65. package/dist/project-skills-root.js.map +1 -0
  66. package/dist/publish.d.ts +5 -3
  67. package/dist/publish.d.ts.map +1 -1
  68. package/dist/publish.js +46 -8
  69. package/dist/publish.js.map +1 -1
  70. package/dist/qe-rounds.d.ts +75 -0
  71. package/dist/qe-rounds.d.ts.map +1 -0
  72. package/dist/qe-rounds.js +174 -0
  73. package/dist/qe-rounds.js.map +1 -0
  74. package/dist/registry.d.ts.map +1 -1
  75. package/dist/registry.js +5 -1
  76. package/dist/registry.js.map +1 -1
  77. package/dist/run-records.d.ts.map +1 -1
  78. package/dist/run-records.js +8 -1
  79. package/dist/run-records.js.map +1 -1
  80. package/dist/setup.d.ts +35 -4
  81. package/dist/setup.d.ts.map +1 -1
  82. package/dist/setup.js +86 -23
  83. package/dist/setup.js.map +1 -1
  84. package/dist/sign.d.ts +29 -24
  85. package/dist/sign.d.ts.map +1 -1
  86. package/dist/sign.js +183 -61
  87. package/dist/sign.js.map +1 -1
  88. package/dist/store-location.d.ts +56 -0
  89. package/dist/store-location.d.ts.map +1 -0
  90. package/dist/store-location.js +56 -0
  91. package/dist/store-location.js.map +1 -0
  92. package/dist/store-lock.d.ts +1 -0
  93. package/dist/store-lock.d.ts.map +1 -1
  94. package/dist/store-lock.js +7 -1
  95. package/dist/store-lock.js.map +1 -1
  96. package/dist/store-merge.d.ts +55 -0
  97. package/dist/store-merge.d.ts.map +1 -0
  98. package/dist/store-merge.js +77 -0
  99. package/dist/store-merge.js.map +1 -0
  100. package/dist/teach-target.d.ts +61 -0
  101. package/dist/teach-target.d.ts.map +1 -0
  102. package/dist/teach-target.js +105 -0
  103. package/dist/teach-target.js.map +1 -0
  104. package/dist/vector-tier.d.ts +30 -0
  105. package/dist/vector-tier.d.ts.map +1 -1
  106. package/dist/vector-tier.js +178 -20
  107. package/dist/vector-tier.js.map +1 -1
  108. package/package.json +13 -13
  109. package/sbom.json +591 -87
  110. package/src/amendment-trace.ts +34 -4
  111. package/src/book-kb.ts +17 -0
  112. package/src/feature-adr-checkpoints.ts +111 -2
  113. package/src/feature-adr-routing.ts +146 -7
  114. package/src/index.ts +55 -3
  115. package/src/learning-backend.ts +62 -11
  116. package/src/lesson-bandit.ts +279 -0
  117. package/src/lesson-payoff.ts +728 -0
  118. package/src/loop-blobs.generated.ts +2 -2
  119. package/src/mutation-gate.ts +74 -17
  120. package/src/named-lock.ts +10 -8
  121. package/src/native-dep-probe.ts +118 -0
  122. package/src/operations.ts +77 -1
  123. package/src/parser-safe-region.ts +65 -0
  124. package/src/patterns.ts +76 -6
  125. package/src/profile.ts +593 -0
  126. package/src/project-skills-root.ts +63 -0
  127. package/src/publish.ts +43 -7
  128. package/src/qe-rounds.ts +226 -0
  129. package/src/registry.ts +5 -1
  130. package/src/run-records.ts +9 -1
  131. package/src/setup.ts +87 -23
  132. package/src/sign.ts +180 -60
  133. package/src/store-location.ts +76 -0
  134. package/src/store-lock.ts +8 -1
  135. package/src/store-merge.ts +91 -0
  136. package/src/teach-target.ts +126 -0
  137. package/src/vector-tier.ts +236 -21
@@ -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.'
@@ -0,0 +1,118 @@
1
+ import { createRequire } from 'node:module';
2
+ import { join, resolve } from 'node:path';
3
+
4
+ export type NativeDepState = 'usable' | 'absent' | 'unusable';
5
+
6
+ export interface NativeDepVerdict {
7
+ readonly state: NativeDepState;
8
+ readonly pkg: string;
9
+ readonly reason?: string;
10
+ readonly path?: string;
11
+ }
12
+
13
+ const verdictCache = new Map<string, NativeDepVerdict>();
14
+ const exerciseIds = new WeakMap<Function, string>();
15
+ let nextExerciseId = 1;
16
+
17
+ function exerciseIdentity(exercise: unknown): string {
18
+ if (typeof exercise !== 'function') return `invalid-${typeof exercise}`;
19
+ const existing = exerciseIds.get(exercise);
20
+ if (existing) return existing;
21
+ const identity = `function-${nextExerciseId++}`;
22
+ exerciseIds.set(exercise, identity);
23
+ return identity;
24
+ }
25
+
26
+ function formatError(error: unknown): string {
27
+ try {
28
+ const message = error instanceof Error ? error.message : String(error);
29
+ const oneLine = message.replace(/\s+/g, ' ').trim() || 'unknown error';
30
+ return oneLine.length > 400 ? `${oneLine.slice(0, 397)}...` : oneLine;
31
+ } catch {
32
+ return 'unknown error';
33
+ }
34
+ }
35
+
36
+ export function exerciseSqliteOpen(mod: unknown): void {
37
+ const defaultExport =
38
+ typeof mod === 'object' && mod !== null && 'default' in mod
39
+ ? (mod as { default: unknown }).default
40
+ : mod;
41
+ const Database = defaultExport as new (filename: string) => { close(): void };
42
+ const database = new Database(':memory:');
43
+ database.close();
44
+ }
45
+
46
+ export function probeNativeDep(
47
+ projectRoot: string,
48
+ pkg: string,
49
+ exercise?: (mod: unknown) => unknown,
50
+ exerciseId?: string,
51
+ ): NativeDepVerdict {
52
+ let cacheKey: string;
53
+ try {
54
+ const resolvedExerciseId =
55
+ exerciseId ?? (exercise === undefined ? undefined : exerciseIdentity(exercise));
56
+ cacheKey = `${resolve(projectRoot)}::${pkg}::${resolvedExerciseId ?? 'none'}`;
57
+ } catch {
58
+ return { state: 'absent', pkg };
59
+ }
60
+
61
+ const cached = verdictCache.get(cacheKey);
62
+ if (cached) return cached;
63
+
64
+ let resolvedPath: string;
65
+ let projectRequire: ReturnType<typeof createRequire>;
66
+ try {
67
+ projectRequire = createRequire(join(projectRoot, 'package.json'));
68
+ resolvedPath = projectRequire.resolve(pkg);
69
+ } catch {
70
+ const verdict: NativeDepVerdict = { state: 'absent', pkg };
71
+ verdictCache.set(cacheKey, verdict);
72
+ return verdict;
73
+ }
74
+
75
+ let verdict: NativeDepVerdict;
76
+ try {
77
+ const mod = projectRequire(resolvedPath);
78
+ const exerciseResult = exercise?.(mod);
79
+ const then =
80
+ exerciseResult !== null &&
81
+ (typeof exerciseResult === 'object' || typeof exerciseResult === 'function')
82
+ ? (exerciseResult as { then?: unknown }).then
83
+ : undefined;
84
+ if (typeof then === 'function') {
85
+ void Promise.resolve(exerciseResult).catch(() => {});
86
+ verdict = {
87
+ state: 'unusable',
88
+ pkg,
89
+ reason:
90
+ 'exercise returned a promise; probeNativeDep is synchronous and cannot await it',
91
+ path: resolvedPath,
92
+ };
93
+ } else {
94
+ verdict = { state: 'usable', pkg, path: resolvedPath };
95
+ }
96
+ } catch (error) {
97
+ verdict = {
98
+ state: 'unusable',
99
+ pkg,
100
+ reason: formatError(error),
101
+ path: resolvedPath,
102
+ };
103
+ }
104
+
105
+ verdictCache.set(cacheKey, verdict);
106
+ return verdict;
107
+ }
108
+
109
+ export function describeNativeDep(verdict: NativeDepVerdict): string {
110
+ if (verdict.state === 'usable') return '';
111
+ if (verdict.state === 'absent') {
112
+ if (verdict.pkg === 'agentdb' || verdict.pkg === 'better-sqlite3') {
113
+ return `${verdict.pkg} not installed in project (run: dz setup --memory agentdb)`;
114
+ }
115
+ return `${verdict.pkg} not installed in project (run: npm i ${verdict.pkg})`;
116
+ }
117
+ return `${verdict.pkg} is installed at ${verdict.path ?? 'an unknown path'}, but its native part will not load on this Node: ${verdict.reason ?? 'unknown error'} (run: npm rebuild ${verdict.pkg})`;
118
+ }
package/src/operations.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  type HooksListHookMetadata,
28
28
  type ManagedEntry,
29
29
  } from './codex-hooks.js';
30
+ import { sweepSkillDrift } from './skill-drift.js';
30
31
  import { generateCodexHelpers } from './codex-hooks-assets.js';
31
32
  import {
32
33
  classifyVetoProbe,
@@ -997,6 +998,40 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
997
998
  detail: '.claude/mcp.json is NOT loaded by Claude Code — re-run dz setup to register agentdb in .mcp.json (project root)',
998
999
  });
999
1000
  }
1001
+ // 8x. agentdb MCP/writer store SEPARATION (ADR-001 agentdb-setup-shared-store-fix, 2026-08-26).
1002
+ // A shared store is a measured data-loss path (2026-07-09: 5 of 20 samples zero bytes, 4 torn) —
1003
+ // the same predicate setup.ts's `agentdb wiring` step uses, imported, never re-implemented.
1004
+ // Deliberately OUTSIDE the `.dz/agentdb-writer.mjs` branch below: a registration can be wrong
1005
+ // before any writer is deployed, and that is exactly when it is cheapest to fix.
1006
+ const mcpConfigPath = join(root, '.mcp.json');
1007
+ if (existsSync(mcpConfigPath)) {
1008
+ try {
1009
+ const { agentdbStoreSeparationProblem } = await import('./setup.js');
1010
+ const mcp = JSON.parse(readFileSync(mcpConfigPath, 'utf-8')) as {
1011
+ mcpServers?: Record<string, { env?: Record<string, string> }>;
1012
+ };
1013
+ const pinned = mcp.mcpServers?.['agentdb']?.env?.['AGENTDB_PATH'];
1014
+ // Only speak when an agentdb server is actually registered — a project without one keeps
1015
+ // its doctor output noise-free, exactly as before this check existed.
1016
+ // A REGISTERED agentdb server with NO AGENTDB_PATH is not a quiet project — it is the
1017
+ // protection DELETED. Without the pin the server resolves its store from its own cwd, which is
1018
+ // exactly the unpinned arrangement that produced an empty orphan store in this repo. The first
1019
+ // version of this check skipped that case, so removing the guard produced no finding at all
1020
+ // (cross-family QE, Codex gpt-5.6-sol). Silence on a deleted protection is the failure mode
1021
+ // this whole feature exists to end.
1022
+ const registered = mcp.mcpServers?.['agentdb'] !== undefined;
1023
+ if (registered && pinned === undefined) {
1024
+ checks.push({
1025
+ name: 'agentdb store separation',
1026
+ ok: false,
1027
+ detail: 'an agentdb MCP server is registered with NO AGENTDB_PATH — it will resolve its store from its own cwd, so nothing guarantees it stays off the session-hook writer store; re-run dz setup --memory agentdb',
1028
+ });
1029
+ } else if (pinned !== undefined) {
1030
+ const problem = agentdbStoreSeparationProblem(root, pinned);
1031
+ if (problem) checks.push({ name: 'agentdb store separation', ok: false, detail: problem });
1032
+ }
1033
+ } catch { /* doctor never throws on a diagnostic */ }
1034
+ }
1000
1035
  // Flat dz hook entries (dz ≤0.3.43 emitted `{type,command}` without the matcher-group wrapper)
1001
1036
  // are silently IGNORED by Claude Code — the writer never fires. Detect and point at the fix.
1002
1037
  try {
@@ -1097,12 +1132,53 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
1097
1132
  checks.push({
1098
1133
  name: 'agentdb version drift',
1099
1134
  ok: false,
1100
- detail: `local agentdb@${localVer} != MCP pin ${pinned} (alpha schema drift risk on the shared DB) — re-run dz setup`,
1135
+ detail: `local agentdb@${localVer} != MCP pin ${pinned} (alpha schema drift risk) — re-run dz setup`,
1101
1136
  });
1102
1137
  }
1103
1138
  } catch { /* either side absent — covered by other checks */ }
1104
1139
  }
1105
1140
 
1141
+ // 8a-bis. DEV-TREE SKILL LAG — advisory, never blocking.
1142
+ //
1143
+ // The `no-skill-drift` HARD rule sweeps scope 'installs', which DELIBERATELY excludes the
1144
+ // hand-edited `.claude/skills/` dev tree: those copies are allowed to lag a published bump, and
1145
+ // holding them to byte-identity would make the gate red-on-arrival. That reasoning is sound and
1146
+ // is NOT changed here.
1147
+ //
1148
+ // What was missing is that nobody SAW the lag. MEASURED 2026-08-26: `.claude/skills/idea2prd-manual`
1149
+ // was a month behind its package copy — missing the entire v3 modernization that landed in
1150
+ // 0.1.3/0.1.4 — and `.claude/skills/observability` had never received the 2026-08-24 tightening of
1151
+ // its dashboard gate. Those dev copies are exactly what the agents in THIS repo read, so "legitimately
1152
+ // lagging" quietly meant "the agents here have been running a stale skill for a month". A narrow
1153
+ // gate plus zero visibility is how a lag becomes a silent regression.
1154
+ //
1155
+ // So: report the DIFFERENCE between the wide sweep and the gated one — i.e. only skills whose sole
1156
+ // divergence is the dev tree. Never `ok:false` for a lag alone; the HARD rule keeps owning what
1157
+ // blocks. `dz drift-check --all` remains the detailed view.
1158
+ try {
1159
+ const allowlist = ((): string[] => {
1160
+ const ap = join(root, '.dz', 'drift-allowlist.json');
1161
+ if (!existsSync(ap)) return [];
1162
+ try {
1163
+ const raw = JSON.parse(readFileSync(ap, 'utf8')) as unknown;
1164
+ const arr = Array.isArray(raw) ? raw : Array.isArray((raw as { skills?: unknown })?.skills) ? (raw as { skills: unknown[] }).skills : [];
1165
+ return arr.map((e) => (typeof e === 'string' ? e : (e as { name?: string })?.name)).filter((n): n is string => typeof n === 'string' && n.length > 0);
1166
+ } catch { return []; }
1167
+ })();
1168
+ const wide = sweepSkillDrift(root, { scope: 'all', allowlist });
1169
+ const gated = new Set(sweepSkillDrift(root, { scope: 'installs', allowlist }).drifted.map((d) => d.name));
1170
+ // Only what the HARD rule does NOT already own: these differ ONLY because of the dev tree.
1171
+ const devOnly = wide.drifted.filter((d) => !gated.has(d.name));
1172
+ if (devOnly.length > 0) {
1173
+ const names = devOnly.slice(0, 5).map((d) => `${d.name} (${d.driftFiles} file(s))`).join(', ');
1174
+ checks.push({
1175
+ name: 'dev-tree skill lag',
1176
+ ok: true, // ADVISORY: the gated scope decides what blocks; this only makes the lag visible.
1177
+ detail: `${devOnly.length} skill(s) differ ONLY in the .claude/skills dev tree: ${names}${devOnly.length > 5 ? ', …' : ''} — a dev copy may legitimately lag a published bump, but it is what the agents here READ, so a month-old copy is a silent regression. Inspect with: dz drift-check --all`,
1178
+ });
1179
+ }
1180
+ } catch { /* doctor never throws on a diagnostic */ }
1181
+
1106
1182
  // 8b. EVIDENCE-CHAIN INTEGRITY (feature event-chain, ADR-001). `.dz/recall-usage.jsonl` and
1107
1183
  // `.dz/guard-audit.jsonl` are what `dz compounding` and `dz guard promote` decide on; a rewrite
1108
1184
  // that loses or duplicates a record there is a wrong verdict with no symptom. Deliberately OUTSIDE
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The `feature-adr.js` workflow's PARSER-SAFE REGION — the shared predicate, not a re-implementation.
3
+ *
4
+ * The Workflow runtime's parser is STRICTER than `node --check`: inside the routing block that runs
5
+ * from `const MODELS =` to `const ROUTER =` a template literal is a hard parse error, so no backtick
6
+ * may appear there. That constraint used to be enforced by a `not.toContain('`')` assertion written
7
+ * inline in one test (`feature-adr-model-routing.test.ts:142`), which proves only that the SHIPPED
8
+ * file is currently clean — delete the assertion and nothing goes red.
9
+ *
10
+ * ADR-001 / AM-1 asked for the other half: a guard that is PROVEN to fire on a real violation. Making
11
+ * the predicate a shared export is what buys that — the same function decides the shipped file and the
12
+ * deliberately mutated copy, so a test can inject one backtick into the region and watch it refuse.
13
+ * Deleting the guard now deletes the thing the mutation test calls.
14
+ *
15
+ * Pure and dependency-free: string in, verdict out. No file I/O, no process state.
16
+ *
17
+ * @packageDocumentation
18
+ */
19
+
20
+ /** Opening marker of the parser-safe region (the first line of the inlined routing block). */
21
+ export const PARSER_SAFE_REGION_START = 'const MODELS =';
22
+ /** Closing marker of the parser-safe region (the first declaration after the routing block). */
23
+ export const PARSER_SAFE_REGION_END = 'const ROUTER =';
24
+
25
+ /** Why a source could not be cleared — `null` when the region is clean. */
26
+ export type ParserSafeRegionRefusal =
27
+ /** a marker is missing or the two markers are out of order: the region cannot be located at all */
28
+ | 'region-not-found'
29
+ /** a backtick sits between the two markers — the Workflow parser rejects the file */
30
+ | 'backtick-in-region';
31
+
32
+ export interface ParserSafeRegionVerdict {
33
+ /** true only when the region was LOCATED and contains no backtick. */
34
+ readonly ok: boolean;
35
+ /** the refusal reason, or `null` on a clean verdict. */
36
+ readonly refusal: ParserSafeRegionRefusal | null;
37
+ /** absolute character offset of the first offending backtick, or `null`. */
38
+ readonly index: number | null;
39
+ /** 1-based line number of the first offending backtick, or `null`. */
40
+ readonly line: number | null;
41
+ }
42
+
43
+ /**
44
+ * Decide whether `source` respects the parser-safe region.
45
+ *
46
+ * A source whose markers cannot be located is NOT quietly cleared — an unlocatable region is
47
+ * `region-not-found`, never `ok`. That asymmetry is deliberate: a refactor that moves or renames the
48
+ * markers must be loud, because a silently-skipped check is indistinguishable from a passing one.
49
+ */
50
+ export function checkParserSafeRegion(source: string): ParserSafeRegionVerdict {
51
+ const start = source.indexOf(PARSER_SAFE_REGION_START);
52
+ const end = source.indexOf(PARSER_SAFE_REGION_END);
53
+ if (start === -1 || end === -1 || end <= start) {
54
+ return { ok: false, refusal: 'region-not-found', index: null, line: null };
55
+ }
56
+ const offset = source.slice(start, end).indexOf('`');
57
+ if (offset === -1) return { ok: true, refusal: null, index: null, line: null };
58
+ const index = start + offset;
59
+ return {
60
+ ok: false,
61
+ refusal: 'backtick-in-region',
62
+ index,
63
+ line: source.slice(0, index).split('\n').length,
64
+ };
65
+ }