@dzhechkov/harness-cli 0.3.226 → 0.3.228

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/README.md CHANGED
@@ -289,6 +289,10 @@ Ed25519 over a file manifest, plus a CycloneDX SBOM. Zero dependencies (`node:cr
289
289
  tamper-evidence, never truthfulness.
290
290
 
291
291
  ```bash
292
+ # One-time: generate the Ed25519 keypair. The PRIVATE key is written OUTSIDE the repo (mode 0600);
293
+ # the PUBLIC key is printed — commit it as keys/dz.pub. dz refuses an --out inside the repo tree.
294
+ dz sign --init --out ~/.dz/keys/dz.key
295
+
292
296
  # Sign a pack. The private key MUST live outside the repo — dz refuses otherwise.
293
297
  dz sign --pack packages/@dzhechkov/skills-qe --key ~/.dz/keys/dz.key
294
298
 
@@ -296,11 +300,17 @@ dz sign --pack packages/@dzhechkov/skills-qe --key ~/.dz/keys/dz.key
296
300
  # whoever replaced the pack would have replaced a key shipped inside it.
297
301
  dz verify-pack --pack packages/@dzhechkov/skills-qe # exit 0 = unmodified
298
302
  dz verify-pack --pack ./downloaded-pack --pubkey keys/dz.pub # explicit trust root
303
+
304
+ # The SBOM on its own (CycloneDX 1.5, a file-level bill of materials for the pack):
305
+ dz sbom --pack packages/@dzhechkov/skills-qe # print to stdout
306
+ dz sbom --pack packages/@dzhechkov/skills-qe --out sbom.json # write to a file
299
307
  ```
300
308
 
301
309
  A single flipped byte, a deleted file, or an **added** file all fail verification and the offending
302
310
  path is named. Every degenerate input (no manifest, empty file list, empty signature, no public key)
303
- fails **closed** — absence never reads as success.
311
+ fails **closed** — absence never reads as success. `dz doctor` **and** `dz drift-check` run this check
312
+ over the installed packs: a **tampered** pack is fatal (blocks); an **unsigned** pack or a missing trust
313
+ root is reported, not fatal (transitional — the existing packs are not yet signed).
304
314
 
305
315
  `dz publish` runs the check before publishing anything. With `keys/dz.pub` committed, an unsigned or
306
316
  mismatching pack **blocks the release**. Until then, packs publish unsigned and `dz publish` says so on
@@ -389,7 +399,7 @@ Each pack is an npm package — click through for the **full per-skill documenta
389
399
  | `health` | 8 | Medical AI (diagnostics, drugs, labs, clinical decisions) |
390
400
  | `keysarium` | 9 | Full research toolkit (feature-adr, presentation, reverse-eng) |
391
401
  | `p-replicator` | 10 | AI product development (/replicate, SPARC PRD, pipeline-forge) |
392
- | `feature-adr` | 5 | Feature pipeline (feature-adr, explore, frontend-design) |
402
+ | `feature-adr` | 8 | Feature pipeline (feature-adr, explore, frontend-design, code-critic, code-impl, system-grill) |
393
403
  | `reasoning` | 4 | Generic reasoning & code-quality (investigate, solid, karpathy-guidelines, agents-md-creator) — stack-neutral, zero coupling |
394
404
  | `devops` | 30 | DevOps skills (terraform, kubernetes, c4-architecture, incident-response, problem-management, risk-assessment, ...) |
395
405
  | `web3` | 12 | Web3/DeFi (quicknode, zerion, symbiosis, bankr, veil, neynar, ...) |
@@ -433,7 +443,7 @@ Get the whole set with `dz init --target claude-code --preset meta`, or pick one
433
443
 
434
444
  > **A skill and its npx toolkit are not duplicates — they're a graduation.** Several skills (e.g. `feature-adr`, `design-thinking`) exist BOTH as a skill inside a `dz` preset AND as a standalone `npx` package. The preset's SKILL.md is **fully functional on its own** (the whole methodology — modules + references — travels with it, and it auto-activates by description), and it's the only way to compile that capability to the **non-Claude platforms** (Codex/OpenCode/Hermes/OpenClaude) via `dz`. The npx package adds **project-level runtime governance** around the same skill: a slash command, governance rules, a context shard, and (for feature-adr) reward-learning + `/harvest`. So: pick the **skill/preset** for a working capability across platforms; pick the **npx toolkit** when you want it as a governed, command-driven fixture of one project.
435
445
 
436
- ## All Commands (51)
446
+ ## All Commands (53)
437
447
 
438
448
  ```
439
449
  dz setup --target <name> [--preset <name>] [--select id,id,...] [--skills-dir <dir>] [--memory agentdb] [--no-memory] [--no-hooks] [--install-driver] [--force]
@@ -487,6 +497,9 @@ dz feature-adr-setup [--plan] [--from-spec <f>] [--apply] # guided project onb
487
497
  dz challenge --plan <plan.md> [--json] [--context-only] [--author <model>] # adversarial plan-gate (behind the `challenge-panel` skill): assemble a WIDE context pack (plan + vision + testing + map + degradations) + the fixed C1-C8 "break it" brief for a FRESH adversary (≠ plan author); advisory, never blocks
488
498
  dz routing [--stage <s>] [--json] # inspect the learned cost-optimal routing store: what `args.models.<stage>='auto-cost'` believes per (stage, complexity-tier, model) — gated attempts/successes/rate (feeds feature-adr model selection)
489
499
  dz bto-optimize --split|--plan|--select|--scope-check|--diff [--json] # deterministic engine behind /bto-optimize: hold-out split + hard-capped budget + no-regress-on-holdout winner selection (defeats judge-gaming); prose-only, diff-confirmed, never auto-writes
500
+ dz discrimination-check --test <f[,f]> [--base <ref>] [--name <filter>] [--runner <cmd>] [--json] # §42 test-discrimination gate for feature-adr Step-8: run the ADR's property test in an isolated git worktree at pre-feature base — it MUST go red without the fix; a green is a false green (HIGH finding, advisory, never auto-aborts)
501
+ dz sign --init --out <path> | --pack <dir> --key <path> # --init: generate the Ed25519 keypair (private OUTSIDE the repo, prints the public key for keys/dz.pub); else sign a pack's manifest + CycloneDX SBOM
502
+ dz sbom --pack <dir> [--out <file>] # emit the CycloneDX 1.5 SBOM for a pack standalone (file-level bill of materials); print to stdout or write to a file
490
503
  dz publish [--filter <name>] [--bump-only] [--claim-check <off|warn|error>] (dry-run by default; pass --yes/--confirm to go live; claim-check gate defaults to warn — surfaces README claim findings, never blocks)
491
504
  dz auto-canonicalize --source <github-url> --pack <skills-pack>
492
505
  dz sync-upstream [--package <dir>] [--list] [--all]
@@ -1558,6 +1571,30 @@ Verify: after a session, `.dz/agentdb.db` grows and `.dz/sessions.jsonl` stays e
1558
1571
 
1559
1572
  Bridge: with the agentdb backend enabled, `dz recall` itself is the bridge — it merges lexical FTS5 with vector similarity over the same store (see *Semantic recall* below). `dz recall --all --json` stays the portable SHARING form.
1560
1573
 
1574
+ ### `dz discrimination-check` — when a test is green but you're not sure it actually catches anything
1575
+
1576
+ feature-adr Step-8 asserts the ADR's load-bearing safety property **has** a test. But a green test can be a
1577
+ false green — it may never exercise the property, so it would stay green even if the property regressed. The
1578
+ `§42` gate (learned from rUv's `cve-bench/evaluate.mjs`) proves the test **discriminates**: it runs the property
1579
+ test in a throwaway git worktree at the pre-feature base (no fix) and requires it to go **red**.
1580
+ ```bash
1581
+ # the Step-7 feature diff is uncommitted mid-pipeline, so HEAD is the pre-feature base
1582
+ dz discrimination-check --test packages/x/test/auth-property.test.ts --base HEAD --json
1583
+ ```
1584
+ ```
1585
+ discrimination-check @ HEAD — verdict: NON_DISCRIMINATING
1586
+ ✗ packages/x/test/auth-property.test.ts: NON_DISCRIMINATING
1587
+
1588
+ [high] non-discriminating property test (false green)
1589
+ 1 property test(s) PASS at pre-feature base — they do not exercise the ADR safety property … (Advisory — the pipeline continues; the owner decides.)
1590
+ ```
1591
+ Verdicts: `DISCRIMINATES` (red by assertion — good) · `DISCRIMINATES_VIA_ERROR` (couldn't load at base, e.g. a
1592
+ brand-new module — inferred) · `NON_DISCRIMINATING` (green at base — a false green, HIGH finding) ·
1593
+ `CANNOT_ISOLATE` (no runnable property test — folds into "property untested"). It **never auto-aborts** (dz's
1594
+ rule: a false gate kills trust) — exit 0 on any verdict, exit 2 only on a usage/setup error. The worktree +
1595
+ sanitation live in tested CLI code; base ref, paths, name filter, and runner are all injection-checked, and the
1596
+ worktree is always removed. Step-8 runs this on the ADR Confirmation's `Required automated check` automatically.
1597
+
1561
1598
  ### Semantic recall (vector tier)
1562
1599
 
1563
1600
  `dz recall` is **hybrid** when the vector tier is available and **exactly the old lexical command** when it is not — enabling it never changes behavior for projects that skip it.
package/dist/cli.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAyOH,2EAA2E;AAC3E,MAAM,WAAW,KAAK;IACpB,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC;;;;OAIG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;CACzB;AAmhJD,wBAAsB,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,GAAE,KAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CAsI5E"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AA4OH,2EAA2E;AAC3E,MAAM,WAAW,KAAK;IACpB,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC;;;;OAIG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;CACzB;AAovJD,wBAAsB,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,GAAE,KAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CA0I5E"}
package/dist/cli.js CHANGED
@@ -3,13 +3,13 @@
3
3
  *
4
4
  * @packageDocumentation
5
5
  */
6
- import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, renameSync, rmdirSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
6
+ import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmdirSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
7
7
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import { execSync } from 'node:child_process';
10
- import { homedir } from 'node:os';
10
+ import { homedir, tmpdir } from 'node:os';
11
11
  import { createRequire } from 'node:module';
12
- import { createSkill, getSkillInfo, getWorkflow, isTargetName, listSkills, runDoctor, runInit, 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, TARGET_NAMES, WORKFLOW_NAMES, importEcc, recordPattern, resolveLearningBackend, storeStats, consolidateSessions, pruneNoisePatterns, lessonDeltaReport, removePatternsByIds, snapshotStore, recallHybrid, teachGuard, mirrorPatternsToVector, vectorMirrorEnabled, vectorTierStatus, resolveVectorEngine, reindexVectorStore, harmonizeVectorStore, importRvfCheckpoint, statuslineData, writeFeatureAdrState, computeUsage, deriveUsageCalibration, normalizeClaudeUsageModelKey, readUsageLimits, 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, buildManifest, buildSbom, resolveTrustRoot, decideVerifyPolicy, decideProvenance, isInsideTree, signManifest, verifyManifest, assertKeyOutsideTree, decidePublishGate, 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, pickAdversaryModel, CHALLENGE_QUESTIONS, loadOutcomes, renderOutcomes, statsForKey, selectAutoCost, recordProvisional, finalizeOutcome, COST_LADDER, splitScenarios, budgetPlan, selectWinner, proseScopeOk, renderProseDiff, readScenarioIds, DEFAULT_MAX_JUDGE_RUNS, } from '@dzhechkov/harness-core';
12
+ import { createSkill, getSkillInfo, getWorkflow, isTargetName, listSkills, runDoctor, runInit, 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, TARGET_NAMES, WORKFLOW_NAMES, importEcc, recordPattern, resolveLearningBackend, storeStats, consolidateSessions, pruneNoisePatterns, lessonDeltaReport, removePatternsByIds, snapshotStore, recallHybrid, teachGuard, mirrorPatternsToVector, vectorMirrorEnabled, vectorTierStatus, resolveVectorEngine, reindexVectorStore, harmonizeVectorStore, importRvfCheckpoint, statuslineData, writeFeatureAdrState, computeUsage, deriveUsageCalibration, normalizeClaudeUsageModelKey, readUsageLimits, 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, buildManifest, buildSbom, resolveTrustRoot, decideVerifyPolicy, generateSigningKeypair, decideProvenance, isInsideTree, signManifest, verifyManifest, assertKeyOutsideTree, decidePublishGate, 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, pickAdversaryModel, CHALLENGE_QUESTIONS, loadOutcomes, renderOutcomes, statsForKey, selectAutoCost, recordProvisional, finalizeOutcome, COST_LADDER, splitScenarios, budgetPlan, selectWinner, proseScopeOk, renderProseDiff, readScenarioIds, DEFAULT_MAX_JUDGE_RUNS, } from '@dzhechkov/harness-core';
13
13
  import { getPreset, PRESET_NAMES } from '@dzhechkov/harness-presets';
14
14
  import { scanGitHub, analyzeRepo, generateReport, deepAnalyze, scanAllSources, ScoutMemory } from '@dzhechkov/scout';
15
15
  const USAGE = `dz - DZ cross-platform harness CLI
@@ -2912,10 +2912,86 @@ function reportPackVerification(cwd, explicitPubkey, requireSigning, write) {
2912
2912
  return fatal > 0 ? 1 : 0;
2913
2913
  }
2914
2914
  function cmdSign(options, flags, cwd, write) {
2915
+ // `dz sign --init` — one-time keygen. Writes the PRIVATE key OUTSIDE the repo (default ~/.dz/keys/dz.key,
2916
+ // mode 0600) and prints the PUBLIC key for the operator to commit as keys/dz.pub. The private key never
2917
+ // touches the repo or a tarball (the hard invariant — enforced by assertKeyOutsideTree).
2918
+ if (flags.has('init')) {
2919
+ const outPath = resolve(cwd, options.get('out') ?? join(homedir(), '.dz', 'keys', 'dz.key'));
2920
+ // Resolve symlinks BEFORE the containment check: an --out (or a parent dir) that is a symlink pointing
2921
+ // INTO the repo would otherwise slip the private key inside the tree past a string-only guard — including a
2922
+ // DANGLING symlink whose target does not exist yet (existsSync follows the link and reports false, so the
2923
+ // symlink itself must be resolved via lstat/readlink, not existsSync).
2924
+ const realTarget = (() => {
2925
+ // walk up to the deepest path that exists as ANY entry (file, dir, or even a dangling symlink).
2926
+ let anc = outPath;
2927
+ const tail = [];
2928
+ for (;;) {
2929
+ try {
2930
+ lstatSync(anc);
2931
+ break;
2932
+ }
2933
+ catch { /* not present */ }
2934
+ const parent = dirname(anc);
2935
+ if (parent === anc)
2936
+ return outPath; // reached root without an existing entry
2937
+ tail.unshift(basename(anc));
2938
+ anc = parent;
2939
+ }
2940
+ let base;
2941
+ try {
2942
+ base = realpathSync(anc); // resolves dirs/files and any RESOLVABLE symlink chain
2943
+ }
2944
+ catch {
2945
+ // `anc` exists but realpath threw → a dangling symlink: resolve its immediate target by hand.
2946
+ try {
2947
+ base = resolve(dirname(anc), readlinkSync(anc));
2948
+ }
2949
+ catch {
2950
+ return outPath;
2951
+ }
2952
+ }
2953
+ return tail.length ? join(base, ...tail) : base;
2954
+ })();
2955
+ try {
2956
+ assertKeyOutsideTree(realTarget, cwd);
2957
+ }
2958
+ catch (err) {
2959
+ write(`dz sign --init: ${err.message}`);
2960
+ write(' choose an --out path (and parent) OUTSIDE the repository (e.g. ~/.dz/keys/dz.key)');
2961
+ return 1;
2962
+ }
2963
+ if (existsSync(outPath) && !flags.has('force')) {
2964
+ write(`dz sign --init: ${outPath} already exists — refusing to overwrite (pass --force to replace)`);
2965
+ write(' overwriting a signing key orphans every pack signed with the old one.');
2966
+ return 1;
2967
+ }
2968
+ const { privateKey, publicKey } = generateSigningKeypair();
2969
+ try {
2970
+ mkdirSync(dirname(outPath), { recursive: true });
2971
+ // Unlink an existing file first: writeFileSync's `mode` is ignored when the file already exists, so a
2972
+ // pre-existing 0644 key would stay world-readable after --force. Removing it forces a fresh 0600 create.
2973
+ if (existsSync(outPath))
2974
+ rmSync(outPath, { force: true });
2975
+ writeFileSync(outPath, privateKey, { mode: 0o600 });
2976
+ chmodSync(outPath, 0o600); // belt-and-suspenders: guarantee 0600 regardless of umask/prior state
2977
+ }
2978
+ catch (err) {
2979
+ write(`dz sign --init: could not write the private key to ${outPath}: ${err.message}`);
2980
+ return 1;
2981
+ }
2982
+ write(`dz sign --init: Ed25519 keypair generated.`);
2983
+ write(` private key → ${outPath} (mode 0600, OUTSIDE the repo — never commit it)`);
2984
+ write(` public key → commit the block below as ${TRUST_ROOT_REL}:`);
2985
+ write('');
2986
+ write(publicKey.trimEnd());
2987
+ write('');
2988
+ write(` then: dz sign --pack <dir> --key ${outPath} (sign a pack)`);
2989
+ return 0;
2990
+ }
2915
2991
  const pack = options.get('pack');
2916
2992
  const key = options.get('key');
2917
2993
  if (!pack || !key) {
2918
- write('dz sign: --pack <dir> and --key <path> are both required');
2994
+ write('dz sign: --pack <dir> and --key <path> are both required (or: dz sign --init to generate a keypair)');
2919
2995
  write(' the key path MUST be outside the repository working tree (a leaked signing key is not revertible)');
2920
2996
  return 1;
2921
2997
  }
@@ -2984,6 +3060,49 @@ function cmdVerifyPack(options, flags, cwd, write) {
2984
3060
  write(` ${f.path}: ${f.reason}`);
2985
3061
  return 1;
2986
3062
  }
3063
+ /**
3064
+ * `dz sbom` — emit a CycloneDX 1.5 SBOM (Software Bill of Materials) for a pack, standalone (no signing).
3065
+ * The same inventory `dz sign` writes as `sbom.json`, available on its own for audit/procurement. Components
3066
+ * are the pack's shipped files with their SHA-256 — a file-level bill of materials for the pack.
3067
+ * --pack <dir> the pack to inventory (required)
3068
+ * --out <file> write here (default: print to stdout)
3069
+ * --json (implied) SPDX/CycloneDX is already JSON
3070
+ */
3071
+ function cmdSbom(options, flags, cwd, write) {
3072
+ const pack = options.get('pack');
3073
+ if (!pack) {
3074
+ write('dz sbom: --pack <dir> is required');
3075
+ return 1;
3076
+ }
3077
+ const packDir = resolve(cwd, pack);
3078
+ if (!existsSync(packDir)) {
3079
+ write(`dz sbom: no such pack: ${packDir}`);
3080
+ return 1;
3081
+ }
3082
+ const files = packFiles(packDir);
3083
+ if (files.length === 0) {
3084
+ write('dz sbom: the pack contains no files');
3085
+ return 1;
3086
+ }
3087
+ const manifest = buildManifest(packDir, basename(packDir), files);
3088
+ const sbom = buildSbom(manifest);
3089
+ const out = JSON.stringify(sbom, null, 2);
3090
+ const outOpt = options.get('out');
3091
+ if (outOpt !== undefined) {
3092
+ const outPath = resolve(cwd, outOpt);
3093
+ try {
3094
+ writeFileSync(outPath, out + '\n');
3095
+ }
3096
+ catch (err) {
3097
+ write(`dz sbom: could not write ${outPath}: ${err.message}`);
3098
+ return 1;
3099
+ }
3100
+ write(`dz sbom: ${sbom.components.length} component(s) → ${outPath} (CycloneDX ${sbom.specVersion})`);
3101
+ return 0;
3102
+ }
3103
+ write(out);
3104
+ return 0;
3105
+ }
2987
3106
  function cmdPublish(options, flags, cwd, write) {
2988
3107
  // Reject unknown flags/options so a typo (e.g. `--dry-rum`) can NEVER be
2989
3108
  // silently swallowed and flip the command into live-publish mode.
@@ -3739,29 +3858,37 @@ function cmdDriftCheck(options, flags, cwd, write) {
3739
3858
  write(`allowlisted (accepted drift): ${r.allowlisted.map((d) => d.name).join(', ')}`);
3740
3859
  }
3741
3860
  write(`DRIFTED (unexpected, byte-differences between copies): ${r.drifted.length}`);
3861
+ let driftExit = 0;
3742
3862
  if (r.drifted.length === 0) {
3743
3863
  write('✓ no unexpected intra-monorepo skill drift');
3744
- return 0;
3745
- }
3746
- write('');
3747
- write('skill'.padEnd(34) + 'copies drift/total');
3748
- for (const d of r.drifted) {
3749
- write(d.name.padEnd(34) +
3750
- String(d.copies).padStart(4) +
3751
- ' ' +
3752
- `${d.driftFiles}/${d.totalFiles}` +
3753
- (d.missingFiles ? ` (+${d.missingFiles} missing)` : ''));
3754
3864
  }
3865
+ else {
3866
+ write('');
3867
+ write('skill'.padEnd(34) + 'copies drift/total');
3868
+ for (const d of r.drifted) {
3869
+ write(d.name.padEnd(34) +
3870
+ String(d.copies).padStart(4) +
3871
+ ' ' +
3872
+ `${d.driftFiles}/${d.totalFiles}` +
3873
+ (d.missingFiles ? ` (+${d.missingFiles} missing)` : ''));
3874
+ }
3875
+ write('');
3876
+ write('→ fix each: heal drift, then commit. Remediation per skill:');
3877
+ for (const d of r.drifted) {
3878
+ const hasMeta = existsSync(join(root, 'packages', '@dzhechkov', 'skills-meta', d.name));
3879
+ write(hasMeta
3880
+ ? ` dz sync-canonical ${d.name}`
3881
+ : ` dz sync-canonical ${d.name} --from <a-known-good-copy> (no skills-meta canonical)`);
3882
+ }
3883
+ write(' (accept a drift intentionally: add its name to .dz/drift-allowlist.json with a reason)');
3884
+ driftExit = 1; // EXIT CODE 1 = the CI gate trips
3885
+ }
3886
+ // Supplementary CI check: installed-pack signatures. A TAMPERED pack trips the gate (fatal); an unsigned
3887
+ // pack or a missing trust root is reported, not fatal — the same warn/block posture as `dz doctor` (the
3888
+ // primary signature gate). drift-check surfaces it so the CI drift view also flags a tampered pack.
3755
3889
  write('');
3756
- write('→ fix each: heal drift, then commit. Remediation per skill:');
3757
- for (const d of r.drifted) {
3758
- const hasMeta = existsSync(join(root, 'packages', '@dzhechkov', 'skills-meta', d.name));
3759
- write(hasMeta
3760
- ? ` dz sync-canonical ${d.name}`
3761
- : ` dz sync-canonical ${d.name} --from <a-known-good-copy> (no skills-meta canonical)`);
3762
- }
3763
- write(' (accept a drift intentionally: add its name to .dz/drift-allowlist.json with a reason)');
3764
- return 1; // EXIT CODE 1 = the CI gate trips
3890
+ const sigFatal = reportPackVerification(root, options.get('pubkey'), flags.has('require-signing'), write);
3891
+ return driftExit || sigFatal;
3765
3892
  }
3766
3893
  /**
3767
3894
  * `dz sync-canonical <skill>` — the healer. Treats the resolved canonical (`--from` →
@@ -4249,6 +4376,162 @@ function cmdChallenge(options, flags, cwd, write) {
4249
4376
  write(`\n── dispatch (panel ≠ plan author) ──\n${adversary.model}: ${adversary.note}`);
4250
4377
  return 0;
4251
4378
  }
4379
+ /**
4380
+ * `dz discrimination-check` — the §42 test-discrimination gate (feature learned from cve-bench/evaluate.mjs).
4381
+ * feature-adr Step-8 already asserts the ADR safety property HAS a test; this asserts that test DISCRIMINATES:
4382
+ * run the property test(s) in an isolated git worktree at the pre-feature base (default HEAD, since the feature
4383
+ * diff is uncommitted mid-pipeline). They MUST go red without the feature diff — a green is a false green.
4384
+ *
4385
+ * --test <a.test.ts[,b.test.ts]> the property test file(s) mapped from the ADR Confirmation (comma list)
4386
+ * --base <ref> the base ref to fail against (default HEAD)
4387
+ * --name '<filter>' optional -t test-name filter applied to every target
4388
+ * --runner '<cmd>' test runner (default `npx vitest run`)
4389
+ * --json machine-readable {plan, results, verdict, finding}
4390
+ *
4391
+ * NEVER auto-aborts: a non-discriminating (false-green) test is reported as a HIGH finding for the owner to
4392
+ * decide (dz's rule — a false gate kills trust). Exit code is 0 on a clean run regardless of verdict; 2 only on
4393
+ * a usage/setup error, so a caller distinguishes "gate ran" from "gate could not run".
4394
+ */
4395
+ function cmdDiscriminationCheck(options, flags, cwd, write) {
4396
+ let repoRoot = cwd;
4397
+ try {
4398
+ repoRoot = execSync('git rev-parse --show-toplevel', { cwd, encoding: 'utf-8' }).trim() || cwd;
4399
+ }
4400
+ catch { /* not git */ }
4401
+ const testArg = options.get('test');
4402
+ if (testArg === undefined || testArg.trim() === '') {
4403
+ write('dz discrimination-check: pass --test <property-test.ts[,...]> (the test(s) the ADR Confirmation names).');
4404
+ return 2;
4405
+ }
4406
+ const nameFilter = options.get('name');
4407
+ const propertyTests = testArg.split(',').map((s) => s.trim()).filter(Boolean).map((file) => nameFilter !== undefined && nameFilter.trim() !== '' ? { file, name: nameFilter.trim() } : { file });
4408
+ const baseRef = options.get('base') ?? 'HEAD';
4409
+ const runnerOpt = options.get('runner');
4410
+ const plan = planDiscriminationCheck(runnerOpt !== undefined ? { baseRef, propertyTests, runner: runnerOpt } : { baseRef, propertyTests });
4411
+ if (!plan.runnable) {
4412
+ // No safe target to run → this is the existing "property untested" finding (empty propertyTests classify).
4413
+ const result = classifyDiscrimination({ propertyTests: [], results: [] });
4414
+ if (flags.has('json')) {
4415
+ write(JSON.stringify({ plan, results: [], ...result }, null, 2));
4416
+ return 0;
4417
+ }
4418
+ write(`discrimination-check: NOT RUN (${plan.reason ?? 'unknown'})`);
4419
+ if (plan.rejected.length)
4420
+ write(' rejected: ' + plan.rejected.map((r) => `${r.file} (${r.reason})`).join(', '));
4421
+ write(` → ${result.finding?.detail ?? 'no property test to check'}`);
4422
+ return 0;
4423
+ }
4424
+ // Execute the plan in a temp worktree WE own; substitute {{WORKTREE}} and always clean up.
4425
+ // `git worktree add` must CREATE the path, so compute a fresh non-existent one (do NOT mkdtemp it).
4426
+ const worktree = join(mkdtempSync(join(tmpdir(), 'dz-disc-')), 'wt');
4427
+ const results = [];
4428
+ try {
4429
+ // 1) add the detached worktree at base (git creates `worktree`; its parent already exists).
4430
+ const addCmd = plan.commands[0].replace(/\{\{WORKTREE\}\}/g, worktree);
4431
+ execSync(addCmd, { cwd: repoRoot, stdio: 'pipe', encoding: 'utf-8' });
4432
+ // 1b) a fresh worktree has NO node_modules — without this, every test fails to load (runner + deps
4433
+ // unresolvable) and the gate collapses to always-VIA_ERROR, blind to false greens. Absolute-path
4434
+ // symlinks point back at the main checkout's already-installed trees, robust across pnpm's layout.
4435
+ const linkNodeModules = (relDir) => {
4436
+ const srcNm = join(repoRoot, relDir, 'node_modules');
4437
+ if (!existsSync(srcNm))
4438
+ return;
4439
+ const dstNm = join(worktree, relDir, 'node_modules');
4440
+ if (existsSync(dstNm))
4441
+ return;
4442
+ try {
4443
+ mkdirSync(dirname(dstNm), { recursive: true });
4444
+ symlinkSync(srcNm, dstNm, 'dir');
4445
+ }
4446
+ catch { /* best effort */ }
4447
+ };
4448
+ linkNodeModules('.'); // root (hoisted deps + .bin)
4449
+ const pkgDirs = new Set();
4450
+ for (const t of plan.targets) {
4451
+ let d = dirname(t.file);
4452
+ while (d && d !== '.' && d !== sep) {
4453
+ if (existsSync(join(repoRoot, d, 'package.json'))) {
4454
+ pkgDirs.add(d);
4455
+ break;
4456
+ }
4457
+ d = dirname(d);
4458
+ }
4459
+ }
4460
+ for (const d of pkgDirs)
4461
+ linkNodeModules(d);
4462
+ // 2) copy each property test into the base worktree, then 3) run it and record pass/fail/error per target.
4463
+ for (const t of plan.targets) {
4464
+ try {
4465
+ const src = resolve(repoRoot, t.file);
4466
+ // containment guard (defense in depth beyond planDiscriminationCheck's path sanitation).
4467
+ if (!resolve(src).startsWith(resolve(repoRoot) + sep)) {
4468
+ results.push(nameFor(t, 'error'));
4469
+ continue;
4470
+ }
4471
+ const dst = join(worktree, t.file);
4472
+ mkdirSync(dirname(dst), { recursive: true });
4473
+ writeFileSync(dst, readFileSync(src));
4474
+ }
4475
+ catch {
4476
+ results.push(nameFor(t, 'error'));
4477
+ continue;
4478
+ }
4479
+ const runner = (runnerOpt !== undefined && plan.commands.some((c) => c.includes(runnerOpt))) ? runnerOpt : 'npx vitest run';
4480
+ // t.file + t.name already passed the engine's strict sanitation (no quotes/metacharacters/leading-dash);
4481
+ // still quote + `--` so a path can never be read as a runner option or split a word.
4482
+ const nameArg = t.name ? ` -t '${t.name}'` : '';
4483
+ try {
4484
+ execSync(`${runner}${nameArg} -- '${t.file}'`, { cwd: worktree, stdio: 'pipe', encoding: 'utf-8' });
4485
+ results.push(nameFor(t, 'pass')); // exit 0 → test PASSED at base → false green
4486
+ }
4487
+ catch (e) {
4488
+ // vitest exits non-zero on failure AND on load/compile error. Distinguish: a load error usually names
4489
+ // "Cannot find module"/"Failed to load"/"No test files"; otherwise treat as an assertion failure (red).
4490
+ const out = String(e.stdout ?? '') + String(e.stderr ?? '');
4491
+ const isLoadError = /cannot find module|failed to load|no test (files )?found|error: cannot|transform failed|esbuild/i.test(out);
4492
+ results.push(nameFor(t, isLoadError ? 'error' : 'fail'));
4493
+ }
4494
+ }
4495
+ }
4496
+ catch (e) {
4497
+ if (flags.has('json')) {
4498
+ write(JSON.stringify({ plan, error: 'worktree-setup-failed', detail: String(e.message).slice(0, 300) }, null, 2));
4499
+ }
4500
+ else
4501
+ write(`discrimination-check: could not create worktree at ${baseRef}: ${String(e.message).slice(0, 200)}`);
4502
+ return 2;
4503
+ }
4504
+ finally {
4505
+ try {
4506
+ execSync(`git worktree remove --force ${worktree}`, { cwd: repoRoot, stdio: 'pipe' });
4507
+ }
4508
+ catch { /* fall through to rm */ }
4509
+ // remove the whole mkdtemp parent (worktree is `<mkdtemp>/wt`), so nothing leaks under tmp even on error.
4510
+ try {
4511
+ rmSync(dirname(worktree), { recursive: true, force: true });
4512
+ }
4513
+ catch { /* best effort */ }
4514
+ try {
4515
+ execSync('git worktree prune', { cwd: repoRoot, stdio: 'pipe' });
4516
+ }
4517
+ catch { /* best effort */ }
4518
+ }
4519
+ const result = classifyDiscrimination({ propertyTests, results });
4520
+ if (flags.has('json')) {
4521
+ write(JSON.stringify({ plan, results, ...result }, null, 2));
4522
+ return 0;
4523
+ }
4524
+ write(`discrimination-check @ ${baseRef} — verdict: ${result.aggregate}`);
4525
+ for (const p of result.perTest)
4526
+ write(` ${p.verdict === 'NON_DISCRIMINATING' ? '✗' : '✓'} ${p.file}${p.name ? ` (${p.name})` : ''}: ${p.verdict}`);
4527
+ if (result.finding)
4528
+ write(`\n [${result.finding.severity}] ${result.finding.title}\n ${result.finding.detail}`);
4529
+ return 0;
4530
+ }
4531
+ /** small helper: build a result row, omitting `name` when absent (exactOptionalPropertyTypes). */
4532
+ function nameFor(t, outcome) {
4533
+ return t.name !== undefined ? { file: t.file, name: t.name, outcome } : { file: t.file, outcome };
4534
+ }
4252
4535
  /**
4253
4536
  * `dz routing` — inspect the learned cost-optimal routing outcome store (feature learned-cost-routing). Shows
4254
4537
  * what `args.models[stage]='auto-cost'` currently believes per (stage, complexity-tier, model): gated
@@ -4628,6 +4911,8 @@ export async function runCli(argv, io = {}) {
4628
4911
  return cmdClaimCheck(options, optionLists, flags, cwd, write);
4629
4912
  case 'sign':
4630
4913
  return cmdSign(options, flags, cwd, write);
4914
+ case 'sbom':
4915
+ return cmdSbom(options, flags, cwd, write);
4631
4916
  case 'verify-pack':
4632
4917
  return cmdVerifyPack(options, flags, cwd, write);
4633
4918
  case 'setup':
@@ -4676,6 +4961,8 @@ export async function runCli(argv, io = {}) {
4676
4961
  return cmdFeatureAdrSetup(options, flags, cwd, write);
4677
4962
  case 'challenge':
4678
4963
  return cmdChallenge(options, flags, cwd, write);
4964
+ case 'discrimination-check':
4965
+ return cmdDiscriminationCheck(options, flags, cwd, write);
4679
4966
  case 'routing':
4680
4967
  return cmdRouting(options, flags, cwd, write);
4681
4968
  case 'bto-optimize':