@dzhechkov/harness-core 0.8.37 → 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 (164) hide show
  1. package/.dz-manifest.json +253 -133
  2. package/README.md +209 -0
  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/cross-family-control.d.ts +35 -0
  23. package/dist/cross-family-control.d.ts.map +1 -1
  24. package/dist/cross-family-control.js +49 -3
  25. package/dist/cross-family-control.js.map +1 -1
  26. package/dist/experiment-assign.d.ts +110 -0
  27. package/dist/experiment-assign.d.ts.map +1 -0
  28. package/dist/experiment-assign.js +229 -0
  29. package/dist/experiment-assign.js.map +1 -0
  30. package/dist/feature-adr-checkpoints.d.ts +7 -2
  31. package/dist/feature-adr-checkpoints.d.ts.map +1 -1
  32. package/dist/feature-adr-checkpoints.js +20 -5
  33. package/dist/feature-adr-checkpoints.js.map +1 -1
  34. package/dist/feature-adr-envelope.d.ts +12 -0
  35. package/dist/feature-adr-envelope.d.ts.map +1 -1
  36. package/dist/feature-adr-envelope.js +12 -1
  37. package/dist/feature-adr-envelope.js.map +1 -1
  38. package/dist/feature-adr-routing.d.ts +4 -0
  39. package/dist/feature-adr-routing.d.ts.map +1 -1
  40. package/dist/feature-adr-routing.js +15 -1
  41. package/dist/feature-adr-routing.js.map +1 -1
  42. package/dist/index.d.ts +12 -5
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +11 -4
  45. package/dist/index.js.map +1 -1
  46. package/dist/ledger-cost-fill.d.ts +58 -0
  47. package/dist/ledger-cost-fill.d.ts.map +1 -0
  48. package/dist/ledger-cost-fill.js +78 -0
  49. package/dist/ledger-cost-fill.js.map +1 -0
  50. package/dist/loop-blobs.generated.js +2 -2
  51. package/dist/loop-blobs.generated.js.map +1 -1
  52. package/dist/mutation-gate.d.ts +29 -0
  53. package/dist/mutation-gate.d.ts.map +1 -1
  54. package/dist/mutation-gate.js +20 -0
  55. package/dist/mutation-gate.js.map +1 -1
  56. package/dist/name-check.d.ts +20 -1
  57. package/dist/name-check.d.ts.map +1 -1
  58. package/dist/name-check.js +42 -1
  59. package/dist/name-check.js.map +1 -1
  60. package/dist/no-stubs.d.ts +10 -0
  61. package/dist/no-stubs.d.ts.map +1 -1
  62. package/dist/no-stubs.js +13 -9
  63. package/dist/no-stubs.js.map +1 -1
  64. package/dist/patterns.d.ts +24 -0
  65. package/dist/patterns.d.ts.map +1 -1
  66. package/dist/patterns.js +49 -9
  67. package/dist/patterns.js.map +1 -1
  68. package/dist/publish-source-scope.d.ts +32 -0
  69. package/dist/publish-source-scope.d.ts.map +1 -0
  70. package/dist/publish-source-scope.js +53 -0
  71. package/dist/publish-source-scope.js.map +1 -0
  72. package/dist/publish.d.ts +5 -0
  73. package/dist/publish.d.ts.map +1 -1
  74. package/dist/publish.js +32 -21
  75. package/dist/publish.js.map +1 -1
  76. package/dist/qe-bridge.d.ts +12 -1
  77. package/dist/qe-bridge.d.ts.map +1 -1
  78. package/dist/qe-bridge.js +21 -11
  79. package/dist/qe-bridge.js.map +1 -1
  80. package/dist/rake-analyzer.d.ts +10 -3
  81. package/dist/rake-analyzer.d.ts.map +1 -1
  82. package/dist/rake-analyzer.js +84 -22
  83. package/dist/rake-analyzer.js.map +1 -1
  84. package/dist/recap.d.ts.map +1 -1
  85. package/dist/recap.js +8 -4
  86. package/dist/recap.js.map +1 -1
  87. package/dist/release.d.ts +2 -1
  88. package/dist/release.d.ts.map +1 -1
  89. package/dist/release.js +18 -4
  90. package/dist/release.js.map +1 -1
  91. package/dist/reqe.d.ts.map +1 -1
  92. package/dist/reqe.js +11 -1
  93. package/dist/reqe.js.map +1 -1
  94. package/dist/review-cost.d.ts +51 -0
  95. package/dist/review-cost.d.ts.map +1 -0
  96. package/dist/review-cost.js +110 -0
  97. package/dist/review-cost.js.map +1 -0
  98. package/dist/round.d.ts +136 -3
  99. package/dist/round.d.ts.map +1 -1
  100. package/dist/round.js +215 -6
  101. package/dist/round.js.map +1 -1
  102. package/dist/run-records.d.ts +51 -0
  103. package/dist/run-records.d.ts.map +1 -1
  104. package/dist/run-records.js +148 -2
  105. package/dist/run-records.js.map +1 -1
  106. package/dist/score.d.ts.map +1 -1
  107. package/dist/score.js +8 -1
  108. package/dist/score.js.map +1 -1
  109. package/dist/sign.d.ts +23 -0
  110. package/dist/sign.d.ts.map +1 -1
  111. package/dist/sign.js +52 -0
  112. package/dist/sign.js.map +1 -1
  113. package/dist/skills-verify.d.ts +8 -5
  114. package/dist/skills-verify.d.ts.map +1 -1
  115. package/dist/skills-verify.js +59 -7
  116. package/dist/skills-verify.js.map +1 -1
  117. package/dist/store-guard-prune.d.ts +22 -0
  118. package/dist/store-guard-prune.d.ts.map +1 -0
  119. package/dist/store-guard-prune.js +54 -0
  120. package/dist/store-guard-prune.js.map +1 -0
  121. package/dist/sweep-failure-classify.d.ts +45 -0
  122. package/dist/sweep-failure-classify.d.ts.map +1 -0
  123. package/dist/sweep-failure-classify.js +90 -0
  124. package/dist/sweep-failure-classify.js.map +1 -0
  125. package/dist/workflow-run-dispatch.d.ts +14 -2
  126. package/dist/workflow-run-dispatch.d.ts.map +1 -1
  127. package/dist/workflow-run-dispatch.js +14 -2
  128. package/dist/workflow-run-dispatch.js.map +1 -1
  129. package/package.json +1 -1
  130. package/sbom.json +432 -132
  131. package/src/agentdb-index.ts +5 -5
  132. package/src/amendment-trace.ts +4 -1
  133. package/src/backlog.ts +3 -2
  134. package/src/brain.ts +8 -2
  135. package/src/bto-optimize.ts +10 -8
  136. package/src/claim-check.ts +48 -12
  137. package/src/cross-family-control.ts +81 -3
  138. package/src/experiment-assign.ts +276 -0
  139. package/src/feature-adr-checkpoints.ts +20 -5
  140. package/src/feature-adr-envelope.ts +24 -1
  141. package/src/feature-adr-routing.ts +15 -1
  142. package/src/index.ts +23 -4
  143. package/src/loop-blobs.generated.ts +2 -2
  144. package/src/mutation-gate.ts +59 -0
  145. package/src/name-check.ts +43 -1
  146. package/src/no-stubs.ts +14 -5
  147. package/src/patterns.ts +49 -8
  148. package/src/publish-source-scope.ts +53 -0
  149. package/src/publish.ts +38 -16
  150. package/src/qe-bridge.ts +30 -11
  151. package/src/rake-analyzer.ts +75 -20
  152. package/src/rake-signatures.json +98 -0
  153. package/src/recap.ts +8 -4
  154. package/src/release.ts +18 -4
  155. package/src/reqe.ts +11 -1
  156. package/src/review-cost.ts +139 -0
  157. package/src/round.ts +327 -11
  158. package/src/run-records.ts +175 -1
  159. package/src/score.ts +8 -1
  160. package/src/sign.ts +53 -0
  161. package/src/skills-verify.ts +69 -9
  162. package/src/store-guard-prune.ts +81 -0
  163. package/src/sweep-failure-classify.ts +88 -0
  164. package/src/workflow-run-dispatch.ts +14 -2
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. */
@@ -151,6 +154,14 @@ export interface BridgeSignoff {
151
154
  promptSha256: string;
152
155
  elapsedMs: number;
153
156
  emittedAt: string;
157
+ /** experiment-instrument FR-1/FR-3/T5 (ADR-001): the task identity of the slug's single open
158
+ * round at the moment this signoff was emitted — filled by the cli (it owns `.dz/rounds/`), null
159
+ * when there was no open round to fill from, never guessed. Optional so a signoff written before
160
+ * this feature landed parses unchanged (NFR-1). */
161
+ taskId?: string | null;
162
+ /** experiment-instrument FR-1/FR-3/T5: present whenever `taskId` above was filled from a lookup —
163
+ * names where it came from (`readOpenRoundTaskId`'s own source, r1-1/r1-2 extended to five values). */
164
+ taskIdSource?: 'open-round' | 'derived-legacy' | 'no-open-round' | 'ambiguous' | 'unavailable';
154
165
  }
155
166
 
156
167
  /** Where each channel was found, for the audit bundle: an auditor can re-derive the verdict from
@@ -337,7 +348,7 @@ export function defangSignoffEchoes(text: string): string {
337
348
  out = out.replace(/qe-bridge-signoff/gi, '[quoted-fence-label]');
338
349
  out = out.split(BRIDGE_EXTRACT_END).join('[quoted-boundary]');
339
350
  // Line-anchored verdict lines only — prose such as "upgrade the grade later" is untouched.
340
- 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');
341
352
  return out;
342
353
  }
343
354
 
@@ -402,13 +413,14 @@ export function buildBridgePrompt(
402
413
  'happen in this environment.',
403
414
  '',
404
415
  'Output format — ALL THREE parts are required, in this order, at the END of your answer:',
405
- ' (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,
406
417
  ' (b) a fenced code block labelled ' + BRIDGE_FENCE_LABEL + ' whose body is JSON:',
407
- ' {"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}]}',
408
419
  ' A genuinely clean review writes "findings": [] — but it must WRITE it.',
409
- ' (c) a final line, on its own: ' + BRIDGE_MARKER + ' grade=<A-F> findings=<n>',
410
- 'The grade in all three places must be the SAME letter — ONE capital letter A, B, C, D, E or F,',
411
- '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.',
412
424
  'Anything else is discarded as a failed call — an unparseable answer is treated as no review at',
413
425
  'all, never as a passing one.',
414
426
  '',
@@ -439,7 +451,14 @@ export function buildBridgePrompt(
439
451
 
440
452
  /** Line-anchored marker. Leading quote/list decoration is allowed on purpose: a planted marker
441
453
  * SHOULD match the grammar — and then lose to the LAST one. Anchoring is the defence, not evasion. */
442
- 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;
443
462
 
444
463
  /** Fenced `qe-bridge-signoff` block. */
445
464
  const SIGNOFF_FENCE = /^[ \t]*(?:`{3,}|~{3,})[ \t]*qe-bridge-signoff[ \t]*\r?\n([\s\S]*?)^[ \t]*(?:`{3,}|~{3,})[ \t]*$/gim;
@@ -530,7 +549,7 @@ export function parseBridgeOutput(
530
549
  // channel 1 — the LAST terminal marker line, which must be the FINAL content of the answer
531
550
  const marker = lastMatch(MARKER_LINE, text);
532
551
  if (marker === null) {
533
- 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');
534
553
  }
535
554
  const markerGrade = String(marker[1]).toUpperCase();
536
555
  const markerCount = Number(marker[2]);
@@ -568,8 +587,8 @@ export function parseBridgeOutput(
568
587
  }
569
588
  const obj = parsedJson as Record<string, unknown>;
570
589
  const jsonGradeRaw = obj['grade'];
571
- if (typeof jsonGradeRaw !== 'string' || !/^[A-Fa-f]$/.test(jsonGradeRaw.trim())) {
572
- 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) + ')');
573
592
  }
574
593
  const jsonGrade = jsonGradeRaw.trim().toUpperCase();
575
594
  const validated = validateFindings(obj['findings']);
@@ -15,6 +15,7 @@
15
15
  */
16
16
 
17
17
  import { existsSync, readFileSync, readdirSync, statSync, realpathSync } from 'node:fs';
18
+ import { maskMarkdown } from './markdown-masker.js';
18
19
  import { join } from 'node:path';
19
20
 
20
21
  export type Severity = 'blocker' | 'high' | 'medium' | 'low' | 'unknown';
@@ -47,28 +48,67 @@ export interface RakeReport {
47
48
  }
48
49
 
49
50
  export interface RakeSignature { readonly id: string; readonly label: string; readonly patterns: readonly RegExp[] }
51
+ export interface RakeSignatureSpec { readonly id: string; readonly label: string; readonly patterns: readonly string[]; readonly flags?: string }
52
+
53
+ /**
54
+ * Decode the data-only signature table. This fails closed: stateful `g`/`y` patterns would make
55
+ * RegExp.test() depend on lastIndex, which breaks signatureOf's determinism.
56
+ */
57
+ export function loadRakeSignatures(raw: unknown): readonly RakeSignature[] {
58
+ if (!Array.isArray(raw)) throw new Error('Rake signatures: expected an array');
59
+ const signatures = raw.map((entry, index): RakeSignature => {
60
+ const name = typeof entry === 'object' && entry !== null && typeof (entry as { id?: unknown }).id === 'string'
61
+ ? (entry as { id: string }).id
62
+ : `entry #${index}`;
63
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) throw new Error(`Rake signature ${name}: expected an object`);
64
+ const spec = entry as Partial<RakeSignatureSpec>;
65
+ if (typeof spec.id !== 'string' || spec.id === '') throw new Error(`Rake signature ${name}: missing id`);
66
+ if (typeof spec.label !== 'string' || spec.label === '') throw new Error(`Rake signature ${name}: missing label`);
67
+ if (!Array.isArray(spec.patterns) || spec.patterns.length === 0 || spec.patterns.some((p) => typeof p !== 'string' || p === '')) {
68
+ throw new Error(`Rake signature ${name}: patterns must be a non-empty string array`);
69
+ }
70
+ const flags = spec.flags ?? '';
71
+ if (typeof flags !== 'string') throw new Error(`Rake signature ${name}: flags must be a string`);
72
+ if (/[gy]/.test(flags)) throw new Error(`Rake signature ${name}: stateful g/y flags are forbidden`);
73
+ let patterns: RegExp[];
74
+ try { patterns = spec.patterns.map((pattern) => new RegExp(pattern, flags)); }
75
+ catch (error) { throw new Error(`Rake signature ${name}: invalid regular expression (${error instanceof Error ? error.message : String(error)})`); }
76
+ for (const pattern of patterns) Object.freeze(pattern);
77
+ return Object.freeze({ id: spec.id, label: spec.label, patterns: Object.freeze(patterns) });
78
+ });
79
+ return Object.freeze(signatures);
80
+ }
50
81
 
51
82
  /**
52
83
  * Known rake classes (extensible, data-only). Seeded from the classes that actually recur in this repo's
53
84
  * QE reports — that IS the dogfood. First match in order wins; unmatched → normalized-text bucket.
54
85
  */
55
- export const RAKE_SIGNATURES: readonly RakeSignature[] = [
56
- { id: 'esm-require-footgun', label: 'ESM lazy require() undefined at runtime', patterns: [/require\(['"]node:/, /\besm\b.*require/i, /lazy require/i] },
57
- { id: 'untested-adr-property', label: 'ADR-named safety property left untested', patterns: [/load-bearing.*(untested|not\s+tested|no\s+test)/i, /adr.*names.*(property|test)/i, /safety property.*test/i] },
58
- { id: 'claim-check-fp', label: 'claim-check false positive / untagged count', patterns: [/claim-check.*(false positive|\bfp\b)/i, /untagged.*(count|claim)/i, /metric term/i] },
59
- // "traversal" alone over-matches (AST/tree traversal); require a filesystem-scope token to CO-OCCUR
60
- // (or a literal `../`) — cross-model QE caught the over-match.
61
- { id: 'path-traversal', label: 'path not constrained to the repo (traversal)', patterns: [/\.\.\//, /(?=.*travers)(?=.*(repo|root|\bpath\b|director|\/etc\/))/i, /escapes.{0,12}repo/i] },
62
- { id: 'silent-drop-or-inject', label: 'silent drop / silent injection (no report)', patterns: [/silent(ly)?\s+(drop|inject|discard|dropped)/i, /no silent (injection|caps|drop)/i] },
63
- { id: 'swallow-generic-exception', label: 'generic except/catch swallows real bugs', patterns: [/except\s+Exception/i, /catch.*swallow/i, /generic (exception|catch)/i] },
64
- { id: 'determinism-hole', label: 'non-deterministic output (unsorted/clock/random)', patterns: [/non-determinis/i, /determinism hole/i, /unsorted|not sorted/i] },
65
- { id: 'cross-model-self-qe', label: 'coder self-QE instead of cross-model', patterns: [/self-qe/i, /coder.*(review|qe).*(itself|self)/i, /cross-model/i] },
66
- { id: 'malformed-input-bypass', label: 'malformed input bypasses validation', patterns: [/array.*(pass|bypass)/i, /malformed.*(bypass|pass|manifest)/i, /typeof.*object/i] },
67
- ];
68
- // Deep-freeze so an external caller can't inject a `/g`-flag regex whose `.test()` mutates lastIndex and
69
- // makes signatureOf non-deterministic (cross-model QE). None of the patterns above use `g`/`y`.
70
- for (const s of RAKE_SIGNATURES) { Object.freeze(s.patterns); Object.freeze(s); }
71
- Object.freeze(RAKE_SIGNATURES);
86
+ /**
87
+ * Таблица живёт В ПАКЕТЕ, а не в `docs/`, и путь записан ровно как у близнеца
88
+ * `slop-lint.ts` → `../src/slop-markers.json`: из собранного `dist/` он указывает в `src/`,
89
+ * который входит в `files[]` и потому ПУБЛИКУЕТСЯ. Запись бэклога предлагала
90
+ * `docs/methodology/`, но чтение стоит на верхнем уровне модуля — в опубликованном пакете
91
+ * такого пути нет, и модуль упал бы ПРИ ИМПОРТЕ, утащив за собой каждого потребителя.
92
+ * Человекочитаемое описание схемы осталось в `docs/methodology/rake-signatures.md`.
93
+ */
94
+ const RAKE_SIGNATURES_FILE = new URL('../src/rake-signatures.json', import.meta.url);
95
+ export const RAKE_SIGNATURES = loadRakeSignatures(readRakeSignaturesFile(RAKE_SIGNATURES_FILE));
96
+
97
+ /** Отдельный читатель, чтобы отсутствие или порча файла назывались СВОИМИ ИМЕНАМИ, а не
98
+ * прилетали сырым ENOENT/SyntaxError из середины загрузки модуля. */
99
+ function readRakeSignaturesFile(url: URL): unknown {
100
+ let raw: string;
101
+ try {
102
+ raw = readFileSync(url, 'utf8');
103
+ } catch (err) {
104
+ throw new Error(`rake-signatures: таблица не найдена по пути ${url.pathname} — пакет собран без src/? (${err instanceof Error ? err.message : String(err)})`);
105
+ }
106
+ try {
107
+ return JSON.parse(raw);
108
+ } catch (err) {
109
+ throw new Error(`rake-signatures: ${url.pathname} не разбирается как JSON: ${err instanceof Error ? err.message : String(err)}`);
110
+ }
111
+ }
72
112
 
73
113
  const byStr = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
74
114
  const uniqSorted = (xs: readonly string[]): string[] => [...new Set(xs)].sort(byStr);
@@ -97,8 +137,8 @@ const toSeverity = (raw: string): Severity => SEV_MAP[raw.trim().toLowerCase()]
97
137
  const SITE_RE = /([\w./-]+\.(?:ts|js|tsx|jsx|py|go|md|json|yml|yaml):\d+)/;
98
138
 
99
139
  /** The signature of a finding: first matching rule, else the normalized-text bucket. */
100
- export function signatureOf(finding: Finding): { id: string; label: string } {
101
- for (const s of RAKE_SIGNATURES) {
140
+ export function signatureOf(finding: Finding, signatures: readonly RakeSignature[] = RAKE_SIGNATURES): { id: string; label: string } {
141
+ for (const s of signatures) {
102
142
  if (s.patterns.some((p) => p.test(finding.text))) return { id: s.id, label: s.label };
103
143
  }
104
144
  const key = normalizeText(finding.text);
@@ -115,13 +155,28 @@ export function signatureOf(finding: Finding): { id: string; label: string } {
115
155
  */
116
156
  export function extractFindings(markdown: string, source: string): Finding[] {
117
157
  const out: Finding[] = [];
158
+ // Строки ВНУТРИ блока кода находками не являются: отчёт QE регулярно показывает ПРИМЕР того,
159
+ // как писать не надо, и пример оформлен той же таблицей. ИЗМЕРЕНО 2026-09-19: отчёт с одной
160
+ // настоящей находкой и одной строкой-примером в ```-блоке давал ДВЕ находки.
161
+ // Маскировщик здесь ОБЩИЙ (`markdown-masker`), тот же, что у `amendment-trace` и у гейта K2 —
162
+ // запись 8b309a04 просила один проход на весь харнесс, а не третий собственный разбор.
163
+ // Решение «считать ли строку» принимается по МАСКЕ, а текст находки берётся из ИСХОДНИКА:
164
+ // маска сохраняет разбиение на строки, но не содержимое.
165
+ // ОДИН механизм: читаем МАСКУ. Маскировщик заменяет строки внутри блока кода филлером той же
166
+ // длины и не трогает остальные, поэтому отдельный список «пропустить замаскированные» был бы
167
+ // ВТОРОЙ защитой — и мутация, снимающая только её, осталась бы эквивалентной (проверено:
168
+ // 35 тестов из 35 зелёные при «снятой» починке). Ровно тот же капкан, что сегодня в гейте K2.
169
+ const maskLines = String(maskMarkdown(markdown)).split('\n');
118
170
  const push = (severity: Severity, text: string): void => {
119
171
  const t = text.replace(/\s+/g, ' ').trim();
120
172
  if (t.length < 8) return; // too short to be a finding
121
173
  const site = SITE_RE.exec(t)?.[1];
122
174
  out.push(site ? { source, severity, text: t, site } : { source, severity, text: t });
123
175
  };
124
- for (const line of markdown.split('\n')) {
176
+ const sourceLines = markdown.split('\n');
177
+ for (let i = 0; i < sourceLines.length; i++) {
178
+ // Читаем маску, а не исходник: строка, спрятанная блоком кода, до правил ниже не доходит.
179
+ const line = maskLines[i] ?? sourceLines[i] ?? '';
125
180
  // (a) table row: | ... | <sev> | <finding> | ...
126
181
  const cells = line.includes('|') ? line.split('|').map((c) => c.trim()) : null;
127
182
  if (cells && cells.length >= 4) {
@@ -0,0 +1,98 @@
1
+ [
2
+ {
3
+ "id": "esm-require-footgun",
4
+ "label": "ESM lazy require() undefined at runtime",
5
+ "patterns": ["require\\(['\\\"]node:", "\\besm\\b.*require", "lazy require"],
6
+ "flags": "i"
7
+ },
8
+ {
9
+ "id": "untested-adr-property",
10
+ "label": "ADR-named safety property left untested",
11
+ "patterns": ["load-bearing.*(untested|not\\s+tested|no\\s+test)", "adr.*names.*(property|test)", "safety property.*test"],
12
+ "flags": "i"
13
+ },
14
+ {
15
+ "id": "claim-check-fp",
16
+ "label": "claim-check false positive / untagged count",
17
+ "patterns": ["claim-check.*(false positive|\\bfp\\b)", "untagged.*(count|claim)", "metric term"],
18
+ "flags": "i"
19
+ },
20
+ {
21
+ "id": "path-traversal",
22
+ "label": "path not constrained to the repo (traversal)",
23
+ "patterns": ["\\.\\./", "(?=.*travers)(?=.*(repo|root|\\bpath\\b|director|/etc/))", "escapes.{0,12}repo"],
24
+ "flags": "i"
25
+ },
26
+ {
27
+ "id": "silent-drop-or-inject",
28
+ "label": "silent drop / silent injection (no report)",
29
+ "patterns": ["silent(ly)?\\s+(drop|inject|discard|dropped)", "no silent (injection|caps|drop)"],
30
+ "flags": "i"
31
+ },
32
+ {
33
+ "id": "swallow-generic-exception",
34
+ "label": "generic except/catch swallows real bugs",
35
+ "patterns": ["except\\s+Exception", "catch.*swallow", "generic (exception|catch)"],
36
+ "flags": "i"
37
+ },
38
+ {
39
+ "id": "determinism-hole",
40
+ "label": "non-deterministic output (unsorted/clock/random)",
41
+ "patterns": ["non-determinis", "determinism hole", "unsorted|not sorted"],
42
+ "flags": "i"
43
+ },
44
+ {
45
+ "id": "cross-model-self-qe",
46
+ "label": "coder self-QE instead of cross-model",
47
+ "patterns": ["self-qe", "coder.*(review|qe).*(itself|self)", "cross-model", "same-family.{0,40}(qe|review)", "cross-family.{0,60}(not obtained|did not happen|fell back)"],
48
+ "flags": "i"
49
+ },
50
+ {
51
+ "id": "malformed-input-bypass",
52
+ "label": "malformed input bypasses validation",
53
+ "patterns": ["array.*(pass|bypass)", "malformed.*(bypass|pass|manifest)", "typeof.*object"],
54
+ "flags": "i"
55
+ },
56
+ {
57
+ "id": "wiring-tail",
58
+ "label": "implementation wiring tail left unfinished",
59
+ "patterns": ["wiring[-\\s]tail", "wiring-хвост", "незавершённая проводка"],
60
+ "flags": "i"
61
+ },
62
+ {
63
+ "id": "canonical-copy-edit",
64
+ "label": "edit made in a canonical copy",
65
+ "patterns": ["canonical\\s+copy", "каноническ\\w*\\s+коп", "edited in the\\s+\\*\\*canonical\\*\\*\\s+copy"],
66
+ "flags": "i"
67
+ },
68
+ {
69
+ "id": "zero-or-partial-landing",
70
+ "label": "zero or partial implementation landing",
71
+ "patterns": ["zero\\s+code\\s+landed", "genuinely-not-landed", "step\\s*7.{0,50}did not land"],
72
+ "flags": "i"
73
+ },
74
+ {
75
+ "id": "corpus-preimage-not-repinned",
76
+ "label": "corpus preimage was not re-pinned",
77
+ "patterns": ["preimage.{0,80}(never|not).{0,30}re-?pinned", "(?:registry|corpus).{0,40}(?:find|anchor).{0,80}(never|not).{0,30}re-?pinned", "find.{0,10}string was never re-pinned"],
78
+ "flags": "i"
79
+ },
80
+ {
81
+ "id": "verdict-from-proxy",
82
+ "label": "verdict inferred from a proxy instead of the subject",
83
+ "patterns": ["(?:verdict|decision|grade).{0,60}proxy", "proxy.{0,60}(?:verdict|decision|grade|property)", "asserted a\\s+\\*\\*?proxy\\*\\*?.{0,80}not the\\s+\\*\\*?property"],
84
+ "flags": "i"
85
+ },
86
+ {
87
+ "id": "single-language-book-silence",
88
+ "label": "book result silently omitted for a single-language query",
89
+ "patterns": ["books are silent", "книги молчат", "book.{0,50}(silent|silence)"],
90
+ "flags": "i"
91
+ },
92
+ {
93
+ "id": "reserved-heading-collision",
94
+ "label": "reserved heading collision",
95
+ "patterns": ["(?:name|heading).{0,12}collision.{0,60}reused.{0,8}#{1,6}\\s", "reused.{0,8}#{1,6}\\s+scenario coverage", "заголов.{0,50}(?:collision|столкнов)"],
96
+ "flags": "i"
97
+ }
98
+ ]
package/src/recap.ts CHANGED
@@ -351,16 +351,20 @@ export function buildRecap(facts: RecapFacts): RecapReport {
351
351
  const covered = coveredWindow(v, facts.window);
352
352
  const items = (facts.deliveries?.items ?? []).map(normaliseDelivery).filter((d) => withinWindow(covered, d.createdIso));
353
353
  const graded = items.filter((d): d is Extract<Delivery, { gradeStatus: 'unique' }> => d.gradeStatus === 'unique');
354
- // fix-round-1: 'invalid' (a malformed QE-VERDICT attempt) is folded into the same printed bucket
355
- // as 'ambiguous' — both are "no single grade can be trusted", just a different reason.
356
- const ambiguous = items.filter((d) => d.gradeStatus === 'ambiguous' || d.gradeStatus === 'invalid');
354
+ // recap-invalid-bucket (backlog 3bd73eec): the two were printed as ONE number, so a reader could
355
+ // not tell "the report states two grades" from "the report's verdict line is malformed" — and the
356
+ // second is a FIXABLE authoring defect in our own artifact, while the first is a real
357
+ // disagreement between reviews. `dz score --slug` already separated them; recap now does too.
358
+ const ambiguous = items.filter((d) => d.gradeStatus === 'ambiguous');
359
+ const invalid = items.filter((d) => d.gradeStatus === 'invalid');
357
360
  const ungraded = items.filter((d) => d.gradeStatus === 'none' || d.gradeStatus === 'no-report');
358
361
  const lines = sectionLines(v, facts.window, (scope) => items.length === 0
359
362
  ? emptyOrUnavailable(v, `no feature directories were created in ${scope}`)
360
363
  : [
361
364
  `${items.length} feature director${items.length === 1 ? 'y' : 'ies'} created in ${scope}`,
362
365
  `${graded.length} carry a grade an independent review stated unambiguously${graded.length > 0 ? `: ${tally(graded.map((d) => d.grade))}` : ''}`,
363
- `${ambiguous.length} have a report that states MORE THAN ONE grade or a malformed QE-VERDICT line — reported as ambiguous, never guessed`,
366
+ `${ambiguous.length} have a report that states MORE THAN ONE grade — reported as ambiguous, never guessed`,
367
+ `${invalid.length} have a findings table but a MALFORMED or missing QE-VERDICT line — the report cannot be read, which is a formatting problem rather than two reviews disagreeing`,
364
368
  `${ungraded.length} have no letter grade in their report, or no report at all`,
365
369
  'cadence is not value: this counts deliveries, not what they were worth',
366
370
  ]);
package/src/release.ts CHANGED
@@ -25,7 +25,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
25
25
  import type { Dirent } from 'node:fs';
26
26
  import { join, relative } from 'node:path';
27
27
 
28
- import { discoverPackages, orderByDependencies } from './publish.js';
28
+ import { discoverPackages, matchesPublishFilter, orderByDependencies } from './publish.js';
29
29
  import { planPackedInstallSmoke, judgePackedInstallSmoke } from './packed-install-smoke.js';
30
30
  import type { PackedInstallPlan, PackedInstallExecution } from './packed-install-smoke.js';
31
31
 
@@ -243,9 +243,14 @@ export function collectPackageFacts(monorepoRoot: string, filter?: readonly stri
243
243
  if (filter !== undefined && filter.length === 0) {
244
244
  throw new Error('release: --filter requires a non-empty list of package-name substrings (empty would match ALL packages)');
245
245
  }
246
+ // The twin of the publish guard: an empty ELEMENT matches every name, so the list being
247
+ // non-empty is not enough. Both doors must be shut or the weaker one becomes the entrance.
248
+ if (filter?.some((f) => f.length === 0)) {
249
+ throw new Error('release: --filter requires non-empty package-name substrings (empty would match ALL packages)');
250
+ }
246
251
  const discovered = discoverPackages(monorepoRoot);
247
252
  const selected =
248
- filter === undefined ? discovered : discovered.filter((p) => filter.some((f) => p.name.includes(f) || p.dir.includes(f)));
253
+ filter === undefined ? discovered : discovered.filter((p) => filter.some((f) => matchesPublishFilter(p, f, monorepoRoot)));
249
254
  const ordered = orderByDependencies(selected);
250
255
 
251
256
  return ordered.map((p) => {
@@ -727,7 +732,8 @@ export function outputTail(stdout: unknown, stderr: unknown, maxLines = 40, maxB
727
732
  * - `token`/`secret`/`password` (case-insensitive) as a `key: value` or `key=value` pair — the
728
733
  * KEY survives, only the value is replaced;
729
734
  * - `Bearer <token>` HTTP auth headers;
730
- * - vendor-prefixed tokens: `npm_…`, `ghp_…`, `sk-…`, `AKIA…`;
735
+ * - vendor-prefixed tokens: `npm_…`, `ghp_…`, `github_pat_…`, `sk-…` (hyphenated forms whole), `AKIA…`;
736
+ * - JSON keys (`"token":"…"`) and env-style names ending in TOKEN/SECRET/PASSWORD/API_KEY;
731
737
  * - long opaque strings (base64/hex-ish, `[A-Za-z0-9+/=]{32,}`) that look like a key/secret even
732
738
  * without a recognisable prefix.
733
739
  * Order matters: prefixed/labelled patterns run BEFORE the generic long-opaque-string pattern so
@@ -738,8 +744,16 @@ export function redactSecrets(text: string): string {
738
744
  out = out.replace(/\bBearer\s+\S+/gi, 'Bearer [redacted]');
739
745
  out = out.replace(/\bnpm_[A-Za-z0-9]+/g, '[redacted]');
740
746
  out = out.replace(/\bghp_[A-Za-z0-9]+/g, '[redacted]');
741
- out = out.replace(/\bsk-[A-Za-z0-9]+/g, '[redacted]');
747
+ out = out.replace(/\bgithub_pat_[A-Za-z0-9_]+/g, '[redacted]');
748
+ // `sk-proj-…` (and any other hyphenated vendor form) is redacted WHOLE — the old `[A-Za-z0-9]+` stopped
749
+ // at the first hyphen and let the tail through (MEASURED 2026-09-13, backlog 2df653d9).
750
+ out = out.replace(/\bsk-[A-Za-z0-9_-]+/g, '[redacted]');
742
751
  out = out.replace(/\bAKIA[A-Za-z0-9]+/g, '[redacted]');
752
+ // JSON keys: `"token":"…"` has a quote between key and colon, so the labelled rule below never saw it.
753
+ out = out.replace(/"(token|secret|password|api[_-]?key|access[_-]?token)"\s*:\s*"[^"]*"/gi, '"$1":"[redacted]"');
754
+ // Env-style names that END in a secret word (`GITHUB_TOKEN=`, `NPM_ACCESS_TOKEN=`): `_` is a word
755
+ // character, so `\btoken` never matched them.
756
+ out = out.replace(/\b([A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|API_KEY))(\s*[:=]\s*)(\S+)/g, '$1$2[redacted]');
743
757
  out = out.replace(/\b(token|secret|password)(\s*[:=]\s*)(\S+)/gi, '$1$2[redacted]');
744
758
  out = out.replace(/\b[A-Za-z0-9+/=]{32,}\b/g, '[redacted]');
745
759
  return out;
package/src/reqe.ts CHANGED
@@ -187,7 +187,17 @@ export interface ReqeSettlement {
187
187
  export function extractReportGrade(text: string): string | null {
188
188
  // the lookahead rejects RANGES in punctuation form (A-F, A/F) AND word form (A through F,
189
189
  // A to F) — Codex QE round-2 #6: 'GRADE A through F' must not read as grade A
190
- const matches = [...String(text ?? '').matchAll(/^\s*(?:\*{0,2}#{0,4}\s*)?GRADE\s*[:=—–-]?\s*([A-F])\b(?!\s*(?:[-–—/]|through|to|thru)\s*[A-F]\b)/gim)];
190
+ // bridge-grade-grammar (backlog ddf83072): the rest of the instrument already speaks in modifiers —
191
+ // `dz round close --grade B-` is accepted and `dz recap` tallies `A-×21` — so a review that writes
192
+ // `GRADE B-` must be readable here too. The modifier is allowed ONLY when a letter does not follow
193
+ // it, because `GRADE A-F` is a RANGE (the boilerplate "GRADE A-F + findings"), not an A-minus.
194
+ // The dash class below is the UNICODE dash family, not just the ASCII hyphen (Codex review HIGH #2):
195
+ // a reviewer's editor turns `A-F` into `A−F` (U+2212) or `A‐F` (U+2010) without asking, and a range
196
+ // written that way used to read as a bare `A`. Any dash-family character following the letter means
197
+ // the text is not a plain grade, so the extractor refuses instead of guessing which half was meant —
198
+ // the same rule that makes `GRADE B-Critical` null. A modifier is only ever its ASCII form (`+`, `-`).
199
+ // hyphen-minus - | hyphen ‐ | non-breaking ‑ | figure ‒ | en – | em — | horizontal bar ― | minus −
200
+ const matches = [...String(text ?? '').matchAll(/^\s*(?:\*{0,2}#{0,4}\s*)?GRADE\s*[:=‐-―−-]?\s*([A-F](?:[+-](?![ \t]*[A-F]\b))?)(?![A-Za-z])(?![ \t]*[-‐-―−])(?!\s*(?:through|to|thru)\s*[A-F]\b)/gim)];
191
201
  const distinct = new Set(matches.map((m) => (m[1] ?? '').toUpperCase()).filter((g) => g !== ''));
192
202
  if (distinct.size !== 1) return null;
193
203
  return [...distinct][0] ?? null;