@dzhechkov/harness-core 0.8.35 → 0.8.36

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 (43) hide show
  1. package/.dz-manifest.json +42 -42
  2. package/README.md +101 -3
  3. package/dist/apply-leg.d.ts +38 -0
  4. package/dist/apply-leg.d.ts.map +1 -1
  5. package/dist/apply-leg.js +263 -16
  6. package/dist/apply-leg.js.map +1 -1
  7. package/dist/codex-hooks-assets.d.ts.map +1 -1
  8. package/dist/codex-hooks-assets.js +67 -5
  9. package/dist/codex-hooks-assets.js.map +1 -1
  10. package/dist/codex-hooks.d.ts +13 -1
  11. package/dist/codex-hooks.d.ts.map +1 -1
  12. package/dist/codex-hooks.js +13 -1
  13. package/dist/codex-hooks.js.map +1 -1
  14. package/dist/index.d.ts +4 -3
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +2 -2
  17. package/dist/index.js.map +1 -1
  18. package/dist/operations.d.ts +1 -0
  19. package/dist/operations.d.ts.map +1 -1
  20. package/dist/operations.js +18 -2
  21. package/dist/operations.js.map +1 -1
  22. package/dist/publish.d.ts +59 -7
  23. package/dist/publish.d.ts.map +1 -1
  24. package/dist/publish.js +205 -32
  25. package/dist/publish.js.map +1 -1
  26. package/dist/release-line.d.ts +16 -0
  27. package/dist/release-line.d.ts.map +1 -1
  28. package/dist/release-line.js +31 -0
  29. package/dist/release-line.js.map +1 -1
  30. package/dist/vector-tier.d.ts +34 -3
  31. package/dist/vector-tier.d.ts.map +1 -1
  32. package/dist/vector-tier.js +105 -14
  33. package/dist/vector-tier.js.map +1 -1
  34. package/package.json +2 -2
  35. package/sbom.json +41 -41
  36. package/src/apply-leg.ts +282 -14
  37. package/src/codex-hooks-assets.ts +67 -5
  38. package/src/codex-hooks.ts +13 -1
  39. package/src/index.ts +6 -1
  40. package/src/operations.ts +18 -3
  41. package/src/publish.ts +247 -30
  42. package/src/release-line.ts +32 -0
  43. package/src/vector-tier.ts +127 -14
package/src/publish.ts CHANGED
@@ -21,7 +21,7 @@ type ExecSyncOptionsWithStringEncoding = NonNullable<Parameters<typeof execSync>
21
21
  import { createHash } from 'node:crypto';
22
22
 
23
23
  import { claimCheck } from './claim-check.js';
24
- import { rewriteReleaseLine } from './release-line.js';
24
+ import { rewriteReleaseLine, isReleaseLineToken } from './release-line.js';
25
25
  import { packedTarballName } from './packed-install-smoke.js';
26
26
 
27
27
  export type ProbeOutcome = {
@@ -54,6 +54,21 @@ export interface PublishResult {
54
54
  * unmodified `publishPackages` call is byte-compatible with pre-gate behavior.
55
55
  */
56
56
  readonly claimCheck?: { readonly findings: number; readonly high: number } | undefined;
57
+ /**
58
+ * FR-3 (feature publish-readme-stamp-scope): a preview of what `planReadmeVersionSync` would do
59
+ * (dry-run) or already did (live) to this package's own README.md — never silent about the
60
+ * lock-step sync. `lines` are the 1-based line numbers actually rewritten; `skippedHistorical` is
61
+ * a TOKEN count (changelog-region entries + tokens outside every recognised ALLOWLIST shape), not
62
+ * a line count; `historyLines` names WHERE those kept-as-history tokens sit (fix-round 1, Codex
63
+ * HIGH: "history has only an aggregate count, not locations"). Absent when the package has no
64
+ * README.md, or on an 'error' result where the sync never ran/mattered.
65
+ */
66
+ readonly readmeSync?: {
67
+ readonly rewrittenLines: number;
68
+ readonly lines: readonly number[];
69
+ readonly skippedHistorical: number;
70
+ readonly historyLines: readonly number[];
71
+ } | undefined;
57
72
  /**
58
73
  * DRY-RUN ONLY, and the reason it exists is a measured incident. A dry run short-circuits
59
74
  * BEFORE build, sign and pack (see the `opts.dryRun` branch below), so the package's own
@@ -501,15 +516,177 @@ export function orderByDependencies<T extends { name: string; dir: string }>(pkg
501
516
  return ordered;
502
517
  }
503
518
 
519
+ /** One README line the sync touched: 1-based line number, and the line before/after the rewrite. */
520
+ export interface ReadmeSyncRewrite {
521
+ readonly line: number;
522
+ readonly before: string;
523
+ readonly after: string;
524
+ }
525
+
526
+ /** The report `planReadmeVersionSync` returns — never silent about what it did and did not touch. */
527
+ export interface ReadmeVersionSyncPlan {
528
+ readonly text: string;
529
+ readonly rewritten: readonly ReadmeSyncRewrite[];
530
+ /** Count of OLD-VERSION token OCCURRENCES left untouched as history (changelog region + every
531
+ * token outside every recognised ALLOWLIST shape — FR-1). */
532
+ readonly skippedHistorical: number;
533
+ /** The 1-based line numbers carrying at least one of those kept-as-history tokens (fix-round 1,
534
+ * Codex HIGH: "history has only an aggregate count, not locations; rewritten lines have
535
+ * numbers"). Deduplicated and sorted ascending — a line with two skipped tokens appears once. */
536
+ readonly historyLines: readonly number[];
537
+ }
538
+
539
+ /**
540
+ * A line carrying this HTML comment opts BACK IN to rewriting, overriding both the changelog-region
541
+ * protection and the allowlist below — the author's explicit "this token is a stamp, not history"
542
+ * (AC-3).
543
+ */
544
+ const DZ_VERSION_MARKER = '<!-- dz:version -->';
545
+
546
+ /**
547
+ * Shape 4 of the allowlist (fix-round 1 design): the `dz publish` CLI's own example line, quoted
548
+ * verbatim in a README — `dz publish: tarball <name>@X sha256:…`. In practice every occurrence of
549
+ * this line is ALSO caught by shape 3 (the version always follows `<name>@`), so this predicate is
550
+ * mostly documentation of intent — named explicitly because the design brief calls it out as its own
551
+ * recognised shape, not an accident of shape 3's reach.
552
+ */
553
+ // Codex r2 HIGH (lead): the tarball example line grants NO whole-line permission any more — its
554
+ // only stampable token is `<name>@X`, which shape 3 (install/pin) already recognises; a trailing
555
+ // `measured on X` on the same example line stays history.
556
+
557
+ /**
558
+ * Shape 2 of the allowlist: a current-release FOOTER PREFIX — a short declarative label stamping the
559
+ * package's OWN current version (`Status: `, `Current release: `, `Current status: `, `Released as `),
560
+ * optionally preceded by a list marker or bold-open, with the label being the ENTIRE prefix up to the
561
+ * token — `Status: vX is current.` allows `vX` because nothing but the label sits before it. This is
562
+ * deliberately POSITION-AWARE (tested against the text before the token, not "does this line contain
563
+ * the word somewhere"): a line that opens with a footer label but cites an UNRELATED older version
564
+ * later in the same sentence — `Current release: 1.0.0. (Previous release (v1.1.0 / v1.0.0) …)` —
565
+ * must allow only the first token, not the second one sitting deep in a citation. `Previous release
566
+ * (vA / vB)` itself never matches at all: it opens with "Previous", not "release".
567
+ */
568
+ // Codex r2 HIGH (lead): exactly the three settled footer labels, at line start, colon required —
569
+ // `Status:`, `Version:`, `Current release:` (optional bold / list marker). `Note: X`, `Released X`
570
+ // and every other label stay history.
571
+ // The settled label set (Codex r2 HIGH, lead): `Status:`, `Version:`, `Current release:`,
572
+ // `Current status:` (colon required, optional bold / list marker) and the original footer
573
+ // sentence `Released as vX` — the shapes the 2026-08-25 tests pin. `Note: X`, `Released X on …`,
574
+ // `Status as of X` and every other label are history.
575
+ const FOOTER_STAMP_PREFIX_RE = /^\s*(?:[-*+]\s+)?(?:(?:\*\*)?(?:status|version|current release|current status)(?::\*\*|\*\*:|:)|released as)\s*$/i;
576
+
577
+ /**
578
+ * Shape 6 of the allowlist: a shields.io-style badge URL segment — `badge/npm-v0.7.7-…` or
579
+ * `badge/version-0.7.7-…`. Scoped to the literal `/badge/` marker (not a bare "-v" anywhere) so an
580
+ * unrelated hyphenated token elsewhere on the line is never mistaken for a badge.
581
+ */
582
+ // Codex r2 HIGH (lead): a badge segment counts only inside a shields.io badge URL, not any `/badge/` path.
583
+ const BADGE_SEGMENT_RE = /img\.shields\.io\/badge\/[\w.%-]*$/i;
584
+
585
+ /**
586
+ * Shape 3 of the allowlist: an install/dependency-pin context. Either the token is immediately
587
+ * preceded by `@` (`npm i @dzhechkov/harness-core@0.7.6`, the tarball example's `<name>@X`), or it
588
+ * sits in the JSON-pin shape `"<package-name>": "X"` (a `package.json`/lockfile-style dependency pin
589
+ * quoted in prose) — the design brief's "for the JSON-pin form accept `\": \"` before the token when
590
+ * the key is a package name".
591
+ */
592
+ function isInstallPinContext(line: string, tokenStart: number): boolean {
593
+ // Codex r2 HIGH (lead): `@X` counts only as `<name>@X` — a package-name character must precede the
594
+ // `@` (`thing@0.8.25`, `@scope/name@0.8.25`); a bare `see @0.8.25` stays history.
595
+ if (line[tokenStart - 1] === '@' && /[A-Za-z0-9._-]/.test(line[tokenStart - 2] ?? '')) return true;
596
+ const before = line.slice(0, tokenStart);
597
+ return /"[@A-Za-z0-9][\w./-]*"\s*:\s*"$/.test(before);
598
+ }
599
+
600
+ /**
601
+ * FR-1 (POSITIVE ALLOWLIST, not a denylist — Codex fix-round 1, 2026-09-15). Outside a changelog
602
+ * region, an old-version token occurrence rewrites ONLY when it sits in one of six recognised
603
+ * shapes — the lock-step feature this sync exists for, and NOTHING beyond it. Every shape NOT named
604
+ * here defaults to HISTORY, whatever prose it is written in: the previous design (a denylist of
605
+ * three named citation phrases — "on X", "X alike", "/ vX") was corruptible by construction, because
606
+ * ANY new prose shape citing the outgoing version ("since X", "measured against X", "X behaviour", a
607
+ * bare "X" in a sentence) rewrote by default until someone thought to deny it too. An allowlist has
608
+ * no such gap: an unrecognised shape is history by default, not by enumeration.
609
+ *
610
+ * 1. a release-line token — `` `harness-core vX` · `harness-cli vY` `` and any generalised
611
+ * `` `<name> vX` `` on the same line, including a trailing `` · `memory vZ` `` segment
612
+ * (release-line.ts `isReleaseLineToken`/`GENERIC_RELEASE_TOKEN_RE`).
613
+ * 2. a current-release FOOTER prefix (`FOOTER_STAMP_PREFIX_RE`) — position-aware, so only the
614
+ * token immediately after the label is allowed.
615
+ * 3. an install/dependency-pin context (`isInstallPinContext`).
616
+ * 4. the `dz publish: tarball <name>@X sha256:…` example line (`TARBALL_EXAMPLE_LINE_RE`).
617
+ * 5. (handled by the caller, not here) a `<!-- dz:version -->` marker forces the rewrite outright,
618
+ * overriding this predicate AND the changelog-region protection (AC-3).
619
+ * 6. a shields badge URL segment (`BADGE_SEGMENT_RE`).
620
+ */
621
+ function isAllowlistedRewriteContext(line: string, tokenStart: number, versionEnd: number): boolean {
622
+ if (isReleaseLineToken(line, tokenStart, versionEnd)) return true; // shape 1
623
+ if (FOOTER_STAMP_PREFIX_RE.test(line.slice(0, tokenStart))) return true; // shape 2
624
+ if (isInstallPinContext(line, tokenStart)) return true; // shape 3 (also covers the tarball example's `<name>@X`)
625
+ if (BADGE_SEGMENT_RE.test(line.slice(0, tokenStart))) return true; // shape 6 (shields.io only)
626
+ return false;
627
+ }
628
+
504
629
  /**
505
- * Sync a package's own README to a freshly-bumped version: every exact occurrence of the OLD
506
- * version token (optionally `v`-prefixed, word-bounded) becomes the new one.
630
+ * Plan how a README's OLD-VERSION tokens would move to NEW-VERSION — a pure function, no I/O.
507
631
  *
508
- * This kills the perpetual footer off-by-one: publish bumps package.json DURING publishing, so a
509
- * hand-synced "vX.Y.Z" status line was always one release behind on npmjs.com (or required
510
- * pre-setting the future version by hand). Exact-old-token matching keeps every other version
511
- * string (dependency pins, historical notes, examples citing other releases) untouched.
512
- * Returns the pre-sync README text for failure restore, or undefined when nothing was rewritten.
632
+ * FR-1 (allowlist, not denylist). Outside a changelog region (FR-2: EVERY entry-shaped run, not
633
+ * only the first — see `changelogRegion`), a token rewrites ONLY when `isAllowlistedRewriteContext`
634
+ * recognises its shape; every other token — historical prose of ANY form — is left untouched by
635
+ * default. A `<!-- dz:version -->` marker on the line forces the rewrite regardless of either
636
+ * protection (AC-3).
637
+ *
638
+ * FR-3 (never silent): every rewritten line is reported with its line number and before/after text;
639
+ * every token left untouched as history is counted AND located, whether the reason was the
640
+ * changelog region or simply not matching any allowlist shape.
641
+ */
642
+ export function planReadmeVersionSync(
643
+ text: string,
644
+ oldVersion: string,
645
+ newVersion: string,
646
+ ): ReadmeVersionSyncPlan {
647
+ const escaped = oldVersion.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
648
+ const token = new RegExp(`(^|[^0-9A-Za-z.])(v?)${escaped}(?![0-9])(?!\\.[0-9])`, 'g');
649
+ const lines = text.split('\n');
650
+ const history = changelogRegion(lines);
651
+ const rewritten: ReadmeSyncRewrite[] = [];
652
+ const historyLineSet = new Set<number>();
653
+ let skippedHistorical = 0;
654
+
655
+ const outLines = lines.map((line, i) => {
656
+ const forced = line.includes(DZ_VERSION_MARKER);
657
+ const lineIsHistory = history.has(i) && !forced;
658
+ let touched = false;
659
+ const after = line.replace(token, (full: string, sep: string, vPrefix: string, offset: number) => {
660
+ const tokenStart = offset + sep.length; // includes the optional 'v' — allowlist shapes need it
661
+ const versionStart = tokenStart + vPrefix.length;
662
+ const versionEnd = versionStart + oldVersion.length;
663
+ const allowed = forced || (!lineIsHistory && isAllowlistedRewriteContext(line, tokenStart, versionEnd));
664
+ if (!allowed) {
665
+ skippedHistorical++;
666
+ historyLineSet.add(i + 1);
667
+ return full;
668
+ }
669
+ touched = true;
670
+ return `${sep}${vPrefix}${newVersion}`;
671
+ });
672
+ if (touched) rewritten.push({ line: i + 1, before: line, after });
673
+ return after;
674
+ });
675
+
676
+ return {
677
+ text: outLines.join('\n'),
678
+ rewritten,
679
+ skippedHistorical,
680
+ historyLines: [...historyLineSet].sort((a, b) => a - b),
681
+ };
682
+ }
683
+
684
+ /**
685
+ * Sync a package's own README to a freshly-bumped version — a thin, atomic-write wrapper around
686
+ * `planReadmeVersionSync`. Returns the pre-sync README text for failure restore, or undefined when
687
+ * nothing was rewritten (same contract as before this function grew a real plan underneath it —
688
+ * `dz publish`'s report reads the plan via `planReadmeVersionSync` directly; this wrapper's return
689
+ * value stays exactly what its callers already depend on).
513
690
  *
514
691
  * Bootstrap invariant: exact-token matching MAINTAINS sync but cannot REPAIR pre-existing drift
515
692
  * (a footer already one release behind contains a token != oldVersion and is skipped). Bring the
@@ -519,18 +696,12 @@ export function syncReadmeVersion(dir: string, oldVersion: string, newVersion: s
519
696
  const readmePath = join(dir, 'README.md');
520
697
  if (!existsSync(readmePath)) return undefined;
521
698
  const original = readFileSync(readmePath, 'utf-8');
522
- const escaped = oldVersion.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
523
- const token = new RegExp(`(^|[^0-9A-Za-z.])(v?)${escaped}(?![0-9])(?!\\.[0-9])`, 'g');
524
- const lines = original.split('\n');
525
- const history = changelogRegion(lines);
526
- const updated = lines
527
- .map((line, i) => (history.has(i) ? line : line.replace(token, `$1$2${newVersion}`)))
528
- .join('\n');
529
- if (updated === original) return undefined;
699
+ const plan = planReadmeVersionSync(original, oldVersion, newVersion);
700
+ if (plan.text === original) return undefined;
530
701
  // Atomic: a write interrupted after truncation would leave a half-written README in the tarball
531
702
  // (cross-family review). temp + rename makes a partial file impossible.
532
703
  const tmp = readmePath + '.sync-tmp';
533
- writeFileSync(tmp, updated);
704
+ writeFileSync(tmp, plan.text);
534
705
  renameSync(tmp, readmePath);
535
706
  return original;
536
707
  }
@@ -601,21 +772,35 @@ function maskFences(lines: readonly string[]): string[] {
601
772
  * The region ENDS at the next heading rather than at end-of-file on purpose: two of these READMEs
602
773
  * carry ordinary sections after Status, and over-protecting them would silently stop the lock-step
603
774
  * sync where it is still wanted.
775
+ *
776
+ * MEASURED 2026-09-15 (00_complexity_assessment.md, feature publish-readme-stamp-scope): this
777
+ * function protected only the FIRST such run. A second `## Status` heading further down the SAME
778
+ * README opens a SECOND entry-shaped run (`memory` 0.2.21/0.2.22 sat under a later `## Status`,
779
+ * after an earlier `0.1.0` entry whose region had already ended) — and that second run was bare,
780
+ * so its entries got relabelled by the next bump exactly like the 2026-08-25 incident this function
781
+ * was written to stop. FR-2: EVERY entry-shaped run in the document is protected, not only the
782
+ * first — the scan restarts after each run ends instead of stopping there.
604
783
  */
605
784
  export function changelogRegion(lines: readonly string[]): Set<number> {
606
785
  const out = new Set<number>();
607
786
  const masked = maskFences(lines);
608
- const start = masked.findIndex((l) => ANY_ENTRY.test(l));
609
- if (start < 0) return out;
610
- // REJECTED design, recorded so it is not retried: "sync the FIRST entry, protect the rest". It
611
- // looks like it restores the lock-step for the current release, and it is unsafe in exactly the
612
- // case that produced the bug — an author who bumps WITHOUT adding a new entry has the previous
613
- // release's entry sitting first, and syncing it relabels that release's contents to the new
614
- // version. The whole region stays protected; writing the newest heading is the author's job, and
615
- // the prompt for it is that the version they type is the version they are about to publish.
616
- for (let i = start; i < masked.length; i++) {
617
- if (i > start && REGION_END.test(masked[i] as string)) break;
618
- out.add(i);
787
+ let i = 0;
788
+ while (i < masked.length) {
789
+ if (!ANY_ENTRY.test(masked[i] as string)) { i++; continue; }
790
+ const start = i;
791
+ // REJECTED design, recorded so it is not retried: "sync the FIRST entry, protect the rest". It
792
+ // looks like it restores the lock-step for the current release, and it is unsafe in exactly the
793
+ // case that produced the bug — an author who bumps WITHOUT adding a new entry has the previous
794
+ // release's entry sitting first, and syncing it relabels that release's contents to the new
795
+ // version. The whole region stays protected; writing the newest heading is the author's job, and
796
+ // the prompt for it is that the version they type is the version they are about to publish.
797
+ while (i < masked.length && !(i > start && REGION_END.test(masked[i] as string))) {
798
+ out.add(i);
799
+ i++;
800
+ }
801
+ // `i` now sits on the heading that ended this run (or at EOF) — NOT consumed, so the outer loop
802
+ // re-examines it: a heading is never itself an entry, but the very next line under it can open a
803
+ // brand-new run, which is exactly the second-`## Status` case above.
619
804
  }
620
805
  return out;
621
806
  }
@@ -734,6 +919,9 @@ export function publishPackages(
734
919
  readonly readmePath: string;
735
920
  readonly originalReadme: string | undefined;
736
921
  readonly claimCheckSummary: { findings: number; high: number } | undefined;
922
+ /** FR-3 (fix-round 1): carried through the two-pass packed transport so the readme-sync report
923
+ * reaches the FINAL 'published'/'error' result too — not only the pass-1 optimistic entry. */
924
+ readonly readmeSyncSummary: PublishResult['readmeSync'];
737
925
  }
738
926
  const pendingPacked: PendingPacked[] = [];
739
927
 
@@ -879,6 +1067,27 @@ export function publishPackages(
879
1067
  }
880
1068
  }
881
1069
 
1070
+ // FR-3 (feature publish-readme-stamp-scope): preview the README sync BEFORE the dry-run
1071
+ // short-circuit, so `--dry-run` shows what the live sync would do — never silent about it, the
1072
+ // same reasoning as the claim-check gate just above. Reading the README never blocks publish;
1073
+ // an unreadable README simply carries no readmeSync summary.
1074
+ let readmeSyncSummary: PublishResult['readmeSync'];
1075
+ try {
1076
+ const readmePath = join(pkg.dir, 'README.md');
1077
+ if (existsSync(readmePath)) {
1078
+ const text = readFileSync(readmePath, 'utf-8');
1079
+ const plan = planReadmeVersionSync(text, oldVersion, newVersion);
1080
+ readmeSyncSummary = {
1081
+ rewrittenLines: plan.rewritten.length,
1082
+ lines: plan.rewritten.map((r) => r.line),
1083
+ skippedHistorical: plan.skippedHistorical,
1084
+ historyLines: plan.historyLines,
1085
+ };
1086
+ }
1087
+ } catch {
1088
+ /* unreadable README never blocks publish or this preview */
1089
+ }
1090
+
882
1091
  if (opts.dryRun) {
883
1092
  // NOT a statement that the package would publish cleanly — only that the gates checked ABOVE
884
1093
  // this line passed. Everything below it (build, re-sign, pack, the package's own
@@ -893,6 +1102,7 @@ export function publishPackages(
893
1102
  results.push({
894
1103
  name: pkg.name, oldVersion, newVersion, status: 'skipped',
895
1104
  claimCheck: claimCheckSummary,
1105
+ readmeSync: readmeSyncSummary,
896
1106
  notVerified: NOT_VERIFIED_BY_DRY_RUN,
897
1107
  });
898
1108
  landedInBatch.add(pkg.name);
@@ -909,7 +1119,12 @@ export function publishPackages(
909
1119
  originalReadme = syncReadmeVersion(pkg.dir, oldVersion, newVersion);
910
1120
 
911
1121
  if (opts.bumpOnly) {
912
- results.push({ name: pkg.name, oldVersion, newVersion, status: 'published', claimCheck: claimCheckSummary });
1122
+ // FR-3 fix-round 1 (Codex HIGH): the readme-sync summary was previously attached only to the
1123
+ // dry-run and main-live paths — --bump-only silently omitted it even though `syncReadmeVersion`
1124
+ // just ran two lines above. Reuse the SAME preview computed before the dry-run branch: it is a
1125
+ // pure function of the same pre-sync text and the same old/new versions, so it already
1126
+ // describes exactly what the write above just did.
1127
+ results.push({ name: pkg.name, oldVersion, newVersion, status: 'published', claimCheck: claimCheckSummary, readmeSync: readmeSyncSummary });
913
1128
  continue;
914
1129
  }
915
1130
 
@@ -1037,6 +1252,7 @@ export function publishPackages(
1037
1252
  readmePath: pathJoin(pkg.dir, 'README.md'),
1038
1253
  originalReadme,
1039
1254
  claimCheckSummary,
1255
+ readmeSyncSummary,
1040
1256
  });
1041
1257
  pinVersions.set(pkg.name, newVersion);
1042
1258
  // Optimistic, mirroring the dry-run branch above: this package WILL land once the
@@ -1062,7 +1278,7 @@ export function publishPackages(
1062
1278
 
1063
1279
  const { registryProbes } = confirmPublished(pkg.name, newVersion, probeLog);
1064
1280
 
1065
- results.push({ name: pkg.name, oldVersion, newVersion, status: 'published', registryProbes, probeLog, claimCheck: claimCheckSummary });
1281
+ results.push({ name: pkg.name, oldVersion, newVersion, status: 'published', registryProbes, probeLog, claimCheck: claimCheckSummary, readmeSync: readmeSyncSummary });
1066
1282
  landedInBatch.add(pkg.name); // only an ACTUAL publish covers dependents (Codex P1)
1067
1283
  } catch (err) {
1068
1284
  // The version was written BEFORE build+publish; on any failure restore the
@@ -1182,6 +1398,7 @@ export function publishPackages(
1182
1398
  results.push({
1183
1399
  name: p.name, oldVersion: p.oldVersion, newVersion: p.newVersion, status: 'published',
1184
1400
  registryProbes, probeLog, claimCheck: p.claimCheckSummary, sha256: p.sha256,
1401
+ readmeSync: p.readmeSyncSummary, // FR-3 fix-round 1: the third publish path that was silent
1185
1402
  });
1186
1403
  // landedInBatch already carries p.name from pass 1 (optimistic) — now confirmed for real.
1187
1404
  } catch (err) {
@@ -29,3 +29,35 @@ export function rewriteReleaseLine(text: string, core: string, cli: string): str
29
29
  );
30
30
  return lines.join('\n');
31
31
  }
32
+
33
+ /**
34
+ * A generic RELEASE-LINE token: a backtick-quoted `<pkg-short-name> vX` pair, anywhere on a line —
35
+ * the shape `RELEASE_LINE_RE` names for the joint `harness-core`/`harness-cli` pair, generalised to
36
+ * ANY package name (feature publish-readme-stamp-scope, FR-1a) so a per-package README's own status
37
+ * line — `` `harness-core vX` · `harness-cli vY` · `memory vZ` `` and similar — is recognised as a
38
+ * release-line shape whatever packages it names, not only the original two, and however many trail
39
+ * after the first pair (an extra `` · `memory vZ` `` segment needs no bespoke regex of its own).
40
+ */
41
+ export const GENERIC_RELEASE_TOKEN_RE = /`[a-z][a-z0-9-]*\s+v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.]+)?`/;
42
+
43
+ /**
44
+ * Is the OLD-VERSION occurrence at `[start, end)` in `line` sitting inside a `` `<name> vX` ``
45
+ * backtick token? A POSITIVE override for `planReadmeVersionSync`'s citation heuristic: a token
46
+ * this shape matches is a release-line stamp, never a historical citation, even where it sits next
47
+ * to punctuation ("/", "on ") the citation heuristic would otherwise read as a citation cue.
48
+ */
49
+ export function isReleaseLineToken(line: string, start: number, end: number): boolean {
50
+ // Codex r2 HIGH (lead): a `` `<name> vX` `` token is a release-line stamp ONLY on a line that carries
51
+ // the JOINT release-line shape (`RELEASE_LINE_RE`); an isolated `` `memory v0.8.25` `` in a
52
+ // historical sentence is history and must not move.
53
+ // Codex r3 HIGH (lead): the permission is the joint pair PLUS the CONTIGUOUS ` · `<name> vX``
54
+ // chain that follows it — not the whole line. `` `harness-core vX` · `harness-cli vY` — historically
55
+ // `memory vZ` `` moves the first two and keeps the third (prose broke the chain).
56
+ const joint = RELEASE_LINE_RE.exec(line);
57
+ if (joint === null) return false;
58
+ const chainTail = new RegExp(`(?:\\s*·\\s*${GENERIC_RELEASE_TOKEN_RE.source})*`, 'y');
59
+ chainTail.lastIndex = joint.index + joint[0].length;
60
+ const tail = chainTail.exec(line);
61
+ const chainEnd = joint.index + joint[0].length + (tail?.[0].length ?? 0);
62
+ return joint.index <= start && end <= chainEnd;
63
+ }
@@ -1158,13 +1158,87 @@ export interface RankedPattern {
1158
1158
 
1159
1159
  const RRF_K = 60;
1160
1160
 
1161
+ /**
1162
+ * One entry in the total order every post-merge ranking step shares (feature
1163
+ * `recall-parity-tie-break`, FR-1). `evidence` is the SAME three-way rank `mergeHybridHits` has
1164
+ * always used (`both` < lexical-only < semantic-only — lower is stronger), computed once by
1165
+ * {@link evidenceRank} from a hit's `backend`.
1166
+ */
1167
+ export interface HybridOrderKey {
1168
+ readonly score: number;
1169
+ readonly evidence: number;
1170
+ readonly dzId: string;
1171
+ }
1172
+
1173
+ /** `both` outranks lexical-only outranks semantic-only (mergeHybridHits' own rule, ADR-001 AM-4). */
1174
+ export function evidenceRank(backend: RecallHit['backend']): number {
1175
+ return backend === 'both' ? 0 : backend === 'vector' ? 2 : 1;
1176
+ }
1177
+
1178
+ /**
1179
+ * The ONE deterministic total order recall uses at every step where a tie can occur: fused score
1180
+ * DESC, then EVIDENCE (both > lexical-only > semantic-only), then `dzId` ASC. `mergeHybridHits`
1181
+ * always applied exactly this rule inline; it is exported here (FR-1) so `dampQuarantined`,
1182
+ * `orderHitsForReRank` (the pre-sort `enhance()` runs before its reinforcement/bandit re-rank) and
1183
+ * any other post-merge sort can share the SAME tiebreak instead of an ad hoc score-only comparator
1184
+ * that is deterministic only because its input already arrived pre-ordered — a property that
1185
+ * silently breaks the moment an upstream step feeds it hits in a different order
1186
+ * (recall-parity-tie-break T0: MEASURED, `apply-leg-recall-parity.test.ts` AM-4, a racy
1187
+ * reinforcement-signal read, not a comparator defect, actually explained the observed tail swap —
1188
+ * this comparator is hardening kept from that round; the actual causal fix, per fix-round 1, is the
1189
+ * byte-level store snapshot AM-4 now takes, not this comparator and not an awaited flush).
1190
+ *
1191
+ * FINITE-NUMBER INVARIANT (fix-round 1, LOW finding): plain subtraction (`b.score - a.score`) is
1192
+ * NOT total over `number` — `NaN - x` is `NaN`, and the `||` chain treats a `NaN` term as falsy,
1193
+ * silently SKIPPING it and falling through to the next key as if score had never been compared.
1194
+ * Both terms below use explicit `>`/`<` comparisons instead (correct as-is for ±Infinity — IEEE 754
1195
+ * orders infinities correctly) plus an explicit NaN case: a `NaN` score or evidence is the WEAKEST
1196
+ * possible value on its own axis, so it sorts deterministically LAST, never a coincidental tie.
1197
+ */
1198
+ function compareScoreDesc(a: number, b: number): number {
1199
+ if (Number.isNaN(a) || Number.isNaN(b)) return Number.isNaN(a) && Number.isNaN(b) ? 0 : Number.isNaN(a) ? 1 : -1;
1200
+ return a > b ? -1 : a < b ? 1 : 0;
1201
+ }
1202
+ function compareEvidenceAsc(a: number, b: number): number {
1203
+ if (Number.isNaN(a) || Number.isNaN(b)) return Number.isNaN(a) && Number.isNaN(b) ? 0 : Number.isNaN(a) ? 1 : -1;
1204
+ return a < b ? -1 : a > b ? 1 : 0;
1205
+ }
1206
+ export function compareHybridHits(a: HybridOrderKey, b: HybridOrderKey): number {
1207
+ return compareScoreDesc(a.score, b.score)
1208
+ || compareEvidenceAsc(a.evidence, b.evidence)
1209
+ || (a.dzId < b.dzId ? -1 : a.dzId > b.dzId ? 1 : 0);
1210
+ }
1211
+
1212
+ /**
1213
+ * Sorts `hits` into the shared total order {@link compareHybridHits} defines — used by `enhance()`
1214
+ * BEFORE its reinforcement/bandit re-rank runs (`applyLearningSignalsWithTerms` et al.,
1215
+ * `learning-backend.ts`, out of this fix's edit scope). That re-rank sorts by an ADJUSTED score
1216
+ * with a STABLE tie-break on each hit's ORIGINAL array position — so pre-ordering the input here
1217
+ * makes any tie in the adjusted score resolve in the SAME evidence/dzId order `compareHybridHits`
1218
+ * would give directly, without touching the re-rank's own internals.
1219
+ *
1220
+ * This closes the ordering gap `enhance()` had (recall-parity-tie-break fix-round 1, HIGH finding):
1221
+ * `dampQuarantined` only ran {@link compareHybridHits} when `memory.learning.quarantine` was ON;
1222
+ * with it OFF (the default), `enhance()`'s final order was whatever the re-rank's own
1223
+ * original-index tie-break happened to preserve — invisible from the printed `score` column,
1224
+ * because the re-rank reorders the hit array but never rewrites `.score`. Real (non-tied) score
1225
+ * differences from reinforcement/bandit re-ranking are UNCHANGED by this — it only decides ties.
1226
+ */
1227
+ export function orderHitsForReRank(hits: readonly HybridHit[], idOf: (p: PatternRecord) => string): HybridHit[] {
1228
+ return [...hits].sort((a, b) => compareHybridHits(
1229
+ { score: a.score, evidence: evidenceRank(a.backend), dzId: idOf(a.pattern) },
1230
+ { score: b.score, evidence: evidenceRank(b.backend), dzId: idOf(b.pattern) },
1231
+ ));
1232
+ }
1233
+
1161
1234
  /**
1162
1235
  * Reciprocal Rank Fusion merge: `score(p) = Σ 1/(60 + rank)` over the lists containing `p`
1163
1236
  * (semantic ranks weighted by `semanticWeight`). Dedup by id; `backend: 'both'` when a pattern
1164
1237
  *
1165
- * Ordering: fused score, then EVIDENCE (`both` before lexical-only before semantic-only), then id.
1166
- * When `semanticWeight > 1` the lexical top-1 is guaranteed a place in the result, taken from the
1167
- * last seat unless that seat holds a `both` hit. See `features/semantic-keeps-exact-hits`.
1238
+ * Ordering: fused score, then EVIDENCE (`both` before lexical-only before semantic-only), then id
1239
+ * — {@link compareHybridHits}. When `semanticWeight > 1` the lexical top-1 is guaranteed a place in
1240
+ * the result, taken from the last seat unless that seat holds a `both` hit. See
1241
+ * `features/semantic-keeps-exact-hits`.
1168
1242
  *
1169
1243
  * appears in both lists. DETERMINISTIC (AC-6): ties break on id, so fixed inputs always yield
1170
1244
  * the same ordering. Pure — no I/O.
@@ -1196,12 +1270,14 @@ export function mergeHybridHits(
1196
1270
  });
1197
1271
  // Ties break by EVIDENCE, not by the id alphabet: a hit both legs found outranks one only a single
1198
1272
  // leg found. Before this, an exact-term match lost a tie to an arbitrary semantic hit purely
1199
- // because its id sorted later (ADR-001 AM-4).
1200
- const evidence = (v: Acc): number => (v.lex !== undefined && v.sem ? 0 : v.lex !== undefined ? 1 : 2);
1273
+ // because its id sorted later (ADR-001 AM-4). Now routed through the shared {@link
1274
+ // compareHybridHits} (FR-1) — same three-part rule, no behaviour change.
1275
+ const evidenceOfAcc = (v: Acc): number => evidenceRank(v.lex !== undefined && v.sem ? 'both' : v.lex ?? 'vector');
1201
1276
  const ordered = [...acc.entries()]
1202
- .sort((a, b) => b[1].score - a[1].score
1203
- || evidence(a[1]) - evidence(b[1])
1204
- || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
1277
+ .sort((a, b) => compareHybridHits(
1278
+ { score: a[1].score, evidence: evidenceOfAcc(a[1]), dzId: a[0] },
1279
+ { score: b[1].score, evidence: evidenceOfAcc(b[1]), dzId: b[0] },
1280
+ ));
1205
1281
  const toHit = ([, v]: [string, Acc]): HybridHit => ({
1206
1282
  pattern: v.pattern,
1207
1283
  backend: v.lex !== undefined && v.sem ? ('both' as const) : v.lex ?? ('vector' as const),
@@ -1256,6 +1332,27 @@ export function mergeHybridHits(
1256
1332
  *
1257
1333
  * `bandit` is passed ONLY when `memory.learning.banditRerank` is armed; when it is absent this
1258
1334
  * function is byte-identical to its pre-feature self — no state file, no lock, no allocation.
1335
+ *
1336
+ * `backend.train()` fires FIRE-AND-FORGET (`void backend.train().catch(() => undefined)`) — this is
1337
+ * a REVERT (recall-parity-tie-break, fix-round 1, MEDIUM finding). An intermediate version of this
1338
+ * fix AWAITED the flush, on the theory that a caller's own reinforcement write landing before it got
1339
+ * an answer would remove the race `apply-leg-recall-parity.test.ts` AM-4 was catching (two tail hits
1340
+ * swapping order between the daemon's `op:recall` reply and a `dz recall --json` invoked a moment
1341
+ * later — MEASURED byte-identical `score` fields, only `uses` differed, so the merge/comparator was
1342
+ * never the cause). MEASURED (lead, 2026-09-15 15:57, temp project, 8 lexical hits, 5 warm runs):
1343
+ * `recallHybrid` took 2 ms with `onRecallHits:false` (no flush at all) vs 57–131 ms with the flush
1344
+ * AWAITED — landing INSIDE the hook's 500 ms `HOOK_RECALL_BUDGET_MS` but a real, avoidable tax on
1345
+ * every recall, for a property the await did not even fully deliver: the awaited write still let a
1346
+ * CLI invoked immediately after the daemon see the DAEMON'S OWN just-computed exposure for that same
1347
+ * query, answering a subtly different question than the daemon had. AM-4 now proves parity by taking
1348
+ * a byte-level SNAPSHOT of the store BEFORE each query's daemon call and pointing `dz recall --json`
1349
+ * at the frozen snapshot (`--project <snapshot>`) — both sides then answer the identical question
1350
+ * from the identical state under PRODUCTION defaults (`onRecallHits` ON), and no write, awaited or
1351
+ * not, can reach the CLI's read. That snapshot is what actually closes the race; this function stays
1352
+ * fire-and-forget, exactly as it always was, because the snapshot makes its timing irrelevant to the
1353
+ * test. `test/lesson-bandit-byte-identity.test.ts` documents the same underlying race in its own
1354
+ * fixture comment and works around it with `onRecallHits: false` there — a narrower, still-valid
1355
+ * isolation for a different test's needs.
1259
1356
  */
1260
1357
  function markRecallHits(
1261
1358
  projectRoot: string,
@@ -1356,7 +1453,15 @@ export async function recallHybrid(
1356
1453
  const q = rec !== undefined && readQuarantineState(rec).quarantined;
1357
1454
  return q ? { ...h, score: h.score * memCfg.quarantineDamp, quarantined: true as const } : h;
1358
1455
  })
1359
- .sort((a, b) => b.score - a.score);
1456
+ // FR-1 (recall-parity-tie-break): score-only used to rely on the INCOMING array already
1457
+ // being pre-ordered (native Array.sort is stable, so a genuine tie only stayed put by
1458
+ // accident of arrival order). Routed through the same {@link compareHybridHits} the merge
1459
+ // uses, so damping two equally-scored hits can never reorder them differently from how the
1460
+ // merge itself would have.
1461
+ .sort((a, b) => compareHybridHits(
1462
+ { score: a.score, evidence: evidenceRank(a.backend), dzId: idOf(a.pattern) },
1463
+ { score: b.score, evidence: evidenceRank(b.backend), dzId: idOf(b.pattern) },
1464
+ ));
1360
1465
  };
1361
1466
  // lesson-bandit-rerank (ADR-001): the payoff axis. Resolved ONCE per recall; `enabled:false` ⇒
1362
1467
  // the Lesson Payoff context is NEVER CONSTRUCTED — the branch is taken BEFORE any work, so the
@@ -1366,7 +1471,15 @@ export async function recallHybrid(
1366
1471
  let banditReport: BanditRecallReport | undefined;
1367
1472
  let banditExplored: readonly string[] = [];
1368
1473
  const enhance = (hits: readonly HybridHit[]): HybridHit[] => {
1369
- const candidates = hits.map((h) => {
1474
+ // FR-1 (recall-parity-tie-break, fix-round 1, HIGH finding): pre-sort into the shared total
1475
+ // order BEFORE the reinforcement/bandit re-rank runs — see {@link orderHitsForReRank}.
1476
+ // Why the pre-sort is sufficient and not "reliance on a stable sort" (Codex round 2): the
1477
+ // re-rank's own sort in learning-backend.ts is `b.adjusted - a.adjusted || a.i - b.i` — an
1478
+ // EXPLICIT tie-break on the incoming index, so an adjusted-score tie resolves to exactly the
1479
+ // order built here (score → evidence → dzId), by construction, on any engine. That generic
1480
+ // function only knows `score`, so it cannot call compareHybridHits itself.
1481
+ const ordered = orderHitsForReRank(hits, idOf);
1482
+ const candidates = ordered.map((h) => {
1370
1483
  const dzId = idOf(h.pattern);
1371
1484
  const rec = idToRecord.get(dzId);
1372
1485
  return { dzId, score: h.score, reinforcement: rec !== undefined ? readReinforcementState(rec) : undefined };
@@ -1392,8 +1505,8 @@ export async function recallHybrid(
1392
1505
  ? []
1393
1506
  : [{ id: 'delta', byIndex: candidates.map((c) => deltaMap.get(c.dzId) ?? 0), cap: REINFORCE_RRF_CAP }];
1394
1507
  // The SAME ranking without the payoff term — the only honest way to say what the term moved.
1395
- const before = dampQuarantined(applyLearningSignalsWithTerms(hits, learning, candidates, REINFORCE_RRF_CAP, baseTerms));
1396
- const after = dampQuarantined(applyLearningSignalsWithTerms(hits, learning, candidates, REINFORCE_RRF_CAP, [
1508
+ const before = dampQuarantined(applyLearningSignalsWithTerms(ordered, learning, candidates, REINFORCE_RRF_CAP, baseTerms));
1509
+ const after = dampQuarantined(applyLearningSignalsWithTerms(ordered, learning, candidates, REINFORCE_RRF_CAP, [
1397
1510
  ...baseTerms,
1398
1511
  // ADDED, never assigned, and pre-bounded to [-1,+1] by the ACL — so `squash` is identity and
1399
1512
  // `cap` is an EXACT bound on this term's contribution (INV-4).
@@ -1423,9 +1536,9 @@ export async function recallHybrid(
1423
1536
  }
1424
1537
  if (deltaMap !== undefined) {
1425
1538
  const deltaByIndex = candidates.map((c) => deltaMap.get(c.dzId) ?? 0);
1426
- return dampQuarantined(applyLearningSignalsWithDelta(hits, learning, candidates, REINFORCE_RRF_CAP, deltaByIndex, REINFORCE_RRF_CAP));
1539
+ return dampQuarantined(applyLearningSignalsWithDelta(ordered, learning, candidates, REINFORCE_RRF_CAP, deltaByIndex, REINFORCE_RRF_CAP));
1427
1540
  }
1428
- return dampQuarantined(applyLearningSignals(hits, learning, candidates, REINFORCE_RRF_CAP));
1541
+ return dampQuarantined(applyLearningSignals(ordered, learning, candidates, REINFORCE_RRF_CAP));
1429
1542
  };
1430
1543
  /** The exposure/telemetry payload for `markRecallHits` — `undefined` while disarmed (INV-1). */
1431
1544
  const banditEmission = (): { readonly contextKey: string; readonly explored: readonly string[]; readonly moved: number; readonly arms: number; readonly deferred?: boolean } | undefined =>