@dzhechkov/harness-cli 0.8.23 → 0.8.24

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/src/cli.ts CHANGED
@@ -92,6 +92,7 @@ import {
92
92
  generatePlugin,
93
93
  publishPackages,
94
94
  runSetup,
95
+ memoryBackendSourceLabel,
95
96
  runMigrate,
96
97
  searchRegistry,
97
98
  runSync,
@@ -318,6 +319,11 @@ import {
318
319
  verifyManifest,
319
320
  hashPackBytes,
320
321
  rewriteWorkspaceSpecs,
322
+ detectSiblingDrift,
323
+ planPackedInstallSmoke,
324
+ judgePackedInstallSmoke,
325
+ type FetchPublished,
326
+ type PackedInstallExecution,
321
327
  listPackFiles,
322
328
  listSignablePackFiles,
323
329
  assertKeyOutsideTree,
@@ -633,7 +639,7 @@ import type { SetupSpec } from '@dzhechkov/harness-core';
633
639
  import type { LogTail } from '@dzhechkov/harness-core';
634
640
  import type { DeadwoodInventoryItem } from '@dzhechkov/harness-core';
635
641
  import type { ContractDiagnostic, ContractEvidenceReader } from '@dzhechkov/harness-core';
636
- import type { ProvenanceMode, PackVerdict, PatternRecord, RecallPatternsOptions, TeachGuardResult, TargetName, IntegrationOutcome, BookKU, HarmonizeReport, ClaimFinding, RecallUsagePatternRow, GateExecution, GateStep, SlopFinding, SlopLintConfig, SlopRegistry } from '@dzhechkov/harness-core';
642
+ import type { ProvenanceMode, PackVerdict, PatternRecord, RecallPatternsOptions, TeachGuardResult, TargetName, IntegrationOutcome, BookKU, HarmonizeReport, ClaimFinding, RecallUsagePatternRow, GateExecution, GateStep, SlopFinding, SlopLintConfig, SlopRegistry, PackedTarballArtifact, PackedTransportSmokeVerdict } from '@dzhechkov/harness-core';
637
643
  import { getPreset, PRESET_NAMES } from '@dzhechkov/harness-presets';
638
644
  import { scanGitHub, analyzeRepo, generateReport, deepAnalyze, scanAllSources, ScoutMemory } from '@dzhechkov/scout';
639
645
 
@@ -925,6 +931,30 @@ export interface CliIo {
925
931
  readonly releaseRunner?: ReleaseExecRunner;
926
932
  /** Post-publish mirror command seam; production uses synchronous shell execution. */
927
933
  readonly publishMirrorRunner?: PublishMirrorRunner;
934
+ /**
935
+ * Test seam for `dz publish`'s sibling-drift gate (feature publish-sibling-drift-gate):
936
+ * overrides the registry fetch (production leaves it unset → real `npm pack` + extract into a
937
+ * temp dir). Tests inject a local directory instead of hitting the real registry.
938
+ */
939
+ readonly publishSiblingDriftFetcher?: FetchPublished;
940
+ /**
941
+ * Test seam for `dz publish`'s packed-install smoke: overrides the pack/install/`--version`
942
+ * subprocesses (production leaves it unset → real `execSync`, stdio piped). Mirrors
943
+ * {@link CliIo.releaseRunner}.
944
+ */
945
+ readonly publishPackedInstallRunner?: ReleaseExecRunner;
946
+ /**
947
+ * AM-1 (feature publish-sibling-drift-gate): overrides EVERY subprocess `publishPackages` would
948
+ * run on a LIVE publish — build, the `npm pack`/`npm publish <tgz>` packedTransport commands, and
949
+ * the `npm view` registry probes (production leaves it unset → real `execSync`, stdio piped).
950
+ * Threaded into `publishPackages`'s `exec` option so a test can drive the FULL live+packedTransport
951
+ * `cmdPublish` path (pack → smoke → publish → registry-confirm) with zero network and zero real
952
+ * `npm publish`.
953
+ */
954
+ readonly publishExecRunner?: (
955
+ command: string,
956
+ options: { cwd?: string | URL | undefined; stdio?: unknown; encoding?: unknown; timeout?: number | undefined; env?: NodeJS.ProcessEnv | undefined },
957
+ ) => string;
928
958
  /**
929
959
  * Test seam for `dz install`: overrides the `npm install` subprocess (production leaves
930
960
  * it unset → real `execSync`, stdio piped). A stub runner that pre-stages a fixture
@@ -6200,7 +6230,15 @@ async function cmdSetup(options: Map<string, string>, flags: Set<string>, cwd: s
6200
6230
 
6201
6231
  // Step 3: Run setup (hooks + memory + config)
6202
6232
  write(`║ 3. Setting up learning environment... ║`);
6203
- const memoryOpt = options.get('memory');
6233
+ const memoryOptRaw = options.get('memory');
6234
+ // FR-1/T3 (feature `setup-backend-from-config`): pass `--memory` through AS-IS — `agentdb`,
6235
+ // `jsonl`, or `undefined` — never collapsed to `undefined` on anything but agentdb. The prior
6236
+ // `memoryOpt === 'agentdb' ? 'agentdb' : undefined` made an explicit `--memory jsonl` INDISTINCT
6237
+ // from "no flag at all", so `runSetup`'s config-aware default (FR-2's downgrade path) could never
6238
+ // fire from the CLI. An unrecognised value (neither `agentdb` nor `jsonl`) still reads as
6239
+ // "no flag" — the same permissive fallback as before.
6240
+ const memoryOpt: 'agentdb' | 'jsonl' | undefined =
6241
+ memoryOptRaw === 'agentdb' ? 'agentdb' : memoryOptRaw === 'jsonl' ? 'jsonl' : undefined;
6204
6242
  // ADR-001 Decision 2 (feature setup-installs-apply-leg): bake THIS CLI's own installed
6205
6243
  // @dzhechkov/harness-core into the generated apply-leg hooks — the installation actually running
6206
6244
  // `dz setup` is the one a consumer's project can always reach, unlike a hard-coded npm prefix
@@ -6217,13 +6255,16 @@ async function cmdSetup(options: Map<string, string>, flags: Set<string>, cwd: s
6217
6255
  projectRoot,
6218
6256
  target,
6219
6257
  preset,
6220
- memory: memoryOpt === 'agentdb' ? 'agentdb' : undefined,
6258
+ memory: memoryOpt,
6221
6259
  noHooks: flags.has('no-hooks'),
6222
6260
  noMemory: flags.has('no-memory'),
6223
6261
  force: flags.has('force'),
6224
6262
  installDriver: flags.has('install-driver'),
6225
6263
  coreDistDir,
6226
6264
  });
6265
+ // FR-3: name the source of the backend actually used — never left to be inferred from the flag
6266
+ // alone, since the backend may now come from `.dz/config.json` or the jsonl default.
6267
+ write(`dz setup: memory backend: ${setupResult.memoryBackend} (${memoryBackendSourceLabel(setupResult.memoryBackendSource)})`);
6227
6268
 
6228
6269
  for (const step of setupResult.steps) {
6229
6270
  const icon = step.status === 'done' ? '✓' : step.status === 'skipped' ? '○' : '✗';
@@ -6275,7 +6316,11 @@ async function cmdSetup(options: Map<string, string>, flags: Set<string>, cwd: s
6275
6316
  // not from package presence — a skipped hook/MCP step must not let the summary claim a store
6276
6317
  // nothing writes to (audit code#3).
6277
6318
  const wiring = setupResult.steps.find((s) => s.name === 'agentdb wiring');
6278
- const backendLabel = memoryOpt === 'agentdb'
6319
+ // Keyed off the RESOLVED backend (setupResult.memoryBackend), not the raw flag: FR-1 means the
6320
+ // flag can be absent while the actual backend is still agentdb (config-sourced) — the old
6321
+ // `memoryOpt === 'agentdb'` check would have mislabeled that run as jsonl right after fixing the
6322
+ // underlying steps to keep it agentdb.
6323
+ const backendLabel = setupResult.memoryBackend === 'agentdb'
6279
6324
  ? (wiring?.status === 'done' ? 'agentdb (.dz/agentdb.db + .dz/agentdb-mcp.db, separate stores)' : `agentdb INCOMPLETE — see setup steps`)
6280
6325
  : 'sessions.jsonl + patterns.jsonl';
6281
6326
  write(`║ Learning: ${backendLabel.padEnd(41)}║`);
@@ -6975,12 +7020,33 @@ function mirrorFailureMessage(error: unknown): string {
6975
7020
  return String(error);
6976
7021
  }
6977
7022
 
7023
+ /**
7024
+ * Scratch root for the packed-install smoke's pack/install dirs (feature
7025
+ * publish-sibling-drift-gate). MEASURED 2026-09-13: npm resolves a LOCAL tarball path (`npm
7026
+ * install <path-to.tgz>`) relative to `os.tmpdir()` — not to cwd — whenever that path sits
7027
+ * INSIDE `os.tmpdir()`, and does the same for the install dir; put pack and install dirs both
7028
+ * under `tmpdir()` and the recorded `file:` spec loses its `tmpdir()` prefix entirely (reproducer:
7029
+ * a fresh `npm pack <src> --pack-destination "$T/pack"` + `cd "$T/install" && npm install
7030
+ * "$T/pack/x.tgz"` with `$T` under `/tmp` silently installs NOTHING — "changed 1 package", empty
7031
+ * node_modules, `reify moves {}` in `--loglevel silly`; the identical commands under `/var/tmp`
7032
+ * install correctly). A directory outside `os.tmpdir()` sidesteps the quirk entirely.
7033
+ */
7034
+ function packedInstallScratchRoot(): string {
7035
+ return existsSync('/var/tmp') ? '/var/tmp' : tmpdir();
7036
+ }
7037
+
6978
7038
  function cmdPublish(
6979
7039
  options: Map<string, string>,
6980
7040
  flags: Set<string>,
6981
7041
  cwd: string,
6982
7042
  writeOutput: Write,
6983
7043
  mirrorRunner?: PublishMirrorRunner,
7044
+ siblingDriftFetcher?: FetchPublished,
7045
+ packedInstallRunner?: ReleaseExecRunner,
7046
+ publishExecRunner?: (
7047
+ command: string,
7048
+ options: { cwd?: string | URL | undefined; stdio?: unknown; encoding?: unknown; timeout?: number | undefined; env?: NodeJS.ProcessEnv | undefined },
7049
+ ) => string,
6984
7050
  ): number {
6985
7051
  const json = flags.has('json');
6986
7052
  // Under --json stdout carries exactly one JSON document, so every human line — guard notes, refusals,
@@ -6989,9 +7055,9 @@ function cmdPublish(
6989
7055
  const write: Write = json ? (line) => { process.stderr.write(`${line}\n`); } : writeOutput;
6990
7056
  // Reject unknown flags/options so a typo (e.g. `--dry-rum`) can NEVER be
6991
7057
  // silently swallowed and flip the command into live-publish mode.
6992
- const allowedFlags = new Set(['dry-run', 'no-dry-run', 'yes', 'confirm', 'bump-only', 'help', 'require-signing', 'provenance', 'no-provenance', 'json', 'no-mirror']);
7058
+ const allowedFlags = new Set(['dry-run', 'no-dry-run', 'yes', 'confirm', 'bump-only', 'help', 'require-signing', 'provenance', 'no-provenance', 'json', 'no-mirror', 'allow-sibling-drift', 'include-drifted']);
6993
7059
  const allowedOptions = new Set(['filter', 'claim-check', 'no-guard', 'sign-key', 'mirror-cmd']);
6994
- const allowedHelp = ' allowed: --dry-run (default), --yes/--confirm/--no-dry-run (go live), --bump-only, --filter <substr>, --claim-check <off|warn|error>, --mirror-cmd <cmd>, --no-mirror, --no-guard "<reason>" (skip the guard pre-flight; logged)';
7060
+ const allowedHelp = ' allowed: --dry-run (default), --yes/--confirm/--no-dry-run (go live), --bump-only, --filter <substr>, --claim-check <off|warn|error>, --mirror-cmd <cmd>, --no-mirror, --no-guard "<reason>" (skip the guard pre-flight; logged), --allow-sibling-drift (override the sibling-drift gate; logged), --include-drifted (auto-extend the batch with a drifted sibling)';
6995
7061
  for (const flag of flags) {
6996
7062
  if (!allowedFlags.has(flag)) {
6997
7063
  write(`dz publish: unknown option --${flag}`);
@@ -7073,12 +7139,269 @@ function cmdPublish(
7073
7139
  const claimCheckOpt = (claimCheckRaw as 'off' | 'warn' | 'error' | undefined) ?? 'warn';
7074
7140
 
7075
7141
  const bumpOnly = flags.has('bump-only');
7076
-
7077
7142
  // SAFETY: dry-run is the DEFAULT. A real publish requires an EXPLICIT opt-in
7078
7143
  // via --yes, --confirm, or --no-dry-run. Without one, we never bump or publish.
7144
+ // Computed HERE (moved up from below the gates, AM-5) so both gates can see it: a dry run keeps
7145
+ // previewing packed-install with the CURRENT pre-bump tarball (nothing to compare a LIVE publish
7146
+ // against yet), while a live run defers the real packed-install-smoke into `publishPackages`'s
7147
+ // `packedTransport` — the one that tests the ACTUAL bytes about to ship (AM-1).
7079
7148
  const wantsLive = flags.has('yes') || flags.has('confirm') || flags.has('no-dry-run');
7080
7149
  const dryRun = !wantsLive;
7081
7150
 
7151
+ // ── FR-1..FR-4 — sibling-drift gate, then packed-install smoke (feature
7152
+ // publish-sibling-drift-gate, ADR-001). The sibling-drift gate runs before the signature gate
7153
+ // and the live-publish banner (so a --include-drifted-expanded batch is checked and shown too).
7154
+ // AM-5: on a DRY RUN both gates always print their verdict, even once sibling-drift already
7155
+ // blocks — the whole point of a preview is full information before anything ships. On a LIVE
7156
+ // run, sibling-drift still refuses immediately (packing/installing a doomed batch wastes real
7157
+ // time); its own packed-install smoke is deferred into `publishPackages`'s `packedTransport`
7158
+ // (AM-1) — the one gate that tests the tarball bytes actually handed to `npm publish`.
7159
+ const allowSiblingDrift = flags.has('allow-sibling-drift');
7160
+ const includeDrifted = flags.has('include-drifted');
7161
+ const allPackages = discoverPackages(cwd);
7162
+ const workspaceVersions = new Map(allPackages.map((p) => [p.name, p.version]));
7163
+ const workspaceDirs = new Map(allPackages.map((p) => [p.name, p.dir]));
7164
+ const matchesFilter = (pk: { name: string; dir: string }): boolean =>
7165
+ filter === undefined || filter.length === 0 || filter.some((f) => pk.name.includes(f) || pk.dir.includes(f));
7166
+ let targets = allPackages.filter(matchesFilter);
7167
+ let batchNames = new Set(targets.map((p) => p.name));
7168
+
7169
+ // Production default: `npm pack <name>@<version>` into a temp dir, extracted. Tests inject a
7170
+ // local directory (ADR-001, "fetchPublished … в тестах — локальный каталог").
7171
+ const fetchPublished: FetchPublished =
7172
+ siblingDriftFetcher ??
7173
+ ((name, version) => {
7174
+ try {
7175
+ const tmp = mkdtempSync(join(tmpdir(), 'dz-sibling-drift-'));
7176
+ execSync(`npm pack ${name}@${version} --pack-destination ${JSON.stringify(tmp)}`, {
7177
+ stdio: 'pipe',
7178
+ encoding: 'utf-8',
7179
+ timeout: 60_000,
7180
+ });
7181
+ const tarball = readdirSync(tmp).find((f) => f.endsWith('.tgz'));
7182
+ if (tarball === undefined) return null;
7183
+ execSync(`tar -xzf ${JSON.stringify(join(tmp, tarball))} -C ${JSON.stringify(tmp)}`, { stdio: 'pipe', timeout: 60_000 });
7184
+ return { dir: join(tmp, 'package') };
7185
+ } catch {
7186
+ return null;
7187
+ }
7188
+ });
7189
+
7190
+ // AM-6: an override (--allow-sibling-drift) is only real once its audit row is DURABLE. A write
7191
+ // failure must refuse the publish rather than print "(logged)" about a log entry that never
7192
+ // landed — the same "absence of a receipt is not success" lesson the registry-probe gate already
7193
+ // enforces for a publish's own confirmation.
7194
+ const auditedOverride = (detail: string, humanMessage: string, pkgNameForBlock: string): boolean => {
7195
+ const wrote = appendPublishGateAudit(cwd, 'sibling-drift', 'warn', detail, '--allow-sibling-drift');
7196
+ if (wrote) {
7197
+ write(`dz publish: ⚠ ${humanMessage} — allowed via --allow-sibling-drift (logged)`);
7198
+ return false;
7199
+ }
7200
+ write(`dz publish: BLOCKED ${pkgNameForBlock} — ${humanMessage}, and the override could not be recorded (audit write failed); refusing rather than proceeding unlogged`);
7201
+ return true;
7202
+ };
7203
+
7204
+ let driftBlocked = 0;
7205
+ const extraBatch = new Set<string>();
7206
+ // AM-2: --include-drifted must reach a FIXED POINT over transitive drifted siblings — a sibling
7207
+ // folded into the batch can itself depend on a drifted sibling outside it, and the round-1 review
7208
+ // (finding 2) showed the single pass never re-checked an EXPANDED batch's own new edges. Capped at
7209
+ // `allPackages.length + 1` rounds (the plan's own "цикл с потолком = число пакетов").
7210
+ const maxRounds = allPackages.length + 1;
7211
+ for (let round = 0; round < maxRounds; round++) {
7212
+ let addedThisRound = false;
7213
+ for (const pk of targets) {
7214
+ let manifestObj: {
7215
+ dependencies?: Record<string, string>;
7216
+ peerDependencies?: Record<string, string>;
7217
+ optionalDependencies?: Record<string, string>;
7218
+ } | undefined;
7219
+ try {
7220
+ manifestObj = JSON.parse(readFileSync(join(pk.dir, 'package.json'), 'utf-8'));
7221
+ } catch (err) {
7222
+ // AM-3: an unreadable/invalid package.json for a BATCH package is an input this HARD gate
7223
+ // cannot build — it must BLOCK, never silently degrade to "no dependencies" (which used to
7224
+ // read as a clean n/a).
7225
+ const reason = `package.json unreadable/invalid (${(err as Error).message.split('\n')[0]})`;
7226
+ if (allowSiblingDrift) {
7227
+ if (auditedOverride(`${pk.name}: ${reason}`, `sibling drift check unavailable for ${pk.name} (${reason})`, pk.name)) driftBlocked++;
7228
+ } else {
7229
+ write(`dz publish: BLOCKED ${pk.name} — sibling drift check unavailable (${reason}); add --allow-sibling-drift to override (logged) or fix the manifest`);
7230
+ driftBlocked++;
7231
+ }
7232
+ continue;
7233
+ }
7234
+ const deps = manifestObj?.dependencies ?? {};
7235
+ const peerDeps = manifestObj?.peerDependencies ?? {};
7236
+ const optionalDeps = manifestObj?.optionalDependencies ?? {};
7237
+
7238
+ // AM-6: a package with no workspace: dependency at all is n/a for THIS gate — recorded as a
7239
+ // pass note, not silence (FR-6 compatibility: output stays unchanged for such a batch).
7240
+ const anyWorkspaceDep = [...Object.values(deps), ...Object.values(peerDeps), ...Object.values(optionalDeps)]
7241
+ .some((spec) => String(spec).startsWith('workspace:'));
7242
+ if (!anyWorkspaceDep) {
7243
+ appendPublishGateAudit(cwd, 'sibling-drift', 'pass', `${pk.name}: n/a — no workspace: dependency declared`);
7244
+ continue;
7245
+ }
7246
+
7247
+ const drifts = detectSiblingDrift({
7248
+ dependencies: deps,
7249
+ peerDependencies: peerDeps,
7250
+ optionalDependencies: optionalDeps,
7251
+ workspaceVersions,
7252
+ workspaceDirs,
7253
+ batch: batchNames,
7254
+ fetchPublished,
7255
+ });
7256
+
7257
+ for (const r of drifts) {
7258
+ if (r.status === 'same') {
7259
+ appendPublishGateAudit(cwd, 'sibling-drift', 'pass', `${r.name}@${r.version} = workspace (dependent: ${pk.name})`);
7260
+ write(`dz publish: ✓ sibling drift: none (${r.name}@${r.version} = workspace)`);
7261
+ } else if (r.status === 'unavailable') {
7262
+ if (allowSiblingDrift) {
7263
+ if (auditedOverride(`${r.name}@${r.version}: ${r.reason}`, `sibling drift check unavailable for ${r.name}@${r.version} (${r.reason})`, pk.name)) driftBlocked++;
7264
+ } else {
7265
+ write(`dz publish: BLOCKED ${pk.name} — sibling drift check unavailable (${r.reason}); add --allow-sibling-drift to override (logged) or check network/registry access`);
7266
+ driftBlocked++;
7267
+ }
7268
+ } else if (includeDrifted) {
7269
+ if (!batchNames.has(r.name) && !extraBatch.has(r.name)) {
7270
+ extraBatch.add(r.name);
7271
+ addedThisRound = true;
7272
+ write(`dz publish: → sibling drift: ${r.name}@${r.version} differs from the workspace (${r.changedFiles.length} file(s)) — adding to the batch via --include-drifted${r.missingExports.length > 0 ? ` (missing exports: ${r.missingExports.join(', ')})` : ''}`);
7273
+ }
7274
+ } else if (allowSiblingDrift) {
7275
+ if (auditedOverride(`${r.name}@${r.version}: ${r.changedFiles.length} file(s) differ from the workspace`, `sibling drift: ${r.name}@${r.version} differs from the workspace (${r.changedFiles.length} file(s))`, pk.name)) driftBlocked++;
7276
+ } else {
7277
+ appendPublishGateAudit(cwd, 'sibling-drift', 'block', `${pk.name} depends on ${r.name}@${r.version}; ${r.changedFiles.length} file(s) differ from the workspace`);
7278
+ const suggestFilter = filterStr !== undefined ? `${filterStr},${r.name}` : `${pk.name},${r.name}`;
7279
+ write(`dz publish: BLOCKED ${pk.name} — sibling drift: @dzhechkov/${r.name.replace(/^@dzhechkov\//, '')}@${r.version} on the registry differs from the workspace (${r.changedFiles.length} file(s)); add ${r.name} to the batch (--filter ${suggestFilter}) or publish it first`);
7280
+ driftBlocked++;
7281
+ }
7282
+ }
7283
+ }
7284
+
7285
+ if (driftBlocked > 0) break; // nothing to expand into a run that already refuses
7286
+ if (!includeDrifted || !addedThisRound) break; // no auto-expand requested, or fixed point reached
7287
+
7288
+ // FR-4: --include-drifted folds the drifted sibling(s) into the batch — they bump patch like
7289
+ // any other package in `publishPackages`' own (unchanged) bump logic. Re-loop: the newly
7290
+ // folded-in sibling(s) may themselves depend on a drifted sibling outside the (now bigger) batch.
7291
+ filter = filter === undefined ? [...batchNames, ...extraBatch] : [...filter, ...extraBatch];
7292
+ targets = allPackages.filter(matchesFilter);
7293
+ batchNames = new Set(targets.map((p) => p.name));
7294
+ }
7295
+
7296
+ const siblingDriftFailed = driftBlocked > 0;
7297
+ if (siblingDriftFailed && !dryRun) {
7298
+ write(`dz publish: refusing to publish (${driftBlocked} sibling-drift violation(s))`);
7299
+ return 1;
7300
+ }
7301
+
7302
+ // FR-3 — packed-install smoke: pack the WHOLE (possibly --include-drifted-expanded) batch,
7303
+ // install every tarball together in a CLEAN dir (out-of-batch siblings resolve from the
7304
+ // registry, exactly like a fresh user's install), then boot every bin with --version.
7305
+ // "n/a" (FR-6) when nothing in the batch has a bin. AM-8: a bin is collected here whether or not
7306
+ // its target file exists YET — a manifest that declares one but ships nothing must BLOCK after a
7307
+ // real install, never silently vanish from the plan (which used to read as n/a, or even skip the
7308
+ // whole gate when it was the batch's only bin).
7309
+ const bins: { pkg: string; binName: string; relPath: string }[] = [];
7310
+ for (const pk of targets) {
7311
+ let manifest: { bin?: string | Record<string, string> } = {};
7312
+ try { manifest = JSON.parse(readFileSync(join(pk.dir, 'package.json'), 'utf-8')); } catch { /* no bin info available */ }
7313
+ if (typeof manifest.bin === 'string') {
7314
+ bins.push({ pkg: pk.name, binName: pk.name.split('/').pop() ?? pk.name, relPath: manifest.bin.replace(/^\.\//, '') });
7315
+ } else if (manifest.bin !== undefined && manifest.bin !== null && typeof manifest.bin === 'object') {
7316
+ for (const [name, relRaw] of Object.entries(manifest.bin)) {
7317
+ bins.push({ pkg: pk.name, binName: name, relPath: String(relRaw).replace(/^\.\//, '') });
7318
+ }
7319
+ }
7320
+ }
7321
+
7322
+ // AM-1/AM-5: the packed-install-smoke PREVIEW below runs on a DRY RUN only, against whatever is
7323
+ // CURRENTLY on disk (pre-bump) — it cannot be the "same bytes that ship" gate AM-1 requires,
7324
+ // because a dry run never bumps/builds/packs anything real to compare against. On a LIVE run the
7325
+ // real gate is `packedTransport` (wired at the `publishPackages` call below), which packs ONCE
7326
+ // post-bump and smokes exactly those tarballs — this preview is skipped entirely then, so its
7327
+ // digest is never confused with the one that actually ships.
7328
+ let packedInstallSmokePreviewFailed = false;
7329
+ if (dryRun) {
7330
+ if (bins.length === 0) {
7331
+ appendPublishGateAudit(cwd, 'packed-install-smoke', 'pass', 'n/a — nothing in the batch declares a bin');
7332
+ write('dz publish: ○ packed install smoke: n/a (nothing in the batch declares a bin)');
7333
+ } else {
7334
+ const scratchRoot = packedInstallScratchRoot();
7335
+ const packDir = mkdtempSync(join(scratchRoot, 'dz-publish-pack-'));
7336
+ const installDir = mkdtempSync(join(scratchRoot, 'dz-publish-install-'));
7337
+ const runSmoke: ReleaseExecRunner =
7338
+ packedInstallRunner ??
7339
+ ((cmd, o) => {
7340
+ try {
7341
+ const stdout = execSync(cmd, { cwd: o.cwd, stdio: 'pipe', encoding: 'utf-8', timeout: o.timeoutMs });
7342
+ return { exitCode: 0, stdout: stdout == null ? '' : String(stdout), stderr: '' };
7343
+ } catch (err) {
7344
+ const e = err as Error & { status?: number | null; signal?: string | null; killed?: boolean; stdout?: unknown; stderr?: unknown };
7345
+ const timedOut = (e.status === null || e.status === undefined) && (e.signal != null || e.killed === true);
7346
+ return {
7347
+ exitCode: typeof e.status === 'number' ? e.status : 1,
7348
+ stdout: e.stdout == null ? '' : String(e.stdout),
7349
+ stderr: e.stderr == null || String(e.stderr).trim() === '' ? formatPublishError(e) : String(e.stderr),
7350
+ timedOut,
7351
+ };
7352
+ }
7353
+ });
7354
+ const smokePlan = planPackedInstallSmoke({
7355
+ packages: targets.map((p) => ({ name: p.name, dir: p.dir, version: p.version })),
7356
+ bins,
7357
+ packDir,
7358
+ installDir,
7359
+ });
7360
+ const smokeExecutions: PackedInstallExecution[] = [];
7361
+ // Lead edit after the live dry-run (13.09 12:05): the preview packed the WORKING directory with
7362
+ // `workspace:^` specs still inside, so `npm install <tgz>` died with EUNSUPPORTEDPROTOCOL — the
7363
+ // preview must stage package.json exactly as the live packedTransport does (sibling pins via
7364
+ // rewriteWorkspaceSpecs, prepublishOnly dropped) and restore the originals afterwards.
7365
+ const stagedOriginals: Array<{ path: string; text: string }> = [];
7366
+ try {
7367
+ for (const p of targets) {
7368
+ const pkgJsonPath = join(p.dir, 'package.json');
7369
+ const original = readFileSync(pkgJsonPath, 'utf-8');
7370
+ const rewritten = JSON.parse(rewriteWorkspaceSpecs(original, workspaceVersions)) as Record<string, unknown>;
7371
+ const scripts = rewritten['scripts'];
7372
+ if (scripts !== null && typeof scripts === 'object' && !Array.isArray(scripts)) delete (scripts as Record<string, unknown>)['prepublishOnly'];
7373
+ stagedOriginals.push({ path: pkgJsonPath, text: original });
7374
+ writeFileSync(pkgJsonPath, JSON.stringify(rewritten, null, 2) + '\n');
7375
+ }
7376
+ for (const step of smokePlan.steps) {
7377
+ const r = runSmoke(step.cmd, { cwd: step.cwd, timeoutMs: step.timeoutMs });
7378
+ smokeExecutions.push({ stepId: step.id, exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr, ...(r.timedOut !== undefined ? { timedOut: r.timedOut } : {}) });
7379
+ }
7380
+ } finally {
7381
+ for (const o of stagedOriginals) { try { writeFileSync(o.path, o.text); } catch (err) { write(`dz publish: ⚠ could not restore ${o.path} after the preview smoke: ${formatPublishError(err)}`); } }
7382
+ }
7383
+ const smokeVerdict = judgePackedInstallSmoke(smokePlan, smokeExecutions);
7384
+ try { rmSync(packDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
7385
+ try { rmSync(installDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
7386
+
7387
+ if (smokeVerdict.ok) {
7388
+ appendPublishGateAudit(cwd, 'packed-install-smoke', 'pass', 'preview: pack/install/--version all clean');
7389
+ write('dz publish: ✓ packed install smoke (preview)');
7390
+ } else {
7391
+ const detail = smokeVerdict.failureDetail ?? smokeVerdict.bins.find((b) => !b.ok)?.detail ?? '(no detail)';
7392
+ appendPublishGateAudit(cwd, 'packed-install-smoke', 'block', detail);
7393
+ write(`dz publish: BLOCKED — packed install smoke failed (preview): ${detail}`);
7394
+ for (const b of smokeVerdict.bins.filter((b) => !b.ok)) write(` ✗ ${b.pkg} (${b.binName}): ${b.detail ?? '(no detail)'}`);
7395
+ packedInstallSmokePreviewFailed = true;
7396
+ }
7397
+ }
7398
+ }
7399
+
7400
+ if (siblingDriftFailed || packedInstallSmokePreviewFailed) {
7401
+ write(`dz publish: refusing to publish (${driftBlocked} sibling-drift violation(s)${packedInstallSmokePreviewFailed ? ', packed install smoke failed' : ''})`);
7402
+ return 1;
7403
+ }
7404
+
7082
7405
  if (!dryRun) {
7083
7406
  // Loud confirmation banner listing exactly what is about to be published.
7084
7407
  const targets = discoverPackages(cwd).filter((p) =>
@@ -7171,12 +7494,77 @@ function cmdPublish(
7171
7494
  // longer exist. Default to the same path `dz sign --init` writes, so the ordinary operator needs no
7172
7495
  // new flag; `--sign-key` overrides it.
7173
7496
  const signKey = (options.get('sign-key') ?? join(homedir(), '.dz', 'keys', 'dz.key')).trim();
7497
+ // AM-1: the packedTransport smoke closure and the actual `npm publish <tgz>` inside
7498
+ // `publishPackages` both read from THIS SAME directory — created once, cleaned up once, after
7499
+ // publishPackages returns (it needs the tarballs on disk through its own publish step).
7500
+ const packedTransportPackDestDir = mkdtempSync(join(packedInstallScratchRoot(), 'dz-publish-packed-'));
7174
7501
  const publishReport = publishPackages(cwd, {
7175
7502
  provenance,
7176
7503
  dryRun,
7177
7504
  filter,
7178
7505
  bumpOnly,
7179
7506
  claimGate: claimCheckOpt,
7507
+ exec: publishExecRunner,
7508
+ packedTransport: {
7509
+ packDestDir: packedTransportPackDestDir,
7510
+ // AM-1: judged ONCE, over every package's packed artifact — nothing in the batch publishes
7511
+ // until this returns ok:true. `bins` (AM-8-fixed: declared bins are collected whether or not
7512
+ // their target file exists yet) was already computed above from the same `targets` this
7513
+ // batch resolves to.
7514
+ smoke: (artifacts): { ok: boolean; reason?: string } => {
7515
+ for (const a of artifacts) write(`dz publish: tarball ${a.name}@${a.newVersion} sha256:${a.sha256}`);
7516
+ if (bins.length === 0) {
7517
+ appendPublishGateAudit(cwd, 'packed-install-smoke', 'pass', 'n/a — nothing in the batch declares a bin');
7518
+ write('dz publish: ○ packed install smoke: n/a (nothing in the batch declares a bin)');
7519
+ return { ok: true };
7520
+ }
7521
+ const scratchRoot = packedInstallScratchRoot();
7522
+ const installDir = mkdtempSync(join(scratchRoot, 'dz-publish-install-'));
7523
+ const runSmoke: ReleaseExecRunner =
7524
+ packedInstallRunner ??
7525
+ ((cmd, o) => {
7526
+ try {
7527
+ const stdout = execSync(cmd, { cwd: o.cwd, stdio: 'pipe', encoding: 'utf-8', timeout: o.timeoutMs });
7528
+ return { exitCode: 0, stdout: stdout == null ? '' : String(stdout), stderr: '' };
7529
+ } catch (err) {
7530
+ const e = err as Error & { status?: number | null; signal?: string | null; killed?: boolean; stdout?: unknown; stderr?: unknown };
7531
+ const timedOut = (e.status === null || e.status === undefined) && (e.signal != null || e.killed === true);
7532
+ return {
7533
+ exitCode: typeof e.status === 'number' ? e.status : 1,
7534
+ stdout: e.stdout == null ? '' : String(e.stdout),
7535
+ stderr: e.stderr == null || String(e.stderr).trim() === '' ? formatPublishError(e) : String(e.stderr),
7536
+ timedOut,
7537
+ };
7538
+ }
7539
+ });
7540
+ const smokePlan = planPackedInstallSmoke({
7541
+ // skipPack (AM-1): these tarballs are ALREADY packed (by publishPackages, above) — a
7542
+ // second, different pack here would smoke bytes other than the ones about to publish.
7543
+ packages: artifacts.map((a) => ({ name: a.name, dir: '(packed already — see skipPack)', version: a.newVersion })),
7544
+ bins,
7545
+ packDir: packedTransportPackDestDir,
7546
+ installDir,
7547
+ skipPack: true,
7548
+ });
7549
+ const smokeExecutions: PackedInstallExecution[] = [];
7550
+ for (const step of smokePlan.steps) {
7551
+ const r = runSmoke(step.cmd, { cwd: step.cwd, timeoutMs: step.timeoutMs });
7552
+ smokeExecutions.push({ stepId: step.id, exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr, ...(r.timedOut !== undefined ? { timedOut: r.timedOut } : {}) });
7553
+ }
7554
+ const verdict = judgePackedInstallSmoke(smokePlan, smokeExecutions);
7555
+ try { rmSync(installDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
7556
+ if (verdict.ok) {
7557
+ appendPublishGateAudit(cwd, 'packed-install-smoke', 'pass', 'pack/install/--version all clean (live, packedTransport)');
7558
+ write('dz publish: ✓ packed install smoke');
7559
+ return { ok: true };
7560
+ }
7561
+ const detail = verdict.failureDetail ?? verdict.bins.find((b) => !b.ok)?.detail ?? '(no detail)';
7562
+ appendPublishGateAudit(cwd, 'packed-install-smoke', 'block', detail);
7563
+ write(`dz publish: BLOCKED — packed install smoke failed: ${detail}`);
7564
+ for (const b of verdict.bins.filter((b) => !b.ok)) write(` ✗ ${b.pkg} (${b.binName}): ${b.detail ?? '(no detail)'}`);
7565
+ return { ok: false, reason: detail };
7566
+ },
7567
+ },
7180
7568
  signKey: signKey === '' ? undefined : resolve(cwd, signKey),
7181
7569
  verifyAfterSign: (packDir: string): { ok: boolean; trustRootPresent: boolean; pack?: string } => {
7182
7570
  // Verify the OUTCOME against the trust root a CONSUMER would use — an existing key may be the
@@ -7250,6 +7638,7 @@ function cmdPublish(
7250
7638
  }
7251
7639
  },
7252
7640
  });
7641
+ try { rmSync(packedTransportPackDestDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
7253
7642
 
7254
7643
  const configMirror = mirrorCommandFromConfig(cwd);
7255
7644
  const configuredCommand = (options.get('mirror-cmd') ?? configMirror.command ?? '').trim();
@@ -7340,6 +7729,9 @@ function cmdPublish(
7340
7729
  ? ` (confirmed by registry after ${pkg.registryProbes} probes)`
7341
7730
  : '';
7342
7731
  write(` ${icon} ${pkg.name.padEnd(35)} ${pkg.oldVersion} → ${pkg.newVersion} ${pkg.status}${receipt}${detail}`);
7732
+ // AM-1: the digest of the EXACT tarball bytes that were smoke-tested AND published — present
7733
+ // only for a packedTransport publish, so "the smoke tested what shipped" is checkable here too.
7734
+ if (pkg.status === 'published' && pkg.sha256 !== undefined) write(` sha256:${pkg.sha256}`);
7343
7735
  if (pkg.status === 'error' && pkg.error) {
7344
7736
  for (const line of pkg.error.split('\n')) write(` ${line}`);
7345
7737
  }
@@ -7676,14 +8068,31 @@ function cmdRelease(options: Map<string, string>, flags: Set<string>, cwd: strin
7676
8068
  factsList = selected;
7677
8069
  }
7678
8070
 
8071
+ // FR-6 (feature publish-sibling-drift-gate): real tmp dirs for the packed-install smoke — only
8072
+ // when something in the set actually has a bin to boot (packing bin-less siblings proves
8073
+ // nothing this gate exists to catch). Planning stays pure (planReleaseGates never mkdtemps
8074
+ // itself); these are cleaned up on every exit path below, dry-run included.
8075
+ const packedInstallEligible = factsList.some((f) => f.bins.some((b) => b.exists));
8076
+ const releaseScratchRoot = packedInstallScratchRoot();
8077
+ const packedInstallDirs = packedInstallEligible
8078
+ ? { packDir: mkdtempSync(join(releaseScratchRoot, 'dz-release-pack-')), installDir: mkdtempSync(join(releaseScratchRoot, 'dz-release-install-')) }
8079
+ : undefined;
8080
+ const cleanupPackedInstallDirs = (): void => {
8081
+ if (packedInstallDirs === undefined) return;
8082
+ try { rmSync(packedInstallDirs.packDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
8083
+ try { rmSync(packedInstallDirs.installDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
8084
+ };
8085
+
7679
8086
  const plan = planReleaseGates(factsList, {
7680
8087
  monorepoRoot: cwd,
7681
8088
  pnpmLockPresent: existsSync(join(cwd, 'pnpm-lock.yaml')),
7682
8089
  includeDevDeps: flags.has('audit-dev'),
8090
+ packedInstall: packedInstallDirs,
7683
8091
  });
7684
8092
 
7685
8093
  // --dry-run: print the full plan, execute NOTHING (deterministic, byte-testable preview).
7686
8094
  if (flags.has('dry-run')) {
8095
+ cleanupPackedInstallDirs();
7687
8096
  if (json) {
7688
8097
  write(JSON.stringify({ dryRun: true, packages: plan.packages, steps: plan.steps, skips: plan.skips, warnings }, null, 2));
7689
8098
  return 0;
@@ -7728,6 +8137,7 @@ function cmdRelease(options: Map<string, string>, flags: Set<string>, cwd: strin
7728
8137
  if (smokeTmp !== undefined) {
7729
8138
  try { rmSync(smokeTmp, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
7730
8139
  }
8140
+ cleanupPackedInstallDirs();
7731
8141
 
7732
8142
  const verdict = classifyGateExecutions(plan, executions);
7733
8143
 
@@ -10455,6 +10865,43 @@ function runGuardEvaluation(root: string, op: string, text: string | undefined,
10455
10865
  return result;
10456
10866
  }
10457
10867
 
10868
+ /**
10869
+ * Feature `publish-sibling-drift-gate` (FR-5/AM-6): both the sibling-drift and packed-install-smoke
10870
+ * gates write to the SAME append-only, hash-chained `.dz/guard-audit.jsonl` the declarative
10871
+ * `dz guard` rules use — visibility for `dz guard promote`/`dz compounding` never depends on
10872
+ * which mechanism produced the finding. `pass` records go through as an informational `note`
10873
+ * (never a violation, so they can never flip the row's own verdict) so a clean check is ALSO on
10874
+ * the record, not just a block or an override (AM-6: "аудит без записи = не аудит").
10875
+ *
10876
+ * Returns whether the write actually landed. Most callers are best-effort (a write failure never
10877
+ * blocks a verdict already decided) — the one exception is an `--allow-sibling-drift` OVERRIDE,
10878
+ * whose caller MUST check this return value: an override is not real without a durable row behind
10879
+ * it (AM-6's load-bearing property — see `auditedOverride` in `cmdPublish`).
10880
+ */
10881
+ function appendPublishGateAudit(
10882
+ root: string,
10883
+ rule: 'sibling-drift' | 'packed-install-smoke',
10884
+ verdict: 'pass' | 'warn' | 'block',
10885
+ detail: string,
10886
+ overrideReason?: string,
10887
+ ): boolean {
10888
+ try {
10889
+ const rec = auditRecord(
10890
+ verdict === 'pass'
10891
+ ? { op: 'publish', verdict, violations: [], checked: [rule], notEstablished: [], notes: [`${rule}: ${detail}`] }
10892
+ : { op: 'publish', verdict, violations: [{ rule, severity: 'hard', detail }], checked: [rule], notEstablished: [] },
10893
+ new Date().toISOString(),
10894
+ overrideReason !== undefined ? { reason: overrideReason } : undefined,
10895
+ );
10896
+ mkdirSync(join(root, '.dz'), { recursive: true });
10897
+ const auditPath = join(root, '.dz', 'guard-audit.jsonl');
10898
+ writeFileSync(auditPath, appendChainedLines([rec], readLogTail(auditPath)), { flag: 'a' });
10899
+ return true;
10900
+ } catch {
10901
+ return false; // audit write failed — the caller decides whether that itself is refusable (AM-6)
10902
+ }
10903
+ }
10904
+
10458
10905
  function renderGuardObservation(observation: GuardObservation): string {
10459
10906
  const tag = observation.status === 'unknown' ? 'note' : 'observe';
10460
10907
  return ` [${tag}] ${observation.rule} ${observation.scope}: ${observation.detail} [${observation.status}]`;
@@ -16903,7 +17350,17 @@ async function cmdQeBridge(options: Map<string, string>, flags: Set<string>, cwd
16903
17350
  );
16904
17351
  }
16905
17352
 
16906
- const recordText = (reportWritten: boolean): string => `${JSON.stringify(buildBridgeSignoffRecord(signoff, {
17353
+ // writeSequence (qe-bridge-signoff-order): diagnostic sequencing metadata — a self-reported
17354
+ // process-local trace with monotonic stamps taken at each named event (start of the first record
17355
+ // write; after the report landed; just before the atomic update). It replaces a wall-clock
17356
+ // mtime comparison that was a race (the record is rewritten AFTER the report by design). It does
17357
+ // NOT prove write order or crash safety: those are proven by the failpoint test (R4-1) and the
17358
+ // report-failure test. Lead edit after Codex review 2026-09-13: honest step names.
17359
+ const seq: Array<{ step: 'signoff-write-started' | 'report-written' | 'record-update-prepared'; monotonicNs: string }> = [
17360
+ { step: 'signoff-write-started', monotonicNs: String(process.hrtime.bigint()) },
17361
+ ];
17362
+
17363
+ const recordText = (reportWritten: boolean, writeSequence: typeof seq): string => `${JSON.stringify(buildBridgeSignoffRecord(signoff, {
16907
17364
  runId,
16908
17365
  claudeBin: resolvedBin,
16909
17366
  binOverride,
@@ -16912,12 +17369,13 @@ async function cmdQeBridge(options: Map<string, string>, flags: Set<string>, cwd
16912
17369
  rawStdoutFile,
16913
17370
  promptSha256,
16914
17371
  ...(parsed.channels === undefined ? {} : { channels: parsed.channels }),
17372
+ writeSequence,
16915
17373
  }), null, 2)}\n`;
16916
17374
 
16917
17375
  let signoffPath: string;
16918
17376
  try {
16919
17377
  signoffPath = uniquePath(join(stateDir, `signoff-${runId}`), '.json');
16920
- writeNewFileOrThrow(signoffPath, recordText(false));
17378
+ writeNewFileOrThrow(signoffPath, recordText(false, seq));
16921
17379
  } catch (error) {
16922
17380
  return failRun(
16923
17381
  'audit-write-failed',
@@ -16935,6 +17393,10 @@ async function cmdQeBridge(options: Map<string, string>, flags: Set<string>, cwd
16935
17393
  }
16936
17394
 
16937
17395
  if (reportError === null) {
17396
+ // the report is on disk: the sequence gains a step BEFORE the `reportWritten:true` record
17397
+ // write, not after — an observer reading the eventual writeSequence must see the report step
17398
+ // land before the record-update step that persists it.
17399
+ seq.push({ step: 'report-written', monotonicNs: String(process.hrtime.bigint()) });
16938
17400
  // the ONLY moment `reportWritten:true` may appear: after the report is on disk
16939
17401
  try {
16940
17402
  // ATOMIC (R4-1): write a sibling temp file, then rename() over the original. On the same
@@ -16943,7 +17405,8 @@ async function cmdQeBridge(options: Map<string, string>, flags: Set<string>, cwd
16943
17405
  // which made the "a crash leaves a record that is true or pessimistic" claim untrue in the
16944
17406
  // one case it was about.
16945
17407
  const tmpPath = `${signoffPath}.tmp.${process.pid}`;
16946
- writeNewFileOrThrow(tmpPath, recordText(true));
17408
+ seq.push({ step: 'record-update-prepared', monotonicNs: String(process.hrtime.bigint()) });
17409
+ writeNewFileOrThrow(tmpPath, recordText(true, seq));
16947
17410
  if (process.env[QE_BRIDGE_FAILPOINT_ENV] === 'hang-before-rename') {
16948
17411
  // test-only: stop dead INSIDE the window, so a SIGKILL can prove the property
16949
17412
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 600_000);
@@ -19681,7 +20144,7 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
19681
20144
  case 'auto-canonicalize':
19682
20145
  return await cmdAutoCanonicalize(options, cwd, write);
19683
20146
  case 'publish':
19684
- return cmdPublish(options, flags, cwd, write, io.publishMirrorRunner);
20147
+ return cmdPublish(options, flags, cwd, write, io.publishMirrorRunner, io.publishSiblingDriftFetcher, io.publishPackedInstallRunner, io.publishExecRunner);
19685
20148
  case 'release':
19686
20149
  return cmdRelease(options, flags, cwd, write, io.releaseRunner);
19687
20150
  case 'parity':