@dzhechkov/harness-core 0.8.34 → 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.
- package/.dz-manifest.json +61 -61
- package/README.md +255 -12
- package/dist/agentdb-index.d.ts +22 -1
- package/dist/agentdb-index.d.ts.map +1 -1
- package/dist/agentdb-index.js +156 -6
- package/dist/agentdb-index.js.map +1 -1
- package/dist/apply-leg.d.ts +180 -2
- package/dist/apply-leg.d.ts.map +1 -1
- package/dist/apply-leg.js +781 -38
- package/dist/apply-leg.js.map +1 -1
- package/dist/codex-hooks-assets.d.ts.map +1 -1
- package/dist/codex-hooks-assets.js +67 -5
- package/dist/codex-hooks-assets.js.map +1 -1
- package/dist/codex-hooks.d.ts +13 -1
- package/dist/codex-hooks.d.ts.map +1 -1
- package/dist/codex-hooks.js +13 -1
- package/dist/codex-hooks.js.map +1 -1
- package/dist/index.d.ts +8 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -4
- package/dist/index.js.map +1 -1
- package/dist/mutation-gate.d.ts +19 -0
- package/dist/mutation-gate.d.ts.map +1 -1
- package/dist/mutation-gate.js +37 -1
- package/dist/mutation-gate.js.map +1 -1
- package/dist/operations.d.ts +17 -1
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +88 -9
- package/dist/operations.js.map +1 -1
- package/dist/publish.d.ts +59 -7
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +205 -32
- package/dist/publish.js.map +1 -1
- package/dist/release-line.d.ts +16 -0
- package/dist/release-line.d.ts.map +1 -1
- package/dist/release-line.js +31 -0
- package/dist/release-line.js.map +1 -1
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +90 -14
- package/dist/setup.js.map +1 -1
- package/dist/skills.d.ts +87 -3
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +266 -15
- package/dist/skills.js.map +1 -1
- package/dist/vector-tier.d.ts +34 -3
- package/dist/vector-tier.d.ts.map +1 -1
- package/dist/vector-tier.js +117 -22
- package/dist/vector-tier.js.map +1 -1
- package/package.json +2 -2
- package/sbom.json +60 -60
- package/src/agentdb-index.ts +158 -7
- package/src/apply-leg.ts +824 -38
- package/src/codex-hooks-assets.ts +67 -5
- package/src/codex-hooks.ts +13 -1
- package/src/index.ts +15 -2
- package/src/mutation-gate.ts +58 -2
- package/src/operations.ts +91 -10
- package/src/publish.ts +247 -30
- package/src/release-line.ts +32 -0
- package/src/setup.ts +81 -16
- package/src/skills.ts +303 -14
- package/src/vector-tier.ts +147 -24
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
|
-
*
|
|
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
|
-
*
|
|
509
|
-
*
|
|
510
|
-
*
|
|
511
|
-
*
|
|
512
|
-
*
|
|
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
|
|
523
|
-
|
|
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,
|
|
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
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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
|
-
|
|
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) {
|
package/src/release-line.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/setup.ts
CHANGED
|
@@ -752,12 +752,17 @@ function installDriverDocs(projectRoot: string, force: boolean): string {
|
|
|
752
752
|
* forth forever and neither step ever reports `skipped`, breaking the pre-existing
|
|
753
753
|
* `setup.test.ts` "PreCompact merge is idempotent" contract (FR-6) this feature must not touch.
|
|
754
754
|
*
|
|
755
|
-
*
|
|
756
|
-
*
|
|
757
|
-
*
|
|
758
|
-
*
|
|
759
|
-
*
|
|
760
|
-
*
|
|
755
|
+
* ADD-OR-REPLACE-IN-PLACE, deliberately NOT `mergeManagedHookEntries`: this step never REORDERS —
|
|
756
|
+
* a match keeps its POSITION, only its command text is swapped — so it stays the same "is our
|
|
757
|
+
* command already referenced under this event, anywhere, in any position?" question
|
|
758
|
+
* `mergeManagedHookEntries`'s drop-and-reappend-at-tail algorithm answers differently (by moving
|
|
759
|
+
* the entry), which is exactly what "Configure hooks" must never do to a foreign SessionStart entry
|
|
760
|
+
* on the very first run (see above). Before feature `apply-leg-install-root` the two commands never
|
|
761
|
+
* changed without an `APPLY_LEG_VERSION` bump (a version bump is about the FILE content, not the
|
|
762
|
+
* hook command), so ADDITIVE-ONLY (skip on any match) and ADD-OR-REPLACE (rewrite text on a
|
|
763
|
+
* stale-form match) were behaviourally identical; an install-root migration now changes the command
|
|
764
|
+
* text on its own, independent of the file version, so a stale `CLAUDE_PROJECT_DIR`-relative entry
|
|
765
|
+
* from a pre-feature install must be rewritten in place on the next `dz setup`, not left stale.
|
|
761
766
|
*/
|
|
762
767
|
function applyLegStepResult(opts: SetupOptions, backend: MemoryBackend): SetupStep {
|
|
763
768
|
if (opts.noHooks) return { name: 'Install apply-leg', status: 'skipped', detail: '--no-hooks' };
|
|
@@ -812,9 +817,10 @@ function applyLegStepResult(opts: SetupOptions, backend: MemoryBackend): SetupSt
|
|
|
812
817
|
wroteHelpers = true;
|
|
813
818
|
}
|
|
814
819
|
|
|
815
|
-
// ADD-
|
|
816
|
-
//
|
|
817
|
-
// OR our own) — see the WHY above for why that
|
|
820
|
+
// ADD-OR-REPLACE, per event: "ours is added when no command of the event references OUR marker
|
|
821
|
+
// yet, and REWRITTEN IN PLACE (same position) when one does but its text is stale". Never
|
|
822
|
+
// removes or reorders an existing entry (foreign OR our own) — see the WHY above for why that
|
|
823
|
+
// matters here.
|
|
818
824
|
//
|
|
819
825
|
// MEDIUM finding "совпадение подстроки в чужой команде" (fix round 1): the substring probe used
|
|
820
826
|
// to be the bare filename (`recall-hook.cjs`), so a foreign command that merely MENTIONS the
|
|
@@ -823,20 +829,79 @@ function applyLegStepResult(opts: SetupOptions, backend: MemoryBackend): SetupSt
|
|
|
823
829
|
// of the command we would emit, or the command containing our full relative PATH
|
|
824
830
|
// (`.claude/helpers/<file>`, the same marker `applyLegStatus` structurally looks for) — a bare
|
|
825
831
|
// filename mention under any other wrapper text no longer counts.
|
|
826
|
-
|
|
832
|
+
// FR-2 (ADR-001 D2, apply-leg-install-root): bake THIS install's own absolute root into the
|
|
833
|
+
// two commands — the deployed helper already bakes an absolute CORE_DIST_DIR, so a relative
|
|
834
|
+
// command only masked that non-portability (issue #2, `Cannot find module` when project ===
|
|
835
|
+
// $HOME and a foreign session's CLAUDE_PROJECT_DIR pointed elsewhere, swallowed by
|
|
836
|
+
// `2>/dev/null || true`).
|
|
837
|
+
const entries = applyLegHookEntries(opts.projectRoot);
|
|
827
838
|
const existingSettings = existsSync(settingsPath)
|
|
828
839
|
? (JSON.parse(readFileSync(settingsPath, 'utf-8')) as Record<string, unknown>)
|
|
829
840
|
: {};
|
|
830
841
|
const hooks = { ...((existingSettings['hooks'] ?? {}) as Record<string, unknown[]>) };
|
|
831
842
|
let hooksAdded = false;
|
|
843
|
+
// ADD-OR-REPLACE, per event (FR-2/AC-3, apply-leg-install-root): a command that already
|
|
844
|
+
// invokes our marker path is OURS, whatever exact form it takes — a pre-feature
|
|
845
|
+
// `CLAUDE_PROJECT_DIR`-relative entry (or, in principle, a relocated install's stale absolute
|
|
846
|
+
// one) is REPLACED by the current command in place, never left stale AND never duplicated. An
|
|
847
|
+
// EXACT match of the command we would emit is a true no-op (idempotent re-setup — this is what
|
|
848
|
+
// keeps a routine re-run from ever thrashing the file, same guarantee the prior ADDITIVE-ONLY
|
|
849
|
+
// design gave when the command text truly never changed without a version bump; it can now
|
|
850
|
+
// change on install-root migration too, so replace must be part of the contract).
|
|
851
|
+
//
|
|
852
|
+
// AM-2 (fix round 1, HIGH): the prior version replaced the WHOLE matching GROUP
|
|
853
|
+
// (`hooks[event][i]`) with our bare `entry` — a group is Claude Code's matcher-plus-commands
|
|
854
|
+
// shape (`{matcher, hooks:[...]}`), so that discarded the group's `matcher` and any FOREIGN
|
|
855
|
+
// sibling command sharing the same `hooks[]` array whenever ours needed an upgrade. Fixed: only
|
|
856
|
+
// the ONE command object inside the group's own `hooks[]` array that matches OUR marker is
|
|
857
|
+
// replaced — the matcher and every other command in that array survive untouched. A single pass
|
|
858
|
+
// also now upgrades EVERY matching group, not just the first `findIndex` hit, so two stale
|
|
859
|
+
// managed entries left in two different groups (a prior bug's residue, or a hand-edited file)
|
|
860
|
+
// are both fixed in place rather than the second one being silently ignored.
|
|
832
861
|
const addIfMissing = (event: string, ownCommand: string, markerPath: string, entry: unknown): void => {
|
|
833
862
|
const current = Array.isArray(hooks[event]) ? hooks[event] : [];
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
)
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
863
|
+
let anyMatch = false;
|
|
864
|
+
let anyChanged = false;
|
|
865
|
+
const updated = current.map((e) => {
|
|
866
|
+
const cmds = commandsOf(e);
|
|
867
|
+
const matchesHere = cmds.some((cmd) => cmd === ownCommand || hookCommandInvokes(cmd, markerPath));
|
|
868
|
+
if (!matchesHere) return e;
|
|
869
|
+
anyMatch = true;
|
|
870
|
+
const group = e as { hooks?: { command?: unknown }[] };
|
|
871
|
+
if (!Array.isArray(group.hooks)) {
|
|
872
|
+
if (cmds.some((cmd) => cmd === ownCommand)) return e; // legacy flat, already exact
|
|
873
|
+
anyChanged = true;
|
|
874
|
+
return entry; // legacy flat {command:...} — nothing else to preserve
|
|
875
|
+
}
|
|
876
|
+
// Codex round-2 (AM-2 residual): a group that holds BOTH the exact own command and a stale
|
|
877
|
+
// copy (or two stale copies) used to be skipped as "already exact" — the stale twin stayed
|
|
878
|
+
// forever. Walk the group once: the first own/stale command becomes the exact form, every
|
|
879
|
+
// later own/stale copy is dropped, every foreign sibling and the group's `matcher` survive.
|
|
880
|
+
let seenOwn = false;
|
|
881
|
+
let groupChanged = false;
|
|
882
|
+
const newGroupHooks: { command?: unknown }[] = [];
|
|
883
|
+
for (const h of group.hooks) {
|
|
884
|
+
const cmd = String((h as { command?: unknown })?.command ?? '');
|
|
885
|
+
const isOurs = cmd === ownCommand || hookCommandInvokes(cmd, markerPath);
|
|
886
|
+
if (!isOurs) { newGroupHooks.push(h); continue; }
|
|
887
|
+
if (seenOwn) { groupChanged = true; continue; } // duplicate of ours — dropped
|
|
888
|
+
seenOwn = true;
|
|
889
|
+
if (cmd !== ownCommand) groupChanged = true;
|
|
890
|
+
newGroupHooks.push(cmd === ownCommand ? h : { ...h, command: ownCommand });
|
|
891
|
+
}
|
|
892
|
+
if (!groupChanged) return e;
|
|
893
|
+
anyChanged = true;
|
|
894
|
+
return { ...(e as Record<string, unknown>), hooks: newGroupHooks };
|
|
895
|
+
});
|
|
896
|
+
if (!anyMatch) {
|
|
897
|
+
hooks[event] = [...current, entry];
|
|
898
|
+
hooksAdded = true;
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
if (anyChanged) {
|
|
902
|
+
hooks[event] = updated;
|
|
903
|
+
hooksAdded = true;
|
|
904
|
+
}
|
|
840
905
|
};
|
|
841
906
|
addIfMissing(
|
|
842
907
|
'UserPromptSubmit',
|