@dzhechkov/harness-core 0.8.44 → 0.8.46

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 (47) hide show
  1. package/.dz-manifest.json +76 -36
  2. package/README.md +54 -6
  3. package/dist/apply-leg.d.ts +1 -1
  4. package/dist/apply-leg.d.ts.map +1 -1
  5. package/dist/apply-leg.js +22 -7
  6. package/dist/apply-leg.js.map +1 -1
  7. package/dist/index.d.ts +3 -0
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +4 -0
  10. package/dist/index.js.map +1 -1
  11. package/dist/lesson-payoff.d.ts.map +1 -1
  12. package/dist/lesson-payoff.js +3 -1
  13. package/dist/lesson-payoff.js.map +1 -1
  14. package/dist/pack-inventory.d.ts +13 -0
  15. package/dist/pack-inventory.d.ts.map +1 -1
  16. package/dist/pack-inventory.js +22 -1
  17. package/dist/pack-inventory.js.map +1 -1
  18. package/dist/round-exec-claim.d.ts +36 -0
  19. package/dist/round-exec-claim.d.ts.map +1 -0
  20. package/dist/round-exec-claim.js +17 -0
  21. package/dist/round-exec-claim.js.map +1 -0
  22. package/dist/session-retro.d.ts +33 -0
  23. package/dist/session-retro.d.ts.map +1 -1
  24. package/dist/session-retro.js +170 -34
  25. package/dist/session-retro.js.map +1 -1
  26. package/dist/setup.d.ts.map +1 -1
  27. package/dist/setup.js +3 -2
  28. package/dist/setup.js.map +1 -1
  29. package/dist/sign.d.ts +44 -0
  30. package/dist/sign.d.ts.map +1 -1
  31. package/dist/sign.js +126 -1
  32. package/dist/sign.js.map +1 -1
  33. package/dist/stamped-path.d.ts +15 -0
  34. package/dist/stamped-path.d.ts.map +1 -0
  35. package/dist/stamped-path.js +32 -0
  36. package/dist/stamped-path.js.map +1 -0
  37. package/package.json +2 -2
  38. package/sbom.json +135 -35
  39. package/src/apply-leg.ts +22 -7
  40. package/src/index.ts +5 -0
  41. package/src/lesson-payoff.ts +3 -1
  42. package/src/pack-inventory.ts +21 -1
  43. package/src/round-exec-claim.ts +43 -0
  44. package/src/session-retro.ts +153 -31
  45. package/src/setup.ts +3 -2
  46. package/src/sign.ts +136 -1
  47. package/src/stamped-path.ts +41 -0
@@ -14,9 +14,10 @@
14
14
  * is taught silently but NOT drilled — no nagging on a one-off. Drills are for recurrent patterns only.
15
15
  */
16
16
 
17
- import { existsSync, readFileSync, readdirSync, statSync, openSync, readSync, closeSync, writeFileSync, renameSync, unlinkSync, mkdirSync } from 'node:fs';
17
+ import { existsSync, readFileSync, readdirSync, statSync, openSync, readSync, closeSync, writeFileSync, renameSync, unlinkSync, mkdirSync, rmSync } from 'node:fs';
18
18
  import { join, basename, dirname } from 'node:path';
19
19
  import { homedir } from 'node:os';
20
+ import { createHash } from 'node:crypto';
20
21
 
21
22
  import { withProjectLockSync, NamedLockTimeoutError } from './named-lock.js';
22
23
 
@@ -538,11 +539,62 @@ export function findLatestTranscript(repoRoot: string): string | null {
538
539
  // ── Per-turn admission-debt scan (feature narrated-error-must-be-taught, ADR-001 D2/D4) ────────────
539
540
  // The Stop hook runs `dz retro --scan-tail` after EVERY assistant turn, so this half is built around
540
541
  // one budget: O(new bytes) — a persisted byte offset, no full re-read, no store open, no subprocess.
541
- // The sentinel `.dz/retro-pending.json` is the debt; the recall hook turns it into a next-prompt
542
- // directive; a REAL teach invocation (a TOOL event — prose promises never pay, ADR-001 D4) clears it.
542
+ // The sentinel `.dz/retro/<session>/pending.json` is the debt; the recall hook turns it into a
543
+ // next-prompt directive; a REAL teach invocation (a TOOL event — prose promises never pay, ADR-001 D4)
544
+ // clears it.
543
545
 
546
+ /** LEGACY flat names (pre retro-debt-sentinel-per-session): the ONE pair every session in a worktree
547
+ * once shared under `.dz/`. A scan ADOPTS its own transcript's flat files once (FR-4) and never
548
+ * writes them again; a stranger's flat file is never touched. */
544
549
  export const RETRO_SCAN_STATE_FILE = 'retro-scan-state.json';
545
550
  export const RETRO_PENDING_FILE = 'retro-pending.json';
551
+
552
+ // ── Per-session layout (feature retro-debt-sentinel-per-session, FR-1; backlog 58f3c56fbb9d6893) ──
553
+ // Two sessions in one worktree shared the flat pair above, so a Stop scan of EITHER unlinked the
554
+ // other's LIVE debt as "foreign", and the shared bookmark made each session's offset meaningless to
555
+ // the other (foreign transcript ⇒ offset 0 ⇒ a bounded re-scan every turn; MEASURED 22.09 20:23, a
556
+ // second session in this repo). The pair now lives in a directory named after the session.
557
+ export const RETRO_SESSION_DIRNAME = 'retro';
558
+ export const RETRO_SESSION_PENDING_BASENAME = 'pending.json';
559
+ export const RETRO_SESSION_STATE_BASENAME = 'scan-state.json';
560
+ /** A session dir whose scan-state is older than this belongs to a dead session: the next scan of
561
+ * any other session sweeps it (FR-5). A live session that ran no Stop for 7 days loses only a debt
562
+ * the hook would have called stale anyway (SENTINEL_FRESH_MS is 30 min) — accepted risk R2. */
563
+ export const RETRO_SESSION_STALE_MS = 7 * 24 * 3600 * 1000;
564
+ /** At most this many `.dz/retro/*` entries are examined per scan — the sweep's cost bound (NFR-1). */
565
+ const RETRO_SWEEP_MAX_ENTRIES = 64;
566
+ const SAFE_SESSION_ID_RE = /^[A-Za-z0-9._-]{1,128}$/;
567
+
568
+ /**
569
+ * The directory name for a session id. PURE. A plain token is used as is; anything else — a
570
+ * path-like string, the empty string, an over-long id, and the two dot names the charset regex
571
+ * alone would let through (`.` ⇒ `<dzDir>/retro`, `..` ⇒ `<dzDir>` itself) — becomes the first
572
+ * 32 hex of its sha256, so a session id can never name a path outside `.dz/retro/` (AC-2).
573
+ * TWIN: the recall hook in `apply-leg.ts` carries a copy (the hook must stay dependency-free);
574
+ * `test/retro-sentinel-per-session.test.ts` pins the two to equal outputs.
575
+ */
576
+ export function safeSessionDirName(sessionId: string): string {
577
+ if (SAFE_SESSION_ID_RE.test(sessionId) && sessionId !== '.' && sessionId !== '..') return sessionId;
578
+ return createHash('sha256').update(sessionId).digest('hex').slice(0, 32);
579
+ }
580
+
581
+ /** The session id the SCANNER derives: the transcript basename without `.jsonl`. Claude Code names
582
+ * the transcript after the session id, so this equals the `session_id` the hook payload carries. PURE. */
583
+ export function sessionIdFromTranscript(transcriptPath: string): string {
584
+ return basename(transcriptPath).replace(/\.jsonl$/, '');
585
+ }
586
+
587
+ export interface RetroSessionPaths {
588
+ readonly dir: string;
589
+ readonly pendingPath: string;
590
+ readonly statePath: string;
591
+ }
592
+
593
+ /** Where ONE session's debt and bookmark live: `<dzDir>/retro/<safeId>/{pending,scan-state}.json`. PURE, no fs. */
594
+ export function retroSessionPaths(dzDir: string, sessionId: string): RetroSessionPaths {
595
+ const dir = join(dzDir, RETRO_SESSION_DIRNAME, safeSessionDirName(sessionId));
596
+ return { dir, pendingPath: join(dir, RETRO_SESSION_PENDING_BASENAME), statePath: join(dir, RETRO_SESSION_STATE_BASENAME) };
597
+ }
546
598
  /** Bound the very FIRST scan of an already-huge transcript; later scans read only the new bytes. */
547
599
  const MAX_TAIL_SCAN_BYTES = 8 * 1024 * 1024;
548
600
  /** Without a session id to compare, a sentinel older than this is stale (fallback freshness only). */
@@ -628,6 +680,10 @@ export interface TailScanOutcome {
628
680
  readonly snippet?: string;
629
681
  readonly scannedBytes: number;
630
682
  readonly offset: number;
683
+ /** The session the scan was scoped to (FR-6) — absent only when no transcript was named. */
684
+ readonly sessionId?: string;
685
+ /** The per-session sentinel path the scan wrote, cleared, or left absent (FR-6). */
686
+ readonly pendingPath?: string;
631
687
  }
632
688
 
633
689
  /** The scan-state + sentinel pair is a read-modify-write store; per the repo concurrency rule
@@ -728,6 +784,8 @@ const writeJsonAtomic = (path: string, value: unknown): void => {
728
784
  */
729
785
  export function runRetroTailScan(dzDir: string, transcriptPath: string | null, nowIso?: string): TailScanOutcome {
730
786
  if (transcriptPath === null || transcriptPath === '') return { status: 'no-transcript', scannedBytes: 0, offset: 0 };
787
+ const sessionId = sessionIdFromTranscript(transcriptPath);
788
+ const ids = { sessionId, pendingPath: retroSessionPaths(dzDir, sessionId).pendingPath };
731
789
  try {
732
790
  return withProjectLockSync(
733
791
  dirname(dzDir),
@@ -736,43 +794,71 @@ export function runRetroTailScan(dzDir: string, transcriptPath: string | null, n
736
794
  { timeoutMs: RETRO_SCAN_LOCK_TIMEOUT_MS },
737
795
  );
738
796
  } catch (e) {
739
- if (e instanceof NamedLockTimeoutError) return { status: 'contended', scannedBytes: 0, offset: 0 };
797
+ if (e instanceof NamedLockTimeoutError) return { status: 'contended', scannedBytes: 0, offset: 0, ...ids };
740
798
  // Compromised lock or any unexpected failure: report nothing, advance nothing (never-block).
741
- return { status: 'none', scannedBytes: 0, offset: 0 };
799
+ return { status: 'none', scannedBytes: 0, offset: 0, ...ids };
742
800
  }
743
801
  }
744
802
 
745
803
  /** The transaction body — call ONLY under the named lock. Never throws for ordinary fs failures. */
746
804
  function scanTailUnderLock(dzDir: string, transcriptPath: string, nowIso?: string): TailScanOutcome {
805
+ // FR-2: every path this scan writes or removes is inside ITS OWN session dir; the only files it
806
+ // ever touches outside are the legacy flat pair, and only to adopt its own (FR-4).
807
+ const sessionId = sessionIdFromTranscript(transcriptPath);
808
+ const { dir, pendingPath, statePath } = retroSessionPaths(dzDir, sessionId);
809
+ const ids = { sessionId, pendingPath };
747
810
  try {
748
- const statePath = join(dzDir, RETRO_SCAN_STATE_FILE);
749
- const pendingPath = join(dzDir, RETRO_PENDING_FILE);
811
+ const legacyStatePath = join(dzDir, RETRO_SCAN_STATE_FILE);
812
+ const legacyPendingPath = join(dzDir, RETRO_PENDING_FILE);
750
813
 
814
+ const readOffset = (p: string): number | null => {
815
+ try {
816
+ const st = JSON.parse(readFileSync(p, 'utf8')) as { transcript?: string; offset?: number };
817
+ if (st.transcript === transcriptPath && typeof st.offset === 'number' && Number.isFinite(st.offset) && st.offset >= 0) return Math.floor(st.offset);
818
+ } catch { /* absent or unreadable */ }
819
+ return null;
820
+ };
751
821
  let offset = 0;
752
- try {
753
- const st = JSON.parse(readFileSync(statePath, 'utf8')) as { transcript?: string; offset?: number };
754
- if (st.transcript === transcriptPath && typeof st.offset === 'number' && Number.isFinite(st.offset) && st.offset >= 0) offset = Math.floor(st.offset);
755
- } catch { /* first scan of this transcript */ }
756
-
757
- // Prior debt carries over ONLY for the same session; a stale sentinel (another session's debt)
758
- // is dropped — the PreCompact/SessionEnd retro of THAT session was its collector, and injecting
759
- // an old session's debt into a new one is noise (acid A9).
760
- let prior: AdmissionDebt | null = null;
761
- let hadSentinel = false;
762
- try {
763
- const s = JSON.parse(readFileSync(pendingPath, 'utf8')) as Partial<RetroPendingSentinel>;
764
- if (s.transcript === transcriptPath && typeof s.snippet === 'string') {
765
- prior = { snippet: s.snippet, ...(s.awaiting !== undefined ? { awaiting: s.awaiting } : {}) };
766
- hadSentinel = true;
767
- }
768
- else { try { unlinkSync(pendingPath); } catch { /* already gone */ } }
769
- } catch { /* no sentinel */ }
822
+ let adoptLegacyState = false;
823
+ const ownOffset = readOffset(statePath);
824
+ if (ownOffset !== null) offset = ownOffset;
825
+ else if (!existsSync(statePath)) {
826
+ // FR-4: the flat bookmark is adopted ONCE — only while this session has no bookmark of its own,
827
+ // and only when it names THIS transcript. Another session's stays where it is.
828
+ const legacyOffset = readOffset(legacyStatePath);
829
+ if (legacyOffset !== null) { offset = legacyOffset; adoptLegacyState = true; }
830
+ }
831
+
832
+ const readSentinel = (p: string): { prior: AdmissionDebt | null; present: boolean } => {
833
+ try {
834
+ const s = JSON.parse(readFileSync(p, 'utf8')) as Partial<RetroPendingSentinel>;
835
+ if (s.transcript === transcriptPath && typeof s.snippet === 'string') {
836
+ return { prior: { snippet: s.snippet, ...(s.awaiting !== undefined ? { awaiting: s.awaiting } : {}) }, present: true };
837
+ }
838
+ return { prior: null, present: true };
839
+ } catch { return { prior: null, present: false }; }
840
+ };
841
+ // Prior debt: this session's own sentinel. One naming ANOTHER transcript can only mean the
842
+ // transcript was moved or renamed under the same basename — it is IGNORED and overwritten (or
843
+ // removed below when nothing is armed), never deleted as "foreign": the pre-feature branch that
844
+ // unlinked a sentinel of another transcript deleted the NEIGHBOUR session's live debt (FR-2).
845
+ const own = readSentinel(pendingPath);
846
+ let prior = own.prior;
847
+ const staleOwn = own.present && own.prior === null;
848
+ let adoptLegacyPending = false;
849
+ if (!own.present) {
850
+ // FR-4: the flat sentinel is adopted only when it is THIS transcript's and this session has no
851
+ // per-session copy yet; a stranger's, or a nobody's (no transcript field), is left untouched.
852
+ const legacy = readSentinel(legacyPendingPath);
853
+ if (legacy.prior !== null) { prior = legacy.prior; adoptLegacyPending = true; }
854
+ }
855
+ const hadSentinel = prior !== null;
770
856
 
771
857
  let size = 0;
772
858
  try { size = statSync(transcriptPath).size; } catch {
773
859
  return prior !== null
774
- ? { status: 'pending', snippet: prior.snippet, scannedBytes: 0, offset }
775
- : { status: 'none', scannedBytes: 0, offset };
860
+ ? { status: 'pending', snippet: prior.snippet, scannedBytes: 0, offset, ...ids }
861
+ : { status: 'none', scannedBytes: 0, offset, ...ids };
776
862
  }
777
863
  if (size < offset) offset = 0; // truncated/rotated transcript
778
864
  let jumped = offset === 0 && size > MAX_TAIL_SCAN_BYTES; // bound the first scan of a huge file
@@ -802,12 +888,12 @@ function scanTailUnderLock(dzDir: string, transcriptPath: string, nowIso?: strin
802
888
 
803
889
  const next = foldAdmissionDebt(events, prior);
804
890
  const newOffset = offset + consumed;
805
- mkdirSync(dzDir, { recursive: true });
891
+ mkdirSync(dir, { recursive: true });
806
892
  let outcome: TailScanOutcome;
807
893
  if (next !== null) {
808
894
  const sentinel: RetroPendingSentinel = {
809
895
  schema: 1,
810
- sessionId: basename(transcriptPath).replace(/\.jsonl$/, ''),
896
+ sessionId,
811
897
  transcript: transcriptPath,
812
898
  snippet: next.snippet.replace(/\s+/g, ' ').trim().slice(0, 200),
813
899
  ts: nowIso ?? new Date().toISOString(),
@@ -826,13 +912,45 @@ function scanTailUnderLock(dzDir: string, transcriptPath: string, nowIso?: strin
826
912
  catch (e) { if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e; }
827
913
  outcome = { status: 'cleared', scannedBytes: consumed, offset: newOffset };
828
914
  } else {
915
+ // Our own stale sentinel (moved transcript, see above) with nothing armed: removed, so the hook
916
+ // never confronts a debt this transcript no longer carries. Same ENOENT-only tolerance.
917
+ if (staleOwn) {
918
+ try { unlinkSync(pendingPath); }
919
+ catch (e) { if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e; }
920
+ }
829
921
  outcome = { status: 'none', scannedBytes: consumed, offset: newOffset };
830
922
  }
831
923
  // Commit the debt BEFORE its offset: an interruption must leave these bytes replayable.
832
924
  writeJsonAtomic(statePath, { schema: 1, transcript: transcriptPath, offset: newOffset });
833
- return outcome;
925
+ // FR-4: the adopted flat files go only AFTER the per-session copies are committed — a failure
926
+ // above leaves them in place for the next scan to adopt again. Best-effort: leftover debris
927
+ // is harmless (a present per-session file blocks re-adoption), a thrown error here would
928
+ // misreport an already-committed scan.
929
+ if (adoptLegacyPending) { try { unlinkSync(legacyPendingPath); } catch { /* debris */ } }
930
+ if (adoptLegacyState) { try { unlinkSync(legacyStatePath); } catch { /* debris */ } }
931
+ sweepStaleSessionDirs(dzDir, dir, nowIso === undefined ? Date.now() : Date.parse(nowIso));
932
+ return { ...outcome, ...ids };
834
933
  } catch {
835
- return { status: 'none', scannedBytes: 0, offset: 0 };
934
+ return { status: 'none', scannedBytes: 0, offset: 0, ...ids };
935
+ }
936
+ }
937
+
938
+ /**
939
+ * FR-5: remove dead sessions' dirs — `<dzDir>/retro/<x>/` whose `scan-state.json` mtime is older
940
+ * than {@link RETRO_SESSION_STALE_MS}. The scanning session's own dir is excluded; a dir with no
941
+ * readable scan-state cannot be dated and is left alone; every error is swallowed; at most
942
+ * {@link RETRO_SWEEP_MAX_ENTRIES} entries are examined (one readdir + ≤64 stats — NFR-1).
943
+ */
944
+ function sweepStaleSessionDirs(dzDir: string, ownDir: string, nowMs: number): void {
945
+ let names: string[];
946
+ try { names = readdirSync(join(dzDir, RETRO_SESSION_DIRNAME)).slice(0, RETRO_SWEEP_MAX_ENTRIES); } catch { return; }
947
+ for (const name of names) {
948
+ const d = join(dzDir, RETRO_SESSION_DIRNAME, name);
949
+ if (d === ownDir) continue;
950
+ try {
951
+ const age = nowMs - statSync(join(d, RETRO_SESSION_STATE_BASENAME)).mtimeMs;
952
+ if (age > RETRO_SESSION_STALE_MS) rmSync(d, { recursive: true, force: true });
953
+ } catch { /* undatable or vanished: leave it */ }
836
954
  }
837
955
  }
838
956
 
@@ -851,6 +969,10 @@ export function retroSentinelIsFresh(
851
969
  ctx: { sessionId?: string; transcriptPath?: string; nowMs: number },
852
970
  ): boolean {
853
971
  if (typeof sentinel.snippet !== 'string' || sentinel.snippet === '') return false;
972
+ // AM-1 (retro-debt-sentinel-per-session): an EMPTY session id carries no identity — a sentinel that
973
+ // names no session never matches anyone, and an empty ctx id is the same as none (the hook side
974
+ // returns '' before any fs call for it; this is the core half of the same rule).
975
+ if (sentinel.sessionId === '') return false;
854
976
  if (typeof ctx.sessionId === 'string' && ctx.sessionId !== '') return sentinel.sessionId === ctx.sessionId;
855
977
  if (typeof ctx.transcriptPath === 'string' && ctx.transcriptPath !== '') return sentinel.transcript === ctx.transcriptPath;
856
978
  const ts = typeof sentinel.ts === 'string' ? Date.parse(sentinel.ts) : NaN;
package/src/setup.ts CHANGED
@@ -21,6 +21,7 @@ import { basename, dirname, isAbsolute, join, relative } from 'node:path';
21
21
  import { execSync, spawnSync } from 'node:child_process';
22
22
 
23
23
  import { mergeManagedHookEntries } from './managed-hooks.js';
24
+ import { writeUniqueStampedFile } from './stamped-path.js';
24
25
  import {
25
26
  CLAUDE_DESTRUCTIVE_HOOK_COMMAND,
26
27
  CLAUDE_DESTRUCTIVE_HOOK_MATCHER,
@@ -1115,8 +1116,8 @@ export function runSetup(opts: SetupOptions): SetupResult {
1115
1116
  if (foreign && current !== null) {
1116
1117
  // Same shape as the codex `hooks.json` backup: the original beside the original, stamped,
1117
1118
  // so `--force` is recoverable rather than merely loud.
1118
- backupPath = `${hookPath}.bak-${new Date().toISOString().replace(/[:.]/g, '-')}`;
1119
- writeFileSync(backupPath, current);
1119
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
1120
+ backupPath = writeUniqueStampedFile(`${hookPath}.bak-`, stamp, current, writeFileSync);
1120
1121
  }
1121
1122
  mkdirSync(dirname(hookPath), { recursive: true });
1122
1123
  writeFileSync(hookPath, generateClaudeDestructiveHook(), { mode: 0o755 });
package/src/sign.ts CHANGED
@@ -46,6 +46,13 @@ export interface VerifyFailure {
46
46
  export interface VerifyResult {
47
47
  readonly ok: boolean;
48
48
  readonly failures: readonly VerifyFailure[];
49
+ /**
50
+ * Paths that failed the sweep with {@link OUTSIDE_FILES_REASON}: present in the DIRECTORY, not in
51
+ * the manifest, and outside `package.json.files` — the packer would never ship them. They are still
52
+ * failures (AM-1: nothing is skipped); this list only lets a caller say how many of the failures
53
+ * describe the directory rather than the pack. Absent when there are none.
54
+ */
55
+ readonly outsideFiles?: readonly string[];
49
56
  }
50
57
 
51
58
 
@@ -371,6 +378,121 @@ export function listSignablePackFiles(root: string): string[] {
371
378
  return out.sort();
372
379
  }
373
380
 
381
+ /**
382
+ * The wording for a swept path that `package.json.files` would keep OUT of the tarball (feature
383
+ * verify-pack-sweep-respects-files, AM-1). MEASURED 2026-09-20 (backlog 338a3b59c878430c): right after
384
+ * `dz sign`, `dz verify-pack` over harness-core's directory named `coverage/coverage-final.json` and
385
+ * `CHANGELOG.md` as «present in the pack but not signed», while `npm pack --dry-run` shipped 922 files,
386
+ * ZERO of them coverage and no CHANGELOG — `files` limits the tarball to six entries. The sweep walks the
387
+ * DIRECTORY (by decision — see `verifyManifest`), so the reason must say so instead of saying PACK.
388
+ */
389
+ export const OUTSIDE_FILES_REASON =
390
+ 'present in the directory but not signed — outside package.json.files, the packer would not ship it';
391
+
392
+ /**
393
+ * npm's always-included ROOT files, MEASURED from npm's own packer rather than its docs
394
+ * (`/usr/lib/node_modules/npm/node_modules/npm-packlist/lib/index.js:283-286`, fix round 1, 2026-09-25):
395
+ * `readme{,.*[^~$]}`, `copying{,.*[^~$]}`, `license{,.*[^~$]}`, `licence{,.*[^~$]}` — case-insensitive.
396
+ * That is the BARE name or `name.<ext>` where the extension does not end in `~` or `$` (editor backups and
397
+ * lock-style droppings). So `README.md`, `readme.txt`, `COPYING`, `LICENCE.md` are inside; `README-old`
398
+ * (no dot), `LICENSE.md~`, `README.` (empty extension) are NOT. Plus `package.json`, which npm always ships.
399
+ * NOT `CHANGELOG*`: MEASURED 2026-09-20, pnpm pack of harness-core omitted `CHANGELOG.md` while `files` did
400
+ * not name it — the npm docs list CHANGES/CHANGELOG/HISTORY, the packer does not; we follow the packer.
401
+ */
402
+ export const NPM_ALWAYS_INCLUDED = /^(package\.json|(readme|copying|licen[cs]e)(\..*[^~$])?)$/i;
403
+
404
+ /** What `package.json` says the tarball contains: normalised `files` entries plus `main` and the bin targets. */
405
+ export interface PackAllowlist {
406
+ readonly entries: readonly string[];
407
+ readonly main: string | null;
408
+ readonly bins: readonly string[];
409
+ }
410
+
411
+ /**
412
+ * POSIX separators, no leading `./`, no trailing `/`. Every trailing slash goes — MEASURED fix round 1:
413
+ * `'.//'` used to survive as `'/'` (a phantom entry) because the strip kept one character; an entry that is
414
+ * nothing but slashes is empty after normalisation and must be dropped (AM-2).
415
+ */
416
+ function normalisePackPath(p: string): string {
417
+ let s = p.replace(/\\/g, '/');
418
+ while (s.startsWith('./')) s = s.slice(2);
419
+ while (s.endsWith('/')) s = s.slice(0, -1);
420
+ return s;
421
+ }
422
+
423
+ /**
424
+ * `null` unless `pkg.files` is a non-empty array of strings — and `null` means «no allowlist, behaviour
425
+ * unchanged», never «everything is outside». Pure: the caller reads and parses `package.json`.
426
+ */
427
+ export function packAllowlistFromPackageJson(pkg: unknown): PackAllowlist | null {
428
+ if (!pkg || typeof pkg !== 'object') return null;
429
+ const { files, main, bin } = pkg as { files?: unknown; main?: unknown; bin?: unknown };
430
+ if (!Array.isArray(files) || files.length === 0 || !files.every((f) => typeof f === 'string')) return null;
431
+ const entries = (files as string[]).map(normalisePackPath).filter((e) => e.length > 0);
432
+ if (entries.length === 0) return null;
433
+ const binValues: string[] =
434
+ typeof bin === 'string' ? [bin]
435
+ : bin && typeof bin === 'object' ? Object.values(bin as Record<string, unknown>).filter((v): v is string => typeof v === 'string')
436
+ : [];
437
+ return {
438
+ entries,
439
+ main: typeof main === 'string' ? normalisePackPath(main) : null,
440
+ bins: binValues.map(normalisePackPath),
441
+ };
442
+ }
443
+
444
+ const GLOB_CHARS = /[*?[]/;
445
+
446
+ /**
447
+ * Minimal `*`-only matcher over the WHOLE relative path: `*` matches any run of characters, `/` included
448
+ * (so `types/**` and `types/*` both cover `types/deep/x.d.ts`). `?`, `[…]` and `{a,b}` are NOT interpreted —
449
+ * an entry carrying them falls back to a literal comparison, which can only UNDER-match (⇒ the plain pack
450
+ * wording, never a false «outside»). Kept deliberately small (NFR-2): this is a wording aid, not a packer.
451
+ */
452
+ function globStarMatch(pattern: string, rel: string): boolean {
453
+ const parts = pattern.split('*');
454
+ if (parts.length === 1) return pattern === rel;
455
+ if (!rel.startsWith(parts[0]!)) return false;
456
+ let pos = parts[0]!.length;
457
+ for (let i = 1; i < parts.length - 1; i += 1) {
458
+ const at = rel.indexOf(parts[i]!, pos);
459
+ if (at < 0) return false;
460
+ pos = at + parts[i]!.length;
461
+ }
462
+ const last = parts[parts.length - 1]!;
463
+ return last === '' || (rel.endsWith(last) && rel.length - last.length >= pos);
464
+ }
465
+
466
+ /**
467
+ * Would the packer ship `rel`? True when `rel` equals an entry, sits under an entry directory, matches a
468
+ * `*` glob entry, equals `main` or a bin target, or is a ROOT-level always-included file. `docs/README.md`
469
+ * is not root-level and is therefore outside unless `docs` is an entry.
470
+ */
471
+ export function isInsidePackAllowlist(rel: string, allow: PackAllowlist): boolean {
472
+ const p = normalisePackPath(rel);
473
+ if (!p.includes('/') && NPM_ALWAYS_INCLUDED.test(p)) return true;
474
+ if (allow.main === p || allow.bins.includes(p)) return true;
475
+ for (const entry of allow.entries) {
476
+ if (GLOB_CHARS.test(entry)) {
477
+ if (globStarMatch(entry, p)) return true;
478
+ continue;
479
+ }
480
+ if (p === entry || p.startsWith(entry + '/')) return true;
481
+ }
482
+ return false;
483
+ }
484
+
485
+ /** `<root>/package.json` → allowlist; absent, symlinked, unreadable or invalid JSON ⇒ `null` (unchanged behaviour). */
486
+ function readPackAllowlist(root: string): PackAllowlist | null {
487
+ try {
488
+ const bytes = readRegularFileNoFollow(join(root, 'package.json'));
489
+ if (bytes === null) return null;
490
+ return packAllowlistFromPackageJson(JSON.parse(bytes.toString('utf8')));
491
+ } catch {
492
+ return null;
493
+ }
494
+ }
495
+
374
496
  /**
375
497
  * The bytes that get signed (FR-7). Sorted by path, LF endings, no trailing whitespace, and no
376
498
  * dependence on JSON key order — a signature must not depend on how a serialiser felt that day.
@@ -533,7 +655,20 @@ export function verifyManifest(
533
655
  if (!listed.has(p)) failures.push({ path: p, reason: 'present in the pack but not signed' });
534
656
  }
535
657
 
536
- return { ok: failures.length === 0, failures };
658
+ // AM-1 (feature verify-pack-sweep-respects-files): the sweep above is the recorded decision and stays
659
+ // byte-identical — every visible path participates, nothing is skipped, the verdict is unchanged. This
660
+ // pass changes WORDING only: a swept path that `package.json.files` keeps out of the tarball is reported
661
+ // as «present in the directory …» (OUTSIDE_FILES_REASON) instead of «present in the pack …», because it
662
+ // never was going to be in the pack. No `files` (or an unreadable package.json) ⇒ nothing is relabelled.
663
+ const allow = readPackAllowlist(root);
664
+ const outside: string[] = [];
665
+ const reported: VerifyFailure[] = allow === null ? failures : failures.map((f) => {
666
+ if (f.reason !== 'present in the pack but not signed' || isInsidePackAllowlist(f.path, allow)) return f;
667
+ outside.push(f.path);
668
+ return { path: f.path, reason: OUTSIDE_FILES_REASON };
669
+ });
670
+
671
+ return { ok: reported.length === 0, failures: reported, ...(outside.length > 0 ? { outsideFiles: outside } : {}) };
537
672
  }
538
673
 
539
674
  /**
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Pure on purpose: harness-core's IO ratchet (test/core-boundary.test.ts) counts files that import node:fs — callers that
3
+ * already do IO pass their own `exists` / `write` (MEASURED 2026-09-25: a default `existsSync` here tripped it, 65 → 66).
4
+ */
5
+
6
+ export type StampedWrite = (path: string, data: string | Uint8Array, options: { flag: 'wx' }) => void;
7
+
8
+ /**
9
+ * Return the first unused stamped path, adding a counter suffix on collision.
10
+ * check-then-act: safe within one process or under a lock; for unlocked cross-process writers use writeUniqueStampedFile
11
+ */
12
+ export function uniqueStampedPath(
13
+ base: string,
14
+ stamp: string,
15
+ exists: (p: string) => boolean,
16
+ ): string {
17
+ let candidate = `${base}${stamp}`;
18
+ for (let n = 1; exists(candidate); n += 1) candidate = `${base}${stamp}-${n}`;
19
+ return candidate;
20
+ }
21
+
22
+ /** Atomically create a stamped file, trying at most 1000 candidates on EEXIST. */
23
+ export function writeUniqueStampedFile(
24
+ base: string,
25
+ stamp: string,
26
+ data: string | Uint8Array,
27
+ write: StampedWrite,
28
+ ): string {
29
+ for (let n = 0; n < 1000; n += 1) {
30
+ const candidate = n === 0 ? `${base}${stamp}` : `${base}${stamp}-${n}`;
31
+ try {
32
+ write(candidate, data, { flag: 'wx' });
33
+ return candidate;
34
+ } catch (error) {
35
+ if ((error as NodeJS.ErrnoException | null)?.code !== 'EEXIST') throw error;
36
+ }
37
+ }
38
+ const error = new Error(`No free stamped file after 1000 attempts: ${base}${stamp}`);
39
+ error.name = 'UniqueStampedFileExhaustedError';
40
+ throw error;
41
+ }