@dzhechkov/harness-core 0.8.40 → 0.8.41

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 (135) hide show
  1. package/.dz-manifest.json +194 -114
  2. package/README.md +43 -8
  3. package/dist/__tests__/golden-baseline.test.js +1 -1
  4. package/dist/__tests__/golden-baseline.test.js.map +1 -1
  5. package/dist/agentdb-index.js +3 -3
  6. package/dist/agentdb-index.js.map +1 -1
  7. package/dist/agentdb-reindex-marker.d.ts +2 -2
  8. package/dist/agentdb-reindex-marker.js +4 -4
  9. package/dist/agentdb-reindex-marker.js.map +1 -1
  10. package/dist/agentdb-snapshot-rotation.d.ts +1 -1
  11. package/dist/agentdb-snapshot-rotation.js +1 -1
  12. package/dist/backup-freshness.d.ts +32 -0
  13. package/dist/backup-freshness.d.ts.map +1 -0
  14. package/dist/backup-freshness.js +69 -0
  15. package/dist/backup-freshness.js.map +1 -0
  16. package/dist/cmd-usage.d.ts +2 -2
  17. package/dist/cmd-usage.d.ts.map +1 -1
  18. package/dist/cmd-usage.js +8 -6
  19. package/dist/cmd-usage.js.map +1 -1
  20. package/dist/codex-hooks-assets.d.ts +2 -1
  21. package/dist/codex-hooks-assets.d.ts.map +1 -1
  22. package/dist/codex-hooks-assets.js +15 -2
  23. package/dist/codex-hooks-assets.js.map +1 -1
  24. package/dist/compounding.d.ts +43 -1
  25. package/dist/compounding.d.ts.map +1 -1
  26. package/dist/compounding.js +96 -0
  27. package/dist/compounding.js.map +1 -1
  28. package/dist/discrimination-gate.d.ts +0 -3
  29. package/dist/discrimination-gate.d.ts.map +1 -1
  30. package/dist/discrimination-gate.js +2 -2
  31. package/dist/discrimination-gate.js.map +1 -1
  32. package/dist/guard.d.ts +22 -2
  33. package/dist/guard.d.ts.map +1 -1
  34. package/dist/guard.js +102 -14
  35. package/dist/guard.js.map +1 -1
  36. package/dist/index.d.ts +15 -6
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +11 -4
  39. package/dist/index.js.map +1 -1
  40. package/dist/integration-apply.js +2 -2
  41. package/dist/integration-apply.js.map +1 -1
  42. package/dist/lesson-payoff.js +3 -3
  43. package/dist/lesson-payoff.js.map +1 -1
  44. package/dist/named-lock.d.ts +5 -2
  45. package/dist/named-lock.d.ts.map +1 -1
  46. package/dist/named-lock.js +30 -10
  47. package/dist/named-lock.js.map +1 -1
  48. package/dist/operations.d.ts.map +1 -1
  49. package/dist/operations.js +24 -0
  50. package/dist/operations.js.map +1 -1
  51. package/dist/pack-inventory.d.ts +51 -0
  52. package/dist/pack-inventory.d.ts.map +1 -0
  53. package/dist/pack-inventory.js +306 -0
  54. package/dist/pack-inventory.js.map +1 -0
  55. package/dist/package-skill-layouts.d.ts +3 -2
  56. package/dist/package-skill-layouts.d.ts.map +1 -1
  57. package/dist/package-skill-layouts.js +3 -2
  58. package/dist/package-skill-layouts.js.map +1 -1
  59. package/dist/patterns.d.ts +8 -0
  60. package/dist/patterns.d.ts.map +1 -1
  61. package/dist/patterns.js +11 -1
  62. package/dist/patterns.js.map +1 -1
  63. package/dist/publish.d.ts +29 -7
  64. package/dist/publish.d.ts.map +1 -1
  65. package/dist/publish.js +92 -15
  66. package/dist/publish.js.map +1 -1
  67. package/dist/registry.d.ts.map +1 -1
  68. package/dist/registry.js +24 -20
  69. package/dist/registry.js.map +1 -1
  70. package/dist/reqe-verdict.d.ts +55 -0
  71. package/dist/reqe-verdict.d.ts.map +1 -0
  72. package/dist/reqe-verdict.js +173 -0
  73. package/dist/reqe-verdict.js.map +1 -0
  74. package/dist/reqe.d.ts +35 -14
  75. package/dist/reqe.d.ts.map +1 -1
  76. package/dist/reqe.js +85 -32
  77. package/dist/reqe.js.map +1 -1
  78. package/dist/round.d.ts +10 -0
  79. package/dist/round.d.ts.map +1 -1
  80. package/dist/round.js +7 -0
  81. package/dist/round.js.map +1 -1
  82. package/dist/session-retro.d.ts +1 -1
  83. package/dist/session-retro.js +3 -3
  84. package/dist/session-retro.js.map +1 -1
  85. package/dist/skill-drift.d.ts +18 -1
  86. package/dist/skill-drift.d.ts.map +1 -1
  87. package/dist/skill-drift.js +46 -13
  88. package/dist/skill-drift.js.map +1 -1
  89. package/dist/statusline.js +2 -2
  90. package/dist/statusline.js.map +1 -1
  91. package/dist/store-guard.js +3 -3
  92. package/dist/store-guard.js.map +1 -1
  93. package/dist/store-lock.d.ts +9 -0
  94. package/dist/store-lock.d.ts.map +1 -1
  95. package/dist/store-lock.js +22 -3
  96. package/dist/store-lock.js.map +1 -1
  97. package/dist/test-receipt.d.ts +66 -0
  98. package/dist/test-receipt.d.ts.map +1 -0
  99. package/dist/test-receipt.js +73 -0
  100. package/dist/test-receipt.js.map +1 -0
  101. package/dist/workflow-run.d.ts.map +1 -1
  102. package/dist/workflow-run.js +7 -8
  103. package/dist/workflow-run.js.map +1 -1
  104. package/package.json +1 -1
  105. package/sbom.json +313 -113
  106. package/src/__tests__/golden-baseline.test.ts +1 -1
  107. package/src/agentdb-index.ts +3 -3
  108. package/src/agentdb-reindex-marker.ts +4 -4
  109. package/src/agentdb-snapshot-rotation.ts +1 -1
  110. package/src/backup-freshness.ts +96 -0
  111. package/src/cmd-usage.ts +7 -6
  112. package/src/codex-hooks-assets.ts +15 -2
  113. package/src/compounding.ts +132 -1
  114. package/src/discrimination-gate.ts +2 -5
  115. package/src/guard.ts +104 -17
  116. package/src/index.ts +23 -5
  117. package/src/integration-apply.ts +2 -2
  118. package/src/lesson-payoff.ts +3 -3
  119. package/src/named-lock.ts +36 -11
  120. package/src/operations.ts +24 -0
  121. package/src/pack-inventory.ts +313 -0
  122. package/src/package-skill-layouts.ts +3 -2
  123. package/src/patterns.ts +18 -1
  124. package/src/publish.ts +101 -22
  125. package/src/registry.ts +23 -17
  126. package/src/reqe-verdict.ts +174 -0
  127. package/src/reqe.ts +111 -35
  128. package/src/round.ts +18 -0
  129. package/src/session-retro.ts +3 -3
  130. package/src/skill-drift.ts +66 -12
  131. package/src/statusline.ts +2 -2
  132. package/src/store-guard.ts +3 -3
  133. package/src/store-lock.ts +20 -3
  134. package/src/test-receipt.ts +106 -0
  135. package/src/workflow-run.ts +7 -8
package/src/guard.ts CHANGED
@@ -24,7 +24,18 @@ import {
24
24
  import { findReleaseLine } from './release-line.js';
25
25
 
26
26
  export type GuardSeverity = 'hard' | 'soft';
27
- export type GuardOp = 'publish' | 'teach' | 'consolidate' | 'reindex';
27
+ // The 'code' operation checks facts already established when code changes, such as shared skill-copy
28
+ // drift or a stale signature on a pack whose files changed. Publication readiness (README, release
29
+ // lines, versions) belongs to publish. On 2026-09-21, story-page's stale signature surfaced six hours
30
+ // after the edit in harness-cli's tests, demonstrating why edit-time facts need an edit-time gate.
31
+ export const GUARD_OPS = [
32
+ 'publish',
33
+ 'teach',
34
+ 'consolidate',
35
+ 'reindex',
36
+ 'code',
37
+ ] as const;
38
+ export type GuardOp = typeof GUARD_OPS[number];
28
39
  export type GuardVerdict = 'pass' | 'warn' | 'block';
29
40
 
30
41
  /** A declarative rule. Built-in rules ship with a checker (below); config can only tune/disable them. */
@@ -154,6 +165,8 @@ export interface GuardFacts {
154
165
  };
155
166
  /** for no-skill-drift: the names that byte-drift between copies (from sweepSkillDrift). */
156
167
  readonly drift?: readonly string[];
168
+ /** for no-skill-drift: canonical-side defects as skill:path (from sweepSkillDrift). */
169
+ readonly canonicalDefects?: readonly string[];
157
170
  /**
158
171
  * for codex-wrapper-for-value-stage: workflow scripts to scan, as (path, text). Absent ⇒ the
159
172
  * rule reports nothing: a guard with no evidence must stay silent rather than invent a verdict.
@@ -161,10 +174,15 @@ export interface GuardFacts {
161
174
  readonly workflowScripts?: readonly { readonly path: string; readonly text: string }[];
162
175
  /** for no-secrets: labelled blobs to scan (lesson text, staged files). */
163
176
  readonly secretTargets?: readonly { readonly label: string; readonly text: string }[];
177
+ /** for no-secrets: findings already scanned from the streaming publish inventory. */
178
+ readonly secretFindings?: readonly { readonly label: string; readonly name: string }[];
164
179
  /** for no-secrets: exact path waivers; entries without a non-empty reason are ignored and noted. */
165
180
  readonly secretWaivers?: readonly SecretWaiver[];
166
181
  /** Publish secret-scan coverage gaps; skips are informational and never verdict inputs. */
167
- readonly secretScan?: { readonly skipped: number };
182
+ /** backlog a530eaca: `skippedPaths` names what the fail-open scan never read (repo-relative, with the
183
+ * reason) so the coverage gap is auditable — a bare count told the owner nothing to check. Optional:
184
+ * an older CLI that sends only the count still renders the count-only note. */
185
+ readonly secretScan?: { readonly skipped: number; readonly skippedPaths?: readonly string[]; readonly scanned?: number; readonly inventory?: string };
168
186
  /** for readme-consistency: labelled (a,b) count pairs that must be equal. */
169
187
  readonly counts?: readonly { readonly label: string; readonly a: number; readonly b: number }[];
170
188
  /** for store-bloat-cap: current learned-store size vs its cap. */
@@ -199,7 +217,7 @@ export interface GuardFacts {
199
217
  /**
200
218
  * for review-round: per publishable package, does this change bump a version AND touch SOURCE, and
201
219
  * did it bring a GRADED QE report with it? `undefined` (the whole fact absent) means the tree could
202
- * not be read — the rule then reports nothing, which is different from reporting "no review".
220
+ * not be read — the rule is NOT-ESTABLISHED, which is different from reporting "no review".
203
221
  */
204
222
  readonly reviewRound?: {
205
223
  readonly packages: readonly { readonly name: string; readonly versionBumped: boolean; readonly sourceChanged: boolean }[];
@@ -477,12 +495,12 @@ export const DEFAULT_RULES: readonly GuardRule[] = [
477
495
  { id: 'no-workspace-star', severity: 'hard', ops: ['publish'], description: 'a published package.json must carry no workspace:* dep (npm ships it verbatim → the install breaks)' },
478
496
  { id: 'plugin-manifest-audit', severity: 'hard', ops: ['publish'], description: 'every .claude-plugin/plugin.json parses and declares a non-empty name, description and a STRICT N.N.N version' },
479
497
  { id: 'sibling-dep-protocol', severity: 'hard', ops: ['publish'], description: 'a dependencies/devDependencies entry on a sibling @dzhechkov package must use the workspace: protocol on disk (peer/optional deps are deliberately exempt — a range is their point)' },
480
- { id: 'no-skill-drift', severity: 'hard', ops: ['publish', 'consolidate'], description: 'no unexpected byte-drift between shared skill copies' },
498
+ { id: 'no-skill-drift', severity: 'hard', ops: ['publish', 'consolidate', 'code'], description: 'no unexpected byte-drift between shared skill copies' },
481
499
  { id: 'backlog-covers-features', severity: 'soft', ops: ['publish', 'consolidate'], description: 'каталог фичи, заведённый после базовой даты, назван записью бэклога — либо несёт именованную оговорку с причиной' },
482
500
  { id: 'no-secrets', severity: 'hard', ops: ['teach', 'publish'], description: 'no private key or API token in lesson text or a published file' },
483
501
  { id: 'readme-consistency', severity: 'soft', ops: ['publish'], description: 'README counts agree (CJM header vs All Commands, etc.)' },
484
502
  { id: 'release-line-in-sync', severity: 'soft', ops: ['publish'], description: 'root and harness-cli README release lines agree with the harness-core and harness-cli package versions' },
485
- { id: 'signature-fresh', severity: 'soft', ops: ['publish'], description: 'a pack whose files changed in this diff still verifies against its signed .dz-manifest.json — a stale signature is named before publish, not at the gate' },
503
+ { id: 'signature-fresh', severity: 'soft', ops: ['publish', 'code'], description: 'a pack whose files changed in this diff still verifies against its signed .dz-manifest.json — a stale signature is named before publish, not at the gate' },
486
504
  { id: 'skills-registrable', severity: 'soft', ops: ['publish'], description: 'every skill directory in a skill pack has a depth-1 SKILL.md (a buried or missing one ships un-registrable — the health-advisor 1.2.0 class)' },
487
505
  { id: 'readme-first', severity: 'soft', ops: ['publish'], description: 'a package with a staged version bump must update its own README.md in the same change (README-first)' },
488
506
  { id: 'routing-store-stale', severity: 'soft', ops: ['publish'], description: 'harvested routing telemetry has been applied to the auto-cost outcome store' },
@@ -527,6 +545,22 @@ export function scanSecrets(text: unknown): { readonly name: string }[] {
527
545
  return [...hits].map((name) => ({ name }));
528
546
  }
529
547
 
548
+ export const SECRET_SCAN_OVERLAP_BYTES = 4096;
549
+
550
+ export function scanSecretsChunked(chunks: Iterable<Buffer>, opts?: { overlapBytes?: number }): { name: string }[] {
551
+ const overlapBytes = opts?.overlapBytes ?? SECRET_SCAN_OVERLAP_BYTES;
552
+ const hits = new Set<string>();
553
+ let tail = Buffer.alloc(0);
554
+ for (const chunk of chunks) {
555
+ const window = Buffer.concat([tail, chunk]);
556
+ const text = window.toString('utf8');
557
+ for (const p of SECRET_PATTERNS) if (p.re.test(text)) hits.add(p.name);
558
+ if (hits.size === SECRET_PATTERNS.length) break;
559
+ tail = window.subarray(Math.max(0, window.length - overlapBytes));
560
+ }
561
+ return [...hits].map((name) => ({ name }));
562
+ }
563
+
530
564
  function secretWaiverState(waivers: readonly SecretWaiver[] | undefined): { waived: ReadonlySet<string>; reasonless: number } {
531
565
  const waived = new Set<string>();
532
566
  let reasonless = 0;
@@ -613,7 +647,7 @@ function mentionsSlug(haystackLower: string, slug: string): boolean {
613
647
  }
614
648
 
615
649
  const CHECKERS: Record<string, (f: GuardFacts, sev: GuardSeverity) => Violation[]> = {
616
- 'rounds-closed': (f, sev) => (f.openRounds ?? [])
650
+ 'rounds-closed': (f, sev) => f.openRounds!
617
651
  .filter((round) => Number.isFinite(round.ageMinutes) && round.ageMinutes > 120)
618
652
  .map((round) => ({
619
653
  rule: 'rounds-closed',
@@ -762,7 +796,11 @@ const CHECKERS: Record<string, (f: GuardFacts, sev: GuardSeverity) => Violation[
762
796
  },
763
797
  'no-skill-drift': (f, sev) => {
764
798
  const drifted = Array.isArray(f.drift) ? f.drift.filter((d) => typeof d === 'string') : [];
765
- return drifted.length === 0 ? [] : [{ rule: 'no-skill-drift', severity: sev, detail: `${drifted.length} skill(s) drift between copies: ${drifted.slice(0, 8).join(', ')}${drifted.length > 8 ? '…' : ''} — heal with dz sync-canonical` }];
799
+ const defects = Array.isArray(f.canonicalDefects) ? f.canonicalDefects.filter((d) => typeof d === 'string') : [];
800
+ const out: Violation[] = [];
801
+ if (drifted.length > 0) out.push({ rule: 'no-skill-drift', severity: sev, detail: `${drifted.length} skill(s) drift between copies: ${drifted.slice(0, 8).join(', ')}${drifted.length > 8 ? '…' : ''} — heal with dz sync-canonical` });
802
+ if (defects.length > 0) out.push({ rule: 'no-skill-drift', severity: sev, detail: `${defects.length} canonical defect(s): ${defects.join(', ')} — fix the canon, not the copies` });
803
+ return out;
766
804
  },
767
805
  'backlog-covers-features': (f, sev) => {
768
806
  const ev = f.featureBacklog;
@@ -787,11 +825,15 @@ const CHECKERS: Record<string, (f: GuardFacts, sev: GuardSeverity) => Violation[
787
825
  out.push({ rule: 'no-secrets', severity: sev, detail: `${t.label}: looks like a ${hit.name} — do not teach/publish a credential` });
788
826
  }
789
827
  }
828
+ for (const hit of f.secretFindings ?? []) {
829
+ if (waived.has(hit.label)) continue;
830
+ out.push({ rule: 'no-secrets', severity: sev, detail: `${hit.label}: looks like a ${hit.name} — do not teach/publish a credential` });
831
+ }
790
832
  return out;
791
833
  },
792
834
  'readme-consistency': (f, sev) => {
793
835
  const out: Violation[] = [];
794
- for (const c of f.counts ?? []) {
836
+ for (const c of f.counts!) {
795
837
  if (Number.isFinite(c.a) && Number.isFinite(c.b) && c.a !== c.b) {
796
838
  out.push({ rule: 'readme-consistency', severity: sev, detail: `${c.label}: ${c.a} ≠ ${c.b} (README counts disagree)` });
797
839
  }
@@ -804,7 +846,7 @@ const CHECKERS: Record<string, (f: GuardFacts, sev: GuardSeverity) => Violation[
804
846
  // a heuristic (a pack counts only if it already has one registrable skill, and only
805
847
  // markdown-bearing dirs are considered intended), so it informs rather than blocks.
806
848
  const out: Violation[] = [];
807
- for (const p of f.skillPacks ?? []) {
849
+ for (const p of f.skillPacks!) {
808
850
  if (!p || !Array.isArray(p.nonRegistrable) || p.nonRegistrable.length === 0) continue;
809
851
  out.push({
810
852
  rule: 'skills-registrable',
@@ -819,7 +861,7 @@ const CHECKERS: Record<string, (f: GuardFacts, sev: GuardSeverity) => Violation[
819
861
  // whose own README.md is untouched in the same diff. SOFT: some republishes legitimately need no doc
820
862
  // change — the point is that skipping the README becomes a VISIBLE decision, not a silent lapse.
821
863
  const out: Violation[] = [];
822
- for (const p of f.readmeFirst ?? []) {
864
+ for (const p of f.readmeFirst!) {
823
865
  if (p && p.versionBumped === true && p.readmeChanged !== true) {
824
866
  out.push({ rule: 'readme-first', severity: sev, detail: `${p.name}: version bumped but its README.md is untouched in this change — README-first: document the change (or consciously proceed; this warning is the record)` });
825
867
  }
@@ -886,7 +928,7 @@ const CHECKERS: Record<string, (f: GuardFacts, sev: GuardSeverity) => Violation[
886
928
  //
887
929
  // Scoped to CHANGED SOURCE on purpose (ADR-001): a HARD rule that also fired on a docs-only
888
930
  // republish would be a rule someone switches off. `undefined` facts mean the tree could not be
889
- // read — silence, not an accusation.
931
+ // read — HAS_INPUT records NOT-ESTABLISHED, not an accusation.
890
932
  const rr = f.reviewRound;
891
933
  if (rr === undefined) return [];
892
934
  const min = typeof rr.minGrade === 'string' ? rr.minGrade.trim().toUpperCase() : undefined;
@@ -942,8 +984,8 @@ const CHECKERS: Record<string, (f: GuardFacts, sev: GuardSeverity) => Violation[
942
984
  * so it belongs on layer 1 rather than in a rule nobody re-reads.
943
985
  */
944
986
  'codex-wrapper-for-value-stage': (f, sev) => {
945
- const scripts = f.workflowScripts;
946
- if (scripts === undefined || scripts.length === 0) return [];
987
+ const scripts = f.workflowScripts!; // Presence is established by HAS_INPUT.
988
+ if (scripts.length === 0) return [];
947
989
  const out: Violation[] = [];
948
990
  for (const s of scripts) {
949
991
  const text = String(s.text ?? '');
@@ -1055,6 +1097,14 @@ const CHECKERS: Record<string, (f: GuardFacts, sev: GuardSeverity) => Violation[
1055
1097
  },
1056
1098
  };
1057
1099
 
1100
+ // Missing volume means NOT-ESTABLISHED. A throwing getter is INTENTIONALLY treated as present
1101
+ // so volume() can emit honest 'unknown' observations instead of failing in the precondition.
1102
+ // This preserves the older contract: test/guard.test.ts:725,
1103
+ // "a hostile volume getter becomes visible unknown evidence instead of a HARD checker error".
1104
+ const volumeInputPresent = (f: GuardFacts): boolean => {
1105
+ try { return f.volume !== undefined; } catch { return true; }
1106
+ };
1107
+
1058
1108
  /** Per-rule evidence predicates. No entry preserves the rule's existing checked behaviour exactly. */
1059
1109
  const HAS_INPUT: Partial<Record<string, (f: GuardFacts) => boolean>> = {
1060
1110
  'rounds-traced': (f) => {
@@ -1070,9 +1120,24 @@ const HAS_INPUT: Partial<Record<string, (f: GuardFacts) => boolean>> = {
1070
1120
  'sibling-dep-protocol': (f) => Array.isArray(f.siblingDeps) && f.siblingDeps.length > 0,
1071
1121
  // Дерево без плагин-манифестов правилу нечего сказать: «прошло» тут значило бы «не смотрели».
1072
1122
  'plugin-manifest-audit': (f) => Array.isArray(f.pluginManifests) && f.pluginManifests.length > 0,
1073
- 'no-secrets': (f) => Array.isArray(f.secretTargets) && f.secretTargets.length > 0,
1123
+ 'no-secrets': (f) => (Array.isArray(f.secretTargets) && f.secretTargets.length > 0)
1124
+ || Array.isArray(f.secretFindings) || typeof f.secretScan?.scanned === 'number',
1074
1125
  'release-line-in-sync': (f) => typeof f.releaseLines === 'object' && f.releaseLines !== null,
1126
+ // 2026-09-21: на одном дереве с дрейфом publish=block, а code=pass без собранного факта drift.
1127
+ 'no-skill-drift': (f) => Array.isArray(f.drift),
1128
+ 'review-round': (f) => f.reviewRound !== undefined,
1129
+ 'no-workspace-star': (f) => Array.isArray(f.packages),
1130
+ 'codex-wrapper-for-value-stage': (f) => Array.isArray(f.workflowScripts),
1075
1131
  'signature-fresh': (f) => Array.isArray(f.signedPacks),
1132
+ 'rounds-closed': (f) => Array.isArray(f.openRounds),
1133
+ 'readme-consistency': (f) => Array.isArray(f.counts),
1134
+ 'skills-registrable': (f) => Array.isArray(f.skillPacks),
1135
+ 'readme-first': (f) => Array.isArray(f.readmeFirst),
1136
+ 'store-bloat-cap': (f) => f.store !== undefined,
1137
+ 'template-context-token-weight': volumeInputPresent,
1138
+ 'template-context-largest-file-share': volumeInputPresent,
1139
+ 'feature-artifact-diff-ratio': volumeInputPresent,
1140
+ 'feature-tier-artifact-set': volumeInputPresent,
1076
1141
  };
1077
1142
 
1078
1143
  /**
@@ -1138,7 +1203,7 @@ export function resolveRules(userRules?: readonly Partial<GuardRule>[]): GuardRu
1138
1203
  // than `lockfile-in-sync`'s tolerant parser, which is already SOFT-only. "I might be wrong"
1139
1204
  // plus "block the publish" is the wrong pair (ADR-004).
1140
1205
  if (isTemplateRule(u)) {
1141
- const ops = Array.isArray(u.ops) && u.ops.every((o) => ['publish', 'teach', 'consolidate', 'reindex'].includes(o as string)) && u.ops.length > 0 ? (u.ops as readonly GuardOp[]) : (['publish'] as const);
1206
+ const ops = Array.isArray(u.ops) && u.ops.every((o) => (GUARD_OPS as readonly string[]).includes(o as string)) && u.ops.length > 0 ? (u.ops as readonly GuardOp[]) : (['publish'] as const);
1142
1207
  byId.set(u.id, {
1143
1208
  id: u.id,
1144
1209
  severity: 'soft',
@@ -1203,9 +1268,11 @@ export function evaluateGuard(facts: GuardFacts, rules: readonly GuardRule[] = D
1203
1268
  try {
1204
1269
  inputPresent = hasInput === undefined ? true : hasInput(facts);
1205
1270
  } catch (error) {
1271
+ // Бросок в предикате улик и бросок в чекере обязаны нести общий маркер fail-closed:
1272
+ // страж не смог решить и потому обвиняет, а не оправдывает; какой перехватчик был первым, потребителю неважно.
1206
1273
  violations.push({
1207
1274
  rule: r.id, severity: r.severity,
1208
- detail: `улики правила нечитаемы (${error instanceof Error ? error.message : String(error)}) — правило считается НАРУШЕННЫМ, а не пройденным`,
1275
+ detail: `улики правила нечитаемы (${error instanceof Error ? error.message : String(error)}) — правило считается НАРУШЕННЫМ, а не пройденным (fail-closed)`,
1209
1276
  });
1210
1277
  checked.push(r.id);
1211
1278
  continue;
@@ -1291,10 +1358,30 @@ export function evaluateGuard(facts: GuardFacts, rules: readonly GuardRule[] = D
1291
1358
  }
1292
1359
  const skipped = facts.secretScan?.skipped;
1293
1360
  if (typeof skipped === 'number' && Number.isFinite(skipped) && skipped > 0) {
1294
- notes.push(`no-secrets: ${skipped} packed inventory item(s) not scanned (oversize/unreadable/binary) — the secret scan is fail-open, so this is a coverage gap on the record, not a violation`);
1361
+ // MEASURED 2026-09-21: the count alone hid that harness-cli's own src/cli.ts and dist/cli.js
1362
+ // (1.3 MiB each, both packed) are among the never-scanned items — name them, capped.
1363
+ // Codex review (B): the legacy sentence stays byte-identical for consumers that match it whole;
1364
+ // the names are APPENDED after it. "…and N more" counts from the FACT's `skipped`, not from the
1365
+ // list length, so a partial list never understates the gap.
1366
+ const paths = facts.secretScan?.skippedPaths ?? [];
1367
+ const shown = paths.slice(0, 6);
1368
+ const rest = Math.max(skipped - shown.length, 0);
1369
+ const named = shown.length > 0
1370
+ ? ` — skipped: ${shown.join(', ')}${rest > 0 ? `, …and ${rest} more` : ''}`
1371
+ : '';
1372
+ // Codex r2: NO trailing period — the legacy sentence must stay byte-identical, names follow after a space.
1373
+ notes.push(`no-secrets: ${skipped} packed inventory item(s) not scanned (oversize/unreadable/binary) — the secret scan is fail-open, so this is a coverage gap on the record, not a violation${named}`);
1374
+ }
1375
+ // Lead fix (guard-change-fact regression): a fixture with zero publishable packages yields an EMPTY
1376
+ // inventory string — 'present but empty' is not a source to report; only a non-empty summary is a line.
1377
+ const inventoryLine = facts.secretScan?.inventory;
1378
+ if (typeof inventoryLine === 'string' && inventoryLine.trim().length > 0) {
1379
+ notes.push(`no-secrets: inventory: ${inventoryLine}`);
1295
1380
  }
1296
1381
  }
1297
1382
  if (checked.includes('review-round') && facts.reviewRound?.gathered === false) {
1383
+ // A wholly absent fact is already NOT-ESTABLISHED via HAS_INPUT; this note covers an explicit
1384
+ // failed gathering attempt on a present fact only.
1298
1385
  // A HARD gate that passes SILENTLY when it could not gather its evidence is a gate you cannot
1299
1386
  // tell from one that checked and approved (raised by cross-family review). It still does not
1300
1387
  // BLOCK — absence of facts is ignorance, not an accusation, and blocking every non-git checkout
package/src/index.ts CHANGED
@@ -118,6 +118,7 @@ export * from './trace-bundle.js';
118
118
  export { BLOBS as LOOP_BLOBS, LOOP_BLOB_NAMES, BLOB_COVERAGE_MANIFEST } from './loop-blobs.generated.js';
119
119
  export type { LoopBlob } from './loop-blobs.generated.js';
120
120
  export * from './sign.js';
121
+ export * from './pack-inventory.js';
121
122
  export * from './publish-source-scope.js';
122
123
  export * from './swarm-brief.js';
123
124
  export * from './skill-schema.js';
@@ -125,7 +126,7 @@ export { createSkill } from './create-skill.js';
125
126
  export type { CreateSkillOptions, CreateSkillResult } from './create-skill.js';
126
127
  export { checkUpstream, checkAllUpstream, discoverSourcePackages, loadSourcesManifest } from './sync-upstream.js';
127
128
  export type { SyncUpstreamReport, UpstreamCheckResult, SourcesManifest, SourcePackageInfo } from './sync-upstream.js';
128
- export { sweepSkillDrift, syncCanonicalSkill } from './skill-drift.js';
129
+ export { sweepSkillDrift, syncCanonicalSkill, findCanonicalDefects } from './skill-drift.js';
129
130
  export { SKILL_INSTALL_ROOTS, SKILL_INSTALL_ROOT_BY_TARGET, DEV_SKILL_ROOT, TARGET_ENRICHMENT_ASSETS } from './skill-install-roots.js';
130
131
  export { stampCheckpointLine } from './checkpoint-stamp.js';
131
132
  export { TELEMETRY_VOCAB_VERSION, TELEMETRY_FIELDS, PROVISIONAL_TELEMETRY_FIELDS, LOCAL_FIELD_ALIASES, RUN_OUTCOMES, telemetryFieldFor, runOutcomeOf } from './telemetry-vocabulary.js';
@@ -137,7 +138,7 @@ export { projectSkillsOneRoot, projectSkillsProbeCommand } from './project-skill
137
138
  export { isRepoBoundary } from './repo-boundary.js';
138
139
  export type { RepoBoundaryIo } from './repo-boundary.js';
139
140
  export type { LedgerBackfillPlan, LedgerBackfillRow, RunCostFacts } from './ledger-backfill.js';
140
- export type { SweepResult, DriftedSkill, SyncResult, SyncCanonicalOptions } from './skill-drift.js';
141
+ export type { SweepResult, DriftedSkill, SyncResult, SyncCanonicalOptions, CanonicalDefect, CanonicalDefectKind } from './skill-drift.js';
141
142
  export { benchmarkSkill, benchmarkSkills, compareSkills } from './benchmark.js';
142
143
  export { buildRegistry, buildShowcaseRegistry, searchRegistry, filterByCategory, skillPackBaseDirs, discoverSkillPackDirs, discoverSkillCarryingDirs, discoverVerifiablePackDirs, packScope, verifiedScopeNote } from './registry.js';
143
144
  export { tokenize, stemToken, stems } from './stem.js';
@@ -149,9 +150,9 @@ export { recommend } from './recommend.js';
149
150
  export { pretrain } from './pretrain.js';
150
151
  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';
151
152
  export { normalizeLessonForms, validateClassTemplate, lessonPairIdOf, mergeLessonFormHits, mergeLessonMatchedForms } from './lesson-generalization.js';
152
- export { withStoreLock, withStoreLockSync, storeLockPath, StoreLockTimeoutError, StoreLockCompromisedError, STALE_LOCK_MS, LOCK_TIMEOUT_MS } from './store-lock.js';
153
+ export { withStoreLock, withStoreLockSync, storeLockPath, storeExists, StoreAbsentError, StoreLockTimeoutError, StoreLockCompromisedError, STALE_LOCK_MS, LOCK_TIMEOUT_MS } from './store-lock.js';
153
154
  export type { StoreLockOptions } from './store-lock.js';
154
- export { withNamedLockSync, namedLockPath, isSafeLockName, NamedLockNameError, NamedLockTimeoutError, NamedLockCompromisedError } from './named-lock.js';
155
+ export { withProjectLockSync, withDirLockSync, namedLockPath, isSafeLockName, NamedLockNameError, NamedLockTimeoutError, NamedLockCompromisedError } from './named-lock.js';
155
156
  export {
156
157
  QE_BRIDGE_SCHEMA,
157
158
  QE_BRIDGE_FAILURE_SCHEMA,
@@ -603,8 +604,11 @@ export {
603
604
  extractReportGrade,
604
605
  settleReqeDebt,
605
606
  renderReqeList,
607
+ countReqeByCause,
606
608
  } from './reqe.js';
607
- export type { ModelFamily, ReqeEmitDecision, ReqeDebt, ReqeBrief, ReqeSettlement } from './reqe.js';
609
+ export type { ModelFamily, ReqeCause, ReqeRungState, ReqeBridgeFacts, ReqeEmitInput, ReqeEmitDecision, ReqeDebt, ReqeBrief, ReqeSettlement } from './reqe.js';
610
+ export { classifyReqeFindings, parsePriorFindings, buildReqeVerdict } from './reqe-verdict.js';
611
+ export type { ReqeVerdict, ReqeFindingsClass, ReqeSeverity, ReqeSeverityCounts, ReqeBlockingRow, ReqePriorVerdict } from './reqe-verdict.js';
608
612
  export {
609
613
  detectQueryLang,
610
614
  relevanceFloorFor,
@@ -1156,6 +1160,14 @@ export * from './skills-verify.js';
1156
1160
  // from darwin-mode; measurements are dz-native and NEVER fake a verdict (INSUFFICIENT_DATA is a
1157
1161
  // finding, not a pass).
1158
1162
  export * from './compounding.js';
1163
+ // Дополнительная метрика доведённой работы: публичные функции и типы.
1164
+ export { joinLessonOutcomes, renderLessonOutcomes } from './compounding.js';
1165
+ export type {
1166
+ LessonOutcomeRow,
1167
+ LessonOutcomeCounters,
1168
+ LessonOutcomeCoverage,
1169
+ JoinedLessonOutcomes,
1170
+ } from './compounding.js';
1159
1171
 
1160
1172
  // Advisory command-invocation telemetry + deadwood report (feature dz-deadwood).
1161
1173
  export * from './cmd-usage.js';
@@ -1349,3 +1361,9 @@ export * from './cross-family-control.js';
1349
1361
 
1350
1362
  export { debtRatchetVerdict, parsePinnedCeiling, ceilingUnreadableMessage } from './debt-ratchet.js';
1351
1363
  export type { DebtRatchetArgs, DebtRatchetVerdict, PinnedCeiling } from './debt-ratchet.js';
1364
+
1365
+ // Квитанция зелёного прогона: проверка на PUSH вместо проверки на коммите (развилка 4, 21.09).
1366
+ export { decideTestReceipt, renderTestReceiptVerdict } from './test-receipt.js';
1367
+ export type { TestReceipt, TestReceiptVerdict, TestReceiptScopeState } from './test-receipt.js';
1368
+ export { decideBackupFreshness, renderBackupFreshness } from './backup-freshness.js';
1369
+ export type { BackupFreshnessVerdict } from './backup-freshness.js';
@@ -20,7 +20,7 @@ import { dirname, join, relative, resolve, sep } from 'node:path';
20
20
 
21
21
  import { canonicalIntegrationJson, parseStrictJson, type CarrierFragment, type IntegrationReasonCode } from '@dzhechkov/core';
22
22
 
23
- import { withNamedLockSync } from './named-lock.js';
23
+ import { withProjectLockSync } from './named-lock.js';
24
24
 
25
25
  const MAX_CARRIER_BYTES = 1024 * 1024;
26
26
  const JOURNAL_REL = '.dz/integrations-ownership.json';
@@ -226,7 +226,7 @@ export function applyIntegrationFragments(options: ApplyIntegrationOptions): Int
226
226
  }
227
227
  if (groups.size === 0) return { written: [], alreadyCurrent: [], journalPath: JOURNAL_REL };
228
228
 
229
- return withNamedLockSync(root, 'integrations', () => {
229
+ return withProjectLockSync(root, 'integrations', () => {
230
230
  const rootBefore = statSync(root);
231
231
  const dzBefore = statSync(join(root, '.dz'));
232
232
  const journalRead = readBoundedJson(journalPath, JOURNAL_REL);
@@ -37,7 +37,7 @@ import { randomBytes } from 'node:crypto';
37
37
  import { join } from 'node:path';
38
38
 
39
39
  import { LessonBandit, type ArmStats, type SerializedBanditState } from './lesson-bandit.js';
40
- import { withNamedLockSync } from './named-lock.js';
40
+ import { withProjectLockSync } from './named-lock.js';
41
41
  import { readMemoryLearningConfig, type MemoryLearningConfig } from './patterns.js';
42
42
 
43
43
  // ── Paths and constants ─────────────────────────────────────────────────────────────────────────
@@ -360,7 +360,7 @@ export interface BanditWriteOutcome {
360
360
  *
361
361
  * **Why the lock and not just the atomic rename.** MEASURED in this repo (2026-08-19, four
362
362
  * barrier-synchronised writers on one JSON file, all exit 0): ONE of four updates survived without a
363
- * lock; all four survived with `withNamedLockSync`. Temp+rename prevents a TORN read; it does
363
+ * lock; all four survived with `withProjectLockSync`. Temp+rename prevents a TORN read; it does
364
364
  * nothing about a LOST update, and the loss is silent — both writers report success.
365
365
  *
366
366
  * No network call, no subprocess, no model turn inside the section — an advisory lock held past its
@@ -372,7 +372,7 @@ function mutateBanditState(
372
372
  mutate: (bandit: LessonBandit, env: { exposures: Record<string, Record<string, number>>; explorePulls: Record<string, Record<string, number>> }) => void,
373
373
  ): BanditWriteOutcome {
374
374
  try {
375
- return withNamedLockSync(projectRoot, BANDIT_LOCK_NAME, (): BanditWriteOutcome => {
375
+ return withProjectLockSync(projectRoot, BANDIT_LOCK_NAME, (): BanditWriteOutcome => {
376
376
  const { state, reason } = loadBanditState(projectRoot);
377
377
  if (reason === 'future-schema') {
378
378
  // Refuse to clobber a state written by a newer dz. The reward is dropped, loudly.
package/src/named-lock.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Named advisory locks — `.dz/locks/<name>.lock` (feature qe-bridge-claude, ADR-001 D4-A).
2
+ * Named advisory locks — project stores use `.dz/locks/<name>.lock`; generic directories
3
+ * use `.dz-locks/<name>.lock`, honouring an existing legacy `.dz/locks` directory.
3
4
  *
4
5
  * WHY. The pattern store already has a cross-process lock (`store-lock.ts`), but it guards ONE
5
6
  * resource. Other read-modify-write surfaces in this repo have the same lost-update shape and no
@@ -27,13 +28,13 @@
27
28
  * backstop for that case, and the rule doc says so in as many words.
28
29
  */
29
30
 
30
- import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
31
+ import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
31
32
  import { randomBytes } from 'node:crypto';
32
33
  import { createRequire } from 'node:module';
33
- import { join, resolve } from 'node:path';
34
+ import { dirname, join, resolve } from 'node:path';
34
35
  import { lockSync } from 'proper-lockfile';
35
36
 
36
- import { LOCK_TIMEOUT_MS, resolveStaleMs } from './store-lock.js';
37
+ import { LOCK_TIMEOUT_MS, resolveStaleMs, storeExists, StoreAbsentError } from './store-lock.js';
37
38
  import type { StoreLockOptions } from './store-lock.js';
38
39
 
39
40
  /** `proper-lockfile` silently clamps `stale` up to this minimum. */
@@ -172,13 +173,11 @@ function ownerMarkerPath(lockPath: string): string {
172
173
  }
173
174
 
174
175
  function tryAcquire(
175
- projectRoot: string,
176
- name: string,
176
+ lockPath: string,
177
+ resourceKey: string,
177
178
  staleMs: number,
178
179
  onCompromised: (e: Error) => void,
179
180
  ): Acquired | 'held' {
180
- const lockPath = namedLockPath(projectRoot, name);
181
- const resourceKey = resolve(projectRoot, '.dz', 'locks', name);
182
181
  try {
183
182
  const release = lockSync(resourceKey, {
184
183
  lockfilePath: lockPath,
@@ -231,21 +230,47 @@ function tryAcquire(
231
230
  * heartbeat cannot fire while a synchronous `fn` blocks the event loop, so keep bodies well under
232
231
  * `staleMs` — the same caveat `withStoreLockSync` carries.
233
232
  */
234
- export function withNamedLockSync<T>(
233
+ export function withProjectLockSync<T>(
235
234
  projectRoot: string,
236
235
  name: string,
237
236
  fn: () => T,
238
237
  opts: StoreLockOptions = {},
239
238
  ): T {
240
239
  const lockPath = namedLockPath(projectRoot, name); // validates the name BEFORE any mkdir
240
+ if (!storeExists(projectRoot)) throw new StoreAbsentError(projectRoot, name);
241
+ mkdirSync(dirname(lockPath), { recursive: true });
242
+ return withLockSync(lockPath, name, fn, opts);
243
+ }
244
+
245
+ /** A directory mutex never seeds a store; an existing legacy lock directory is shared. */
246
+ export function withDirLockSync<T>(
247
+ dir: string,
248
+ name: string,
249
+ fn: () => T,
250
+ opts: StoreLockOptions = {},
251
+ ): T {
252
+ const legacyPath = namedLockPath(dir, name); // validation precedes the filesystem question
253
+ const legacyDir = dirname(legacyPath);
254
+ let lockPath: string;
255
+ if (existsSync(legacyDir)) {
256
+ lockPath = legacyPath; // never create the legacy directory
257
+ } else {
258
+ const lockDir = join(dir, '.dz-locks');
259
+ mkdirSync(lockDir, { recursive: true });
260
+ lockPath = join(lockDir, `${name}.lock`);
261
+ }
262
+ return withLockSync(lockPath, name, fn, opts);
263
+ }
264
+
265
+ function withLockSync<T>(lockPath: string, name: string, fn: () => T, opts: StoreLockOptions): T {
266
+ const resourceKey = resolve(dirname(lockPath), name);
241
267
  const { staleMs, timeoutMs, pollMs } = resolveOpts(opts);
242
- mkdirSync(join(projectRoot, '.dz', 'locks'), { recursive: true });
243
268
  const started = Date.now();
244
269
  const deadline = started + timeoutMs;
245
270
  let compromised: Error | undefined;
246
271
  const onCompromised = (e: Error): void => { compromised = e; };
247
272
  for (;;) {
248
- const got = tryAcquire(projectRoot, name, staleMs, onCompromised);
273
+ const got = tryAcquire(lockPath, resourceKey, staleMs, onCompromised);
249
274
  if (got !== 'held') {
250
275
  let result: T;
251
276
  let outcome: 'released' | 'stolen' | 'stolen-undisarmed' = 'released';
package/src/operations.ts CHANGED
@@ -52,6 +52,7 @@ import { AGENTS_MD_BLOCK_BEGIN, mergeAgentsMd, mergeGeminiMd, mergePolicyBlock,
52
52
  import type { CanonicalSkill, EmitResult, SkillAsset } from '@dzhechkov/core';
53
53
  import { computeRiskScore } from './risk-scoring.js';
54
54
  import { checkInstrumentFreshness, checkRankingState } from './doctor-instrument.js';
55
+ import { decideBackupFreshness, renderBackupFreshness } from './backup-freshness.js';
55
56
 
56
57
  import { applyEmitResult } from './apply.js';
57
58
  import { describeSkillLoadFailure, discoverSkillIds, loadSkillFromDir } from './skills.js';
@@ -1071,6 +1072,29 @@ export async function runDoctor(options: { projectRoot: string; instrumentPath?:
1071
1072
  const nodeMajor = Number(process.versions.node.split('.')[0] ?? '0');
1072
1073
  checks.push({ name: 'node >= 20', ok: nodeMajor >= 20, detail: `node ${process.version}` });
1073
1074
 
1075
+ // Свежесть копии — отдельная ось: ok:false изменил бы код выхода doctor и заблокировал
1076
+ // автоматику публикации. Поэтому отказ копии — WARN, отсутствие свидетельства — UNKNOWN.
1077
+ const backupLogPath = process.env.DZ_BACKLOG_BACKUP_LOG ?? '/var/log/backlog-backup.log';
1078
+ try {
1079
+ const verdict = decideBackupFreshness({
1080
+ lines: readFileSync(backupLogPath, 'utf8').split(/\r?\n/),
1081
+ now: new Date(),
1082
+ maxAgeHours: 36,
1083
+ });
1084
+ checks.push({
1085
+ name: 'backlog backup',
1086
+ ok: true,
1087
+ ...(verdict.state === 'fresh' ? {} : { level: verdict.state === 'stale' ? 'warn' as const : 'unknown' as const }),
1088
+ detail: renderBackupFreshness(verdict),
1089
+ });
1090
+ } catch (error) {
1091
+ const code = (error as NodeJS.ErrnoException).code ?? 'UNKNOWN';
1092
+ const detail = code === 'ENOENT'
1093
+ ? `журнал копии не найден: ${backupLogPath}`
1094
+ : `журнал копии нечитаем: ${backupLogPath} (${code})`;
1095
+ checks.push({ name: 'backlog backup', ok: true, level: 'unknown', detail });
1096
+ }
1097
+
1074
1098
  // 2/3. MONOREPO-ONLY checks, gated on PROJECT KIND (backlog fcf29728: in a consumer project
1075
1099
  // skills-meta and the 10 adapters are not there and MUST not be — both checks were red forever
1076
1100
  // and dz doctor could never exit 0 outside this repository, a standing false BLOCK for any