@dzhechkov/harness-core 0.8.45 → 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.
- package/.dz-manifest.json +48 -28
- package/README.md +52 -6
- package/dist/apply-leg.d.ts +1 -1
- package/dist/apply-leg.d.ts.map +1 -1
- package/dist/apply-leg.js +22 -7
- package/dist/apply-leg.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/pack-inventory.d.ts +13 -0
- package/dist/pack-inventory.d.ts.map +1 -1
- package/dist/pack-inventory.js +22 -1
- package/dist/pack-inventory.js.map +1 -1
- package/dist/round-exec-claim.d.ts +36 -0
- package/dist/round-exec-claim.d.ts.map +1 -0
- package/dist/round-exec-claim.js +17 -0
- package/dist/round-exec-claim.js.map +1 -0
- package/dist/session-retro.d.ts +33 -0
- package/dist/session-retro.d.ts.map +1 -1
- package/dist/session-retro.js +170 -34
- package/dist/session-retro.js.map +1 -1
- package/dist/sign.d.ts +44 -0
- package/dist/sign.d.ts.map +1 -1
- package/dist/sign.js +126 -1
- package/dist/sign.js.map +1 -1
- package/package.json +2 -2
- package/sbom.json +77 -27
- package/src/apply-leg.ts +22 -7
- package/src/index.ts +4 -0
- package/src/pack-inventory.ts +21 -1
- package/src/round-exec-claim.ts +43 -0
- package/src/session-retro.ts +153 -31
- package/src/sign.ts +136 -1
package/src/pack-inventory.ts
CHANGED
|
@@ -36,6 +36,26 @@ export interface PublishSecretSeams {
|
|
|
36
36
|
readonly tgzPathFor?: (dir: string) => string | undefined;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Share of NUL bytes above which a sample is binary. MEASURED 2026-09-21 (backlog d3841a3b): one NUL in
|
|
41
|
+
* 19 628 bytes of TypeScript made the old `includes(0)` sniff skip a packed TEXT file from the no-secrets scan.
|
|
42
|
+
*/
|
|
43
|
+
export const NUL_RATIO = 0.01;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* PURE binary sniff over the first bytes of a file: `true` iff NUL bytes exceed `NUL_RATIO` of the sample length.
|
|
47
|
+
* Empty ⇒ text. ONLY NULs count (fix round 1, F1): the rule this replaces skipped on a NUL and nothing else, so a
|
|
48
|
+
* criterion over other control bytes or UTF-8 validity would SKIP files the old rule SCANNED — a latin-1 source with
|
|
49
|
+
* a secret, a text with \x01 separators — and that is a fail-open regression. One NUL in 8 KiB is text and gets
|
|
50
|
+
* scanned; UTF-16 (about half NULs) is binary as before; a PNG with few NULs is scanned — harmless, the scan is read-only.
|
|
51
|
+
*/
|
|
52
|
+
export function looksBinarySample(sample: Buffer): boolean {
|
|
53
|
+
if (sample.length === 0) return false;
|
|
54
|
+
let nuls = 0;
|
|
55
|
+
for (const byte of sample) if (byte === 0) nuls++;
|
|
56
|
+
return nuls > NUL_RATIO * sample.length;
|
|
57
|
+
}
|
|
58
|
+
|
|
39
59
|
/** Scan the publish inventory as streams, retaining every coverage gap by name. */
|
|
40
60
|
export function gatherPublishSecretFacts(root: string, packageDirs: readonly string[], seams: PublishSecretSeams = {}):
|
|
41
61
|
{ readonly secretFindings: readonly { label: string; name: string }[];
|
|
@@ -74,7 +94,7 @@ export function gatherPublishSecretFacts(root: string, packageDirs: readonly str
|
|
|
74
94
|
let first = true;
|
|
75
95
|
try {
|
|
76
96
|
for (const chunk of reader(absolute, 1024 * 1024)) {
|
|
77
|
-
if (first && chunk.subarray(0, 8 * 1024)
|
|
97
|
+
if (first && looksBinarySample(chunk.subarray(0, 8 * 1024))) { binary = true; return; }
|
|
78
98
|
first = false;
|
|
79
99
|
yield chunk;
|
|
80
100
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* round-exec-claim-takeover (FR-1): the pure verdict behind `dz round exec`'s claim transaction.
|
|
3
|
+
*
|
|
4
|
+
* A round claimed by an `exec` whose process died (SIGTERM, OOM, reboot) used to refuse every later
|
|
5
|
+
* `exec` forever (backlog 280e914607397474). This decides — from facts the caller measured under the
|
|
6
|
+
* state lock — whether the standing claim is held, provably stale-dead, or unknowable:
|
|
7
|
+
*
|
|
8
|
+
* - `pidAlive === true` ⇒ `held`, regardless of age (a live owner is never taken over);
|
|
9
|
+
* - `pidAlive === null` ⇒ `unknown` (the probe was inconclusive — refusal is the safe side);
|
|
10
|
+
* - `pid` not a positive safe integer ⇒ `unknown` («no pid recorded») — nothing to probe;
|
|
11
|
+
* - `execClaimedAt` unparsable ⇒ `unknown` («claim time unreadable») — no age to debounce on;
|
|
12
|
+
* - `pidAlive === false` and age ≥ `staleMinutes` ⇒ `stale-dead` with the floored age;
|
|
13
|
+
* - `pidAlive === false` but younger ⇒ `held` — a just-died claim may be a restart in flight, and
|
|
14
|
+
* the threshold is the debounce.
|
|
15
|
+
*
|
|
16
|
+
* Pure: no clock, no process table — `now` and `pidAlive` are inputs so the caller keeps the
|
|
17
|
+
* critical section short (`process.kill(pid, 0)`, NFR-1) and tests need no real processes.
|
|
18
|
+
*/
|
|
19
|
+
export type ExecClaimVerdict =
|
|
20
|
+
| { kind: 'held' }
|
|
21
|
+
| { kind: 'stale-dead'; ageMinutes: number }
|
|
22
|
+
| { kind: 'unknown'; reason: string };
|
|
23
|
+
|
|
24
|
+
export type ExecClaimTakeoverInput = {
|
|
25
|
+
readonly execClaimedAt?: string | undefined;
|
|
26
|
+
readonly pid?: number | undefined;
|
|
27
|
+
readonly pidAlive: boolean | null;
|
|
28
|
+
readonly now: number;
|
|
29
|
+
readonly staleMinutes: number;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function decideExecClaimTakeover(input: ExecClaimTakeoverInput): ExecClaimVerdict {
|
|
33
|
+
if (input.pidAlive === true) return { kind: 'held' };
|
|
34
|
+
if (input.pid === undefined || !Number.isSafeInteger(input.pid) || input.pid <= 0) {
|
|
35
|
+
return { kind: 'unknown', reason: 'no pid recorded' };
|
|
36
|
+
}
|
|
37
|
+
if (input.pidAlive === null) return { kind: 'unknown', reason: 'PID probe inconclusive' };
|
|
38
|
+
const claimedMs = input.execClaimedAt === undefined ? Number.NaN : Date.parse(input.execClaimedAt);
|
|
39
|
+
if (!Number.isFinite(claimedMs)) return { kind: 'unknown', reason: 'claim time unreadable' };
|
|
40
|
+
const ageMinutes = Math.floor((input.now - claimedMs) / 60_000);
|
|
41
|
+
if (ageMinutes >= input.staleMinutes) return { kind: 'stale-dead', ageMinutes };
|
|
42
|
+
return { kind: 'held' };
|
|
43
|
+
}
|
package/src/session-retro.ts
CHANGED
|
@@ -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
|
|
542
|
-
// directive; a REAL teach invocation (a TOOL event — prose promises never pay, ADR-001 D4)
|
|
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
|
|
749
|
-
const
|
|
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
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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/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
|
-
|
|
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
|
/**
|