@dzhechkov/harness-cli 0.6.1 → 0.7.0

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/dist/cli.js CHANGED
@@ -10,9 +10,9 @@ import { execFileSync, execSync, spawn } from 'node:child_process';
10
10
  import { createHash, randomBytes } from 'node:crypto';
11
11
  import { homedir, tmpdir } from 'node:os';
12
12
  import { createRequire } from 'node:module';
13
- import { createSkill, getSkillInfo, listSkillsDetailed, formatSkillLoadFailures, formatSkillApplyFailures, resolveTargetName, formatTargetProblem, formatTargetAliasNote, TARGET_NAMES_SORTED, runDoctor, runInit, resolvePackageSkillRoots, PACKAGE_SKILL_LAYOUTS, benchmarkSkill, benchmarkSkills, scanMcp, reconcileCapabilities, RECONCILE_BANNER, buildRegistry, discoverSkillPackDirs, checkUpstream, compareSkills, checkAllUpstream, sweepSkillDrift, syncCanonicalSkill, checkUpgrades, discoverPackages, discoverSourcePackages, fetchAllDownloads, filterByCategory, pretrain, recommend, generatePlugin, publishPackages, runSetup, runMigrate, searchRegistry, runSync, runVerify, runInitAgentsMd, runInitGeminiMd, runSyncAgentsPolicy, runSyncCodexHooks, resolveCodexHome, withNamedLockSync,
13
+ import { createSkill, getSkillInfo, listSkillsDetailed, formatSkillLoadFailures, formatSkillApplyFailures, resolveTargetName, formatTargetProblem, formatTargetAliasNote, TARGET_NAMES_SORTED, runDoctor, runInit, resolvePackageSkillRoots, PACKAGE_SKILL_LAYOUTS, benchmarkSkill, benchmarkSkills, scanMcp, reconcileCapabilities, RECONCILE_BANNER, buildRegistry, discoverSkillPackDirs, discoverVerifiablePackDirs, checkUpstream, compareSkills, checkAllUpstream, sweepSkillDrift, syncCanonicalSkill, checkUpgrades, discoverPackages, discoverSourcePackages, fetchAllDownloads, filterByCategory, pretrain, recommend, generatePlugin, publishPackages, runSetup, runMigrate, searchRegistry, runSync, runVerify, runInitAgentsMd, runInitGeminiMd, runSyncAgentsPolicy, runSyncCodexHooks, resolveCodexHome, withNamedLockSync,
14
14
  // dz workflow run (feature dz-workflow-run): the pure scheduler + the dispatch adapters.
15
- TRACE_RUNID_RE, WF_RUN_OWNER_HOST, preflight, runWorkflow, makeClaudePDispatcher, makeCodexExecDispatcher, NamedLockTimeoutError, NamedLockCompromisedError, POLICY_SOURCES, detectPolicyDrift, hasPolicyFence, TARGET_NAMES, buildParityMatrix, downgradeForStaleEvidence, findStaleTranscriptEvidence, TARGET_CAPABILITIES, TARGET_SHORT_LABELS, WORKFLOW_TEMPLATES_RETIRED_MESSAGE, parsePlan, isParseErrors, validatePlan, normalizePlan, planDigest, toTraceProjection, renderPlan, mergeRender, lint, lintExitCode, LOOP_BLOBS, parseTrace, assembleTimeline, runInvariants, deriveAttestation, stampAttestation, corroborate, NOT_WITNESSED, renderTimelineHtml, importEcc, recordPattern, resolveLearningBackend, storeStats, consolidateSessions, pruneNoisePatterns, lessonDeltaReport, removePatternsByIds, snapshotStore, recallHybrid, teachGuard, mirrorPatternsToVector, mirrorEntriesToVector, patternVectorEntry, readMemoryLearningConfig, promotePatterns, quarantineExpiryCandidates, pruneQuarantinePatterns, clearAgentdbQuarantine, vectorMirrorEnabled, vectorTierStatus, resolveVectorEngine, reindexVectorStore, harmonizeVectorStore, importRvfCheckpoint, statuslineData, writeFeatureAdrState, computeUsage, deriveCostLedger, renderCostLedger, verifyCostLedgerReport, writeCostLedgerJsonl, COST_LEDGER_SCOPE, deriveUsageCalibration, normalizeClaudeUsageModelKey, readUsageLimits, parseWeeklyResetAnchor, claimCheck, summarize, queryBookKnowledge, loadStorePatternsSync, patternRecordId, loadStoreRecords, recordToPattern, bundleSkills, brainHome, listBrain, promoteProjectToBrain, updateBrainSource, queryBrain, groundPrompt, expandKu, reindexBrainVectors, buildPrimer, exportBrainSlice, importBrainSlice, registerKusToBrain, RECALL_USAGE_LOG_RELATIVE, RECALL_USAGE_LOG_MAX_BYTES, parseRecallUsageLog, buildRecallUsageReport, EVENT_CHAIN_TAIL_BYTES, EMPTY_LOG_TAIL, readTailInfo, appendChainedLines, verifyEventChainText, buildManifest, buildSbom, resolveTrustRoot, decideVerifyPolicy, generateSigningKeypair, evaluateGuard, resolveRules, auditRecord, guardExitCode, DEFAULT_RULES, parsePnpmLockImporters, scannableStubPath,
15
+ TRACE_RUNID_RE, WF_RUN_OWNER_HOST, preflight, runWorkflow, makeClaudePDispatcher, makeCodexExecDispatcher, NamedLockTimeoutError, NamedLockCompromisedError, POLICY_SOURCES, detectPolicyDrift, hasPolicyFence, TARGET_NAMES, buildParityMatrix, downgradeForStaleEvidence, findStaleTranscriptEvidence, TARGET_CAPABILITIES, TARGET_SHORT_LABELS, WORKFLOW_TEMPLATES_RETIRED_MESSAGE, parsePlan, isParseErrors, validatePlan, normalizePlan, planDigest, toTraceProjection, renderPlan, mergeRender, lint, lintExitCode, LOOP_BLOBS, parseTrace, assembleTimeline, runInvariants, deriveAttestation, stampAttestation, corroborate, NOT_WITNESSED, renderTimelineHtml, importEcc, recordPattern, resolveLearningBackend, storeStats, consolidateSessions, pruneNoisePatterns, lessonDeltaReport, removePatternsByIds, snapshotStore, recallHybrid, teachGuard, mirrorPatternsToVector, mirrorEntriesToVector, patternVectorEntry, readMemoryLearningConfig, promotePatterns, quarantineExpiryCandidates, pruneQuarantinePatterns, clearAgentdbQuarantine, vectorMirrorEnabled, vectorTierStatus, resolveVectorEngine, reindexVectorStore, harmonizeVectorStore, importRvfCheckpoint, statuslineData, writeFeatureAdrState, computeUsage, deriveCostLedger, renderCostLedger, verifyCostLedgerReport, writeCostLedgerJsonl, COST_LEDGER_SCOPE, deriveUsageCalibration, normalizeClaudeUsageModelKey, readUsageLimits, parseWeeklyResetAnchor, claimCheck, summarize, queryBookKnowledge, loadStorePatternsSync, patternRecordId, loadStoreRecords, recordToPattern, bundleSkills, brainHome, listBrain, bookKbPath, promoteProjectToBrain, updateBrainSource, queryBrain, groundPrompt, expandKu, reindexBrainVectors, buildPrimer, exportBrainSlice, importBrainSlice, registerKusToBrain, RECALL_USAGE_LOG_RELATIVE, RECALL_USAGE_LOG_MAX_BYTES, parseRecallUsageLog, buildRecallUsageReport, EVENT_CHAIN_TAIL_BYTES, EMPTY_LOG_TAIL, readTailInfo, appendChainedLines, verifyEventChainText, buildManifest, buildSbom, resolveTrustRoot, decideVerifyPolicy, generateSigningKeypair, evaluateGuard, resolveRules, auditRecord, guardExitCode, DEFAULT_RULES, parsePnpmLockImporters, scannableStubPath,
16
16
  // guard-promotion (feature guard-promotion, scout idea #1)
17
17
  assembleCandidates, renderPromotionReport, renderPromotionAdr, normalizePromotionState, nextPromotionState, globMatch, promotionAdrRelPath, DEFAULT_WINDOW_DAYS, DEFAULT_PERIODS, MAX_CONTENT_FETCHES, BUILTIN_COVERAGE, decideProvenance, isInsideTree, signManifest, verifyManifest, listSignablePackFiles, assertKeyOutsideTree, decidePublishGate, collectPackageFacts, planReleaseGates, selectAffectedPackages, classifyGateExecutions, buildFailureIssue, buildReleaseNotes, releaseTagName, firstOutputLine, formatPublishError, MANIFEST_NAME, SBOM_NAME, buildArchitectureMap, renderMapHuman, findArchitectureDrift, renderDriftReport, scanWorkspacePackages, loadSubsystemManifest, loadProductVision, checkFeatureAgainstArchitecture, renderArchCheck, planProjectSkills, guidanceForStage, renderInjectionReport, analyzeCorpus, renderRakeReport, renderCriticSection, rakeAsLesson, rakeReward, DEFAULT_RAKE_THRESHOLDS, streamSessionEvents, findLatestTranscript, detectProcessRakes, buildRetro, renderRetro, retroLessonText, PROCESS_SIGNATURES, RETRO_DOMAIN, scanForSetup, buildSetupPlan, scaffoldFromSpec, renderScaffoldPreview, readExistingForScaffold, assembleChallengeContext, buildChallengeBrief, planDiscriminationCheck, classifyDiscrimination, classifyExecutionEvidence, pickAdversaryModel, CHALLENGE_QUESTIONS, loadOutcomes, renderOutcomes, statsForKey, selectAutoCost, recordProvisional, finalizeOutcome, harvestStageOutcomes, recommendModels, planFeed, GRADE_SUCCESS_FLOOR, COST_LADDER, splitScenarios, budgetPlan, selectWinner, proseScopeOk, renderProseDiff, readScenarioIds, DEFAULT_MAX_JUDGE_RUNS, collectDeliveryFacts, planDeliveryCheck, renderDeliveryBrief, classifyDelivery, isUsablePlaneResult, renderDeliveryReview, scanSkillsLayout, declaredPluginSurface, parseInitFacts, verifyRegistration, buildContentProbePrompt, classifyContentProbe, renderContentProbe, findNonRegistrableSkillDirs, assembleCompoundingReport,
18
18
  // Cold-vs-warm EPOCH RUNNER (feature epoch-replay) — orchestrates + scores, never calls a model.
@@ -22,7 +22,7 @@ readBacklogConfig, readIdeas, writeIdeas, ideaId, dedupIdea, readGoalMap, readGo
22
22
  // qe-bridge (feature qe-bridge-claude, ADR-001): the pure half of the reverse QE bridge.
23
23
  KNOWN_CLAUDE, isSafeClaudeId, claudeProbeArgs, claudeReviewArgs, interpretClaudeProbe, modelFamily, buildBridgePrompt, parseBridgeOutput, buildBridgeFailureRecord, buildBridgeSignoffRecord, renderBridgeReport, isSafeSlug, hasUnsafePathChars, hasDotDotSegment, buildReqeBrief, settleReqeDebt, renderReqeList, REQE_SCOPE,
24
24
  // Mutation gate (feature ha-mutation-gate) — break each named protection, run the suite, require red.
25
- parseMutationRegistry, applyMutationToText, countFailingTests, classifyBaseline, classifyRunFailure, classifyMutationOutcome, mutationGateExitCode, summarizeMutationResults, renderMutationReport, TRACE_BUNDLE_LEDGER_PATH, TRACE_BUNDLE_SCHEMA, TRACE_BUNDLE_RUN_META_FILE, buildBundle, serializeBundle, parseBundle, planImport, decideCheckpointWrite, amendmentSection, planSaysNoAmendments, parseAmendments, resolveAmendments, decideAmendmentOutcome, amendmentVerdictLine, amendmentsMissingFromPlan, AMENDMENT_VACUITY_NOTE, decideRecordWrite, decideReadBack, recordVerdictLine, } from '@dzhechkov/harness-core';
25
+ parseMutationRegistry, applyMutationToText, countFailingTests, classifyBaseline, classifyRunFailure, classifyMutationOutcome, mutationGateExitCode, summarizeMutationResults, renderMutationReport, TRACE_BUNDLE_LEDGER_PATH, TRACE_BUNDLE_SCHEMA, TRACE_BUNDLE_RUN_META_FILE, buildBundle, serializeBundle, parseBundle, planImport, decideCheckpointWrite, amendmentSection, planSaysNoAmendments, parseAmendments, resolveAmendments, decideAmendmentOutcome, amendmentVerdictLine, amendmentsMissingFromPlan, AMENDMENT_VACUITY_NOTE, decideSignableSet, signableSetLine, decideRecordWrite, decideReadBack, recordVerdictLine, } from '@dzhechkov/harness-core';
26
26
  import { getPreset, PRESET_NAMES } from '@dzhechkov/harness-presets';
27
27
  import { scanGitHub, analyzeRepo, generateReport, deepAnalyze, scanAllSources, ScoutMemory } from '@dzhechkov/scout';
28
28
  const USAGE = `dz - DZ cross-platform harness CLI
@@ -2168,6 +2168,13 @@ async function cmdTeach(options, flags, cwd, write) {
2168
2168
  }
2169
2169
  write(`Imported ${imported} pattern(s) from ${fromJson}`);
2170
2170
  write(` Skipped ${skipped} (duplicates already in the store, or invalid entries)`);
2171
+ // Carrying a brain to a new machine goes through this path, and the mirror gate is the SAME one
2172
+ // teach uses — so without a config the whole import lands unindexed while `vector status` still
2173
+ // prints `pending: 0`. Say it here, where the user can act on it (FR-6).
2174
+ if (imported > 0 && !flags.has('no-mirror') && !vectorMirrorEnabled(projectRoot)) {
2175
+ write(` ⚠ the vector mirror writer is OFF — these ${imported} pattern(s) are LEXICAL ONLY`);
2176
+ write(` enable it in .dz/config.json (memory.backend=agentdb), then run: dz vector reindex`);
2177
+ }
2171
2178
  // Bulk import preserves the DOMAIN of every record, so it can put medical lessons in
2172
2179
  // a shared store as silently as a hand-typed teach — and it returned before the
2173
2180
  // advice single-teach prints. The same advice, at the same point in the flow: after
@@ -2581,6 +2588,43 @@ async function cmdRecallForget(options, flags, projectRoot, write) {
2581
2588
  write(' the vector mirror still holds them — run `dz vector reindex` to resync');
2582
2589
  return 0;
2583
2590
  }
2591
+ /**
2592
+ * Run `fn` with anything written to STDOUT by code we do not own routed to STDERR instead.
2593
+ *
2594
+ * Used to keep `--json` output parseable: a dependency that greets stdout on first load (currently
2595
+ * transformers.js) would otherwise sit in front of the JSON array. Nothing is swallowed — the text
2596
+ * still reaches the terminal, on the stream diagnostics belong on. Restoration is in `finally`, so a
2597
+ * throwing `fn` cannot leave stdout redirected.
2598
+ */
2599
+ export async function withForeignStdoutOnStderr(fn) {
2600
+ // Re-entrant: a nested call must not restore stdout when the INNER scope ends, or the outer scope
2601
+ // silently loses its guard. Depth-counted, and only the outermost exit restores (found by
2602
+ // independent review). Backpressure is not proxied — every writer here emits short diagnostic
2603
+ // lines, and returning stderr's own boolean is closer to the truth than inventing one.
2604
+ // Counted for EVERY caller, nested or concurrent. The first version only incremented when it
2605
+ // installed the patch, so an overlapping call that arrived second was not counted — and when the
2606
+ // FIRST finished it restored stdout while the second was still running, leaking exactly what the
2607
+ // guard exists to catch (found by cross-family review). The original `write` is captured once, by
2608
+ // the caller that installs the patch, and restored by the last one to leave.
2609
+ if (stdoutRedirectDepth === 0) {
2610
+ originalStdoutWrite = process.stdout.write;
2611
+ process.stdout.write = ((chunk, ...rest) => process.stderr.write(chunk, ...rest));
2612
+ }
2613
+ stdoutRedirectDepth += 1;
2614
+ try {
2615
+ return await fn();
2616
+ }
2617
+ finally {
2618
+ stdoutRedirectDepth -= 1;
2619
+ // the EXACT original function, not a fresh binding of it
2620
+ if (stdoutRedirectDepth === 0 && originalStdoutWrite !== undefined) {
2621
+ process.stdout.write = originalStdoutWrite;
2622
+ originalStdoutWrite = undefined;
2623
+ }
2624
+ }
2625
+ }
2626
+ let stdoutRedirectDepth = 0;
2627
+ let originalStdoutWrite;
2584
2628
  /**
2585
2629
  * `dz recall --promote <dzId>[,<dzId>…] [--apply]` — lift quarantine from NAMED records
2586
2630
  * (lesson-quarantine FR-6b). Dry-run by default, the --forget symmetry. Also clears the
@@ -2727,10 +2771,13 @@ async function cmdRecall(options, flags, cwd, write) {
2727
2771
  return 1;
2728
2772
  }
2729
2773
  if (flags.has('books')) {
2730
- const { hits, error } = await queryBookKnowledge(projectRoot, query, {
2774
+ // Same guard as the hybrid path: book knowledge is a vector search, so it loads the same
2775
+ // embedder and greeted stdout ahead of the JSON array (found by independent review).
2776
+ const runBooks = () => queryBookKnowledge(projectRoot, query, {
2731
2777
  limit,
2732
2778
  ...(bookFilter !== undefined ? { book: bookFilter } : {}),
2733
2779
  });
2780
+ const { hits, error } = asJson ? await withForeignStdoutOnStderr(runBooks) : await runBooks();
2734
2781
  if (asJson) {
2735
2782
  write(JSON.stringify(hits));
2736
2783
  return 0;
@@ -2742,6 +2789,35 @@ async function cmdRecall(options, flags, cwd, write) {
2742
2789
  const src = h.chapter !== undefined ? ` [${h.book} гл.${h.chapter}${h.pages ? ` с.${h.pages[0]}-${h.pages[1]}` : ''}]` : ` [${h.book}]`;
2743
2790
  write(` (${h.type}) ${h.name}${src}`);
2744
2791
  }
2792
+ // A zero-hit search must say WHERE it looked, and — only then — whether the other shelf has
2793
+ // anything. `--books` reads THIS PROJECT's store; digitised books are promoted to a machine-wide
2794
+ // brain. MEASURED: the same query gives 3 hits in this repository, 0 in any other directory, and
2795
+ // 2 through `dz brain query` from that same other directory — with no sign that the knowledge
2796
+ // was one command away (features/books-names-the-brain).
2797
+ // A FAILED search is not an empty one. With `error` set, `hits` is empty because the store could
2798
+ // not be read — claiming it was searched, and pointing elsewhere, would turn a fault into a
2799
+ // "nothing here" (found by cross-family review; the error itself is already printed above).
2800
+ if (hits.length === 0 && error === undefined) {
2801
+ write(` searched this project's book store: ${bookKbPath(projectRoot)}`);
2802
+ // Read the brain ONLY here: the happy path must not pay for the empty one. A brain that
2803
+ // cannot be read says NOTHING — an unreadable shelf is not an empty shelf.
2804
+ let sources;
2805
+ try {
2806
+ sources = listBrain();
2807
+ }
2808
+ catch {
2809
+ sources = undefined;
2810
+ }
2811
+ if (sources !== undefined && sources.length > 0) {
2812
+ // Deliberately NOT asserting that `--book <slug>` exists in the brain — nothing here checked
2813
+ // that, and advising a filter that will also miss is the same defect wearing a hat.
2814
+ // POSIX single-quoting: the query is USER text and lands in a command the reader will paste.
2815
+ // Interpolating it into double quotes breaks on a `"` and invites `$(…)`/backticks to be
2816
+ // read by their shell (found by cross-family review).
2817
+ const quoted = `'${query.replace(/'/g, `'\\''`)}'`;
2818
+ write(` the machine-wide brain holds ${sources.length} source(s) — search it with: dz brain query ${quoted}`);
2819
+ }
2820
+ }
2745
2821
  return 0;
2746
2822
  }
2747
2823
  // Hybrid recall (dz-rvf-vector-bridge FR-3): lexical FIRST (unchanged baseline), then a
@@ -2768,10 +2844,24 @@ async function cmdRecall(options, flags, cwd, write) {
2768
2844
  const shownQuery = oneLine(query);
2769
2845
  const shownDomain = wantedDomain === undefined ? undefined : oneLine(wantedDomain);
2770
2846
  const fetchLimit = wantedDomain !== undefined ? Math.min(limit * 3, limit + 20) : limit;
2771
- const result = await recallHybrid(projectRoot, query, { limit: fetchLimit, mode });
2847
+ // `--json` promises MACHINE-READABLE stdout, and transformers.js writes `Transformers.js loaded:
2848
+ // <model>` straight to stdout when the embedding model loads — so the machine mode was unparseable
2849
+ // in exactly the mode that makes it machine-readable (MEASURED 2026-08-22: it broke this project's
2850
+ // own measurement script and produced a false result). The same noise already forced
2851
+ // `.claude/helpers/agentdb-mcp-shim.mjs` to exist for the MCP stdio channel; this is that class,
2852
+ // second occurrence. Foreign stdout is routed to stderr for the duration of the engine call — our
2853
+ // own output is written after it returns.
2854
+ const result = asJson
2855
+ ? await withForeignStdoutOnStderr(() => recallHybrid(projectRoot, query, { limit: fetchLimit, mode }))
2856
+ : await recallHybrid(projectRoot, query, { limit: fetchLimit, mode });
2772
2857
  if (mode === 'semantic' && result.vectorEngine === 'none') {
2773
2858
  // --semantic is an explicit ask — degrading it silently would be dishonest (FR-3).
2774
- write(`dz recall --semantic: ${result.vectorReason ?? 'no vector engine available — run: dz setup --memory agentdb'}`);
2859
+ const why = result.vectorReason ?? 'no vector engine available — run: dz setup --memory agentdb';
2860
+ // …and under --json the refusal must itself be JSON. This branch wrote PROSE to stdout, so the
2861
+ // one mode that promises machine-readable output broke exactly where the feature is loudest
2862
+ // (found by cross-family review; MEASURED: `--semantic --json` with no engine printed a
2863
+ // sentence). An error the caller cannot parse is not an honest refusal, only a different lie.
2864
+ write(asJson ? JSON.stringify({ error: 'semantic-unavailable', reason: why, hits: [] }) : `dz recall --semantic: ${why}`);
2775
2865
  return 1;
2776
2866
  }
2777
2867
  // Domain-aware re-ranking (health-advisor slice H): `--domain <name>` lifts lessons
@@ -2779,6 +2869,15 @@ async function cmdRecall(options, flags, cwd, write) {
2779
2869
  // shared store keeps the cross-domain transfers that make it worth more than two stores.
2780
2870
  const boost = wantedDomain !== undefined ? applyDomainBoost(result.hits, wantedDomain) : null;
2781
2871
  const hits = (boost ? boost.hits : result.hits).slice(0, limit);
2872
+ // Computed ONCE, honoured by EVERY return path. It used to live only on the text tail, so the two
2873
+ // paths that return earlier — `--json` and the zero-hits branch — still reported success. That
2874
+ // made the contract change invisible to exactly the caller the ADR justifies it by: a script
2875
+ // (MEASURED: text mode exited 1, `--json` exited 0 on the same query).
2876
+ // `vectorError` is EXCLUDED on purpose: an engine that was asked and failed/timed out is the
2877
+ // documented degraded path (exit 0, 05 §2.3) and stays that way. This code is for a tier that had
2878
+ // nothing to give, not for one that broke — conflating them would make the exit status depend on a
2879
+ // timeout and so vary run to run (found by independent review).
2880
+ const semanticUnserved = mode === 'semantic' && result.vectorError === undefined && result.semanticRanked === 0;
2782
2881
  // The boost never drops a hit, but the CUT still can: promoting a match into the top
2783
2882
  // `limit` pushes the last one out, so a lesson visible WITHOUT --domain can vanish
2784
2883
  // WITH it. Cross-model review called this out as a lie by omission — the note said
@@ -2788,7 +2887,19 @@ async function cmdRecall(options, flags, cwd, write) {
2788
2887
  if (asJson) {
2789
2888
  // Portable contract UNCHANGED (I-7/AC-6): a plain PatternRecord[] — round-trips through
2790
2889
  // `dz teach --from-json` regardless of which backend ranked each hit.
2791
- write(JSON.stringify(hits.map((h) => h.pattern)));
2890
+ // The RRF relevance that ranked these very records was computed and then dropped, so no
2891
+ // automated consumer could threshold on it (MEASURED: keys were exactly
2892
+ // pattern,type,reward,domain,ts,source). It rides as a COMPANION key: `dz teach --from-json`
2893
+ // ignores unknown keys, so the round-trip is preserved — PROVEN by running, not assumed.
2894
+ // `relevance` is null under `--domain`: the domain boost REORDERS the list, so the RRF score no
2895
+ // longer explains the order shown, and printing it beside a boosted ranking would be a number
2896
+ // that contradicts its own list. Null means "not applicable here", never "zero relevance".
2897
+ // The condition is the BOOST, not `'score' in h`: boosted hits carry a score too, so the first
2898
+ // version emitted the number while its own comment promised null (found by independent review).
2899
+ write(JSON.stringify(hits.map((h) => ({
2900
+ ...h.pattern,
2901
+ relevance: boost === null && 'score' in h && typeof h.score === 'number' ? h.score : null,
2902
+ }))));
2792
2903
  // The honesty notes go to STDERR here rather than being skipped: the JSON branch
2793
2904
  // used to return before them, so a scripted caller was told nothing about a boost
2794
2905
  // that had promoted a match and pushed a visible hit past the --limit cut.
@@ -2798,7 +2909,7 @@ async function cmdRecall(options, flags, cwd, write) {
2798
2909
  if (cutNoteJson !== '')
2799
2910
  process.stderr.write(`${cutNoteJson}\n`);
2800
2911
  }
2801
- return 0;
2912
+ return semanticUnserved ? 1 : 0;
2802
2913
  }
2803
2914
  if (hits.length === 0) {
2804
2915
  write(`dz recall "${shownQuery}"`);
@@ -2808,11 +2919,29 @@ async function cmdRecall(options, flags, cwd, write) {
2808
2919
  // boost had been applied and found nothing, or had not run at all.
2809
2920
  if (boost !== null && shownDomain !== undefined)
2810
2921
  write(renderDomainBoostNote(boost, shownDomain));
2811
- return 0;
2812
- }
2813
- const vectorOn = result.vectorEngine !== 'none' && result.vectorError === undefined && mode !== 'lexical';
2922
+ return semanticUnserved ? 1 : 0;
2923
+ }
2924
+ // `vectorOn` used to mean "an engine RESOLVED", so the header claimed vector ranking over a store
2925
+ // with zero vectors while each hit's own label honestly read ⟨sqlite⟩ (MEASURED 2026-08-22).
2926
+ // It now means what it says: a vector actually ranked something (ADR-001).
2927
+ const vectorOn = result.semanticRanked > 0 && result.vectorError === undefined && mode !== 'lexical';
2928
+ const engineUp = result.vectorEngine !== 'none' && result.vectorError === undefined;
2929
+ // Whether a tier EXISTS, regardless of whether this query's search succeeded. The advice below
2930
+ // must key on existence: gated on `engineUp`, a timed-out but installed tier was told to install
2931
+ // itself, one line under "vector search degraded" (found by independent review).
2932
+ const engineInstalled = result.vectorEngine !== 'none';
2814
2933
  const lexLabel = result.lexicalBackend === 'sqlite' ? 'SQLite FTS5' : 'keyword (JSON)';
2815
- const ranking = vectorOn ? `${lexLabel} + vector (${result.vectorEngine}) ranking` : `${lexLabel} ranking (lexical)`;
2934
+ const ranking = vectorOn
2935
+ ? `${lexLabel} + vector (${result.vectorEngine}) ranking`
2936
+ : engineUp && mode !== 'lexical'
2937
+ // the engine is up and returned nothing usable — name the state and the fix, do not claim a
2938
+ // ranking that did not happen and do not advise installing what is already installed
2939
+ // `semanticCandidates` earns its place here: an engine that returned candidates which were ALL
2940
+ // orphans is a different problem from an engine with nothing in it, and the fix differs too.
2941
+ ? result.semanticCandidates > 0
2942
+ ? `${lexLabel} only (the semantic tier returned ${result.semanticCandidates} stale id(s) — run: dz consolidate)`
2943
+ : `${lexLabel} only (semantic tier empty — run: dz vector reindex)`
2944
+ : `${lexLabel} ranking (lexical)`;
2816
2945
  write(`dz recall "${shownQuery}" — ${hits.length} hit(s), ${ranking}`);
2817
2946
  let sawQuarantined = false;
2818
2947
  for (const h of hits) {
@@ -2851,10 +2980,20 @@ async function cmdRecall(options, flags, cwd, write) {
2851
2980
  // FR-8 hint swap: with an engine the old MCP-only hint is gone; without one, the SAME
2852
2981
  // conditional position carries an actionable enablement line instead — the only permitted
2853
2982
  // output change on the degraded path.
2854
- if (!vectorOn && result.vectorError === undefined && existsSync(join(projectRoot, '.dz', 'agentdb.db'))) {
2983
+ // ... and NOT when the tier is already installed: in the repo (539 vectors, a 2.9 MB agentdb.db)
2984
+ // `--no-semantic` advised installing the tier it was deliberately not using (MEASURED 2026-08-22).
2985
+ // ... and NOT when the user explicitly asked for lexical recall. Under `--no-semantic` the result
2986
+ // reports `vectorEngine: 'none'` BY CONSTRUCTION, so this line fired over a tier that was installed
2987
+ // and full — in this repo, 539 vectors and a 2.9 MB agentdb.db, advising the user to install it
2988
+ // (MEASURED 2026-08-22). Someone who passed --no-semantic has opted out; advice is noise there.
2989
+ if (!vectorOn && !engineInstalled && mode !== 'lexical' && existsSync(join(projectRoot, '.dz', 'agentdb.db'))) {
2855
2990
  write(` ℹ semantic (vector) recall needs the agentdb vector tier — run: dz setup --memory agentdb`);
2856
2991
  }
2857
- return 0;
2992
+ // An EXPLICIT --semantic that no vector could serve is not a success. The caller who most needs to
2993
+ // know is the one that cannot read the prose above it (ADR-001; this is a contract change).
2994
+ // Exit 1, the SAME code the documented sibling case uses ("--semantic … exit 1 if no engine"):
2995
+ // an explicit ask that could not be served is one failure class, not two.
2996
+ return semanticUnserved ? 1 : 0;
2858
2997
  }
2859
2998
  /* ------------------------------------------------------------------ */
2860
2999
  /* vector — the semantic-tier observability + export surface (FR-10) */
@@ -2935,11 +3074,36 @@ async function cmdVector(options, flags, cwd, write) {
2935
3074
  if (st.embeddingModel !== undefined)
2936
3075
  write(` Embedding model: ${st.embeddingModel}`);
2937
3076
  write(` Lexical patterns: ${st.lexicalMirrorable} mirrorable (${st.lexicalTotal} total)`);
2938
- write(` Mirrored vectors: ${st.mirrored !== undefined ? st.mirrored : 'n/a (no engine)'}`);
3077
+ // Each line NAMES its scope. `Mirrored vectors` used to count three task types and sit directly
3078
+ // under a one-task-type lexical count, and a reader took the pair at face value: 547 vs 274 read
3079
+ // as half the index orphaned, and a task was filed to prune it. MEASURED: 273 of those were
3080
+ // `dz-backlog` idea ids and there were ZERO orphans (ADR-001, features/mirror-counts-comparable).
3081
+ write(` Mirrored vectors (learned patterns): ${st.mirrored !== undefined ? st.mirrored : 'n/a (no engine)'}`);
3082
+ if (st.mirroredOther !== undefined && st.mirroredOther > 0) {
3083
+ write(` Other dz-owned vectors (backlog ideas): ${st.mirroredOther} — counted separately, not part of the pair above`);
3084
+ }
3085
+ if (st.orphaned !== undefined && st.orphaned > 0) {
3086
+ write(` Orphan vectors (no lexical record): ${st.orphaned} — run: dz vector reindex`);
3087
+ }
2939
3088
  write(` Pending mirror queue: ${st.pending}`);
2940
- if (st.available && st.mirrored !== undefined && st.mirrored < st.lexicalMirrorable) {
3089
+ // `pending: 0` used to stand alone, and it reads as "no debt" when it actually means "no queue
3090
+ // was ever opened" — an unconfigured project printed the same line as a fully-mirrored store
3091
+ // (MEASURED: two projects differing by one config file, 0 vs 1 for the same record).
3092
+ write(` Mirror writer: ${st.mirrorWriterEnabled ? 'ON' : 'OFF (.dz/config.json has no memory.backend=agentdb — teach is NOT queueing)'}`);
3093
+ // "not in the mirror" is ALL the set difference proves — a vector written and later deleted is
3094
+ // indistinguishable from one never offered, so the label must not claim "never queued".
3095
+ // `undefined` has two causes and they are different advice, so they are printed differently.
3096
+ const unknownReason = st.available ? 'unknown (the engine failed to list its ids)' : 'unknown (no engine to ask)';
3097
+ write(` Not in the mirror: ${st.unmirrored !== undefined ? st.unmirrored : unknownReason}`);
3098
+ // The old advice compared `mirrored < lexicalMirrorable`, two counts of different things — so
3099
+ // backlog ideas inflating `mirrored` could SILENCE it while patterns really were missing.
3100
+ // `unmirrored` is a set difference over ids and answers the same question correctly.
3101
+ if (st.unmirrored !== undefined && st.unmirrored > 0) {
2941
3102
  write(` ℹ mirror behind the lexical store — run: dz consolidate (backfill)`);
2942
3103
  }
3104
+ if (st.unmirrored !== undefined && st.unmirrored > 0) {
3105
+ write(` ℹ ${st.unmirrored} mirrorable pattern(s) are not in the vector mirror — run: dz vector reindex`);
3106
+ }
2943
3107
  return 0;
2944
3108
  }
2945
3109
  if (sub === 'reindex') {
@@ -4032,7 +4196,10 @@ function verifyInstalledPacks(cwd, explicitPubkey) {
4032
4196
  repo: existsSync(repoKey) ? repoKey : undefined,
4033
4197
  packaged: packagedTrustRootPath(),
4034
4198
  });
4035
- const packs = discoverSkillPackDirs(cwd);
4199
+ // ADR-001: verification asks "which packs carry a signature?", which is NOT the question
4200
+ // `discoverSkillPackDirs` answers. MEASURED 2026-08-21 — the prefix filter left 26 of 52 signed
4201
+ // packs invisible, `keysarium` drifted unnoticed, and the summary line read as coverage.
4202
+ const packs = discoverVerifiablePackDirs(cwd);
4036
4203
  // Cross-model review: `--pubkey <pack>/evil.pub` would let the artifact supply its own verifying key
4037
4204
  // through the caller. The tool must never verify a pack against a key that lives inside it.
4038
4205
  if (trustRoot?.source === 'explicit') {
@@ -4071,6 +4238,35 @@ function verifyInstalledPacks(cwd, explicitPubkey) {
4071
4238
  checks.push({ pack, verdict: 'no-trust-root', failures: [] });
4072
4239
  continue;
4073
4240
  }
4241
+ // A SOURCE tree legitimately holds files the tarball never ships (tests, coverage, CHANGELOG), so
4242
+ // the added-file sweep is meaningless there — scoping it to the manifest's own list disables it.
4243
+ // An INSTALLED pack under node_modules IS the extracted artifact, and there the sweep is the whole
4244
+ // point: it is what catches a file an attacker added. Same function, two honest modes.
4245
+ //
4246
+ // This distinction had to be drawn the moment the SIGNER started covering only the shipped set
4247
+ // (2026-08-21). Leaving it undrawn made every source pack report TAMPERED — the third time in one
4248
+ // day that a scope change on one side was not mirrored on the other.
4249
+ // An ARTIFACT is an extracted tarball, and extracted tarballs live in `node_modules`. Anything
4250
+ // else is a checkout. Deliberately NOT keyed on `cwd`: packs are also discovered from the CLI's
4251
+ // own install location, which is outside the project being checked — keying on cwd made the
4252
+ // repo's own source packs look like artifacts and report TAMPERED from a temp-dir fixture.
4253
+ // Resolve the link FIRST: pnpm links workspace packages into `node_modules`, so a source checkout
4254
+ // is reachable by a path that looks like an artifact. Judging by the given path made the repo's
4255
+ // own packs verify as tarballs and report TAMPERED (measured while wiring this).
4256
+ let realDir = dir;
4257
+ try {
4258
+ realDir = realpathSync(dir);
4259
+ }
4260
+ catch { /* keep the given path */ }
4261
+ const isSourceTree = !realDir.split(sep).includes('node_modules');
4262
+ if (isSourceTree) {
4263
+ // The manifest describes the PUBLISHED TARBALL, and a source checkout is a different object —
4264
+ // `pnpm publish` re-serialises package.json and rewrites `workspace:*`. Hash-verifying a
4265
+ // checkout against it produces a guaranteed false TAMPERED, so this reports a state of its own
4266
+ // instead of an alarm. `dz verify-pack` packs and checks the real artifact.
4267
+ checks.push({ pack, verdict: 'source-tree', failures: [] });
4268
+ continue;
4269
+ }
4074
4270
  const res = verifyManifest(dir, signed, keyPem);
4075
4271
  checks.push({
4076
4272
  pack,
@@ -4080,6 +4276,30 @@ function verifyInstalledPacks(cwd, explicitPubkey) {
4080
4276
  }
4081
4277
  return { trustRoot, checks };
4082
4278
  }
4279
+ /**
4280
+ * The signature verdicts as DATA. The text reporter and the `--json` output both render this, so the
4281
+ * two cannot disagree about what was found — the failure this feature removes is exactly a verdict
4282
+ * that exists in one surface and not the other.
4283
+ */
4284
+ function collectPackVerification(cwd, explicitPubkey) {
4285
+ let trustRoot = null;
4286
+ let checks = [];
4287
+ try {
4288
+ ({ trustRoot, checks } = verifyInstalledPacks(cwd, explicitPubkey));
4289
+ }
4290
+ catch {
4291
+ // A refusal is a result: an empty listing with no trust root, not a crash and not a silent pass.
4292
+ return { trustRoot: null, counts: { verified: 0, unsigned: 0, tampered: 0, 'no-trust-root': 0, 'source-tree': 0 }, packs: [] };
4293
+ }
4294
+ const counts = { verified: 0, unsigned: 0, tampered: 0, 'no-trust-root': 0, 'source-tree': 0 };
4295
+ for (const c of checks)
4296
+ counts[c.verdict]++;
4297
+ return {
4298
+ trustRoot: trustRoot === null ? null : { source: trustRoot.source, path: trustRoot.path },
4299
+ counts,
4300
+ packs: checks.map((c) => ({ pack: c.pack, verdict: c.verdict, failures: [...c.failures] })),
4301
+ };
4302
+ }
4083
4303
  /** Print the pack verdicts and return 1 iff the policy says any of them is fatal. */
4084
4304
  function reportPackVerification(cwd, explicitPubkey, requireSigning, write) {
4085
4305
  let trustRoot;
@@ -4094,7 +4314,7 @@ function reportPackVerification(cwd, explicitPubkey, requireSigning, write) {
4094
4314
  }
4095
4315
  if (checks.length === 0)
4096
4316
  return 0;
4097
- const counts = { verified: 0, unsigned: 0, tampered: 0, 'no-trust-root': 0 };
4317
+ const counts = { verified: 0, unsigned: 0, tampered: 0, 'no-trust-root': 0, 'source-tree': 0 };
4098
4318
  let fatal = 0;
4099
4319
  for (const c of checks) {
4100
4320
  counts[c.verdict]++;
@@ -4110,7 +4330,7 @@ function reportPackVerification(cwd, explicitPubkey, requireSigning, write) {
4110
4330
  }
4111
4331
  const root = trustRoot ? `${trustRoot.source} (${trustRoot.path})` : 'none';
4112
4332
  write(` signatures: ${counts.verified} verified, ${counts.unsigned} unsigned, ` +
4113
- `${counts.tampered} TAMPERED, ${counts['no-trust-root']} unverifiable; trust root: ${root}`);
4333
+ `${counts.tampered} TAMPERED, ${counts['no-trust-root']} unverifiable, ${counts['source-tree']} source-tree (not an artifact); trust root: ${root}`);
4114
4334
  // A signature proves the bytes are unmodified. It never proves the skill is any good.
4115
4335
  return fatal > 0 ? 1 : 0;
4116
4336
  }
@@ -4214,15 +4434,40 @@ function cmdSign(options, flags, cwd, write) {
4214
4434
  write(`dz sign: private key not found: ${resolve(cwd, key)}`);
4215
4435
  return 1;
4216
4436
  }
4217
- const files = packFiles(packDir);
4437
+ // The manifest covers what the CONSUMER receives. `dz sign` and the publish-time re-sign MUST use
4438
+ // the same rule, or the two produce different manifests for the same pack — a second, divergent
4439
+ // answer beside the real one, which is the class of defect this change removes.
4440
+ // Hash the EXTRACTED TARBALL, not the working tree. `pnpm publish` re-serialises package.json and
4441
+ // rewrites `workspace:*`, so a hash taken from disk is stale before the tarball exists — MEASURED
4442
+ // 2026-08-21: the published `skills-news` package.json is 1050 bytes where the tree's is 1051, and
4443
+ // that single missing newline made six freshly re-signed packs report TAMPERED to every consumer.
4444
+ // `dz sign` and the publish-time re-sign use this same path, or they produce different manifests for
4445
+ // the same pack.
4446
+ let hashRoot = packDir;
4447
+ let cleanupPack = null;
4448
+ let files = packFiles(packDir);
4449
+ try {
4450
+ const extracted = extractPublishTarball(packDir);
4451
+ hashRoot = extracted.dir;
4452
+ cleanupPack = extracted.cleanup;
4453
+ files = packFiles(hashRoot);
4454
+ write(`dz sign: hashing the packed tarball (${files.length} file(s)) — the bytes a recipient receives`);
4455
+ }
4456
+ catch (err) {
4457
+ // Not an npm package, or no pnpm: sign the tree and SAY SO. A silent fallback would restore the
4458
+ // divergence this change closes.
4459
+ write(`dz sign: could not pack this directory (${err.message.split('\n')[0]}) — signing the working tree instead`);
4460
+ }
4218
4461
  if (files.length === 0) {
4462
+ cleanupPack?.();
4219
4463
  write('dz sign: the pack contains no files — refusing to sign nothing');
4220
4464
  return 1;
4221
4465
  }
4222
- const manifest = buildManifest(packDir, basename(packDir), files);
4466
+ const manifest = buildManifest(hashRoot, basename(packDir), files);
4223
4467
  const signed = signManifest(manifest, readFileSync(resolve(cwd, key), 'utf8'));
4224
4468
  writeFileSync(join(packDir, MANIFEST_NAME), JSON.stringify(signed, null, 2) + '\n');
4225
4469
  writeFileSync(join(packDir, SBOM_NAME), JSON.stringify(buildSbom(manifest), null, 2) + '\n');
4470
+ cleanupPack?.();
4226
4471
  write(`dz sign: signed ${files.length} file(s) in ${packDir}`);
4227
4472
  write(` ${MANIFEST_NAME} + ${SBOM_NAME} written. Ed25519 gives tamper-evidence, never truthfulness.`);
4228
4473
  return 0;
@@ -4253,7 +4498,16 @@ function cmdVerifyPack(options, flags, cwd, write) {
4253
4498
  write(`dz verify-pack: ${MANIFEST_NAME} is not valid JSON`);
4254
4499
  return 1;
4255
4500
  }
4256
- const res = verifyManifest(packDir, signed, readFileSync(pubPath, 'utf8'));
4501
+ // Same rule as the signer: a working-tree file `files[]` excludes was never "added to the pack".
4502
+ // Unscoped when npm cannot answer — an extracted tarball verifies exactly as before.
4503
+ let shippedForVerify;
4504
+ try {
4505
+ shippedForVerify = npmPackedPaths(packDir);
4506
+ }
4507
+ catch {
4508
+ shippedForVerify = undefined;
4509
+ }
4510
+ const res = verifyManifest(packDir, signed, readFileSync(pubPath, 'utf8'), shippedForVerify);
4257
4511
  if (res.ok) {
4258
4512
  write(`dz verify-pack: OK — ${packDir} matches its signed manifest`);
4259
4513
  return 0;
@@ -4441,7 +4695,14 @@ function cmdPublish(options, flags, cwd, write) {
4441
4695
  if (trustRootPresent && manifestPresent) {
4442
4696
  try {
4443
4697
  const signed = JSON.parse(readFileSync(manifestPath, 'utf8'));
4444
- verifyOk = verifyManifest(pk.dir, signed, readFileSync(trustRoot, 'utf8')).ok;
4698
+ let shippedForGate;
4699
+ try {
4700
+ shippedForGate = npmPackedPaths(pk.dir);
4701
+ }
4702
+ catch {
4703
+ shippedForGate = undefined;
4704
+ }
4705
+ verifyOk = verifyManifest(pk.dir, signed, readFileSync(trustRoot, 'utf8'), shippedForGate).ok;
4445
4706
  }
4446
4707
  catch {
4447
4708
  verifyOk = false;
@@ -4473,16 +4734,68 @@ function cmdPublish(options, flags, cwd, write) {
4473
4734
  bumpOnly,
4474
4735
  claimGate: claimCheckOpt,
4475
4736
  signKey: signKey === '' ? undefined : resolve(cwd, signKey),
4737
+ verifyAfterSign: (packDir) => {
4738
+ // Verify the OUTCOME against the trust root a CONSUMER would use — an existing key may be the
4739
+ // WRONG key, and enumerating that state is a losing game (round-1 review). The pack NAME travels
4740
+ // with the verdict so a pass about a different artifact cannot be mistaken for this one.
4741
+ const trustRoot = resolve(cwd, TRUST_ROOT_REL);
4742
+ if (!existsSync(trustRoot))
4743
+ return { ok: false, trustRootPresent: false, pack: basename(packDir) };
4744
+ try {
4745
+ const signed = JSON.parse(readFileSync(join(packDir, MANIFEST_NAME), 'utf8'));
4746
+ let shipped;
4747
+ try {
4748
+ shipped = npmPackedPaths(packDir);
4749
+ }
4750
+ catch {
4751
+ shipped = undefined;
4752
+ }
4753
+ const res = verifyManifest(packDir, signed, readFileSync(trustRoot, 'utf8'), shipped);
4754
+ return { ok: res.ok, trustRootPresent: true, pack: basename(packDir) };
4755
+ }
4756
+ catch {
4757
+ return { ok: false, trustRootPresent: true, pack: basename(packDir) };
4758
+ }
4759
+ },
4476
4760
  reSign: (packDir, keyPath) => {
4477
4761
  // The same three steps `dz sign` performs, including the SBOM — a manifest refreshed without
4478
4762
  // its SBOM would leave the two describing different trees.
4479
- const files = packFiles(packDir);
4480
- if (files.length === 0)
4481
- throw new Error(`refusing to sign an empty pack: ${packDir}`);
4482
- const manifest = buildManifest(packDir, basename(packDir), files);
4483
- const signed = signManifest(manifest, readFileSync(keyPath, 'utf-8'));
4484
- writeFileSync(join(packDir, MANIFEST_NAME), `${JSON.stringify(signed, null, 2)}\n`);
4485
- writeFileSync(join(packDir, SBOM_NAME), `${JSON.stringify(buildSbom(manifest), null, 2)}\n`);
4763
+ //
4764
+ // Path A: the manifest must cover what the CONSUMER receives, not what the author has on disk.
4765
+ // MEASURED 2026-08-21 by a live install of the published 0.6.1 — with the trust root restored,
4766
+ // six packs reported TAMPERED, and only half of that was the version bump. The rest was
4767
+ // `CHANGELOG.md: listed in the manifest but absent`: signed on disk, excluded by `files[]`,
4768
+ // therefore missing for every recipient forever. So the file list comes from `npm pack`, which
4769
+ // is the authority on what ships — we do not reimplement its globbing.
4770
+ // Hash the EXTRACTED TARBALL, not the working tree. `pnpm publish` re-serialises package.json and
4771
+ // rewrites `workspace:*`, so a hash taken from disk is stale before the tarball exists — MEASURED
4772
+ // 2026-08-21: the published `skills-news` package.json is 1050 bytes where the tree's is 1051,
4773
+ // and that one missing newline is what made six freshly re-signed packs report TAMPERED.
4774
+ const { dir: shippedDir, cleanup } = extractPublishTarball(packDir);
4775
+ try {
4776
+ const onDisk = packFiles(shippedDir);
4777
+ const packed = packFiles(shippedDir);
4778
+ const setDecision = decideSignableSet({ signable: onDisk, packed });
4779
+ write(signableSetLine(basename(packDir), setDecision));
4780
+ if (setDecision.publishedButUnsigned.length > 0) {
4781
+ // A shipped file no signature covers is WORSE than an unsigned pack: the badge says verified
4782
+ // while part of the payload is unchecked. Refuse rather than sign a partial claim.
4783
+ throw new Error(`refusing to sign a pack with ${setDecision.publishedButUnsigned.length} SHIPPED BUT UNSIGNED file(s): ${setDecision.publishedButUnsigned.slice(0, 5).join(', ')}`);
4784
+ }
4785
+ const files = [...setDecision.sign];
4786
+ if (files.length === 0)
4787
+ throw new Error(`refusing to sign an empty pack: ${packDir}`);
4788
+ // Hashes come from the extracted tarball; the manifest is WRITTEN to the source dir so the next
4789
+ // pack carries it. The re-pack normalises package.json identically (deterministic — MEASURED by
4790
+ // packing twice and comparing hashes), so the entries still describe what ships.
4791
+ const manifest = buildManifest(shippedDir, basename(packDir), files);
4792
+ const signed = signManifest(manifest, readFileSync(keyPath, 'utf-8'));
4793
+ writeFileSync(join(packDir, MANIFEST_NAME), `${JSON.stringify(signed, null, 2)}\n`);
4794
+ writeFileSync(join(packDir, SBOM_NAME), `${JSON.stringify(buildSbom(manifest), null, 2)}\n`);
4795
+ }
4796
+ finally {
4797
+ cleanup();
4798
+ }
4486
4799
  },
4487
4800
  });
4488
4801
  write(`\ndz publish${dryRun ? ' --dry-run' : ''}${bumpOnly ? ' --bump-only' : ''}${claimCheckOpt !== 'warn' ? ` --claim-check ${claimCheckOpt}` : ''}`);
@@ -5913,8 +6226,14 @@ function cmdDriftCheck(options, flags, cwd, write) {
5913
6226
  const allowlist = readDriftAllowlist(root);
5914
6227
  const r = sweepSkillDrift(root, { scope, allowlist });
5915
6228
  if (flags.has('json')) {
5916
- write(JSON.stringify({ ...r, scope, allowlist }));
5917
- return r.drifted.length > 0 ? 1 : 0;
6229
+ // The signature verdicts were printed in the TEXT output and absent from the JSON, so a CI job
6230
+ // parsing `--json` saw `drifted: 0` and concluded all was well while packs were TAMPERED. A gate
6231
+ // silent in the form CI reads is not a gate (MEASURED 2026-08-21: keys were duplicated, drifted,
6232
+ // allowlisted, scope, allowlist — and nothing else).
6233
+ const sig = collectPackVerification(root, options.get('pubkey'));
6234
+ const sigBlocking = sig.packs.filter((c) => decideVerifyPolicy(c.verdict, flags.has('require-signing')).action === 'fail').length;
6235
+ write(JSON.stringify({ ...r, scope, allowlist, signatures: sig }));
6236
+ return r.drifted.length > 0 || sigBlocking > 0 ? 1 : 0;
5918
6237
  }
5919
6238
  write(`Skills duplicated across ≥2 ${scope === 'packages' ? 'package' : ''} locations: ${r.duplicated}`);
5920
6239
  if (r.allowlisted.length > 0) {
@@ -6127,6 +6446,61 @@ function gatherGuardFacts(op, root, text, storeCap) {
6127
6446
  facts['readmeFirst'] = [...perPack.entries()].map(([name, e]) => ({ name: '@dzhechkov/' + name, versionBumped: e.pkgJson, readmeChanged: e.readme }));
6128
6447
  }
6129
6448
  catch { /* not a git repo — rule skips */ }
6449
+ // review-round: the same WORKING-TREE diff, asked a different question — does a package that
6450
+ // bumps its version and changes SOURCE bring a GRADED QE report with it? Scoped to source so a
6451
+ // docs-only republish is never blocked (ADR-001, features/publish-needs-a-review). A throw here
6452
+ // leaves the whole fact undefined, and the rule then reports NOTHING: absence of a report is an
6453
+ // accusation, absence of facts is ignorance, and they must not render the same.
6454
+ try {
6455
+ const status = execSync('git status --porcelain -uall', { cwd: root, encoding: 'utf-8' });
6456
+ const changed = status.split('\n').map((l) => l.slice(3).trim()).filter(Boolean);
6457
+ const perPack = new Map();
6458
+ for (const rel of changed) {
6459
+ const m = rel.match(/^packages\/@dzhechkov\/([^/]+)\/(.+)$/);
6460
+ if (!m || !m[1] || !m[2])
6461
+ continue;
6462
+ const e = perPack.get(m[1]) ?? { versionBumped: false, sourceChanged: false };
6463
+ if (m[2] === 'package.json')
6464
+ e.versionBumped = true;
6465
+ // SOURCE = what ships and can be wrong at runtime. Tests, docs and fixtures are excluded:
6466
+ // a test-only change still ships, but it is not the class the review gate is about, and
6467
+ // widening the scope is what makes a HARD gate get switched off.
6468
+ if (/^(src|lib|bin|skills)\//.test(m[2]) && !/\.(md|json|txt)$/.test(m[2]))
6469
+ e.sourceChanged = true;
6470
+ perPack.set(m[1], e);
6471
+ }
6472
+ const grades = [];
6473
+ for (const rel of changed) {
6474
+ if (!/^features\/[^/]+\/08_qe_report\.md$/.test(rel))
6475
+ continue;
6476
+ let text;
6477
+ try {
6478
+ text = readFileSync(join(root, rel), 'utf-8');
6479
+ }
6480
+ catch {
6481
+ continue;
6482
+ }
6483
+ // The grade the report itself STATES — `grade C`, `GRADE: B`, `**grade: A**`. A report that
6484
+ // never states one is not evidence (AM-2), so nothing is pushed for it.
6485
+ const m = text.match(/\bgrade\s*:?\s*\**\s*([ABCDF])\b/i);
6486
+ if (m && m[1])
6487
+ grades.push({ report: rel, grade: m[1] });
6488
+ }
6489
+ const cfgMin = loadGuardConfig(root).reviewRound?.minGrade;
6490
+ const minGrade = typeof cfgMin === 'string' ? cfgMin : undefined;
6491
+ facts['reviewRound'] = {
6492
+ packages: [...perPack.entries()].map(([name, e]) => ({ name: '@dzhechkov/' + name, ...e })),
6493
+ grades,
6494
+ gathered: true,
6495
+ ...(minGrade !== undefined ? { minGrade } : {}),
6496
+ };
6497
+ }
6498
+ catch {
6499
+ // TRIED and could not read the tree. Say so on the record rather than passing silently: a HARD
6500
+ // gate that is quiet about ungathered evidence cannot be told from one that checked (raised by
6501
+ // cross-family review). It still does not BLOCK — ignorance is not an accusation.
6502
+ facts['reviewRound'] = { packages: [], grades: [], gathered: false };
6503
+ }
6130
6504
  // skills-registrable: every skill dir in a skill pack must carry a depth-1 SKILL.md, or it ships
6131
6505
  // registering nowhere (the health-advisor 1.2.0 class). Pure-toolkit packages yield nothing.
6132
6506
  try {
@@ -8674,6 +9048,54 @@ function scanOneReqeRoot(featuresDir, keyMatchesDir, out, addMalformed) {
8674
9048
  * the exit code. A courier can neither refuse nor verify, which is how four workflow runs finished
8675
9049
  * with no cost row at all.
8676
9050
  */
9051
+ /**
9052
+ * The file list of the tarball `npm pack` would produce, `package/` prefix stripped. npm is the
9053
+ * authority on what `files[]` ships; reimplementing its globbing would put a second, divergent answer
9054
+ * next to the real one — which is the class of defect this whole change exists to remove.
9055
+ */
9056
+ function npmPackedPaths(packDir) {
9057
+ // `pnpm`, not `npm`: the PUBLISHER is `pnpm publish` (see `publishArgv`), and the two packers do not
9058
+ // agree. MEASURED 2026-08-21 on `skills-news`: `npm pack` emits a 1051-byte package.json identical
9059
+ // to the working tree, `pnpm pack` emits 1050 — pnpm re-serialises it (dropping the trailing
9060
+ // newline, and expanding `workspace:*`). Asking one tool what ships while a different tool ships it
9061
+ // is how a signature ends up describing a file nobody receives.
9062
+ const out = execFileSync('pnpm', ['pack', '--pack-destination', mkdtempSync(join(tmpdir(), 'dz-pack-probe-')), '--json'], {
9063
+ cwd: packDir,
9064
+ encoding: 'utf-8',
9065
+ maxBuffer: 64 * 1024 * 1024,
9066
+ });
9067
+ const parsed = JSON.parse(out);
9068
+ const entry = Array.isArray(parsed) ? parsed[0] : parsed;
9069
+ const files = entry?.files ?? [];
9070
+ return files.map((f) => f.path.replace(/^package\//, '')).sort();
9071
+ }
9072
+ /**
9073
+ * Pack the package with the SAME tool that publishes it, extract the tarball, and return the directory
9074
+ * holding its contents. Hashing THAT is the only way a manifest can describe what a recipient gets:
9075
+ * `pnpm publish` re-serialises package.json and rewrites `workspace:*`, so any hash taken from the
9076
+ * working tree is stale before the tarball exists.
9077
+ */
9078
+ function extractPublishTarball(packDir) {
9079
+ const tmp = mkdtempSync(join(tmpdir(), 'dz-sign-pack-'));
9080
+ const out = execFileSync('pnpm', ['pack', '--pack-destination', tmp, '--json'], {
9081
+ cwd: packDir,
9082
+ encoding: 'utf-8',
9083
+ maxBuffer: 64 * 1024 * 1024,
9084
+ });
9085
+ const parsed = JSON.parse(out);
9086
+ const entry = Array.isArray(parsed) ? parsed[0] : parsed;
9087
+ const tgz = entry?.filename;
9088
+ if (tgz === undefined)
9089
+ throw new Error(`pnpm pack did not name a tarball for ${packDir}`);
9090
+ // pnpm reports an ABSOLUTE filename (it already contains --pack-destination); joining again would
9091
+ // double the directory. npm reports a bare name. Accept both rather than assuming either.
9092
+ const tgzPath = isAbsolute(tgz) ? tgz : join(tmp, tgz);
9093
+ execFileSync('tar', ['-xzf', tgzPath, '-C', tmp]);
9094
+ return { dir: join(tmp, 'package'), cleanup: () => { try {
9095
+ rmSync(tmp, { recursive: true, force: true });
9096
+ }
9097
+ catch { /* best-effort */ } } };
9098
+ }
8677
9099
  function cmdFeatureAdrRecord(options, flags, cwd, write) {
8678
9100
  const json = flags.has('json');
8679
9101
  const kind = (options.get('kind') ?? '').trim();