@dzhechkov/harness-core 0.8.33 → 0.8.35

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 (53) hide show
  1. package/.dz-manifest.json +52 -52
  2. package/README.md +228 -5
  3. package/dist/agentdb-index.d.ts +39 -7
  4. package/dist/agentdb-index.d.ts.map +1 -1
  5. package/dist/agentdb-index.js +217 -23
  6. package/dist/agentdb-index.js.map +1 -1
  7. package/dist/apply-leg.d.ts +197 -6
  8. package/dist/apply-leg.d.ts.map +1 -1
  9. package/dist/apply-leg.js +858 -46
  10. package/dist/apply-leg.js.map +1 -1
  11. package/dist/index.d.ts +7 -6
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +9 -4
  14. package/dist/index.js.map +1 -1
  15. package/dist/mutation-gate.d.ts +19 -0
  16. package/dist/mutation-gate.d.ts.map +1 -1
  17. package/dist/mutation-gate.js +37 -1
  18. package/dist/mutation-gate.js.map +1 -1
  19. package/dist/operations.d.ts +16 -1
  20. package/dist/operations.d.ts.map +1 -1
  21. package/dist/operations.js +115 -13
  22. package/dist/operations.js.map +1 -1
  23. package/dist/publish-sibling-drift.d.ts +72 -0
  24. package/dist/publish-sibling-drift.d.ts.map +1 -1
  25. package/dist/publish-sibling-drift.js +150 -4
  26. package/dist/publish-sibling-drift.js.map +1 -1
  27. package/dist/release.d.ts +72 -0
  28. package/dist/release.d.ts.map +1 -1
  29. package/dist/release.js +236 -19
  30. package/dist/release.js.map +1 -1
  31. package/dist/setup.d.ts.map +1 -1
  32. package/dist/setup.js +90 -14
  33. package/dist/setup.js.map +1 -1
  34. package/dist/skills.d.ts +87 -3
  35. package/dist/skills.d.ts.map +1 -1
  36. package/dist/skills.js +266 -15
  37. package/dist/skills.js.map +1 -1
  38. package/dist/vector-tier.d.ts +27 -2
  39. package/dist/vector-tier.d.ts.map +1 -1
  40. package/dist/vector-tier.js +117 -4
  41. package/dist/vector-tier.js.map +1 -1
  42. package/package.json +2 -2
  43. package/sbom.json +51 -51
  44. package/src/agentdb-index.ts +223 -24
  45. package/src/apply-leg.ts +875 -46
  46. package/src/index.ts +18 -2
  47. package/src/mutation-gate.ts +58 -2
  48. package/src/operations.ts +117 -14
  49. package/src/publish-sibling-drift.ts +209 -4
  50. package/src/release.ts +263 -17
  51. package/src/setup.ts +81 -16
  52. package/src/skills.ts +303 -14
  53. package/src/vector-tier.ts +157 -5
package/src/index.ts CHANGED
@@ -12,6 +12,12 @@ export const HARNESS_CORE_VERSION: string =
12
12
 
13
13
  export { REPOSITORY_ORIGIN } from './repository-origin.js';
14
14
 
15
+ // Fix-round 1 (feature recall-short-terms, Codex HIGH-1c): `dz recall`'s CLI printer needs the
16
+ // single source of truth for "why did this query tokenize to nothing" without harness-cli taking
17
+ // a new direct dependency on `@dzhechkov/memory` (a publishing-surface change outside this
18
+ // feature's scope) — harness-core already depends on memory, so it re-exports the one helper.
19
+ export { noSearchableTermsReason } from '@dzhechkov/memory';
20
+
15
21
  export * from './skills.js';
16
22
  export * from './apply.js';
17
23
  export {
@@ -246,6 +252,7 @@ export {
246
252
  applyLegHookEntries,
247
253
  applyLegStatus,
248
254
  applyLegReasonMessage,
255
+ probeApplyLeg,
249
256
  resolveIdleMs,
250
257
  IDLE_MS_INT32_MAX,
251
258
  } from './apply-leg.js';
@@ -255,6 +262,7 @@ export type {
255
262
  ApplyLegHookPresence,
256
263
  ApplyLegStatus,
257
264
  ApplyLegNotInstalledReason,
265
+ ApplyLegProbeResult,
258
266
  ResolvedIdleMs,
259
267
  } from './apply-leg.js';
260
268
  // embed-socket-short-path: the ONE resolver the daemon (inlined text), the recall hook (inlined
@@ -303,7 +311,7 @@ export {
303
311
  segmentRun,
304
312
  } from './eta.js';
305
313
  export type { CheckpointObservation, EtaEstimate, EtaInput, IncompleteCoverageSample, RunSegment, StageDurationSample, StageSample } from './eta.js';
306
- export { indexPatternsToAgentdb, resolveAgentdbPath, searchAgentdbPatterns, listAgentdbDzIds, resolveAgentdbEmbedder, cosineSimilarity, importVectorsToAgentdb, reindexAgentdbRows, bumpAgentdbUses, clearAgentdbQuarantine, deleteAgentdbByDzIds, readAgentdbRowsByTaskType, DZ_OWNED_TASK_TYPES, ensureAgentdbSchema } from './agentdb-index.js';
314
+ export { indexPatternsToAgentdb, resolveAgentdbPath, searchAgentdbPatterns, listAgentdbDzIds, resolveAgentdbEmbedder, resetAgentdbEmbedderCache, getAgentdbEmbedderCacheStats, cosineSimilarity, importVectorsToAgentdb, reindexAgentdbRows, bumpAgentdbUses, clearAgentdbQuarantine, deleteAgentdbByDzIds, readAgentdbRowsByTaskType, DZ_OWNED_TASK_TYPES, ensureAgentdbSchema, readStoreGeneration, bumpStoreGeneration } from './agentdb-index.js';
307
315
  export type { AgentdbSearchHit, AgentdbSearchResult, AgentdbImportRow } from './agentdb-index.js';
308
316
  export { DEFAULT_EMBED_MODEL, LEGACY_EMBED_MODEL, DEFAULT_EMBED_DIM, KNOWN_EMBED_DIMS, resolveEmbedModel, readEmbedManifest, writeEmbedManifest, embedManifestPath, legacyEmbedManifest } from './embedding-config.js';
309
317
  export type { EmbedModelConfig, EmbedModelSource, EmbedManifest } from './embedding-config.js';
@@ -742,6 +750,8 @@ export {
742
750
  buildReleaseNotes,
743
751
  releaseTagName,
744
752
  firstOutputLine,
753
+ testsFailureDetail,
754
+ outputTail,
745
755
  RELEASE_GATE_ORDER,
746
756
  RELEASE_TIMEOUTS,
747
757
  } from './release.js';
@@ -763,13 +773,19 @@ export type {
763
773
  } from './release.js';
764
774
  export { formatPublishError } from './publish.js';
765
775
  // Sibling-drift + packed-install-smoke gates (feature publish-sibling-drift-gate, ADR-001).
766
- export { detectSiblingDrift } from './publish-sibling-drift.js';
776
+ export { detectSiblingDrift, parseNpmPackInventory } from './publish-sibling-drift.js';
767
777
  export type {
768
778
  SiblingDriftStatus,
779
+ InventorySource,
769
780
  SiblingDriftResult,
770
781
  FetchedPublished,
771
782
  FetchPublished,
772
783
  DetectSiblingDriftOptions,
784
+ PackInventory,
785
+ PackInventoryUnavailable,
786
+ LocalInventoryResult,
787
+ PackedTree,
788
+ LocalInventory,
773
789
  } from './publish-sibling-drift.js';
774
790
  export { planPackedInstallSmoke, judgePackedInstallSmoke, packedTarballName } from './packed-install-smoke.js';
775
791
  export type {
@@ -77,6 +77,22 @@ export interface MutationRegistry {
77
77
  readonly testCommand?: string;
78
78
  /** opt-in proof that the suite harness reached its clean completion path. */
79
79
  readonly requireCompletionReceipt?: boolean;
80
+ /**
81
+ * optional per-registry suite-run ceiling in milliseconds (mutation-gate-timeout-verdict FR-3):
82
+ * a package whose real baseline runs longer than the executor's 300000ms default (e.g. this
83
+ * repo's core package, MEASURED ≈5-8 min) declares its own floor here so `dz mutation-gate` with
84
+ * no `--timeout` flag still succeeds — precedence is flag > this field > the 300000ms default.
85
+ */
86
+ readonly timeoutMs?: number;
87
+ /**
88
+ * optional per-registry vitest worker ceiling (mutation-gate-baseline-honesty FR-2): baseline and
89
+ * mutant runs spawn the package's FULL `testCommand` at vitest's default worker count (= cpu
90
+ * cores), and under embedding-daemon tests (0.7-3.5 GB/process) this repo's core package measured
91
+ * load 62-358 and 0.4-1.8 GB free on an 8-core/16GB box — three full runs died overnight
92
+ * (0bb74d66). The same suite with `--maxWorkers=2` passed (6909/6909). Precedence is the
93
+ * `--max-workers` flag > this field > `min(4, max(1, floor(cpus/2)))`.
94
+ */
95
+ readonly maxWorkers?: number;
80
96
  readonly entries: readonly MutationRegistryEntry[];
81
97
  }
82
98
 
@@ -306,10 +322,12 @@ export function parseMutationRegistry(text: string): ParsedRegistry {
306
322
  let entriesRaw: unknown;
307
323
  let testCommand: string | undefined;
308
324
  let requireCompletionReceipt: boolean | undefined;
325
+ let timeoutMs: number | undefined;
326
+ let maxWorkers: number | undefined;
309
327
  if (Array.isArray(raw)) {
310
328
  entriesRaw = raw;
311
329
  } else if (raw && typeof raw === 'object') {
312
- const obj = raw as { testCommand?: unknown; requireCompletionReceipt?: unknown; entries?: unknown };
330
+ const obj = raw as { testCommand?: unknown; requireCompletionReceipt?: unknown; timeoutMs?: unknown; maxWorkers?: unknown; entries?: unknown };
313
331
  entriesRaw = obj.entries;
314
332
  if (obj.testCommand !== undefined) {
315
333
  if (typeof obj.testCommand !== 'string' || obj.testCommand.trim() === '') {
@@ -323,6 +341,18 @@ export function parseMutationRegistry(text: string): ParsedRegistry {
323
341
  }
324
342
  requireCompletionReceipt = obj.requireCompletionReceipt;
325
343
  }
344
+ if (obj.timeoutMs !== undefined) {
345
+ if (typeof obj.timeoutMs !== 'number' || !Number.isFinite(obj.timeoutMs) || obj.timeoutMs <= 0) {
346
+ return { registry: null, entryResults: [], errors: ['timeoutMs must be a finite number > 0 when present'] };
347
+ }
348
+ timeoutMs = obj.timeoutMs;
349
+ }
350
+ if (obj.maxWorkers !== undefined) {
351
+ if (typeof obj.maxWorkers !== 'number' || !Number.isInteger(obj.maxWorkers) || obj.maxWorkers < 1) {
352
+ return { registry: null, entryResults: [], errors: ['maxWorkers must be a positive integer when present'] };
353
+ }
354
+ maxWorkers = obj.maxWorkers;
355
+ }
326
356
  }
327
357
  if (!Array.isArray(entriesRaw)) {
328
358
  return { registry: null, entryResults: [], errors: ['registry must be an array of entries or {testCommand?, requireCompletionReceipt?, entries: [...]}'] };
@@ -439,6 +469,8 @@ export function parseMutationRegistry(text: string): ParsedRegistry {
439
469
  registry: {
440
470
  ...(testCommand !== undefined ? { testCommand } : {}),
441
471
  ...(requireCompletionReceipt !== undefined ? { requireCompletionReceipt } : {}),
472
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
473
+ ...(maxWorkers !== undefined ? { maxWorkers } : {}),
442
474
  entries,
443
475
  },
444
476
  entryResults,
@@ -446,6 +478,26 @@ export function parseMutationRegistry(text: string): ParsedRegistry {
446
478
  };
447
479
  }
448
480
 
481
+ // ── Feature scoping (qe-step-gate-scoped-to-feature) — pure registry diff, no IO ───────────────
482
+ //
483
+ // Step 8's QE mutation gate ran the FULL registry unconditionally (MEASURED 2026-09-12: 358
484
+ // entries on this repo's core package, 30-40 minutes, timed out INCONCLUSIVE every time), though a
485
+ // feature only owns its own touched files and any entries it newly declares. The `--touched` and
486
+ // `--added-since` CLI selectors (harness-cli's executor) scope the run; the executor reads the base
487
+ // registry via `git show <ref>:<path>` (I/O) and hands both parsed registries to this PURE diff so
488
+ // the comparison itself stays testable without a filesystem or git process (NFR-1).
489
+
490
+ /** Entry ids present in `current` but absent from `base` (by id, not by content). A `null` base
491
+ * means the registry did not exist at the reference point — every current entry counts as added. */
492
+ export function registryEntriesAddedSince(
493
+ base: MutationRegistry | null,
494
+ current: MutationRegistry,
495
+ ): string[] {
496
+ if (base === null) return current.entries.map((entry) => entry.id);
497
+ const baseIds = new Set(base.entries.map((entry) => entry.id));
498
+ return current.entries.filter((entry) => !baseIds.has(entry.id)).map((entry) => entry.id);
499
+ }
500
+
449
501
  // ── Mutation application — exact text surgery, exactly once (rule 1) ──────────────────────────
450
502
 
451
503
  export interface AppliedMutation {
@@ -732,7 +784,11 @@ export function attributeBaselineRedness(
732
784
  if (file !== null && !files.includes(file)) files.push(file);
733
785
  };
734
786
 
735
- const vitestMatches = [...output.matchAll(/^\s*FAIL\s+(\S+)/gm)];
787
+ // mutation-gate-baseline-honesty FR-1: vitest 3 prints an optional POOL LABEL between `FAIL` and
788
+ // the file path (`FAIL |serial| test/x.test.ts > case`, `FAIL |parallel| …`) — the plain
789
+ // `(\S+)` used to capture the label itself as "the file", which normaliseReportedFile then
790
+ // rejects, turning a perfectly parseable red run into `unparseable from runner output`.
791
+ const vitestMatches = [...output.matchAll(/^\s*FAIL\s+(?:\|[^|\n]*\|\s+)?(\S+)/gm)];
736
792
  for (const match of vitestMatches) add(match[1] ?? '');
737
793
 
738
794
  const tapMatches = [...output.matchAll(/^not ok \d+\s+-\s+(.+)$/gm)];
package/src/operations.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * @packageDocumentation
7
7
  */
8
8
 
9
- import { execFileSync, spawnSync } from 'node:child_process';
9
+ import { execFileSync, spawnSync, type SpawnSyncOptionsWithStringEncoding } from 'node:child_process';
10
10
  import { randomBytes } from 'node:crypto';
11
11
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
12
12
  import { homedir, tmpdir } from 'node:os';
@@ -1437,18 +1437,34 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
1437
1437
  const WRITER_EVENTS = ['SessionStart', 'SessionEnd', 'PreCompact'];
1438
1438
  let eventsWithWriter: string[] = [];
1439
1439
  let settingsReadable = false;
1440
+ // Lead edit after Codex review (finding 1, 2026-09-13): three states, not a boolean — an
1441
+ // EXISTING settings.json that cannot be parsed is "unknowable", never "agrees".
1442
+ const settingsPath = join(root, '.claude', 'settings.json');
1443
+ const settingsPresent = existsSync(settingsPath);
1440
1444
  try {
1441
- const settings = JSON.parse(readFileSync(join(root, '.claude', 'settings.json'), 'utf-8')) as {
1445
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf-8')) as {
1442
1446
  hooks?: Record<string, unknown[]>;
1443
1447
  };
1444
- settingsReadable = true;
1445
1448
  eventsWithWriter = WRITER_EVENTS.filter((ev) => (settings.hooks?.[ev] ?? []).some((h) => commandsOf(h).some(invokesWriter)));
1449
+ // Lead edit after Codex round 2 (new finding 2): "readable" means the hooks were actually
1450
+ // INSPECTED — a parseable but malformed shape (`null`, a non-array event) throws inside the
1451
+ // traversal and must land in the "cannot be compared" row, never in the OK row.
1452
+ settingsReadable = true;
1446
1453
  } catch {
1447
1454
  }
1455
+ const settingsUnreadable = settingsPresent && !settingsReadable;
1448
1456
  const allWired = eventsWithWriter.length === WRITER_EVENTS.length;
1449
1457
  const hooksInvokeAgentdbWriter = eventsWithWriter.length > 0;
1450
1458
 
1451
- if (configuredMemoryBackend === 'agentdb' && !allWired) {
1459
+ if (settingsUnreadable) {
1460
+ // Lead edit after Codex round 2: precedence — an unparseable settings.json is diagnosed FIRST for
1461
+ // both backends; the hooks are UNKNOWABLE, so neither "not wired" nor "in agreement" may be claimed.
1462
+ checks.push({
1463
+ name: 'memory hooks match config',
1464
+ ok: false,
1465
+ detail: `.dz/config.json says memory.backend=${configuredMemoryBackend} but .claude/settings.json exists and could not be parsed or inspected (invalid JSON or a malformed hooks shape) — the hooks cannot be compared; fix the file (or re-run dz setup)`,
1466
+ });
1467
+ } else if (configuredMemoryBackend === 'agentdb' && !allWired) {
1452
1468
  checks.push({
1453
1469
  name: 'memory hooks match config',
1454
1470
  ok: false,
@@ -1460,6 +1476,15 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
1460
1476
  ok: false,
1461
1477
  detail: '.dz/config.json says memory.backend=jsonl but SessionStart/SessionEnd/PreCompact hooks still invoke agentdb-writer.mjs — run: dz setup --target claude-code --memory jsonl (or --memory agentdb to keep agentdb and bring the config back in sync)',
1462
1478
  });
1479
+ } else {
1480
+ // the OK receipt comes from the SAME comparison that produces the red rows (all three events observed)
1481
+ checks.push({
1482
+ name: 'memory hooks match config',
1483
+ ok: true,
1484
+ detail: configuredMemoryBackend === 'agentdb'
1485
+ ? 'memory.backend=agentdb — SessionStart/SessionEnd/PreCompact all invoke .dz/agentdb-writer.mjs'
1486
+ : `memory.backend=jsonl — SessionStart/SessionEnd/PreCompact do not invoke .dz/agentdb-writer.mjs${settingsPresent ? '' : ' (no .claude/settings.json — no hooks at all)'}`,
1487
+ });
1463
1488
  }
1464
1489
  }
1465
1490
  // configuredMemoryBackend === 'unknown': no .dz/config.json yet — nothing to compare, same
@@ -1498,13 +1523,52 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
1498
1523
  resolved.reason === 'tmpdir-short'
1499
1524
  ? ` (tmpdir-short: project path ${projectPathBytes} bytes > ${EMBED_SOCKET_PATH_BYTES_LIMIT})`
1500
1525
  : '';
1526
+ // FR-6 (hook-recall-hybrid-parity): a socket that merely EXISTS is not proof of what it
1527
+ // answers with — probe it. A non-live fixture (a plain file, no listener) fails the probe
1528
+ // near-instantly and this note stays empty, so every pre-existing detail string here is
1529
+ // untouched.
1530
+ // AM-8 (fix round 1): the socket-speaking probe itself now lives in apply-leg.ts (the
1531
+ // module that already owns the daemon's wire protocol + socket-path resolution) rather
1532
+ // than duplicating a `node:net` IO surface here — dynamic `import()`, same pattern as
1533
+ // `embed-socket-path.js` two lines above, so operations.ts carries no new top-level IO
1534
+ // import for this.
1535
+ const { probeRecallEngine } = await import('./apply-leg.js');
1536
+ const engine = sockAlive ? await probeRecallEngine(resolved.path) : undefined;
1537
+ const engineNote = engine !== undefined ? ` (engine: ${engine})` : '';
1501
1538
  checks.push({
1502
1539
  name: 'apply-leg alive (embed daemon)',
1503
1540
  ok: sockAlive,
1504
1541
  detail: sockAlive
1505
- ? `embed socket present at ${resolved.path}${tmpdirNote} — recall injection can run`
1542
+ ? `embed socket present at ${resolved.path}${tmpdirNote}${engineNote} — recall injection can run`
1506
1543
  : `embed socket ABSENT at ${resolved.path}: the recall hook is wired but cannot inject (the hook self-heals on the next prompt; a persistent absence means the daemon cannot start)`,
1507
1544
  });
1545
+
1546
+ // APPLY-LEG INJECTS — live probe (feature `apply-leg-never-silent`, ADR-001 Decision 1).
1547
+ // Issue #2's whole defect was that `installed`/`alive` above can ALL read green while the
1548
+ // leg injects nothing in every session but one (a foreign install-root symptom the file-
1549
+ // presence and socket-presence checks above cannot see by construction — they check for
1550
+ // the RIGHT FILES at the RIGHT PATH, never whether the deployed COMMAND actually resolves
1551
+ // to this project's store from a foreign cwd). This row is the difference: it runs the
1552
+ // ACTUAL configured hook command end-to-end and is green ONLY on an observed injection.
1553
+ // Deliberately a SEPARATE try/catch from the socket-alive check above: a probe failure
1554
+ // must never suppress the (already useful) socket-presence row, and vice versa.
1555
+ try {
1556
+ const { probeApplyLeg } = await import('./apply-leg.js');
1557
+ const probe = await probeApplyLeg(root);
1558
+ checks.push({
1559
+ name: 'apply-leg injects (live probe)',
1560
+ ok: probe.ok,
1561
+ detail: probe.ok
1562
+ ? `live probe injected its beacon lesson in ${probe.elapsedMs}ms — the recall hook actually finds this project's store`
1563
+ : `installed but silent: ${probe.reason ?? 'unknown'} (probed in ${probe.elapsedMs}ms) — dz setup wrote the hook, but it is not injecting anything into real sessions`,
1564
+ });
1565
+ } catch (err) {
1566
+ checks.push({
1567
+ name: 'apply-leg injects (live probe)',
1568
+ ok: false,
1569
+ detail: `live probe could not run: ${err instanceof Error ? err.message : String(err)}`,
1570
+ });
1571
+ }
1508
1572
  }
1509
1573
  }
1510
1574
  } catch {
@@ -1815,18 +1879,57 @@ function probeCodexVersion(): string | null {
1815
1879
  * with the helper's own self-failure note unable to fire because the process never started.
1816
1880
  * Grading on file presence would call that "installed".
1817
1881
  */
1818
- export function probeHookLiveness(command: string, payload: string): { readonly status: number | null; readonly stderr: string } {
1882
+ /**
1883
+ * `opts` (feature `apply-leg-never-silent`, ADR-001 D1): additive, optional — every pre-existing
1884
+ * 2-arg caller (the Codex veto-hook liveness checks above) is unaffected. `cwd`/`env` let a caller
1885
+ * reproduce the EXACT conditions a real invoking session presents (a foreign cwd, an overridden
1886
+ * `CLAUDE_PROJECT_DIR`) rather than always running from THIS process's own cwd/env — the apply-leg
1887
+ * live probe needs exactly that to prove install-root resolution end-to-end, not merely structurally.
1888
+ * `stdout` is returned alongside `stderr`/`status` for the same reason: a UserPromptSubmit hook's
1889
+ * payload (`hookSpecificOutput.additionalContext`) rides stdout, not stderr (see `probeApplyLeg`'s
1890
+ * own doc comment for the measured stderr-visibility fact this displaces).
1891
+ */
1892
+ export function probeHookLiveness(
1893
+ command: string,
1894
+ payload: string,
1895
+ opts: { readonly cwd?: string; readonly env?: Readonly<Record<string, string>>; readonly timeoutMs?: number } = {},
1896
+ ): { readonly status: number | null; readonly stdout: string; readonly stderr: string } {
1819
1897
  const shell = process.env['SHELL'] ?? '/bin/sh';
1820
1898
  try {
1821
- const res = spawnSync(shell, ['-lc', command], {
1899
+ // AM-5 (fix round 1, apply-leg-never-silent): `detached: true` puts the shell in its OWN
1900
+ // process GROUP (pgid === its own pid) instead of sharing the caller's — `spawnSync`'s own
1901
+ // timeout kill signals only the DIRECT child (the shell), never anything the shell forked, so a
1902
+ // `node "<hook>" || true` grandchild that is still running when the shell dies is orphaned but
1903
+ // free to keep touching whatever this probe is about to remove (the beacon, the temp cwd).
1904
+ // Killing the NEGATIVE pid below reaches the whole group in one signal. `@types/node`'s
1905
+ // `SpawnSyncOptions` does not DECLARE `detached` (only the async `SpawnOptions` does) — MEASURED
1906
+ // this is a typings gap, not a runtime one: a real child under `spawnSync(..., {detached:true})`
1907
+ // reports its own pgid === its own pid (verified with `ps -o pgid=`), exactly as it would under
1908
+ // async `spawn`. Widened via an inline type intersection rather than `as any` so every OTHER key
1909
+ // stays checked.
1910
+ const spawnOpts: SpawnSyncOptionsWithStringEncoding & { readonly detached?: boolean } = {
1822
1911
  input: payload,
1823
1912
  encoding: 'utf8',
1824
- timeout: 20_000,
1825
- env: { ...process.env, DZ_HOOK_LIVENESS_PROBE: '1' },
1826
- });
1827
- return { status: res.status, stderr: res.stderr ?? '' };
1913
+ timeout: opts.timeoutMs ?? 20_000,
1914
+ detached: true,
1915
+ ...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
1916
+ env: { ...process.env, DZ_HOOK_LIVENESS_PROBE: '1', ...(opts.env ?? {}) },
1917
+ };
1918
+ const res = spawnSync(shell, ['-lc', command], spawnOpts);
1919
+ // Belt, run on EVERY outcome (timeout OR a clean, on-time exit): a `node` grandchild can still
1920
+ // be alive in the group even after the shell itself exited normally (e.g. it double-forked or
1921
+ // outlived a `|| true` that already returned). ESRCH — the common, successful case, everything
1922
+ // already exited — is swallowed; this is best-effort cleanup, never a probe failure.
1923
+ if (typeof res.pid === 'number' && res.pid > 0) {
1924
+ try {
1925
+ process.kill(-res.pid, 'SIGKILL');
1926
+ } catch {
1927
+ /* group already gone */
1928
+ }
1929
+ }
1930
+ return { status: res.status, stdout: res.stdout ?? '', stderr: res.stderr ?? '' };
1828
1931
  } catch (err) {
1829
- return { status: null, stderr: String((err as Error)?.message ?? err) };
1932
+ return { status: null, stdout: '', stderr: String((err as Error)?.message ?? err) };
1830
1933
  }
1831
1934
  }
1832
1935
 
@@ -2006,7 +2109,7 @@ export function runSyncCodexHooks(options: CodexHooksSyncOptions = {}): CodexHoo
2006
2109
  // a home that never opted in (the leg-1 F12 lesson).
2007
2110
  if (options.check === true) {
2008
2111
  const drift = diffCodexHooks(currentText, entries, manifest);
2009
- const live = drift.installed && options.liveness !== false ? probeHookLiveness(entries[0]!.command, ALLOWED_PROBE_PAYLOAD) : { status: null, stderr: '' };
2112
+ const live = drift.installed && options.liveness !== false ? probeHookLiveness(entries[0]!.command, ALLOWED_PROBE_PAYLOAD) : { status: null, stdout: '', stderr: '' };
2010
2113
  const executable = drift.installed && (options.liveness === false || live.status === 0 || live.status === 2);
2011
2114
  // `--check` must report the TRUST axis too. Without it the report said `installed && executable`
2012
2115
  // with `trust: 'unknown'`, and the CLI printed a success word for it — the exact G-G/AM-17
@@ -2105,7 +2208,7 @@ export function runSyncCodexHooks(options: CodexHooksSyncOptions = {}): CodexHoo
2105
2208
 
2106
2209
 
2107
2210
  // (6) LIVENESS: exit 127 is ALLOW to the runtime, so it must never be graded as installed (G-L).
2108
- const live = options.liveness === false ? { status: 0, stderr: '' } : probeHookLiveness(entries[0]!.command, ALLOWED_PROBE_PAYLOAD);
2211
+ const live = options.liveness === false ? { status: 0, stdout: '', stderr: '' } : probeHookLiveness(entries[0]!.command, ALLOWED_PROBE_PAYLOAD);
2109
2212
  const executable = live.status === 0 || live.status === 2;
2110
2213
  if (!executable) {
2111
2214
  warnings.push(
@@ -28,10 +28,24 @@
28
28
 
29
29
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
30
30
  import { createHash } from 'node:crypto';
31
- import { join, relative } from 'node:path';
31
+ import { join, relative, isAbsolute } from 'node:path';
32
32
 
33
33
  export type SiblingDriftStatus = 'same' | 'drift' | 'unavailable';
34
34
 
35
+ /**
36
+ * AM-6 (feature publish-gate-audit-durable): which mechanism produced BOTH sides' file inventory
37
+ * for this comparison — named on every result, never left implicit. `'npm-pack'`: the caller
38
+ * injected {@link DetectSiblingDriftOptions.localInventory} (the CLI's `npm pack --dry-run --json`
39
+ * via {@link parseNpmPackInventory}); the workspace side is exactly what npm will ship, and the
40
+ * published side is hashed by a FULL recursive walk of the already-unpacked tarball (AM-1 — the
41
+ * two sides must be symmetric: "every file npm put there" on one side, "every file npm will put
42
+ * there" on the other). `'readdir-approximation'`: no provider was injected — BOTH sides fall back
43
+ * to the pre-existing `dist`/`files`/`bin` walk ({@link shippedInventoryDirs}), which stays
44
+ * symmetric by construction (same function, same rules, both sides) but can miss a file
45
+ * `.npmignore` excludes or include one npm would never ship.
46
+ */
47
+ export type InventorySource = 'npm-pack' | 'pnpm-pack' | 'readdir-approximation';
48
+
35
49
  export interface SiblingDriftResult {
36
50
  readonly name: string;
37
51
  readonly version: string;
@@ -42,6 +56,12 @@ export interface SiblingDriftResult {
42
56
  readonly missingExports: readonly string[];
43
57
  /** Present only when status === 'unavailable'. */
44
58
  readonly reason?: string;
59
+ /**
60
+ * AM-6: named per-result (not merely per-call) because `detectSiblingDrift` short-circuits to
61
+ * `'unavailable'` before ever reaching the hashing step for some entries — those still carry the
62
+ * source that WOULD have been used, so a reader never has to guess.
63
+ */
64
+ readonly inventorySource: InventorySource;
45
65
  }
46
66
 
47
67
  export interface FetchedPublished {
@@ -65,6 +85,21 @@ export interface DetectSiblingDriftOptions {
65
85
  /** Names being published in THIS batch — they publish fresh, so drift cannot be measured against them. */
66
86
  readonly batch: ReadonlySet<string>;
67
87
  readonly fetchPublished: FetchPublished;
88
+ /**
89
+ * FR-3 (feature publish-gate-audit-durable): the LOCAL (workspace) package's shipped-file
90
+ * inventory, asked from npm instead of approximated by walking `dist`/`files`/`bin` by hand —
91
+ * `.npmignore` and nested ignore rules make the hand-rolled walk wrong in both directions (a file
92
+ * npm will never ship can still be read off disk, producing a false drift). No production default
93
+ * lives in THIS module — core stays pure (never spawns `npm`, per the core-boundary import
94
+ * ratchet). The CLI runs `npm pack --dry-run --json` and hands the stdout to
95
+ * {@link parseNpmPackInventory}, then passes the resulting closure here; a caller that injects
96
+ * nothing (`undefined`) makes `detectSiblingDrift` fall back to the named
97
+ * `'readdir-approximation'` {@link InventorySource} on BOTH sides (AM-1) — never a silent "no
98
+ * drift".
99
+ */
100
+ readonly localInventory?: LocalInventory;
101
+ /** Label for the injected provider's source (default `'npm-pack'`); the CLI passes `'pnpm-pack'` for a packed tree. */
102
+ readonly localInventorySource?: InventorySource;
68
103
  }
69
104
 
70
105
  function listFilesRecursive(root: string, dir: string): string[] {
@@ -83,6 +118,120 @@ function sha256(data: string | Buffer): string {
83
118
  return createHash('sha256').update(data).digest('hex');
84
119
  }
85
120
 
121
+ // ── FR-3 (feature publish-gate-audit-durable): ask npm, don't approximate ──────────────────────
122
+
123
+ /** The exact set of relative paths `npm pack` will ship for a package — no `.npmignore` guessing. */
124
+ export interface PackInventory {
125
+ readonly paths: readonly string[];
126
+ }
127
+
128
+ /** `npm pack --dry-run --json` could not be run or answered in a shape this code cannot use. */
129
+ export interface PackInventoryUnavailable {
130
+ readonly unavailable: string;
131
+ }
132
+
133
+ /**
134
+ * Lead fix after the fix-round's live dry-run (2026-09-14 01:02): the workspace side PACKED BY THE
135
+ * LIVE TRANSPORT (`pnpm pack`) and unpacked into `packedDir`. pnpm synthesises a LICENSE from the
136
+ * workspace root into the tarball of a package whose own tree has none; `npm pack --dry-run --json`
137
+ * never lists that file, so a `paths` inventory read every such sibling as "LICENSE only in the
138
+ * published copy" — 2 false drifts (harness-presets, scout) on a tree unchanged since publication.
139
+ * A packed tree is hashed by the SAME full walk as the published side, symmetric by construction.
140
+ */
141
+ export interface PackedTree {
142
+ readonly packedDir: string;
143
+ }
144
+
145
+ export type LocalInventoryResult = PackInventory | PackedTree | PackInventoryUnavailable;
146
+
147
+ /** Ask what npm would ship for the package rooted at `dir`. Injected in tests (no subprocess). */
148
+ export type LocalInventory = (dir: string) => LocalInventoryResult;
149
+
150
+ /**
151
+ * C-1/AM-4: `npm pack --dry-run --json` is a real subprocess call — the CLI caches its result per
152
+ * absolute directory for the lifetime of ONE `dz publish` run (not per package being checked), so
153
+ * a run that checks the same sibling from more than one dependent package packs it only once. Core
154
+ * itself never runs the subprocess or owns the cache (core-boundary import ratchet) — this parser
155
+ * is the pure half only.
156
+ *
157
+ * Parses `npm pack --dry-run --json`'s stdout (an array with one element; `files[]` holds
158
+ * `{path,size,mode}` per shipped path, plus `integrity`/`shasum`/`entryCount`) into the exact set of
159
+ * relative paths npm intends to ship, honouring `.npmignore`/`files`/default-ignore exactly the way
160
+ * a real `npm publish` would. A failure to run, parse, or make sense of the shape — including a
161
+ * malformed individual `files[]` element (AM-5: a corrupt entry is a reason to say the WHOLE
162
+ * inventory is untrustworthy, never a file to silently drop) — is `{ unavailable: reason }`: an
163
+ * input this gate cannot read is a reason to say so, never a silent "nothing to compare".
164
+ */
165
+ export function parseNpmPackInventory(stdout: string): LocalInventoryResult {
166
+ try {
167
+ const parsed: unknown = JSON.parse(stdout);
168
+ const entry = Array.isArray(parsed) ? (parsed[0] as unknown) : undefined;
169
+ const files = entry !== null && typeof entry === 'object' ? (entry as Record<string, unknown>)['files'] : undefined;
170
+ if (!Array.isArray(files)) return { unavailable: 'npm pack --dry-run --json returned no files[] array' };
171
+ // AM-5 (Codex round-1 finding 6, medium): a malformed element used to be `.filter()`ed out
172
+ // silently — a `files[]` entry npm itself always shapes as `{path,size,mode}` should never fail
173
+ // to parse; if one DOES (missing/non-string `path`, or a non-object element), that is a signal
174
+ // this output cannot be trusted, not a single file to quietly drop from the comparison. Say so.
175
+ const paths: string[] = [];
176
+ for (let i = 0; i < files.length; i++) {
177
+ const f = files[i];
178
+ if (f === null || typeof f !== 'object') {
179
+ return { unavailable: `npm pack --dry-run --json files[${i}] is not an object (got ${JSON.stringify(f)})` };
180
+ }
181
+ const path = (f as Record<string, unknown>)['path'];
182
+ if (typeof path !== 'string' || path === '') {
183
+ return { unavailable: `npm pack --dry-run --json files[${i}].path is missing or not a non-empty string (got ${JSON.stringify(path)})` };
184
+ }
185
+ paths.push(path);
186
+ }
187
+ return { paths };
188
+ } catch (err) {
189
+ return { unavailable: `npm pack --dry-run --json output could not be parsed: ${(err as Error).message.split('\n')[0]}` };
190
+ }
191
+ }
192
+
193
+ /** Hash exactly the paths `npm pack` names (package.json normalized separately, as {@link hashTree} does). */
194
+ /**
195
+ * Codex round-2 (2026-09-14) new findings 1+2: a listed path that is absent, a directory, absolute,
196
+ * or that climbs out of `dir` via `..` used to be SKIPPED silently — a comparison over a listing
197
+ * the tree does not match is not a comparison, it is `unavailable`; and an inventory must never
198
+ * read outside the package directory. Thrown here, turned into an `unavailable` result by the caller.
199
+ */
200
+ class InventoryListingError extends Error {}
201
+
202
+ function hashTreeFromPaths(dir: string, paths: readonly string[], manifest: Record<string, unknown>): Map<string, string> {
203
+ const map = new Map<string, string>();
204
+ for (const rel of paths) {
205
+ if (rel === 'package.json') continue; // normalized below, not hashed raw
206
+ if (isAbsolute(rel) || rel.split(/[\\/]/).includes('..')) {
207
+ throw new InventoryListingError(`inventory path "${rel}" is absolute or leaves the package directory`);
208
+ }
209
+ const abs = join(dir, rel);
210
+ if (!existsSync(abs)) throw new InventoryListingError(`inventory path "${rel}" does not exist in the workspace copy`);
211
+ if (statSync(abs).isDirectory()) throw new InventoryListingError(`inventory path "${rel}" is a directory, not a file`);
212
+ map.set(rel, sha256(readFileSync(abs)));
213
+ }
214
+ map.set('package.json', sha256(normalizedPackageJsonText(manifest)));
215
+ return map;
216
+ }
217
+
218
+ /**
219
+ * AM-1: hash EVERY file under `dir` (the already-unpacked published tarball) — the literal "full
220
+ * recursive walk of what npm put there" the amendment names, used ONLY as the symmetric partner to
221
+ * {@link hashTreeFromPaths} (i.e. only when a `localInventory` provider is injected). `dir` here is
222
+ * always an extracted tarball, never the workspace tree, so there is no `.npmignore` to consult:
223
+ * everything that exists on disk is, by construction, exactly what npm shipped.
224
+ */
225
+ function hashTreeFull(dir: string, manifest: Record<string, unknown>): Map<string, string> {
226
+ const map = new Map<string, string>();
227
+ for (const rel of listFilesRecursive(dir, dir)) {
228
+ if (rel === 'package.json') continue; // normalized below, not hashed raw
229
+ map.set(rel, sha256(readFileSync(join(dir, rel))));
230
+ }
231
+ map.set('package.json', sha256(normalizedPackageJsonText(manifest)));
232
+ return map;
233
+ }
234
+
86
235
  /**
87
236
  * package.json PARSED and validated. `null` (never `undefined`) means "this side cannot be built
88
237
  * at all" — AM-3: a missing or unparseable manifest on EITHER side must surface as `unavailable`,
@@ -208,6 +357,9 @@ function missingExportNames(publishedDir: string, workspaceDir: string): string[
208
357
  export function detectSiblingDrift(opts: DetectSiblingDriftOptions): SiblingDriftResult[] {
209
358
  const results: SiblingDriftResult[] = [];
210
359
  const seen = new Set<string>();
360
+ // AM-6: named ONCE per call — every result below (including the short-circuited `unavailable`
361
+ // ones) carries the source that is or would have been used for this comparison.
362
+ const inventorySource: InventorySource = opts.localInventory !== undefined ? (opts.localInventorySource ?? 'npm-pack') : 'readdir-approximation';
211
363
  // AM-3: `optionalDependencies` ships and pins EXACTLY like `dependencies`/`peerDependencies` —
212
364
  // checking only the first two let a stale optional sibling through untouched (round-1 finding 3).
213
365
  const entries = [
@@ -233,6 +385,7 @@ export function detectSiblingDrift(opts: DetectSiblingDriftOptions): SiblingDrif
233
385
  changedFiles: [],
234
386
  missingExports: [],
235
387
  reason: `${dep} is declared workspace:-protocol but is not a known workspace package`,
388
+ inventorySource,
236
389
  });
237
390
  continue;
238
391
  }
@@ -246,6 +399,7 @@ export function detectSiblingDrift(opts: DetectSiblingDriftOptions): SiblingDrif
246
399
  changedFiles: [],
247
400
  missingExports: [],
248
401
  reason: `could not fetch ${dep}@${version} from the registry (network unavailable or the version was not found)`,
402
+ inventorySource,
249
403
  });
250
404
  continue;
251
405
  }
@@ -264,12 +418,62 @@ export function detectSiblingDrift(opts: DetectSiblingDriftOptions): SiblingDrif
264
418
  changedFiles: [],
265
419
  missingExports: [],
266
420
  reason: `${dep}@${version}: package.json in ${side} is missing or not valid JSON — cannot compare`,
421
+ inventorySource,
267
422
  });
268
423
  continue;
269
424
  }
270
425
 
271
- const publishedHashes = hashTree(fetched.dir, publishedManifest);
272
- const workspaceHashes = hashTree(workspaceDir, workspaceManifest);
426
+ // FR-3/AM-1: the LOCAL package's inventory comes from npm, not from a hand-rolled dist/files/bin
427
+ // walk — `.npmignore` (and nested ignore rules) can exclude a file this gate would otherwise walk
428
+ // straight into, producing a false drift about a file npm was never going to ship. AM-1 (Codex
429
+ // review, round-1 finding 3, high): the two sides must stay SYMMETRIC. With a provider injected,
430
+ // the workspace side is npm's OWN shipped-path list; the published side must then be hashed by a
431
+ // FULL recursive walk of the already-unpacked tarball (every file npm actually put there —
432
+ // README/LICENSE included, since npm auto-packs those regardless of `files`), not the narrower
433
+ // `dist`/`files`/`bin` approximation `hashTree` uses — that approximation would silently OMIT an
434
+ // auto-packed README/LICENSE from the published side while the workspace side (via real `npm
435
+ // pack`) correctly includes them, reading as a false "only in workspace" drift. WITHOUT a
436
+ // provider, core has no way to ask npm on either side, so it degrades to the SAME approximation
437
+ // on BOTH sides (symmetry preserved, just cruder) — a named approximation, never a subprocess.
438
+ let workspaceHashes: Map<string, string>;
439
+ let publishedHashes: Map<string, string>;
440
+ if (opts.localInventory !== undefined) {
441
+ const localResult = opts.localInventory(workspaceDir);
442
+ if ('unavailable' in localResult) {
443
+ results.push({
444
+ name: dep,
445
+ version,
446
+ status: 'unavailable',
447
+ changedFiles: [],
448
+ missingExports: [],
449
+ reason: `${dep}@${version}: local package inventory unavailable (${localResult.unavailable})`,
450
+ inventorySource,
451
+ });
452
+ continue;
453
+ }
454
+ try {
455
+ workspaceHashes = 'packedDir' in localResult
456
+ ? hashTreeFull(localResult.packedDir, workspaceManifest)
457
+ : hashTreeFromPaths(workspaceDir, localResult.paths, workspaceManifest);
458
+ } catch (err) {
459
+ if (!(err instanceof InventoryListingError)) throw err;
460
+ results.push({
461
+ name: dep,
462
+ version,
463
+ status: 'unavailable',
464
+ changedFiles: [],
465
+ missingExports: [],
466
+ reason: `${dep}@${version}: local package inventory unusable (${err.message})`,
467
+ inventorySource,
468
+ });
469
+ continue;
470
+ }
471
+ publishedHashes = hashTreeFull(fetched.dir, publishedManifest);
472
+ } else {
473
+ workspaceHashes = hashTree(workspaceDir, workspaceManifest);
474
+ publishedHashes = hashTree(fetched.dir, publishedManifest);
475
+ }
476
+
273
477
  const allKeys = new Set<string>([...publishedHashes.keys(), ...workspaceHashes.keys()]);
274
478
  const changed: string[] = [];
275
479
  for (const key of allKeys) {
@@ -278,7 +482,7 @@ export function detectSiblingDrift(opts: DetectSiblingDriftOptions): SiblingDrif
278
482
  changed.sort();
279
483
 
280
484
  if (changed.length === 0) {
281
- results.push({ name: dep, version, status: 'same', changedFiles: [], missingExports: [] });
485
+ results.push({ name: dep, version, status: 'same', changedFiles: [], missingExports: [], inventorySource });
282
486
  } else {
283
487
  results.push({
284
488
  name: dep,
@@ -286,6 +490,7 @@ export function detectSiblingDrift(opts: DetectSiblingDriftOptions): SiblingDrif
286
490
  status: 'drift',
287
491
  changedFiles: changed,
288
492
  missingExports: missingExportNames(fetched.dir, workspaceDir),
493
+ inventorySource,
289
494
  });
290
495
  }
291
496
  }