@dzhechkov/harness-core 0.8.38 → 0.8.39

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 (149) hide show
  1. package/.dz-manifest.json +223 -123
  2. package/README.md +97 -2
  3. package/dist/agentdb-index.d.ts +3 -3
  4. package/dist/agentdb-index.js +5 -5
  5. package/dist/agentdb-index.js.map +1 -1
  6. package/dist/amendment-trace.d.ts.map +1 -1
  7. package/dist/amendment-trace.js +4 -1
  8. package/dist/amendment-trace.js.map +1 -1
  9. package/dist/backlog.d.ts +2 -1
  10. package/dist/backlog.d.ts.map +1 -1
  11. package/dist/backlog.js +3 -2
  12. package/dist/backlog.js.map +1 -1
  13. package/dist/brain.d.ts.map +1 -1
  14. package/dist/brain.js +9 -2
  15. package/dist/brain.js.map +1 -1
  16. package/dist/bto-optimize.d.ts.map +1 -1
  17. package/dist/bto-optimize.js +9 -12
  18. package/dist/bto-optimize.js.map +1 -1
  19. package/dist/claim-check.d.ts.map +1 -1
  20. package/dist/claim-check.js +50 -14
  21. package/dist/claim-check.js.map +1 -1
  22. package/dist/experiment-assign.d.ts +110 -0
  23. package/dist/experiment-assign.d.ts.map +1 -0
  24. package/dist/experiment-assign.js +229 -0
  25. package/dist/experiment-assign.js.map +1 -0
  26. package/dist/feature-adr-checkpoints.d.ts +7 -2
  27. package/dist/feature-adr-checkpoints.d.ts.map +1 -1
  28. package/dist/feature-adr-checkpoints.js +20 -5
  29. package/dist/feature-adr-checkpoints.js.map +1 -1
  30. package/dist/feature-adr-envelope.d.ts +12 -0
  31. package/dist/feature-adr-envelope.d.ts.map +1 -1
  32. package/dist/feature-adr-envelope.js +12 -1
  33. package/dist/feature-adr-envelope.js.map +1 -1
  34. package/dist/feature-adr-routing.d.ts +4 -0
  35. package/dist/feature-adr-routing.d.ts.map +1 -1
  36. package/dist/feature-adr-routing.js +4 -0
  37. package/dist/feature-adr-routing.js.map +1 -1
  38. package/dist/index.d.ts +9 -4
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +8 -3
  41. package/dist/index.js.map +1 -1
  42. package/dist/ledger-cost-fill.d.ts +58 -0
  43. package/dist/ledger-cost-fill.d.ts.map +1 -0
  44. package/dist/ledger-cost-fill.js +78 -0
  45. package/dist/ledger-cost-fill.js.map +1 -0
  46. package/dist/loop-blobs.generated.js +2 -2
  47. package/dist/loop-blobs.generated.js.map +1 -1
  48. package/dist/mutation-gate.d.ts +29 -0
  49. package/dist/mutation-gate.d.ts.map +1 -1
  50. package/dist/mutation-gate.js +20 -0
  51. package/dist/mutation-gate.js.map +1 -1
  52. package/dist/name-check.d.ts +20 -1
  53. package/dist/name-check.d.ts.map +1 -1
  54. package/dist/name-check.js +42 -1
  55. package/dist/name-check.js.map +1 -1
  56. package/dist/no-stubs.d.ts +10 -0
  57. package/dist/no-stubs.d.ts.map +1 -1
  58. package/dist/no-stubs.js +13 -9
  59. package/dist/no-stubs.js.map +1 -1
  60. package/dist/patterns.d.ts +24 -0
  61. package/dist/patterns.d.ts.map +1 -1
  62. package/dist/patterns.js +49 -9
  63. package/dist/patterns.js.map +1 -1
  64. package/dist/publish-source-scope.d.ts +32 -0
  65. package/dist/publish-source-scope.d.ts.map +1 -0
  66. package/dist/publish-source-scope.js +53 -0
  67. package/dist/publish-source-scope.js.map +1 -0
  68. package/dist/publish.d.ts +5 -0
  69. package/dist/publish.d.ts.map +1 -1
  70. package/dist/publish.js +32 -21
  71. package/dist/publish.js.map +1 -1
  72. package/dist/qe-bridge.d.ts +4 -1
  73. package/dist/qe-bridge.d.ts.map +1 -1
  74. package/dist/qe-bridge.js +21 -11
  75. package/dist/qe-bridge.js.map +1 -1
  76. package/dist/rake-analyzer.d.ts +10 -3
  77. package/dist/rake-analyzer.d.ts.map +1 -1
  78. package/dist/rake-analyzer.js +84 -22
  79. package/dist/rake-analyzer.js.map +1 -1
  80. package/dist/recap.d.ts.map +1 -1
  81. package/dist/recap.js +8 -4
  82. package/dist/recap.js.map +1 -1
  83. package/dist/release.d.ts +2 -1
  84. package/dist/release.d.ts.map +1 -1
  85. package/dist/release.js +18 -4
  86. package/dist/release.js.map +1 -1
  87. package/dist/reqe.d.ts.map +1 -1
  88. package/dist/reqe.js +11 -1
  89. package/dist/reqe.js.map +1 -1
  90. package/dist/run-records.d.ts +18 -4
  91. package/dist/run-records.d.ts.map +1 -1
  92. package/dist/run-records.js +61 -7
  93. package/dist/run-records.js.map +1 -1
  94. package/dist/score.d.ts.map +1 -1
  95. package/dist/score.js +8 -1
  96. package/dist/score.js.map +1 -1
  97. package/dist/sign.d.ts +23 -0
  98. package/dist/sign.d.ts.map +1 -1
  99. package/dist/sign.js +52 -0
  100. package/dist/sign.js.map +1 -1
  101. package/dist/skills-verify.d.ts +8 -5
  102. package/dist/skills-verify.d.ts.map +1 -1
  103. package/dist/skills-verify.js +59 -7
  104. package/dist/skills-verify.js.map +1 -1
  105. package/dist/store-guard-prune.d.ts +22 -0
  106. package/dist/store-guard-prune.d.ts.map +1 -0
  107. package/dist/store-guard-prune.js +54 -0
  108. package/dist/store-guard-prune.js.map +1 -0
  109. package/dist/sweep-failure-classify.d.ts +45 -0
  110. package/dist/sweep-failure-classify.d.ts.map +1 -0
  111. package/dist/sweep-failure-classify.js +90 -0
  112. package/dist/sweep-failure-classify.js.map +1 -0
  113. package/dist/workflow-run-dispatch.d.ts +14 -2
  114. package/dist/workflow-run-dispatch.d.ts.map +1 -1
  115. package/dist/workflow-run-dispatch.js +14 -2
  116. package/dist/workflow-run-dispatch.js.map +1 -1
  117. package/package.json +1 -1
  118. package/sbom.json +372 -122
  119. package/src/agentdb-index.ts +5 -5
  120. package/src/amendment-trace.ts +4 -1
  121. package/src/backlog.ts +3 -2
  122. package/src/brain.ts +8 -2
  123. package/src/bto-optimize.ts +10 -8
  124. package/src/claim-check.ts +48 -12
  125. package/src/experiment-assign.ts +276 -0
  126. package/src/feature-adr-checkpoints.ts +20 -5
  127. package/src/feature-adr-envelope.ts +24 -1
  128. package/src/feature-adr-routing.ts +4 -0
  129. package/src/index.ts +17 -3
  130. package/src/loop-blobs.generated.ts +2 -2
  131. package/src/mutation-gate.ts +59 -0
  132. package/src/name-check.ts +43 -1
  133. package/src/no-stubs.ts +14 -5
  134. package/src/patterns.ts +49 -8
  135. package/src/publish-source-scope.ts +53 -0
  136. package/src/publish.ts +38 -16
  137. package/src/qe-bridge.ts +22 -11
  138. package/src/rake-analyzer.ts +75 -20
  139. package/src/rake-signatures.json +98 -0
  140. package/src/recap.ts +8 -4
  141. package/src/release.ts +18 -4
  142. package/src/reqe.ts +11 -1
  143. package/src/run-records.ts +78 -10
  144. package/src/score.ts +8 -1
  145. package/src/sign.ts +53 -0
  146. package/src/skills-verify.ts +69 -9
  147. package/src/store-guard-prune.ts +81 -0
  148. package/src/sweep-failure-classify.ts +88 -0
  149. package/src/workflow-run-dispatch.ts +14 -2
package/src/index.ts CHANGED
@@ -116,6 +116,7 @@ export * from './trace-bundle.js';
116
116
  export { BLOBS as LOOP_BLOBS, LOOP_BLOB_NAMES, BLOB_COVERAGE_MANIFEST } from './loop-blobs.generated.js';
117
117
  export type { LoopBlob } from './loop-blobs.generated.js';
118
118
  export * from './sign.js';
119
+ export * from './publish-source-scope.js';
119
120
  export * from './swarm-brief.js';
120
121
  export * from './skill-schema.js';
121
122
  export { createSkill } from './create-skill.js';
@@ -144,7 +145,7 @@ export { tokenize, stemToken, stems } from './stem.js';
144
145
  export * from './package-skill-layouts.js';
145
146
  export { recommend } from './recommend.js';
146
147
  export { pretrain } from './pretrain.js';
147
- export { loadPatterns, loadSessions, computePatternBoost, readLearningConfig, readMemoryLearningConfig, BOOST_CAP, recordPattern, recordLessonForms, loadStorePatternsSync, loadStoreRecords, findExactLesson, patternToRecord, recordToPattern, patternRecordId, patternIdentityOf, dreamRecordId, isMirrorableLearning, consolidateSessions, recallPatterns, pruneNoisePatterns, removePatternsByIds, snapshotStore, readReinforcementState, encodeReinforcementState, reinforcePattern, updateReinforcementState, storeStats, lessonDeltaReport, lessonDeltaMap, readQuarantineState, encodeQuarantineState, promotePatterns, quarantineExpiryCandidates, pruneQuarantinePatterns } from './patterns.js';
148
+ export { loadPatterns, loadSessions, computePatternBoost, readLearningConfig, readMemoryLearningConfig, BOOST_CAP, recordPattern, recordLessonForms, loadStorePatternsSync, loadStoreRecords, findExactLesson, patternToRecord, recordToPattern, patternRecordId, patternIdentityOf, dreamRecordId, isMirrorableLearning, consolidateSessions, recallPatterns, pruneNoisePatterns, removePatternsByIds, snapshotStore, readReinforcementState, encodeReinforcementState, reinforcePattern, resolveReinforceTarget, updateReinforcementState, storeStats, lessonDeltaReport, lessonDeltaMap, readQuarantineState, encodeQuarantineState, promotePatterns, quarantineExpiryCandidates, pruneQuarantinePatterns } from './patterns.js';
148
149
  export { normalizeLessonForms, validateClassTemplate, lessonPairIdOf, mergeLessonFormHits, mergeLessonMatchedForms } from './lesson-generalization.js';
149
150
  export { withStoreLock, withStoreLockSync, storeLockPath, StoreLockTimeoutError, StoreLockCompromisedError, STALE_LOCK_MS, LOCK_TIMEOUT_MS } from './store-lock.js';
150
151
  export type { StoreLockOptions } from './store-lock.js';
@@ -304,6 +305,13 @@ export type {
304
305
  StoreMarkWriteOptions,
305
306
  StoreResetReceipt,
306
307
  } from './store-guard.js';
308
+ export { planStoreGuardPrune } from './store-guard-prune.js';
309
+ export type {
310
+ StoreGuardPruneBucket,
311
+ StoreGuardPruneEntry,
312
+ StoreGuardPrunePlan,
313
+ StoreGuardPruneDeps,
314
+ } from './store-guard-prune.js';
307
315
  export { statuslineData, readFeatureAdrState, writeFeatureAdrState, featureAdrStateDir, featureAdrStatePath, writeFeatureAdrStateDetailed, renderFeatureAdrPhaseLine } from './statusline.js';
308
316
  export type { StatuslineData, StatuslineStoreHealth, FeatureAdrState, WriteFeatureAdrStateInput, WriteFeatureAdrStateResult } from './statusline.js';
309
317
  export {
@@ -501,6 +509,7 @@ export {
501
509
  } from './amendment-trace.js';
502
510
  export {
503
511
  RECORD_MAX_LINE_CHARS,
512
+ INCOMPLETE_REASON_CODES,
504
513
  decideRecordWrite,
505
514
  decideReadBack,
506
515
  recordVerdictLine,
@@ -528,7 +537,7 @@ export type {
528
537
  } from './feature-adr-envelope.js';
529
538
  export { decidePublishSigning, decidePostSigningVerification, decideSignableSet, publishSigningLine, signableSetLine } from './publish-signing.js';
530
539
  export type { PublishSigningVerdict, PublishSigningDecision, SignableSetDecision } from './publish-signing.js';
531
- export type { RecordKind, RecordVerdict, RecordDecision, LedgerEnrichInput, LedgerPriceEntry, ParsedModelSpec, TaskIdLookup } from './run-records.js';
540
+ export type { RecordKind, RecordVerdict, RecordDecision, LedgerEnrichInput, LedgerPriceEntry, ParsedModelSpec, TaskIdLookup, IncompleteReasonCode } from './run-records.js';
532
541
  export type { AmendmentAmbiguity, AmendmentRow, AmendmentVerdict, AmendmentResolution, AmendmentOutcome, AmendmentDecision, PlanCoverageGap } from './amendment-trace.js';
533
542
  // contract-checklist (ADR-001): pure extraction, canonical rendering, typed report parsing, and
534
543
  // exact per-item verification. Filesystem discovery/containment stays in harness-cli.
@@ -755,7 +764,7 @@ export type {
755
764
  ChainDefectAges,
756
765
  ChainedJournal,
757
766
  } from './event-chain.js';
758
- export { decideProvenance, environmentCanMintProvenance, publishArgv, discoverPackages, publishPackages, bumpPatch, compareVersions, findUnpackagedSkills, findUnpublishedWorkspaceFloors, rewriteWorkspaceSpecs, orderByDependencies, syncReadmeVersion, isChangelogEntryLine, changelogRegion, planReadmeVersionSync } from './publish.js';
767
+ export { decideProvenance, environmentCanMintProvenance, publishArgv, matchesPublishFilter, discoverPackages, publishPackages, bumpPatch, compareVersions, findUnpackagedSkills, findUnpublishedWorkspaceFloors, rewriteWorkspaceSpecs, orderByDependencies, syncReadmeVersion, isChangelogEntryLine, changelogRegion, planReadmeVersionSync } from './publish.js';
759
768
  export type { ReadmeVersionSyncPlan, ReadmeSyncRewrite } from './publish.js';
760
769
  export { RELEASE_LINE_RE, findReleaseLine, rewriteReleaseLine } from './release-line.js';
761
770
  export * from './course-staleness.js';
@@ -1148,6 +1157,11 @@ export * from './compounding.js';
1148
1157
  // Advisory command-invocation telemetry + deadwood report (feature dz-deadwood).
1149
1158
  export * from './cmd-usage.js';
1150
1159
 
1160
+ // Deterministic, blocked arm assignment for a prospective ablation (feature ablation-c-start,
1161
+ // ADR-001): pure — no fs, no clock, reuses `mulberry32` (the repo's only PRNG).
1162
+ export { assignArm, readAssignments, verifyAssignmentRecord } from './experiment-assign.js';
1163
+ export type { AssignArmInput, AssignArmResult, AssignmentRecord, ReadAssignmentsResult, VerifyAssignmentResult } from './experiment-assign.js';
1164
+
1151
1165
  // Cold-vs-warm EPOCH RUNNER (feature epoch-replay, scout idea #4) — the RESULT leg to compounding's
1152
1166
  // readiness leg. Orchestrates + scores; never calls a model. SUPPORTED requires two DISJOINT Wilson
1153
1167
  // intervals; INCONCLUSIVE is a first-class honest outcome.
@@ -52,11 +52,11 @@ export const BLOBS: Record<string, LoopBlob> = {
52
52
  "checkpoints": {
53
53
  name: "checkpoints",
54
54
  version: "1.2.0",
55
- contentHash: "a44560c6036fd143b7a3f125fec00fa8b91c3c06ac5b9e1ecfb83d27a5211e9b",
55
+ contentHash: "b602357649042d6b8aa68b5ca5593f7f5c483fdb36a287742e7b27bda8a2a6c7",
56
56
  sourcePath: "packages/@dzhechkov/harness-core/src/feature-adr-checkpoints.ts",
57
57
  requires: [],
58
58
  exports: ["CHECKPOINT_STAGES","STAGE_ARTIFACTS","CHECKPOINT_MAX_RESULT_CHARS","CKPT_SCHEMA_VERSION","fnv1a","fnv1a64","checkpointInputHash","resumeMode","decideCheckpointResume","serializeCheckpoint","CHECKPOINT_LS_SENTINEL","parseCheckpointRead","shellQuote","checkpointReadCmd","checkpointAppendCmd","DESIGN_SUBSTAGES","designStageKey","decideDesignFanResume","parseArtifactProbe","ROUTER_CONTRACT_TOKEN"],
59
- code: "const CHECKPOINT_STAGES = ['router', 'design', 'plan', 'code', 'qe', 'fleet'];\nconst STAGE_ARTIFACTS = {\n router: '00_complexity_assessment.md',\n design: '01_requirements.md',\n plan: '06_implementation_plan.md',\n code: '07_code_changes/change_manifest.md',\n qe: '08_qe_report.md',\n fleet: '09_fleet_qe_assessment.md',\n};\nconst CHECKPOINT_MAX_RESULT_CHARS = 12000;\nconst CKPT_SCHEMA_VERSION = 'fa-ckpt-3';\nconst ROUTER_CONTRACT_TOKEN = 'router-writes-00-v1';\nfunction fnv1a(str) {\n let h = 0x811c9dc5;\n for (let i = 0; i < str.length; i++) {\n h ^= str.charCodeAt(i);\n h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;\n }\n return h.toString(16).padStart(8, '0');\n}\nfunction fnv1a64(str) {\n return fnv1a(str) + fnv1a('fa-ckpt-salt' + str);\n}\nfunction checkpointInputHash(stage, parts) {\n return fnv1a64(JSON.stringify([CKPT_SCHEMA_VERSION, stage, ...parts.map((p) => (p === undefined ? null : p))]));\n}\nfunction resumeMode(raw) {\n return raw === 'never' ? 'never' : raw === 'force' ? 'force' : 'auto';\n}\nconst DESIGN_SUBSTAGES = ['requirements', 'adr', 'qcsd', 'architecture'];\nfunction designStageKey(sub) {\n return 'design:' + sub;\n}\nfunction decideDesignFanResume(opts) {\n const missingSubstages = [];\n opts.required.forEach((sub, i) => {\n const r = opts.results[i];\n if (r === null || r === undefined)\n missingSubstages.push(sub);\n });\n const missingArtifacts = [];\n let probeMissing = false;\n if (opts.artifacts.length > 0) {\n if (opts.postRunListing === null)\n probeMissing = true;\n else\n for (const rel of opts.artifacts)\n if (!opts.postRunListing.has(rel))\n missingArtifacts.push(rel);\n }\n const reason = missingSubstages.length > 0 ? 'substage-missing'\n : probeMissing ? 'probe-not-established'\n : missingArtifacts.length > 0 ? 'artifact-missing'\n : 'ok';\n return { complete: reason === 'ok', missingSubstages, missingArtifacts, reason };\n}\nfunction decideCheckpointResume(opts) {\n if (opts.mode === 'never')\n return { resume: false, reason: 'mode-never' };\n if (!opts.entry || opts.entry.result === null || opts.entry.result === undefined) {\n return { resume: false, reason: 'no-checkpoint' };\n }\n if (opts.entry.inputHash !== opts.inputHash)\n return { resume: false, reason: 'stale-input' };\n if (opts.mode === 'force')\n return { resume: true, reason: 'resumed-force' };\n const required = opts.artifactRel === null ? [] : (typeof opts.artifactRel === 'string' ? [opts.artifactRel] : opts.artifactRel);\n for (const rel of required) {\n if (!opts.listing.has(rel))\n return { resume: false, reason: 'artifact-missing' };\n }\n return { resume: true, reason: 'resumed' };\n}\nfunction serializeCheckpoint(stage, inputHash, result) {\n if (result === null || result === undefined)\n return null;\n let line;\n try {\n line = JSON.stringify({ stage, inputHash, result });\n }\n catch {\n return null;\n }\n if (typeof line !== 'string' || line.length > CHECKPOINT_MAX_RESULT_CHARS)\n return null;\n return line;\n}\nconst CHECKPOINT_LS_SENTINEL = '---FA-CKPT-LS---';\nfunction parseCheckpointRead(text) {\n const out = { entries: {}, listing: new Set(), malformedLines: 0 };\n const raw = String(text ?? '');\n const lines = raw.split('\\n');\n const sentinelAt = lines.findIndex((l) => l.trim() === CHECKPOINT_LS_SENTINEL);\n const body = sentinelAt === -1 ? lines : lines.slice(0, sentinelAt);\n const ls = sentinelAt === -1 ? [] : lines.slice(sentinelAt + 1);\n for (const line of body) {\n const t = line.trim();\n if (t === '')\n continue;\n try {\n const e = JSON.parse(t);\n if (e && typeof e === 'object' && typeof e.stage === 'string' && typeof e.inputHash === 'string' && 'result' in e && e.result !== null && e.result !== undefined) {\n out.entries[e.stage] = e;\n }\n else {\n if (e && typeof e === 'object' && typeof e.stage === 'string')\n delete out.entries[e.stage];\n out.malformedLines++;\n }\n }\n catch {\n out.malformedLines++;\n }\n }\n for (const line of ls) {\n const t = line.trim();\n if (t !== '')\n out.listing.add(t);\n }\n return out;\n}\nfunction shellQuote(s) {\n return \"'\" + String(s).replace(/'/g, \"'\\\\''\") + \"'\";\n}\nfunction checkpointReadCmd(fdirAbs) {\n const q = shellQuote(fdirAbs);\n return ('cat ' + q + '/.fa-state/checkpoints.jsonl 2>/dev/null || true; ' +\n \"echo '\" + CHECKPOINT_LS_SENTINEL + \"'; \" +\n 'cd ' + q + ' 2>/dev/null && find . -maxdepth 2 -type f 2>/dev/null | sed \"s|^\\\\./||\" || true');\n}\nfunction checkpointAppendCmd(fdirAbs, line) {\n const dir = shellQuote(fdirAbs + '/.fa-state');\n const file = shellQuote(fdirAbs + '/.fa-state/checkpoints.jsonl');\n return 'mkdir -p ' + dir + \" && printf '%s\\\\n' \" + shellQuote(line) + ' >> ' + file;\n}\nfunction parseArtifactProbe(opts) {\n if (opts.stdout === null || opts.stdout === undefined)\n return null;\n const known = new Map();\n for (const rel of opts.required)\n known.set('HAVE:' + rel, rel);\n const found = new Set();\n let sentinels = 0;\n for (const raw of String(opts.stdout).split('\\n')) {\n const line = raw.trim();\n if (line === '')\n continue;\n if (line === opts.sentinel) {\n sentinels++;\n continue;\n }\n if (sentinels > 0)\n return null;\n const rel = known.get(line);\n if (rel === undefined)\n return null;\n found.add(rel);\n }\n if (sentinels !== 1)\n return null;\n return found;\n}",
59
+ code: "const CHECKPOINT_STAGES = ['router', 'design', 'plan', 'code', 'qe', 'fleet'];\nconst STAGE_ARTIFACTS = {\n router: '00_complexity_assessment.md',\n design: '01_requirements.md',\n plan: '06_implementation_plan.md',\n code: '07_code_changes/change_manifest.md',\n qe: '08_qe_report.md',\n fleet: '09_fleet_qe_assessment.md',\n};\nconst CHECKPOINT_MAX_RESULT_CHARS = 12000;\nconst CKPT_SCHEMA_VERSION = 'fa-ckpt-3';\nconst ROUTER_CONTRACT_TOKEN = 'router-writes-00-v1';\nfunction fnv1a(str) {\n let h = 0x811c9dc5;\n for (let i = 0; i < str.length; i++) {\n h ^= str.charCodeAt(i);\n h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;\n }\n return h.toString(16).padStart(8, '0');\n}\nfunction fnv1a64(str) {\n return fnv1a(str) + fnv1a('fa-ckpt-salt' + str);\n}\nfunction checkpointInputHash(stage, parts) {\n return fnv1a64(JSON.stringify([CKPT_SCHEMA_VERSION, stage, ...parts.map((p) => (p === undefined ? null : p))]));\n}\nfunction resumeMode(raw) {\n return raw === 'never' ? 'never' : raw === 'force' ? 'force' : 'auto';\n}\nconst DESIGN_SUBSTAGES = ['requirements', 'adr', 'qcsd', 'architecture'];\nfunction designStageKey(sub) {\n return 'design:' + sub;\n}\nfunction decideDesignFanResume(opts) {\n const missingSubstages = [];\n opts.required.forEach((sub, i) => {\n const r = opts.results[i];\n if (r === null || r === undefined)\n missingSubstages.push(sub);\n });\n const missingArtifacts = [];\n let probeMissing = false;\n if (opts.artifacts.length > 0) {\n if (opts.postRunListing === null)\n probeMissing = true;\n else\n for (const rel of opts.artifacts)\n if (!opts.postRunListing.has(rel))\n missingArtifacts.push(rel);\n }\n const reason = missingSubstages.length > 0 ? 'substage-missing'\n : probeMissing ? 'probe-not-established'\n : missingArtifacts.length > 0 ? 'artifact-missing'\n : 'ok';\n return { complete: reason === 'ok', missingSubstages, missingArtifacts, reason };\n}\nfunction decideCheckpointResume(opts) {\n if (opts.mode === 'never')\n return { resume: false, reason: 'mode-never' };\n if (!opts.entry || opts.entry.result === null || opts.entry.result === undefined) {\n return { resume: false, reason: 'no-checkpoint' };\n }\n if (opts.entry.inputHash !== opts.inputHash)\n return { resume: false, reason: 'stale-input' };\n if (opts.mode === 'force')\n return { resume: true, reason: 'resumed-force' };\n const required = opts.artifactRel === null ? [] : (typeof opts.artifactRel === 'string' ? [opts.artifactRel] : opts.artifactRel);\n for (const rel of required) {\n if (!opts.listing.has(rel))\n return { resume: false, reason: 'artifact-missing' };\n }\n return { resume: true, reason: 'resumed' };\n}\nfunction serializeCheckpoint(stage, inputHash, result, ts) {\n if (result === null || result === undefined)\n return null;\n let line;\n try {\n line = JSON.stringify({ stage, inputHash, result, ...(ts === undefined ? {} : { ts }) });\n }\n catch {\n return null;\n }\n if (typeof line !== 'string' || line.length > CHECKPOINT_MAX_RESULT_CHARS)\n return null;\n return line;\n}\nconst CHECKPOINT_LS_SENTINEL = '---FA-CKPT-LS---';\nconst CHECKPOINT_TIMESTAMP = /^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{1,3})?Z$/;\nfunction parseCheckpointRead(text) {\n const out = { entries: {}, listing: new Set(), malformedLines: 0 };\n const raw = String(text ?? '');\n const lines = raw.split('\\n');\n const sentinelAt = lines.findIndex((l) => l.trim() === CHECKPOINT_LS_SENTINEL);\n const body = sentinelAt === -1 ? lines : lines.slice(0, sentinelAt);\n const ls = sentinelAt === -1 ? [] : lines.slice(sentinelAt + 1);\n for (const line of body) {\n const t = line.trim();\n if (t === '')\n continue;\n try {\n const e = JSON.parse(t);\n if (e && typeof e === 'object' && typeof e.stage === 'string' && typeof e.inputHash === 'string' && 'result' in e && e.result !== null && e.result !== undefined) {\n const { ts, ...entry } = e;\n out.entries[e.stage] = typeof ts === 'string' && CHECKPOINT_TIMESTAMP.test(ts) ? { ...entry, ts } : entry;\n }\n else {\n if (e && typeof e === 'object' && typeof e.stage === 'string')\n delete out.entries[e.stage];\n out.malformedLines++;\n }\n }\n catch {\n out.malformedLines++;\n }\n }\n for (const line of ls) {\n const t = line.trim();\n if (t !== '')\n out.listing.add(t);\n }\n return out;\n}\nfunction shellQuote(s) {\n return \"'\" + String(s).replace(/'/g, \"'\\\\''\") + \"'\";\n}\nfunction checkpointReadCmd(fdirAbs) {\n const q = shellQuote(fdirAbs);\n return ('cat ' + q + '/.fa-state/checkpoints.jsonl 2>/dev/null || true; ' +\n \"echo '\" + CHECKPOINT_LS_SENTINEL + \"'; \" +\n 'cd ' + q + ' 2>/dev/null && find . -maxdepth 2 -type f 2>/dev/null | sed \"s|^\\\\./||\" || true');\n}\nfunction checkpointAppendCmd(fdirAbs, line) {\n const dir = shellQuote(fdirAbs + '/.fa-state');\n const file = shellQuote(fdirAbs + '/.fa-state/checkpoints.jsonl');\n const plain = 'mkdir -p ' + dir + \" && printf '%s\\\\n' \" + shellQuote(line) + ' >> ' + file;\n if (typeof line !== 'string' || !line.startsWith('{'))\n return plain;\n return 'ts=$(date -u +%Y-%m-%dT%H:%M:%SZ); mkdir -p ' + dir\n + ' && { printf \\'{\"ts\":\"%s\",\\' \"$ts\"; printf \\'%s\\\\n\\' ' + shellQuote(line.slice(1)) + '; } >> ' + file;\n}\nfunction parseArtifactProbe(opts) {\n if (opts.stdout === null || opts.stdout === undefined)\n return null;\n const known = new Map();\n for (const rel of opts.required)\n known.set('HAVE:' + rel, rel);\n const found = new Set();\n let sentinels = 0;\n for (const raw of String(opts.stdout).split('\\n')) {\n const line = raw.trim();\n if (line === '')\n continue;\n if (line === opts.sentinel) {\n sentinels++;\n continue;\n }\n if (sentinels > 0)\n return null;\n const rel = known.get(line);\n if (rel === undefined)\n return null;\n found.add(rel);\n }\n if (sentinels !== 1)\n return null;\n return found;\n}",
60
60
  },
61
61
  "training-pairs": {
62
62
  name: "training-pairs",
@@ -1488,6 +1488,65 @@ export function summarizeMutationResults(results: readonly MutationEntryResult[]
1488
1488
  };
1489
1489
  }
1490
1490
 
1491
+ // ── Durable verdicts (instrument-round-b T3/FR-3/A3/A4, ADR-001 D3) ────────────────────────────
1492
+ //
1493
+ // The registry (`test/mutation-registry.json`) is the gate's INPUT — what to mutate. A verdict is
1494
+ // its OUTPUT — what happened the last time the gate ran. Mixing them means the input would change
1495
+ // on every run and every reader of the registry (mutation-registry-freshness included) would have
1496
+ // to start tolerating a moving file; A4 keeps them apart on purpose. `dz mutation-gate` (the CLI
1497
+ // executor) appends one JSON line per classified entry to a SEPARATE durable file
1498
+ // (`.dz/mutation-gate/verdicts.jsonl`) so a verdict survives the executor's own scratch-dir
1499
+ // teardown and the host's next reboot — today it lives only in `DZ_MUTGATE_OUTPUT_DIR`, a temp
1500
+ // directory (00_complexity_assessment.md: 0 of 377 registry entries carry a persisted verdict).
1501
+
1502
+ /** Everything the CLI knows that this pure module does not: when, in which package, and under
1503
+ * which run. `runId` is honest-absent (`null`) when the caller has none to offer — never guessed. */
1504
+ export interface MutationVerdictMeta {
1505
+ readonly ts: string;
1506
+ readonly package: string;
1507
+ readonly runId: string | null;
1508
+ }
1509
+
1510
+ /** One durable line — append-only, one per classified registry entry. `observed` is the
1511
+ * registry's OWN anchor (copied, never recomputed) so a reader can see the historical coverage
1512
+ * claim next to the fresh verdict without re-opening the registry. */
1513
+ export interface MutationVerdictRow {
1514
+ readonly ts: string;
1515
+ readonly package: string;
1516
+ readonly entryId: string;
1517
+ readonly verdict: MutationVerdict;
1518
+ readonly failingCount: number | null;
1519
+ readonly observed: number | null;
1520
+ readonly drop: boolean;
1521
+ readonly dropComparable: boolean;
1522
+ readonly runId: string | null;
1523
+ }
1524
+
1525
+ /**
1526
+ * Pure: payload in, row out — no filesystem (NFR-2; the CLI owns the append). `entry` is `null`
1527
+ * for a result that never resolved to a valid registry entry (`ENTRY_INVALID` / `COVERAGE_GAP` —
1528
+ * `parseMutationRegistry` excludes those from `registry.entries` by construction, so the CALLER
1529
+ * cannot always hand one in) — `observed` then stays `null`, honestly, rather than the caller
1530
+ * inventing a fallback entry object just to satisfy this signature.
1531
+ */
1532
+ export function mutationVerdictRow(
1533
+ entry: MutationRegistryEntry | null,
1534
+ result: MutationEntryResult,
1535
+ meta: MutationVerdictMeta,
1536
+ ): MutationVerdictRow {
1537
+ return {
1538
+ ts: meta.ts,
1539
+ package: meta.package,
1540
+ entryId: result.id,
1541
+ verdict: result.verdict,
1542
+ failingCount: result.failingCount,
1543
+ observed: entry !== null && typeof entry.observed === 'number' ? entry.observed : null,
1544
+ drop: result.drop,
1545
+ dropComparable: result.dropComparable,
1546
+ runId: meta.runId,
1547
+ };
1548
+ }
1549
+
1491
1550
  const VERDICT_MARK: Record<MutationVerdict, string> = {
1492
1551
  PROVEN: '✓',
1493
1552
  ENTRY_INVALID: '✗',
package/src/name-check.ts CHANGED
@@ -127,7 +127,37 @@ export function decideNameCheck(queries: readonly NameQuery[], facts: NameFacts)
127
127
  return { outcome: 'free', exit: 0, results, reason: `all ${results.length} name(s) are free` };
128
128
  }
129
129
 
130
- export function renderNameCheck(decision: NameDecision, scanned?: NameFacts['scanned']): string[] {
130
+ /**
131
+ * Имена того же вида, ДЕЛЯЩИЕ СЛОВО с запрошенным.
132
+ *
133
+ * ЗАЧЕМ ЭТО ЗДЕСЬ. `name-check` отвечает на вопрос «занято ли ИМЯ», и отвечает верно. Но у
134
+ * него есть соседний вопрос, которого он не задаёт: «а нет ли уже ПРИБОРА для этой работы».
135
+ * ИЗМЕРЕНО 2026-09-19: `dz name-check --module ledger-cost-fill` честно ответил FREE, и по
136
+ * этому ответу был построен модуль, дублирующий существующий `ledger-backfill` — подключённый
137
+ * к CLI, с тем же измерением в шапке, заполняющий 47 строк против 31 у дубля и вдобавок
138
+ * намеренно НЕ делающий того, что дубль сделал (вывод минут и агентов, тихо переопределивший
139
+ * две колонки).
140
+ *
141
+ * Поэтому здесь НЕ вердикт и НЕ отказ — свободное имя остаётся свободным. Это СВЕДЕНИЯ в точке
142
+ * решения: список соседей по корню слова, которые оператор увидит ровно тогда, когда ещё не
143
+ * написал ни строки. Разница между «занято» и «рядом есть похожее» сохранена намеренно:
144
+ * превратить второе в отказ значило бы запретить `publish-order` рядом с `publish-signing`.
145
+ *
146
+ * Слова короче четырёх букв игнорируются: `fa`, `dz`, `to`, `run` роднят почти всё со всем.
147
+ */
148
+ export function nearNames(query: NameQuery, facts: NameFacts, limit = 5): string[] {
149
+ const words = (n: string): string[] =>
150
+ n.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length >= 4);
151
+ const asked = new Set(words(query.name));
152
+ if (asked.size === 0) return [];
153
+ const pool: string[] = query.kind === 'command'
154
+ ? [...facts.commands]
155
+ : query.kind === 'module' ? [...facts.modules.keys()] : [...facts.exports.keys()];
156
+ const hits = pool.filter((n) => n !== query.name && words(n).some((w) => asked.has(w)));
157
+ return hits.sort().slice(0, limit);
158
+ }
159
+
160
+ export function renderNameCheck(decision: NameDecision, scanned?: NameFacts['scanned'], facts?: NameFacts): string[] {
131
161
  const out: string[] = [];
132
162
  if (scanned !== undefined) {
133
163
  // Printed always, pass or fail: the operator must be able to see that the sweep looked at a real
@@ -145,6 +175,18 @@ export function renderNameCheck(decision: NameDecision, scanned?: NameFacts['sca
145
175
  // Said on the passing path, because that is where the limit gets forgotten: the scan reads
146
176
  // declarations, so a re-export under a different name (`export { a as b }`) is invisible to it.
147
177
  out.push(' note: this reads declarations in source. A re-export under a different name is not visible here — the build still owns that case.');
178
+ if (facts !== undefined) {
179
+ // Свободное имя — не доказательство отсутствия прибора. Соседи печатаются СВЕДЕНИЯМИ,
180
+ // вердикт при этом не меняется: отказывать за сходство значило бы запретить
181
+ // `publish-order` рядом с `publish-signing`.
182
+ for (const r of decision.results) {
183
+ const near = nearNames({ kind: r.kind, name: r.name }, facts);
184
+ if (near.length > 0) {
185
+ out.push(` рядом уже есть ${r.kind}(s), делящие слово с «${r.name}»: ${near.join(', ')}`);
186
+ out.push(' прочитайте их ПЕРЕД тем, как писать: свободное ИМЯ не означает отсутствия ПРИБОРА для этой работы');
187
+ }
188
+ }
189
+ }
148
190
  }
149
191
  return out;
150
192
  }
package/src/no-stubs.ts CHANGED
@@ -118,6 +118,8 @@ export interface StubFinding {
118
118
  readonly detail: string;
119
119
  }
120
120
 
121
+ import { maskMarkdown } from './markdown-masker.js';
122
+
121
123
  /** A config waiver: path-keyed, reason MANDATORY. A reasonless entry is refused, never honoured. */
122
124
  export interface StubWaiver {
123
125
  readonly path?: string;
@@ -141,18 +143,25 @@ function maskInlineCode(line: string): string {
141
143
  * clean, while a naked stub line in doc prose still fires. Code files are scanned in full: a marker
142
144
  * a code file must legitimately carry (another gate's source, a fixture) takes an inline
143
145
  * `no-stubs: <reason>` waiver — visible, reasoned, greppable.
146
+ *
147
+ * Block scoping is DELEGATED to the canonical masker, not re-implemented here. The hand-rolled
148
+ * `inFence = !inFence` toggle it replaces broke two CommonMark rules and produced MEASURED false
149
+ * positives (2026-09-20): a fence closes only on its OWN marker, so a `~~~` line inside a backtick
150
+ * block is content — the toggle read it as a close and scanned the rest of the code block as prose;
151
+ * and a closing fence may carry NO info string, so a second info-string line closed the block.
152
+ * `unclosed: 'hide'` preserves THIS reader's existing policy — an unclosed opener hides the tail
153
+ * rather than restoring it, because a gate must not invent findings out of a half-written block.
154
+ * Four-space indented code stays unmasked (the masker default): unchanged from the toggle era, a
155
+ * known limit rather than a new one.
144
156
  */
145
157
  export function scanStubs(path: string, text: unknown): StubFinding[] {
146
158
  if (typeof text !== 'string' || text.length === 0) return [];
147
159
  const md = isMarkdown(path);
148
160
  const out: StubFinding[] = [];
149
- let inFence = false;
150
161
  const lines = text.split('\n');
162
+ const scanLines = md ? String(maskMarkdown(text, { unclosed: 'hide' })).split('\n') : lines;
151
163
  for (let i = 0; i < lines.length; i++) {
152
- const raw = lines[i]!;
153
- if (md && /^\s*(```|~~~)/.test(raw)) { inFence = !inFence; continue; }
154
- if (md && inFence) continue;
155
- const line = md ? maskInlineCode(raw) : raw;
164
+ const line = md ? maskInlineCode(scanLines[i] ?? '') : lines[i]!;
156
165
 
157
166
  // Inline waiver first: with a reason ⇒ the line is exempt; without one ⇒ refuse loudly AND
158
167
  // leave the marker finding in place (a refused waiver must not half-work).
package/src/patterns.ts CHANGED
@@ -755,10 +755,52 @@ export async function reinforcePattern(projectRoot: string, dzIdOrText: string,
755
755
  return result;
756
756
  }
757
757
 
758
+ /**
759
+ * Which learned pattern does `--reinforce <arg>` mean? PURE, so the branch that REFUSES has a test.
760
+ *
761
+ * Order: the full id, then the exact text, then a BARE id suffix.
762
+ *
763
+ * The suffix step exists because an id is PRINTED as `<namespace>:<suffix>` — `teach:1a4a9bdd…` —
764
+ * by every surface that emits one (`dz teach`'s own `ID:` line, `dz recall --json`'s `dzId`), and a
765
+ * caller who kept only the hex got `no learned pattern matches`. The advisory then pointed at
766
+ * `dz recall`, which cannot surface a QUARANTINED lesson by its own text, so a lesson taught
767
+ * minutes ago had no reachable path at all (MEASURED 2026-09-20, backlog 2d3059fc). The report's
768
+ * wider claim — that ids are rejected outright — is REFUTED: the full printed id has always worked.
769
+ *
770
+ * An ambiguous suffix is refused BY NAME, never guessed. Reinforcing the wrong lesson writes
771
+ * silently and leaves no signal afterwards that the wrong record moved, so a wrong guess here is
772
+ * strictly worse than a refusal the caller can act on. Today only `teach:` ids exist, but `dream:`
773
+ * is a second canonical namespace this store already documents, so the collision is reachable.
774
+ */
775
+ export function resolveReinforceTarget(
776
+ records: readonly MemoryRecord[],
777
+ dzIdOrText: string,
778
+ ): { rec: MemoryRecord; matchedBy: 'id' | 'text' | 'suffix' } | { rec: undefined; error: string } {
779
+ // The KIND of match is part of the answer, not a detail: the caller prints it in a receipt, and a
780
+ // receipt that says "matched by text" about a suffix match is a receipt that lies. MEASURED
781
+ // 2026-09-20: the CLI inferred the kind from `resolved !== input`, which is true for BOTH a text
782
+ // match and a suffix match, so the suffix path reported the wrong one.
783
+ const byId = records.find((r) => r.id === dzIdOrText);
784
+ if (byId !== undefined) return { rec: byId, matchedBy: 'id' };
785
+ const byText = records.find((r) => r.text === dzIdOrText);
786
+ if (byText !== undefined) return { rec: byText, matchedBy: 'text' };
787
+ const bare = records.filter((r) => typeof r.id === 'string' && r.id.endsWith(':' + dzIdOrText));
788
+ if (bare.length > 1) {
789
+ return {
790
+ rec: undefined,
791
+ error: `${JSON.stringify(dzIdOrText)} is a bare id suffix shared by ${bare.length} learned patterns (${bare.map((r) => r.id).join(', ')}) — pass the full id`,
792
+ };
793
+ }
794
+ const only = bare[0];
795
+ if (only !== undefined) return { rec: only, matchedBy: 'suffix' };
796
+ return { rec: undefined, error: `no learned pattern matches ${JSON.stringify(dzIdOrText)}` };
797
+ }
798
+
758
799
  async function reinforcePatternLocked(projectRoot: string, dzIdOrText: string, opts: { reward?: number; ts?: string; mergedFrom?: readonly string[]; exposure?: boolean }): Promise<ReinforcePatternResult> {
759
800
  const records = loadStoreRecords(projectRoot);
760
- const rec = records.find((r) => r.id === dzIdOrText || r.text === dzIdOrText);
761
- if (rec === undefined) return { ok: false, error: `no learned pattern matches ${JSON.stringify(dzIdOrText)}` };
801
+ const resolved = resolveReinforceTarget(records, dzIdOrText);
802
+ if (resolved.rec === undefined) return { ok: false, error: resolved.error };
803
+ const rec = resolved.rec;
762
804
  const pairId = typeof rec.metadata?.['lessonPairId'] === 'string' ? rec.metadata['lessonPairId'] : undefined;
763
805
  const targets = pairId === undefined
764
806
  ? [rec]
@@ -781,14 +823,14 @@ async function reinforcePatternLocked(projectRoot: string, dzIdOrText: string, o
781
823
  for (const target of targets) {
782
824
  const promotedMeta = { ...(target.metadata ?? {}), ...encodeReinforcementState(nextState) };
783
825
  if (opts.exposure !== true) {
784
- delete promotedMeta['qStatus'];
785
- delete promotedMeta['quarantinedAt'];
826
+ promotedMeta['qStatus'] = 'promoted';
827
+ promotedMeta['promotedAt'] = ts;
786
828
  }
787
829
  const put = await putStoreRecord(projectRoot, { ...target, metadata: promotedMeta });
788
830
  if ('error' in put) return { ok: false, dzId: rec.id, error: put.error };
789
831
  }
790
832
  try {
791
- appendFileSync(join(projectRoot, '.dz', 'sessions.jsonl'), JSON.stringify({ event: 'reinforce', ts, dzId: rec.id, uses: nextState.uses }) + '\n');
833
+ appendFileSync(join(projectRoot, '.dz', 'sessions.jsonl'), JSON.stringify({ event: 'reinforce', ts, dzId: rec.id, uses: nextState.uses, exposure: opts.exposure === true }) + '\n');
792
834
  } catch { /* best-effort */ }
793
835
  return { ok: true, dzId: rec.id, uses: nextState.uses, reward: observed };
794
836
  }
@@ -845,10 +887,9 @@ export async function promotePatterns(projectRoot: string, dzIds: readonly strin
845
887
  const targets = pairId === undefined ? [rec] : records.filter((r) => r.metadata?.['lessonPairId'] === pairId);
846
888
  const pending = targets.filter((target) => !handled.has(target.id) && readQuarantineState(target).quarantined);
847
889
  if (pending.length === 0) { notQuarantined.push(id); continue; }
890
+ const promotedAt = new Date().toISOString();
848
891
  for (const target of pending) {
849
- const meta = { ...(target.metadata ?? {}) };
850
- delete meta['qStatus'];
851
- delete meta['quarantinedAt'];
892
+ const meta = { ...(target.metadata ?? {}), qStatus: 'promoted', promotedAt };
852
893
  const put = await putStoreRecord(projectRoot, { ...target, metadata: meta });
853
894
  if ('error' in put) return { ok: false, promoted, notFound, notQuarantined, error: put.error };
854
895
  handled.add(target.id);
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Что считается ПУБЛИКУЕМЫМ ИСХОДНИКОМ для гейта review-round.
3
+ *
4
+ * ЗАЧЕМ ЭТО ВЫНЕСЕНО. Правило `review-round` — единственный жёсткий гейт, спрашивающий, читал ли
5
+ * изменённый код кто-то, кроме автора. Его сборщик фактов жил одной строкой в `cli.ts` и помечал
6
+ * изменение исходником только по пути `^(src|lib|bin|skills)/`.
7
+ *
8
+ * ИЗМЕРЕНО (запись 5be1bee1, релиз 2026-08-25): пак навыков возит своё содержимое из `templates/`
9
+ * и `<имя-навыка>/`, поэтому `skills-feature-adr` мог поднять версию, изменить сам конвейер
10
+ * `templates/.claude/workflows/feature-adr.js` и гейт `feature-adr/scripts/*.mjs` — и правило НЕ
11
+ * СРАБАТЫВАЛО, а гейт печатал PASS. То есть пакеты, несущие ВЕСЬ конвейер, лежали вне
12
+ * единственной проверки на прочитанность. Страж, который не может сработать, хуже отсутствующего:
13
+ * он создаёт видимость защиты.
14
+ *
15
+ * ПОЧЕМУ НЕ «ВСЁ, ЧТО ПУБЛИКУЕТСЯ». Комментарий прежней строки защищал осознанное решение:
16
+ * «SOURCE = то, что ОТГРУЖАЕТСЯ и может ОШИБИТЬСЯ В РАНТАЙМЕ. Тесты, документация и фикстуры
17
+ * исключены… расширение области — это то, из-за чего жёсткий гейт выключают». Взять список
18
+ * публикуемых файлов целиком значило бы затянуть в гейт всю документацию пака и вернуть ровно ту
19
+ * переусердствующую форму, от которой это решение защищает.
20
+ *
21
+ * ПОЭТОМУ КРИТЕРИЙ — ИСПОЛНИМОСТЬ, А НЕ РАСПОЛОЖЕНИЕ: файл считается исходником, если он либо
22
+ * лежит в одном из исторических каталогов, либо ИСПОЛНЯЕТСЯ у потребителя по своему расширению —
23
+ * где бы он ни лежал. Документация, данные и тесты исключены в обоих случаях.
24
+ */
25
+
26
+ /** Каталоги, чьё содержимое отгружается, но исходником для этого гейта не считается. */
27
+ const NOT_SOURCE_DIR = /(^|\/)(test|tests|__tests__|fixtures|__fixtures__|dist|node_modules|coverage)(\/|$)/;
28
+
29
+ /** Исторические каталоги исходников — сохранены дословно, чтобы поведение не поехало. */
30
+ const LEGACY_SOURCE_DIR = /^(src|lib|bin|skills)\//;
31
+
32
+ /** Расширения, которые исполняются у потребителя. Разметка и данные сюда НЕ входят. */
33
+ const EXECUTABLE_EXT = /\.(?:[cm]?js|[cm]?ts|sh|bash|py)$/;
34
+
35
+ /** Расширения, которые исторически исключались даже внутри каталогов исходников. */
36
+ const NEVER_SOURCE_EXT = /\.(?:md|json|txt|yaml|yml)$/;
37
+
38
+ /**
39
+ * Изменение этого файла означает, что пакет отгружает НОВЫЙ ИСПОЛНЯЕМЫЙ код?
40
+ *
41
+ * @param relPath путь ВНУТРИ каталога пакета (`templates/.claude/workflows/feature-adr.js`),
42
+ * а не от корня репозитория
43
+ */
44
+ export function isShippedSource(relPath: string): boolean {
45
+ if (typeof relPath !== 'string' || relPath === '') return false;
46
+ const p = relPath.replace(/\\/g, '/');
47
+ // Порядок важен: исключение каталога сильнее любого включения, иначе `test/helper.ts`
48
+ // и пересобранный `dist/index.js` начали бы требовать круг ревью.
49
+ if (NOT_SOURCE_DIR.test(p)) return false;
50
+ if (NEVER_SOURCE_EXT.test(p)) return false;
51
+ if (LEGACY_SOURCE_DIR.test(p)) return true;
52
+ return EXECUTABLE_EXT.test(p);
53
+ }
package/src/publish.ts CHANGED
@@ -5,8 +5,9 @@
5
5
  * @packageDocumentation
6
6
  */
7
7
 
8
+ import { maskMarkdown } from './markdown-masker.js';
8
9
  import { existsSync, readFileSync, writeFileSync, readdirSync, renameSync } from 'node:fs';
9
- import { join as pathJoin, relative as pathRelative, resolve as pathResolve } from 'node:path';
10
+ import { join as pathJoin, relative as pathRelative, resolve as pathResolve, isAbsolute as pathIsAbsolute, sep as pathSep } from 'node:path';
10
11
 
11
12
  import { decidePublishSigning, decidePostSigningVerification } from './publish-signing.js';
12
13
  import { join } from 'node:path';
@@ -381,6 +382,24 @@ export function publishArgv(mode: ProvenanceMode, env: NodeJS.ProcessEnv): strin
381
382
  return decideProvenance(mode, env).useProvenance ? base + ' --provenance' : base;
382
383
  }
383
384
 
385
+ /** Match substrings against package identity and path without including the checkout root. */
386
+ export function matchesPublishFilter(
387
+ pkg: { name: string; dir: string },
388
+ filter: string,
389
+ monorepoRoot: string,
390
+ ): boolean {
391
+ const normalizedFilter = filter.replace(/\\/g, '/');
392
+ const relativeDir = pathRelative(pathResolve(monorepoRoot), pathResolve(pkg.dir));
393
+ // A foreign path must not reintroduce checkout ancestors into the match.
394
+ const outsideRoot = relativeDir === '..' || relativeDir.startsWith(`..${pathSep}`) || pathIsAbsolute(relativeDir);
395
+ // Only Windows spells separators with a backslash. On POSIX a backslash is a legal character
396
+ // INSIDE a directory name, so rewriting it here would invent a separator the filesystem does
397
+ // not have and over-select that package — the same class as the root-substring defect.
398
+ const comparableDir = pathSep === '\\' ? relativeDir.replace(/\\/g, '/') : relativeDir;
399
+ return pkg.name.includes(normalizedFilter)
400
+ || (!outsideRoot && comparableDir.includes(normalizedFilter));
401
+ }
402
+
384
403
  /** Discover all publishable @dzhechkov packages. */
385
404
  export function discoverPackages(monorepoRoot: string): { name: string; dir: string; version: string }[] {
386
405
  const baseDir = join(monorepoRoot, 'packages', '@dzhechkov');
@@ -741,21 +760,20 @@ const REGION_END = /^ {0,3}#{1,2}\s/;
741
760
 
742
761
  /** Blank out fenced-code lines while PRESERVING line numbering, so a `## Status` or an entry-shaped
743
762
  * line inside an example neither starts nor ends the region (the same fence blindness was found in
744
- * another checker on the same day). */
763
+ * another checker on the same day).
764
+ *
765
+ * Scoping is DELEGATED to the canonical masker, not re-implemented. The local version this replaces
766
+ * tracked only the marker CHARACTER and closed on any run of three or more, ignoring CommonMark's
767
+ * rule that a closing fence may not be SHORTER than the opening one. MEASURED 2026-09-20: in a
768
+ * README whose entry body quotes a markdown example as ````markdown … ```` , the inner ``` closed
769
+ * the outer block early, the quoted `## Status` became visible, the region ENDED there, and the
770
+ * body line below — `В `1.2.2` эта строка тела …` — lost protection. That is exactly the
771
+ * 2026-08-25 incident this function exists to stop: a record's body relabelled forward and shipped
772
+ * to npm. `unclosed: 'hide'` preserves the local version's policy — an unclosed opener masks to
773
+ * end of file, which is also the CommonMark reading.
774
+ */
745
775
  function maskFences(lines: readonly string[]): string[] {
746
- const out: string[] = [];
747
- let fence: string | null = null;
748
- for (const line of lines) {
749
- const open = /^ {0,3}(```+|~~~+)/.exec(line);
750
- if (fence === null && open !== null && open[1] !== undefined) { fence = open[1][0] as string; out.push(''); continue; }
751
- if (fence !== null) {
752
- out.push('');
753
- if (new RegExp('^ {0,3}' + fence + '{3,}\\s*$').test(line)) fence = null;
754
- continue;
755
- }
756
- out.push(line);
757
- }
758
- return out;
776
+ return String(maskMarkdown(lines.join('\n'), { unclosed: 'hide' })).split('\n');
759
777
  }
760
778
 
761
779
  /**
@@ -883,10 +901,14 @@ export function publishPackages(
883
901
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
884
902
  });
885
903
 
904
+ if (opts.filter?.some((filter) => filter.length === 0)) {
905
+ throw new Error('publish: --filter requires non-empty package-name substrings (empty would match ALL packages)');
906
+ }
907
+
886
908
  const packages = discoverPackages(monorepoRoot);
887
909
  const results: PublishResult[] = [];
888
910
  const filtered = opts.filter && opts.filter.length > 0
889
- ? packages.filter((p) => opts.filter!.some((f) => p.name.includes(f) || p.dir.includes(f)))
911
+ ? packages.filter((p) => opts.filter!.some((f) => matchesPublishFilter(p, f, monorepoRoot)))
890
912
  : packages;
891
913
  // Publish dependencies before dependents so pnpm rewrites workspace:* to the
892
914
  // freshly-bumped version, never a stale one (the harness-cli@0.3.122 breakage).
package/src/qe-bridge.ts CHANGED
@@ -50,7 +50,10 @@ export const CLAUDE_BRIDGE_PROMPT_CEILING_CHARS = 200_000;
50
50
  */
51
51
  export const KNOWN_CLAUDE: Record<string, 1> = { opus: 1, sonnet: 1, haiku: 1 };
52
52
 
53
- /** The terminal verdict grammar: `QE-BRIDGE-SIGNOFF grade=<A-F> findings=<n>`. */
53
+ /** The terminal verdict grammar: `QE-BRIDGE-SIGNOFF grade=<A-F, optionally + or -> findings=<n>`.
54
+ * bridge-grade-grammar (ddf83072): a live Sonnet review wrote `grade=B-` in all three channels on
55
+ * 2026-09-17 06:52 and the whole 145-second review was discarded as no-grade-marker, while every
56
+ * other consumer (round close, score, recap) already accepted modifiers. */
54
57
  export const BRIDGE_MARKER = 'QE-BRIDGE-SIGNOFF';
55
58
 
56
59
  /** The fenced block's info string. */
@@ -345,7 +348,7 @@ export function defangSignoffEchoes(text: string): string {
345
348
  out = out.replace(/qe-bridge-signoff/gi, '[quoted-fence-label]');
346
349
  out = out.split(BRIDGE_EXTRACT_END).join('[quoted-boundary]');
347
350
  // Line-anchored verdict lines only — prose such as "upgrade the grade later" is untouched.
348
- out = out.replace(/^([ \t>*~-]*)(?:\*{0,2}#{0,4}[ \t]*)?GRADE[ \t]*[:=—–-]?[ \t]*([A-F])\b/gim, '$1[quoted-grade] $2');
351
+ out = out.replace(/^([ \t>*~-]*)(?:\*{0,2}#{0,4}[ \t]*)?GRADE[ \t]*[:=—–-]?[ \t]*([A-F](?:[+-](?![ \t]*[A-F]\b))?)(?![A-Za-z])/gim, '$1[quoted-grade] $2');
349
352
  return out;
350
353
  }
351
354
 
@@ -410,13 +413,14 @@ export function buildBridgePrompt(
410
413
  'happen in this environment.',
411
414
  '',
412
415
  'Output format — ALL THREE parts are required, in this order, at the END of your answer:',
413
- ' (a) your review prose, containing exactly ONE line that reads: GRADE: <A-F>',
416
+ ' (a) your review prose, containing exactly ONE line that reads: GRADE: ' + GRADE_PLACEHOLDER,
414
417
  ' (b) a fenced code block labelled ' + BRIDGE_FENCE_LABEL + ' whose body is JSON:',
415
- ' {"grade":"<A-F>","findings":[{"n":1,"severity":"major","title":"…","file":"path","line":12}]}',
418
+ ' {"grade":"' + GRADE_PLACEHOLDER + '","findings":[{"n":1,"severity":"major","title":"…","file":"path","line":12}]}',
416
419
  ' A genuinely clean review writes "findings": [] — but it must WRITE it.',
417
- ' (c) a final line, on its own: ' + BRIDGE_MARKER + ' grade=<A-F> findings=<n>',
418
- 'The grade in all three places must be the SAME letter — ONE capital letter A, B, C, D, E or F,',
419
- 'with NO + or - suffix (write B, never B- or B+; a modifier makes the whole answer unparseable).',
420
+ ' (c) a final line, on its own: ' + BRIDGE_MARKER + ' grade=' + GRADE_PLACEHOLDER + ' findings=<n>',
421
+ 'The grade in all three places must be the SAME grade, character for character — one capital',
422
+ 'letter A, B, C, D, E or F, optionally followed by + or -.',
423
+ 'A modifier is fine (B+, B, B-) and is carried through verbatim; what breaks the parse is a RANGE (A-F), prose in place of the letter, or two channels disagreeing.',
420
424
  'Anything else is discarded as a failed call — an unparseable answer is treated as no review at',
421
425
  'all, never as a passing one.',
422
426
  '',
@@ -447,7 +451,14 @@ export function buildBridgePrompt(
447
451
 
448
452
  /** Line-anchored marker. Leading quote/list decoration is allowed on purpose: a planted marker
449
453
  * SHOULD match the grammar — and then lose to the LAST one. Anchoring is the defence, not evasion. */
450
- const MARKER_LINE = /^[ \t>*~-]*QE-BRIDGE-SIGNOFF[ \t]+grade=([A-F])[ \t]+findings=(\d{1,6})[ \t]*$/gim;
454
+ /** The grade placeholder the reviewer prompt shows in ALL THREE output slots. It lives in one
455
+ * constant because round-2 review (2026-09-18, HIGH #1 partial) found the opposite: the marker slot
456
+ * invited a modifier while the prose and JSON slots still read `<A-F>` — which is also the exact
457
+ * spelling of the RANGE the parser refuses, so the template taught the reviewer to write the one
458
+ * thing that breaks the parse. One spelling, three slots, no drift. */
459
+ const GRADE_PLACEHOLDER = '<A-F, optionally with + or ->';
460
+
461
+ const MARKER_LINE = /^[ \t>*~-]*QE-BRIDGE-SIGNOFF[ \t]+grade=([A-F][+-]?)[ \t]+findings=(\d{1,6})[ \t]*$/gim;
451
462
 
452
463
  /** Fenced `qe-bridge-signoff` block. */
453
464
  const SIGNOFF_FENCE = /^[ \t]*(?:`{3,}|~{3,})[ \t]*qe-bridge-signoff[ \t]*\r?\n([\s\S]*?)^[ \t]*(?:`{3,}|~{3,})[ \t]*$/gim;
@@ -538,7 +549,7 @@ export function parseBridgeOutput(
538
549
  // channel 1 — the LAST terminal marker line, which must be the FINAL content of the answer
539
550
  const marker = lastMatch(MARKER_LINE, text);
540
551
  if (marker === null) {
541
- return fail('no-grade-marker', 'no `' + BRIDGE_MARKER + ' grade=<A-F> findings=<n>` line anywhere in ' + text.length + ' chars of reviewer output — text without a verdict marker is not a verdict');
552
+ return fail('no-grade-marker', 'no `' + BRIDGE_MARKER + ' grade=<A-F, optionally with + or -> findings=<n>` line anywhere in ' + text.length + ' chars of reviewer output — text without a verdict marker is not a verdict');
542
553
  }
543
554
  const markerGrade = String(marker[1]).toUpperCase();
544
555
  const markerCount = Number(marker[2]);
@@ -576,8 +587,8 @@ export function parseBridgeOutput(
576
587
  }
577
588
  const obj = parsedJson as Record<string, unknown>;
578
589
  const jsonGradeRaw = obj['grade'];
579
- if (typeof jsonGradeRaw !== 'string' || !/^[A-Fa-f]$/.test(jsonGradeRaw.trim())) {
580
- return fail('no-signoff-json', 'the fenced JSON block names no `grade` letter A-F (saw ' + JSON.stringify(jsonGradeRaw) + ')');
590
+ if (typeof jsonGradeRaw !== 'string' || !/^[A-Fa-f][+-]?$/.test(jsonGradeRaw.trim())) {
591
+ return fail('no-signoff-json', 'the fenced JSON block names no `grade` letter A-F (optionally with + or -) (saw ' + JSON.stringify(jsonGradeRaw) + ')');
581
592
  }
582
593
  const jsonGrade = jsonGradeRaw.trim().toUpperCase();
583
594
  const validated = validateFindings(obj['findings']);