@dzhechkov/harness-cli 0.8.9 → 0.8.10

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
@@ -13,7 +13,7 @@ import { isBooleanFlag } from './boolean-flags.js';
13
13
  import { resolveInstallSpec } from './install-spec.js';
14
14
  import { execFile, execFileSync, execSync, spawn, spawnSync, type ChildProcess } from 'node:child_process';
15
15
  import { createHash, randomBytes } from 'node:crypto';
16
- import { homedir, tmpdir } from 'node:os';
16
+ import { homedir, hostname, tmpdir } from 'node:os';
17
17
  import { createRequire } from 'node:module';
18
18
  import { isDeepStrictEqual } from 'node:util';
19
19
 
@@ -30,6 +30,8 @@ import {
30
30
  runDoctor,
31
31
  runInit,
32
32
  discoverSkillIds,
33
+ resolveSelection,
34
+ formatSelectRefusal,
33
35
  runIntegrationsVerify,
34
36
  resolvePackageSkillRoots,
35
37
  PACKAGE_SKILL_LAYOUTS,
@@ -210,6 +212,8 @@ import {
210
212
  readTailInfo,
211
213
  appendChainedLines,
212
214
  verifyEventChainText,
215
+ classifyChainDefects,
216
+ CHAINED_JOURNALS,
213
217
  buildManifest,
214
218
  buildSbom,
215
219
  resolveTrustRoot,
@@ -371,6 +375,11 @@ import {
371
375
  type EpochOutcome,
372
376
  scoreRun,
373
377
  readQeGrade,
378
+ scoreReceiptToAggregateRow,
379
+ readScoreAggregateRows,
380
+ dedupeScoreAggregateRows,
381
+ buildScoreAggregateReport,
382
+ renderScoreAggregateReport,
374
383
  recapWindow,
375
384
  decideHorizon,
376
385
  withinWindow,
@@ -478,6 +487,7 @@ import {
478
487
  // Mutation gate (feature ha-mutation-gate) — break each named protection, run the suite, require red.
479
488
  parseMutationRegistry,
480
489
  applyMutationToText,
490
+ attributeBaselineRedness,
481
491
  countFailingTests,
482
492
  detectSuiteCompletionReceipt,
483
493
  detectSuiteReceiptMismatch,
@@ -487,6 +497,7 @@ import {
487
497
  mutationGateExitCode,
488
498
  summarizeMutationResults,
489
499
  renderMutationReport,
500
+ runWithOneInternalRetry,
490
501
  TRACE_BUNDLE_LEDGER_PATH,
491
502
  TRACE_BUNDLE_SCHEMA,
492
503
  TRACE_BUNDLE_RUN_META_FILE,
@@ -573,7 +584,7 @@ export const DZ_COMMANDS: readonly string[] = [
573
584
  'epoch-replay', 'score', 'recap', 'cadence', 'qe-rounds', 'restart-advisor', 'tg-post',
574
585
  'name-check', 'provenance-check', 'feature-adr-record', 'amendment-check', 'contract-check',
575
586
  'feature-adr-checkpoint', 'profile', 'reqe', 'qe-bridge', 'backlog', 'routing',
576
- 'bto-optimize', 'dashboard', 'roam', 'import-ecc',
587
+ 'bto-optimize', 'dashboard', 'roam', 'import-ecc', 'chain',
577
588
  ];
578
589
 
579
590
  const USAGE = `dz - DZ cross-platform harness CLI
@@ -615,6 +626,7 @@ Usage:
615
626
  dz epoch-replay --judge <filled-work-order.json> [--out <file>] (blind judge prompts from the filled plans)
616
627
  dz epoch-replay --score <judgments.json> --work-order <file> [--slice <name>] [--json] (un-blind against the pre-registered assignment → SUPPORTED only when the two 95% Wilson CIs are DISJOINT, else FALSIFIED / INCONCLUSIVE)
617
628
  dz score --slug <feature> [--project <dir>] [--json] (process scorecard for ONE feature-adr run, from its artifacts: ADR confirmation, discrimination, cross-model QE grade, live verification, README-first, learning loop, amendments — descriptive-only, a low score exits 0)
629
+ dz score --all [--project <dir>] [--json] (sweep features/*/.fa-state/score-*.json into the append-only chained scorecards aggregate — descriptive-only, always exits 0)
618
630
  dz recap [--day|--week|--month] [--at <ISO date>] [--project <dir>] [--json] (what was done over a window, from records only: deliveries with the grade an independent review STATED — a report naming two grades is reported ambiguous, never guessed — registry publishes, gate verdicts, knowledge reuse. --quarter/--half-year/--year are RECOGNISED and REFUSED with the real span in days: there is one complete quarter and the longest record is 174 days. Every section carries its own data-start date, and "the source was not read" never prints as zero. Contaminated measures — commit count, lines, tokens, learning-event volume, inventory counts, lesson count — are not computed, and the report says so. exit 0 reported / 2 refused)
619
631
  dz cadence [--window day|week|month|quarter|halfyear|year] [--json] (the WHAT-SHIPPED aggregator: graded-shipment cadence by ISO week + npm-publish cadence (recap cache) + guard repeat decay on the FIXED rule set + recall reuse; a window deeper than 2× the record is REFUSED with the depth named (ADR: a cadence from one point is scale forgery); exit 0 report / 2 refused-window / 1 usage)
620
632
  dz qe-rounds (--slug <feature> | --feature-dir <abs>) [--ceiling <n>] [--project <dir>] [--json] (how many Step-8 review rounds has this feature ALREADY had? Reads what dz qe-bridge already wrote — signoff-<runId>.json and failed-*.json under features/<slug>/.fa-state/qe-bridge — and writes nothing itself, so it can answer for runs already past. A round is a runId, not a file; an attempt with no verdict is counted SEPARATELY and never merged; an unreadable record is NAMED and the count is declared a LOWER BOUND. ONE directory, never a union across checkouts. exit 0 under the ceiling / 1 at-or-over — owner decides, the command does not judge whether the rounds were warranted / 2 NOT ESTABLISHED, which is never "zero rounds")
@@ -633,7 +645,7 @@ Usage:
633
645
  dz feature-adr-checkpoint (--slug <feature> | --feature-dir <abs>) --stage <s> --input-hash <h> --result <json> [--artifact a,b] [--json] (record a pipeline stage ONLY after measuring its artifacts on disk; refuses a null result, an absent artifact, or a stage that declares none — the subagent runs a COMMAND instead of hand-writing durable state)
634
646
  dz profile [init|show|set|sync] [--json] (WHO the assistant is talking to — per-user store at ~/.dz/profile.json (0600, NEVER in a project), delivered as a marked block in ~/.claude/CLAUDE.md so it loads in EVERY project, dz installed or not. init = five questions (language, register, deep/weak domains as comma lists — "networking (CCIE; NSX)" keeps the parenthetical as the note, Enter skips — teaches y/n with one re-ask, never a silent default); show ALWAYS prints the store path + age + drift verdict + the rendered block; set register|language|teaches <v> or set deep|weak add|rm <tag> [note] — register accepts the owner's own words (профи / профи лайт / просто), an unknown value is REFUSED naming the accepted set; sync re-writes the block (runs automatically after init/set; foreign content byte-for-byte, timestamped backup before every modifying write). The register changes FORM, never FACTS, and governs dialogue only — never ADRs/commits/QE reports; both rules are baked into the rendered block at every level. exit 0 done / 1 no profile or failed / 2 refused input)
635
647
  dz reqe [--slug <feature> [--done --report <f>]] [--json] (the re-QE debt ledger: a usage-switched run whose Step-8 QE ran on the coder's OWN family records a debt; list debts, print the cross-family review brief, settle FAIL-CLOSED against a graded report — the settlement lands in 08_qe_report.md)
636
- dz qe-bridge --family claude --slug <feature> [--coder-family codex|claude] [--model <id>] [--files a,b] [--out <f>] [--timeout <s>] [--allow-same-family] [--json] (the REVERSE QE bridge: run an INDEPENDENT Claude reviewer over a feature's Step-8 artifacts from ANY host — a Codex session included, plain shell, no Claude agent plane needed — and land a PARSED signoff. The reviewer runs ISOLATED: an EMPTY temp cwd plus --safe-mode --strict-mcp-config --tools '' --no-session-persistence, so no CLAUDE.md/skills/plugins/hooks/MCP load, and the verdict is read from the --output-format json RESULT ENVELOPE — text a session customization printed onto the same stdout can never become a signoff. Probes the model before trusting it; sends SCOPED extracts with a loud 200k-char ceiling (never silent truncation); the grade must AGREE across three LAST-anchored channels (terminal marker line, fenced qe-bridge-signoff JSON, the report's own GRADE line) AND the marker must be the FINAL content — empty, gradeless, self-contradicting or miscounted output is one of 18 NAMED failures with an audit record under features/<slug>/.fa-state/qe-bridge/ (runId, resolved executable + binOverride, prompt sha256, channel offsets, requestedOut, reportWritten, retained raw stdout; 0600 files in a 0700 dir), never a clean review. A --coder-family that contradicts the recorded reqe debt is refused. Writes features/<slug>/08b_reqe_report.md, which dz reqe --done settles unchanged. DISCLOSURE: the extracts you scope are sent to the Claude runtime; the bridge cannot classify secrets. DZ_QE_BRIDGE_CLAUDE_BIN is a TEST SEAM, not a flag. exit 0 signoff parsed (ANY grade — it reports, it does not gate) / 1 named failure / 2 usage)
648
+ dz qe-bridge --family claude --slug <feature> [--coder-family codex|claude] [--model <id>] [--files a,b] [--out <f>] [--timeout <s>] [--allow-same-family] [--json] (the REVERSE QE bridge: run an INDEPENDENT Claude reviewer over a feature's Step-8 artifacts from ANY host — a Codex session included, plain shell, no Claude agent plane needed — and land a PARSED signoff. The reviewer runs ISOLATED: an EMPTY temp cwd plus --safe-mode --strict-mcp-config --tools '' --no-session-persistence, so no CLAUDE.md/skills/plugins/hooks/MCP load, and the verdict is read from the --output-format json RESULT ENVELOPE — text a session customization printed onto the same stdout can never become a signoff. Probes the model before trusting it; sends SCOPED extracts with a loud 200k-char ceiling (never silent truncation); the grade must AGREE across three LAST-anchored channels (terminal marker line, fenced qe-bridge-signoff JSON, the report's own GRADE line) AND the marker must be the FINAL content — empty, gradeless, self-contradicting or miscounted output is one of 17 NAMED failures with an audit record under features/<slug>/.fa-state/qe-bridge/ (runId, resolved executable + binOverride, prompt sha256, channel offsets, requestedOut, reportWritten, retained raw stdout; 0600 files in a 0700 dir), never a clean review. A --coder-family that contradicts the recorded reqe debt is refused. Writes features/<slug>/08b_reqe_report.md, which dz reqe --done settles unchanged. DISCLOSURE: the extracts you scope are sent to the Claude runtime; the bridge cannot classify secrets. DZ_QE_BRIDGE_CLAUDE_BIN is a TEST SEAM, not a flag. exit 0 signoff parsed (ANY grade — it reports, it does not gate) / 1 named failure / 2 usage)
637
649
  dz mutation-gate [--package <dir>] [--registry <file>] [--test-cmd "<cmd>"] [--only <id[,id]>] [--timeout <ms>] [--rebaseline per-entry|final] [--keep-scratch] [--json] (prove each NAMED protection has a test that DISCRIMINATES: copy the package to a scratch dir, verify the baseline suite is green, apply each registry mutation, run the suite, REQUIRE red, restore. The red must be BEHAVIOURAL: a mutation that no longer parses is MUTATION_UNPARSEABLE; a red run whose OWN output reports a test FILE failing to load (node --test file-level not-ok with exitCode, vitest Failed Suites) is MUTATION_LOAD_FATAL — the signal comes from the same run as the failing count, never from a separate isolated import; red output whose shape matches no known runner is INCONCLUSIVE (a runner-coverage gap, loud, never PROVEN); a count far above the entry's bound is OVER_FAILING; a restored tree that does not reproduce green makes the entry INCONCLUSIVE (flaky). Mutation writes are realpath-contained to the scratch copy: a symlink escape or a node_modules/ target is refused (exit 2), the real tree is never written. A mutation that does not apply, a green suite, or an inconclusive run is a FAILURE — never a skip. exit 0 all proven / 1 gate failed / 2 setup error)
638
650
  dz backlog add "<idea>" [--effort 1-5] [--proposal <text>] [--dry-run] [--project <dir>] [--json] (capture an idea: semantic dedup against existing ideas via the Brain vector engine (DUPLICATE>=0.92 merges, RELATED links, NEW creates) + GoalMap alignment; --dry-run classifies without writing)
639
651
  dz backlog list [--status <s>] [--goal <id>] [--project <dir>] [--json] (list captured ideas, filterable by status/goal)
@@ -672,6 +684,7 @@ Usage:
672
684
  dz statusline --fa-record --slug <s> --step "<label>" [--kind <feature-adr|loop>] [--recalled <n>] [--stored <n>] [--mode <m>] (feature-adr: record live per-run learning state → 📐 panel segment)
673
685
  dz usage [--json] [--project <dir>] | dz usage --calibrate --session <pct> --weekly <pct> [--model fable=<pct>] [--project <dir>] (ESTIMATE Claude usage from fixed reset windows; optional per-model weekly binding; exit 0 ALWAYS; pct=null when limits unconfigured)
674
686
  dz usage --by-stage [--run <runId> | --slug <slug>] [--epsilon <0..1>] [--write <file.jsonl>] [--json] (per-stage cost ledger for ONE feature-adr run + the reconciliation invariant: accounted + unaccounted = run total; verdict BALANCED | DEFECT | INSUFFICIENT_DATA; local transcript ESTIMATES — catches ATTRIBUTION errors, not pricing errors)
687
+ dz chain [--project <dir>] [--json] (verify EVERY hash-chained journal in ONE command: coverage is DERIVED from the CHAINED_JOURNALS registry, never typed, so a journal cannot be given a chain and checked by nobody. An ABSENT journal is NAMED absent, never omitted — omission and cleanliness are indistinguishable in a report. Statuses: ok | healed (defects the current unbroken run has outlived — verdicts over present records are sound) | unchained (present, no chained record yet — legal) | absent | broken | unreadable. Exit 1 on broken/unreadable: a verifier that reports damage and exits 0 is one no automation can act on)
675
688
  dz claim-check [paths...] [--json] [--fail-on high|medium|none] [--project <dir>] (enforce the Integrity Rule: flag untagged/overstated accuracy claims; default scan = root README.md + every discovered package's README.md + features/*/08_qe_report.md + docs/**/*.md (historical feature artifacts are NOT scanned — pass paths explicitly); exit 1 only at/above --fail-on, default high)
676
689
  dz lint [paths...] [--json] [--config <file>] [--registry <file>] [--project <dir>] (advisory EN/RU prose-style lint; findings exit 0, incomplete input/policy exits 1, usage exits 2)
677
690
  dz pretrain [--project <dir>]
@@ -717,6 +730,17 @@ Workflows: author loop-plan/1 plans with dz workflow init/validate/render; gate
717
730
  Targets: ${TARGET_NAMES.join(', ')}
718
731
  Presets: ${PRESET_NAMES.join(', ')}`;
719
732
 
733
+ export interface MutationGateRunnerObservation {
734
+ readonly exitCode: number | null;
735
+ readonly output: string;
736
+ readonly failureReason?: string;
737
+ }
738
+
739
+ export type MutationGateRunner = (
740
+ command: string,
741
+ options: { readonly cwd: string; readonly timeoutMs: number },
742
+ ) => MutationGateRunnerObservation;
743
+
720
744
  /** Output sink + working directory — injectable so the CLI is testable. */
721
745
  export interface CliIo {
722
746
  readonly cwd?: string;
@@ -761,6 +785,8 @@ export interface CliIo {
761
785
  * offline, hermetically — mirrors the {@link CliIo.releaseRunner} idiom.
762
786
  */
763
787
  readonly installRunner?: (command: string, cwd: string) => void;
788
+ /** Fault seam for proving mutation-gate catches and retries thrown runner internals. */
789
+ readonly mutationGateRunner?: MutationGateRunner;
764
790
  }
765
791
 
766
792
  /** Injected subprocess runner used by `dz release` (see {@link CliIo.releaseRunner}). */
@@ -850,6 +876,12 @@ function discoverSkillsDirs(cwd: string, explicitSkillsDir?: string | undefined)
850
876
 
851
877
  /** Outcome of {@link installSkills}. */
852
878
  interface InstallSkillsResult {
879
+ /**
880
+ * Set when an explicit `--select` named an id no root provides (backlog 9d15b9b6, PR-A). The
881
+ * caller MUST print it and exit non-zero: `0 skill(s)` is not a success, and by the time this is
882
+ * set nothing has been written yet — the refusal is decided before the first byte.
883
+ */
884
+ readonly selectRefusal?: string;
853
885
  readonly results: { id: string; written: number; skipped: number }[];
854
886
  readonly dirsSearched: number;
855
887
  readonly written: number;
@@ -885,11 +917,44 @@ async function installSkills(opts: {
885
917
  noIntegrations?: boolean;
886
918
  noVerify?: boolean;
887
919
  allowIntegrations?: string;
920
+ writeErr?: (line: string) => void;
888
921
  }): Promise<InstallSkillsResult> {
889
922
  const { target, projectRoot, cwd, explicitSkillsDir, select, force, enrich } = opts;
890
923
 
891
924
  const skillsDirs = discoverSkillsDirs(cwd, explicitSkillsDir);
892
925
 
926
+ // PREFLIGHT (backlog 9d15b9b6, PR-A) — resolve the REQUEST once, before anything is written.
927
+ //
928
+ // Two defects lived in asking each root independently instead of resolving the request: a skill
929
+ // present in two roots was installed TWICE and counted twice (the field report's `2 skill(s)` was
930
+ // one skill installed twice), and a skill present in NO root produced a warning and exit 0 —
931
+ // `0 skill(s)` reading as success. Both are gone once the decision happens here.
932
+ //
933
+ // Placement is load-bearing: an exit 1 that arrives after hooks and memory are written leaves a
934
+ // half-configured project, which is worse than either clean outcome. This runs before the loop
935
+ // below and before every target adapter.
936
+ //
937
+ // Dependency closure is deliberately NOT resolved here — that is PR-B. This preflight fixes the
938
+ // count and the exit contract, and gives that work a base it can trust.
939
+ if (select !== undefined) {
940
+ const roots = skillsDirs.map((dir) => ({ dir, ids: discoverSkillIds(dir) }));
941
+ const resolution = resolveSelection(select, roots);
942
+ for (const shadow of resolution.shadowed) {
943
+ opts.writeErr?.(
944
+ `dz: skill '${shadow.id}' is offered by ${shadow.alsoIn.length + 1} roots; ` +
945
+ `installing from ${shadow.chosen} (earlier root wins). Also present in: ${shadow.alsoIn.join(', ')}`,
946
+ );
947
+ }
948
+ const refusal = formatSelectRefusal(resolution, roots);
949
+ if (refusal !== null) {
950
+ return {
951
+ selectRefusal: refusal,
952
+ results: [], dirsSearched: skillsDirs.length, written: 0, skipped: 0,
953
+ missing: [...resolution.missing], failures: [], applyFailures: [], integrations: [],
954
+ };
955
+ }
956
+ }
957
+
893
958
  // agents-md and gemini are FLATTENING single-file targets: each must aggregate
894
959
  // every selected skill from ALL discovered dirs into ONE root file (AGENTS.md /
895
960
  // GEMINI.md) in a single merge. A per-dir runInit loop (like the tree targets
@@ -1014,6 +1079,7 @@ async function cmdInit(options: Map<string, string>, flags: Set<string>, cwd: st
1014
1079
  cwd,
1015
1080
  explicitSkillsDir,
1016
1081
  select,
1082
+ writeErr,
1017
1083
  force: flags.has('force'),
1018
1084
  enrich: flags.has('enrich'),
1019
1085
  noHooks: flags.has('no-hooks'),
@@ -1022,6 +1088,13 @@ async function cmdInit(options: Map<string, string>, flags: Set<string>, cwd: st
1022
1088
  ...(options.get('allow-integrations') !== undefined ? { allowIntegrations: options.get('allow-integrations')! } : {}),
1023
1089
  });
1024
1090
 
1091
+ // PR-A: an explicit --select that named a skill no root provides is a REFUSAL, not a warning.
1092
+ // Printed and returned here, before any target adapter runs — nothing has been written yet.
1093
+ if (r.selectRefusal !== undefined) {
1094
+ writeErr(r.selectRefusal);
1095
+ return 1;
1096
+ }
1097
+
1025
1098
  // Codex keeps its established user-registry writer, but its result is normalized into the same
1026
1099
  // two-outcome contract before JSON/human rendering. A write without a live ready observation is
1027
1100
  // a refusal with applied=true, never a second success channel.
@@ -5471,6 +5544,26 @@ async function cmdSetup(options: Map<string, string>, flags: Set<string>, cwd: s
5471
5544
  const projectRoot = resolve(cwd, options.get('project') ?? '.');
5472
5545
  const presetName = options.get('preset');
5473
5546
 
5547
+ // PREFLIGHT BEFORE THE FIRST WRITE (backlog 9d15b9b6, PR-A). Step 3 configures the learning
5548
+ // environment and step 4 installs skills, so refusing at step 4 would leave a project that has
5549
+ // memory and hooks but not the skills the operator asked for — a half-configured state worse than
5550
+ // either clean outcome. The request is therefore resolved HERE, before the banner's first step.
5551
+ //
5552
+ // Only an EXPLICIT --select is refused. A preset names skills the package itself ships, so a gap
5553
+ // there is our packaging defect, not the operator's typo, and it is reported by the existing
5554
+ // missing-list rather than by refusing the whole run.
5555
+ const setupSelectRaw = options.get('select');
5556
+ if (setupSelectRaw !== undefined) {
5557
+ const requested = setupSelectRaw.split(',').map((x) => x.trim()).filter((x) => x.length > 0);
5558
+ const roots = discoverSkillsDirs(cwd, options.get('skills-dir')).map((dir) => ({ dir, ids: discoverSkillIds(dir) }));
5559
+ const resolution = resolveSelection(requested, roots);
5560
+ for (const shadow of resolution.shadowed) {
5561
+ writeErr(`dz: skill '${shadow.id}' is offered by ${shadow.alsoIn.length + 1} roots; installing from ${shadow.chosen} (earlier root wins). Also present in: ${shadow.alsoIn.join(', ')}`);
5562
+ }
5563
+ const refusal = formatSelectRefusal(resolution, roots);
5564
+ if (refusal !== null) { writeErr(refusal); return 1; }
5565
+ }
5566
+
5474
5567
  write(`\n╔══════════════════════════════════════════════════════╗`);
5475
5568
  write(`║ DZ SETUP — Full Environment ║`);
5476
5569
  write(`╠══════════════════════════════════════════════════════╣`);
@@ -5622,7 +5715,7 @@ function cmdPretrain(options: Map<string, string>, cwd: string, write: Write): n
5622
5715
  return 0;
5623
5716
  }
5624
5717
 
5625
- function cmdRecommend(options: Map<string, string>, cwd: string, write: Write): number {
5718
+ function cmdRecommend(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
5626
5719
  const task = options.get('_positional_0');
5627
5720
  if (!task) {
5628
5721
  write('dz recommend: task description required');
@@ -5632,17 +5725,41 @@ function cmdRecommend(options: Map<string, string>, cwd: string, write: Write):
5632
5725
 
5633
5726
  const registry = buildRegistry(cwd);
5634
5727
  const report = recommend(task, registry, cwd);
5728
+ if (flags.has('json')) {
5729
+ write(JSON.stringify(report, null, 2));
5730
+ return 0;
5731
+ }
5635
5732
 
5636
5733
  write(`\n╔══════════════════════════════════════════════════════════════╗`);
5637
5734
  write(`║ DZ RECOMMEND — Task Advisor ║`);
5638
5735
  write(`╠══════════════════════════════════════════════════════════════╣`);
5639
5736
  write(`║ Task: ${report.task.slice(0, 52).padEnd(52)}║`);
5640
- const topicSuffix = report.pretrainFallback ? ' (via pretrain)' : '';
5641
- write(`║ Topics: ${(report.topics.join(', ') + topicSuffix).slice(0, 50).padEnd(50)}║`);
5737
+ if (report.topicSource === 'task') {
5738
+ write(`║ Topics: ${report.topics.join(', ').slice(0, 50).padEnd(50)}║`);
5739
+ } else if (report.topicSource === 'project-stack') {
5740
+ write(`║ Topics: ${'not matched in the question'.padEnd(50)}║`);
5741
+ } else {
5742
+ write(`║ Topics: ${'not recognized — no recommendations'.padEnd(50)}║`);
5743
+ }
5642
5744
  write(`╠══════════════════════════════════════════════════════════════╣`);
5643
5745
 
5746
+ if (report.topicSource === 'project-stack') {
5747
+ write(`⚠ Тема запроса не распознана — подбор ниже сделан по СТЕКУ ПРОЕКТА, не по вашему вопросу.`);
5748
+ write(` (topic not recognized — recommendations reflect the project stack, not the question)`);
5749
+ write(`PROJECT-STACK SUGGESTIONS`);
5750
+ } else if (report.topicSource === 'none') {
5751
+ write(`Тема запроса не распознана; рекомендаций нет.`);
5752
+ write(`Переформулируйте задачу или используйте dz registry search <слово> / /skill-advisor.`);
5753
+ write(`╚══════════════════════════════════════════════════════════════╝`);
5754
+ return 0;
5755
+ }
5756
+
5757
+ const stackDerived = report.topicSource === 'project-stack';
5758
+
5644
5759
  if (report.presets.length > 0) {
5645
- write(`║ RECOMMENDED PRESETS ║`);
5760
+ write(stackDerived
5761
+ ? `║ PROJECT-STACK PRESETS ║`
5762
+ : `║ RECOMMENDED PRESETS ║`);
5646
5763
  for (const p of report.presets) {
5647
5764
  const matched = p.matchedSkills.length > 0 ? ` (${p.matchedSkills.slice(0, 3).join(', ')})` : '';
5648
5765
  write(`║ ${p.name.padEnd(15)} ${String(p.skills).padStart(2)} skills coverage: ${String(p.coverage).padStart(2)} topics${matched.padEnd(15)}║`);
@@ -5651,7 +5768,9 @@ function cmdRecommend(options: Map<string, string>, cwd: string, write: Write):
5651
5768
  }
5652
5769
 
5653
5770
  if (report.skills.length > 0) {
5654
- write(`║ RECOMMENDED SKILLS (top ${Math.min(report.skills.length, 8)})${' '.repeat(35)}║`);
5771
+ write(stackDerived
5772
+ ? `║ PROJECT-STACK SKILLS (top ${Math.min(report.skills.length, 8)})${' '.repeat(35)}║`
5773
+ : `║ RECOMMENDED SKILLS (top ${Math.min(report.skills.length, 8)})${' '.repeat(35)}║`);
5655
5774
  for (const s of report.skills.slice(0, 8)) {
5656
5775
  const desc = s.description.length > 35 ? s.description.slice(0, 32) + '...' : s.description;
5657
5776
  write(`║ ${s.id.padEnd(24)} ${desc.padEnd(36)}║`);
@@ -5660,7 +5779,9 @@ function cmdRecommend(options: Map<string, string>, cwd: string, write: Write):
5660
5779
  }
5661
5780
 
5662
5781
  if (report.toolkits.length > 0) {
5663
- write(`║ FULL PIPELINE (npx toolkits) ║`);
5782
+ write(stackDerived
5783
+ ? `║ PROJECT-STACK PIPELINE (npx toolkits) ║`
5784
+ : `║ FULL PIPELINE (npx toolkits) ║`);
5664
5785
  for (const tk of report.toolkits) {
5665
5786
  const desc = tk.description.length > 44 ? tk.description.slice(0, 41) + '...' : tk.description;
5666
5787
  write(`║ ${tk.name.padEnd(16)} ${desc.padEnd(44)}║`);
@@ -5671,13 +5792,17 @@ function cmdRecommend(options: Map<string, string>, cwd: string, write: Write):
5671
5792
  }
5672
5793
 
5673
5794
  write(`╠══════════════════════════════════════════════════════════════╣`);
5674
- write(`║ STEP-BY-STEP PLAN ║`);
5795
+ write(stackDerived
5796
+ ? `║ PROJECT-STACK PLAN ║`
5797
+ : `║ STEP-BY-STEP PLAN ║`);
5675
5798
  for (const step of report.plan) {
5676
5799
  const line = step.length > 60 ? step.slice(0, 57) + '...' : step;
5677
5800
  write(`║ ${line.padEnd(58)}║`);
5678
5801
  }
5679
5802
  write(`╠══════════════════════════════════════════════════════════════╣`);
5680
- write(`║ QUICK INSTALL ║`);
5803
+ write(stackDerived
5804
+ ? `║ PROJECT-STACK QUICK INSTALL ║`
5805
+ : `║ QUICK INSTALL ║`);
5681
5806
  const cmd = report.installCommand.length > 58 ? report.installCommand.slice(0, 55) + '...' : report.installCommand;
5682
5807
  write(`║ ${cmd.padEnd(58)}║`);
5683
5808
  write(`╚══════════════════════════════════════════════════════════════╝`);
@@ -7426,6 +7551,84 @@ function looksBinaryText(text: string): boolean {
7426
7551
  * per-file findings (each enriched with its `file`), and applies the exit-code contract.
7427
7552
  * `--json` ALWAYS emits valid JSON `{ok, findings, scanned}`, even on the failure path.
7428
7553
  */
7554
+ /**
7555
+ * `dz chain` — verify EVERY hash-chained journal in one command (W0-chain, backlog bc4ee35c).
7556
+ *
7557
+ * The machinery to verify a chain has worked for weeks. What was missing is the ABILITY TO ASK:
7558
+ * verification lived inside two consumers, each carrying its own hardcoded list of which files are
7559
+ * chained, so a journal could be given a chain and still be checked by nobody. Coverage here is
7560
+ * DERIVED from CHAINED_JOURNALS, never typed — adding a journal to the registry adds it to this
7561
+ * report by construction.
7562
+ *
7563
+ * An ABSENT journal is reported as `absent`, not omitted. Omission and cleanliness are
7564
+ * indistinguishable in a report, and that indistinguishability is how the original blind spot
7565
+ * survived; the same reason `broken` exits NON-ZERO rather than merely printing — a verifier that
7566
+ * reports damage and exits 0 is one no automation can act on, and this verb exists to run unattended.
7567
+ *
7568
+ * A journal that exists but carries NO chained records is `unchained`, which is legal (a log may
7569
+ * predate the chain) and therefore does not fail the command. Calling it a defect would train the
7570
+ * reader to ignore the output — the failure mode already measured once on the doctor's own line.
7571
+ */
7572
+ function cmdChain(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
7573
+ const root = options.get('project') ?? cwd;
7574
+ const journals = CHAINED_JOURNALS.map((journal) => {
7575
+ const path = join(root, journal.rel);
7576
+ if (!existsSync(path)) {
7577
+ return { rel: journal.rel, decides: journal.decides, status: 'absent' as const, chained: 0, defects: 0, detail: 'file not present' };
7578
+ }
7579
+ let text = '';
7580
+ try {
7581
+ text = readFileSync(path, 'utf-8');
7582
+ } catch {
7583
+ // Unreadable is NOT clean. It is the one outcome that must never be quietly folded into
7584
+ // "nothing to report": we did not look, so we know nothing.
7585
+ return { rel: journal.rel, decides: journal.decides, status: 'unreadable' as const, chained: 0, defects: 0, detail: 'file could not be read' };
7586
+ }
7587
+ const v = verifyEventChainText(text);
7588
+ if (v.chained === 0) {
7589
+ return { rel: journal.rel, decides: journal.decides, status: 'unchained' as const, chained: 0, defects: 0, detail: 'present, but no record carries a chain (legal — the log predates chaining)' };
7590
+ }
7591
+ const total = text.split('\n').filter((l) => l.trim() !== '').length;
7592
+ const age = classifyChainDefects(v, total);
7593
+ if (v.ok) {
7594
+ return { rel: journal.rel, decides: journal.decides, status: 'ok' as const, chained: v.chained, defects: 0, detail: `${v.chained} chained record(s), ${v.resets} recorded restart(s)` };
7595
+ }
7596
+ // A break the current unbroken run has already outlived does not make TODAY's records unsound.
7597
+ // Reporting both alike is what made the doctor's equivalent line permanently red for four weeks.
7598
+ const historical = age.inRun.length === 0 && age.runRecords > 0;
7599
+ return {
7600
+ rel: journal.rel,
7601
+ decides: journal.decides,
7602
+ status: historical ? ('healed' as const) : ('broken' as const),
7603
+ chained: v.chained,
7604
+ defects: v.defects.length,
7605
+ detail: historical
7606
+ ? `${v.defects.length} defect(s), all BEFORE the current run — the last ${age.runRecords} record(s) are unbroken, so verdicts over those are sound`
7607
+ : `${v.defects.length} defect(s) with NO sound records after them: verdicts computed from this log are unsafe`,
7608
+ };
7609
+ });
7610
+
7611
+ const failed = journals.filter((j) => j.status === 'broken' || j.status === 'unreadable');
7612
+ const ok = failed.length === 0;
7613
+
7614
+ if (flags.has('json')) {
7615
+ write(JSON.stringify({ ok, root, journals }, null, 2));
7616
+ return ok ? 0 : 1;
7617
+ }
7618
+
7619
+ write(`dz chain — ${journals.length} registered journal(s) under ${root}`);
7620
+ write('');
7621
+ const MARK: Record<string, string> = { ok: '\u2713', healed: '\u2713', unchained: '\u00b7', absent: '\u00b7', broken: '\u2717', unreadable: '\u2717' };
7622
+ for (const j of journals) {
7623
+ write(` ${MARK[j.status] ?? '?'} ${j.rel} — ${j.status}`);
7624
+ write(` ${j.detail}`);
7625
+ write(` decides: ${j.decides}`);
7626
+ }
7627
+ write('');
7628
+ write(ok ? ' all registered journals are sound for present verdicts' : ` ${failed.length} journal(s) UNSAFE — see above`);
7629
+ return ok ? 0 : 1;
7630
+ }
7631
+
7429
7632
  function cmdClaimCheck(
7430
7633
  options: Map<string, string>,
7431
7634
  _optionLists: Map<string, string[]>,
@@ -10098,9 +10301,74 @@ function cmdDiscriminationCheck(options: Map<string, string>, flags: Set<string>
10098
10301
  const timeoutOpt = Number(options.get('timeout') ?? '300000');
10099
10302
  const timeoutMs = Number.isFinite(timeoutOpt) && timeoutOpt > 0 ? timeoutOpt : 300000;
10100
10303
 
10101
- const plan = planDiscriminationCheck(runnerOpt !== undefined ? { baseRef, propertyTests, runner: runnerOpt } : { baseRef, propertyTests });
10304
+ // Runner honesty (feature instrument-honesty, ADR-001): the runner is selected from the TARGET
10305
+ // package's own scripts.test, never from a global default. The package dir is the nearest
10306
+ // ancestor of the FIRST named test that carries a package.json — walked here, at the seam,
10307
+ // because the pure half deliberately takes the script text as data and never touches the fs.
10308
+ let packageTestScript: string | null = null;
10309
+ let packageDevDependencies: string[] = [];
10310
+ let packageDir = repoRoot;
10311
+ {
10312
+ const firstTest = propertyTests[0]?.file;
10313
+ // QE-1 (instrument-honesty, HIGH): this walk runs on the RAW --test argument, BEFORE the
10314
+ // engine's sanitation — a `../` traversal made it read an arbitrary package.json OUTSIDE the
10315
+ // repo and echo its scripts.test verbatim into the JSON output (MEASURED with a planted
10316
+ // marker file). Containment first: a start point outside the repo root never gets walked,
10317
+ // the script stays null, and the engine's own path sanitation then refuses the test path.
10318
+ const walkStart = firstTest !== undefined ? resolve(cwd, dirname(firstTest)) : undefined;
10319
+ if (firstTest !== undefined && walkStart !== undefined
10320
+ && (walkStart === resolve(repoRoot) || walkStart.startsWith(resolve(repoRoot) + sep))) {
10321
+ let probe = walkStart;
10322
+ // walk up to the repo root looking for package.json (bounded by the fs root either way)
10323
+ for (;;) {
10324
+ if (existsSync(join(probe, 'package.json'))) { packageDir = probe; break; }
10325
+ const parent = dirname(probe);
10326
+ if (parent === probe || probe === repoRoot) break;
10327
+ probe = parent;
10328
+ }
10329
+ try {
10330
+ const pkg = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf-8')) as {
10331
+ scripts?: Record<string, string>; devDependencies?: Record<string, string>;
10332
+ };
10333
+ packageTestScript = typeof pkg.scripts?.test === 'string' ? pkg.scripts.test : null;
10334
+ packageDevDependencies = Object.keys(pkg.devDependencies ?? {});
10335
+ } catch { /* unreadable package.json → selection falls through to the honest REFUSE */ }
10336
+ }
10337
+ }
10338
+ // The pure half's path sanitation expects a REPO-RELATIVE package dir ('.'-rooted), not an
10339
+ // absolute one — an absolute path is refused as unsafe-package-dir by design.
10340
+ const packageDirRel = relative(repoRoot, packageDir) || '.';
10341
+ const planInput = runnerOpt !== undefined
10342
+ ? { baseRef, propertyTests, runner: runnerOpt, packageTestScript, packageDevDependencies, packageDir: packageDirRel }
10343
+ : { baseRef, propertyTests, packageTestScript, packageDevDependencies, packageDir: packageDirRel };
10344
+ const plan = planDiscriminationCheck(planInput);
10102
10345
 
10103
10346
  if (!plan.runnable) {
10347
+ // QE-2 (instrument-honesty, MEDIUM): a runner REFUSE used to be reported through the generic
10348
+ // "no property test to check"/map-a-test framing — the operator-facing surface re-created the
10349
+ // exact "instrument gap misread as test gap" class ADR-001 names as the reason three duplicate
10350
+ // backlog entries existed. The plan's own named reason is the verdict; the generic classify
10351
+ // stays only for the genuinely-empty-target case.
10352
+ const runnerRefusal = typeof plan.reason === 'string' && plan.reason.startsWith('unsupported-runner');
10353
+ if (runnerRefusal) {
10354
+ const refusal = {
10355
+ aggregate: 'CANNOT_ISOLATE',
10356
+ measurementValid: false,
10357
+ primaryAction: plan.primaryAction ?? 'fix-runner-invocation',
10358
+ finding: {
10359
+ severity: 'high',
10360
+ verdict: 'CANNOT_ISOLATE',
10361
+ files: plan.targets.map((t) => t.file),
10362
+ detail: `runner refused: ${plan.reason} — the INSTRUMENT could not run, nothing was measured; `
10363
+ + `declare scripts.test in the target package (or pass --runner) and re-run. `
10364
+ + `This is NOT a statement about the tests.`,
10365
+ },
10366
+ };
10367
+ if (flags.has('json')) { write(JSON.stringify({ plan, results: [], perTest: [], ...refusal }, null, 2)); return 0; }
10368
+ write(`discrimination-check: REFUSED (${plan.reason})`);
10369
+ write(` → ${refusal.finding.detail}`);
10370
+ return 0;
10371
+ }
10104
10372
  // No safe target to run → this is the existing "property untested" finding (empty propertyTests classify).
10105
10373
  const result = classifyDiscrimination({ propertyTests: [], results: [] });
10106
10374
  if (flags.has('json')) { write(JSON.stringify({ plan, results: [], ...result }, null, 2)); return 0; }
@@ -10189,10 +10457,24 @@ function cmdDiscriminationCheck(options: Map<string, string>, flags: Set<string>
10189
10457
 
10190
10458
  // t.file + t.name already passed the engine's strict sanitation (no quotes/metacharacters/leading-dash);
10191
10459
  // still quote + `--` so a path can never be read as a runner option or split a word.
10460
+ // Runner honesty (ADR-001): the run executes FROM the target package dir with a
10461
+ // package-relative path — a root-cwd `npx vitest run packages/...` loads the ROOT config
10462
+ // (none) and reds unclassifiably, which is exactly the CANNOT_ISOLATE artifact this
10463
+ // feature removes. The plan's own commands encode the same cd; this body mirrors it.
10464
+ const pkgRel = plan.packageDir === '.' ? '' : plan.packageDir;
10465
+ const fileInPkg = pkgRel !== '' && t.file.startsWith(pkgRel + '/') ? t.file.slice(pkgRel.length + 1) : t.file;
10466
+ const execDirBase = pkgRel === '' ? worktree : join(worktree, pkgRel);
10467
+ const execDirTip = pkgRel === '' ? repoRoot : join(repoRoot, pkgRel);
10192
10468
  const nameArg = t.name ? ` -t '${t.name}'` : '';
10193
- const cmd = `${runner}${nameArg} -- '${t.file}'`;
10194
- const base = runCapturedTest(cmd, worktree, timeoutMs);
10195
- const evidence = classifyExecutionEvidence(base.output, base.exitCode, t.file);
10469
+ // NO `--` before the path: MEASURED 2026-09-02 — `npx vitest run -- 'file'` IGNORES the
10470
+ // filter and runs the whole suite (5269 tests), which is the exact whole-repo artifact
10471
+ // this feature removes (QE ha-intake-archive F5). The path is engine-sanitized (no
10472
+ // leading dash, no metacharacters), so it can never be read as an option.
10473
+ const cmd = `${runner}${nameArg} '${fileInPkg}'`;
10474
+ const base = runCapturedTest(cmd, execDirBase, timeoutMs);
10475
+ // The classifier's targetSeen is a substring probe: the run now prints PACKAGE-relative
10476
+ // paths, so it must be probed with the same form, or every hit reads as target-unseen.
10477
+ const evidence = classifyExecutionEvidence(base.output, base.exitCode, fileInPkg);
10196
10478
  const outcome = discriminationOutcomeOf(base.exitCode, evidence);
10197
10479
  const row: Record<string, unknown> = t.name !== undefined
10198
10480
  ? { file: t.file, name: t.name, outcome, evidence }
@@ -10204,8 +10486,8 @@ function cmdDiscriminationCheck(options: Map<string, string>, flags: Set<string>
10204
10486
  // base rows per the matrix; running it is cheap and only ever on an already-broken path.
10205
10487
  // Do NOT "simplify" this to evidenced-error-only — that silently breaks Confirmation 17.
10206
10488
  if (base.exitCode !== null && base.exitCode !== 0 && evidence.failureKind !== 'assertions') {
10207
- const tip = runCapturedTest(cmd, repoRoot, timeoutMs);
10208
- const tipEvidence = classifyExecutionEvidence(tip.output, tip.exitCode, t.file);
10489
+ const tip = runCapturedTest(cmd, execDirTip, timeoutMs);
10490
+ const tipEvidence = classifyExecutionEvidence(tip.output, tip.exitCode, fileInPkg);
10209
10491
  row['tipOutcome'] = discriminationOutcomeOf(tip.exitCode, tipEvidence);
10210
10492
  row['tipEvidence'] = tipEvidence;
10211
10493
  // R15, named honestly: the base run is isolated in a worktree, but the tip runs in the LIVE
@@ -10329,9 +10611,9 @@ function nameFor(t: { file: string; name?: string }, outcome: 'pass' | 'fail' |
10329
10611
  *
10330
10612
  * Exit codes: 0 every entry PROVEN · 1 the gate ran and failed (undefended / not-applied /
10331
10613
  * below-min / unparseable / load-fatal / over-failing / inconclusive entry) · 2 usage or setup
10332
- * error (missing registry, red BASELINE a red unmutated copy proves nothing and must not be
10333
- * read as a mutation result — or an entry whose file RESOLVES outside the scratch copy: a
10614
+ * error (missing registry or an entry whose file RESOLVES outside the scratch copy: a
10334
10615
  * symlink escape is refused before anything is written, SPEC rule 3).
10616
+ * A RED/no-exit baseline is a measured failing verdict (exit 1), never a usage error.
10335
10617
  */
10336
10618
  /**
10337
10619
  * Route-a guard for `dz mutation-gate`: parse-check a MUTATED file as its own language BEFORE the
@@ -10340,7 +10622,14 @@ function nameFor(t: { file: string; name?: string }, outcome: 'pass' | 'fail' |
10340
10622
  * redness says nothing about the named protection. Returns `{error}` when a parser ran and the
10341
10623
  * text does not parse; `{skipped}` (reported loudly, never silently) when no parser is available.
10342
10624
  */
10343
- function parseCheckMutatedFile(absFile: string, text: string): { error?: string; skipped?: string } {
10625
+ interface MutationParseCheckResult {
10626
+ readonly error?: string;
10627
+ readonly skipped?: string;
10628
+ readonly internalFailureReason?: string;
10629
+ readonly internalAttempts?: ReturnType<typeof runWithOneInternalRetry>['attempts'];
10630
+ }
10631
+
10632
+ function parseCheckMutatedFile(absFile: string, text: string): MutationParseCheckResult {
10344
10633
  interface TsLike {
10345
10634
  transpileModule(t: string, o: { reportDiagnostics: boolean; compilerOptions: Record<string, unknown> }): { diagnostics?: { category: number; code: number; messageText: unknown }[] };
10346
10635
  flattenDiagnosticMessageText(m: unknown, s: string): string;
@@ -10364,17 +10653,34 @@ function parseCheckMutatedFile(absFile: string, text: string): { error?: string;
10364
10653
  try { JSON.parse(text); return {}; } catch (e) { return { error: String((e as Error).message).slice(0, 200) }; }
10365
10654
  }
10366
10655
  if (ext === '.js' || ext === '.cjs' || ext === '.mjs' || ext === '') {
10367
- try {
10368
- // `node --check` on the file IN PLACE, so the nearest package.json decides the module goal.
10369
- execFileSync(process.execPath, ['--check', absFile], { stdio: 'pipe' });
10370
- return {};
10371
- } catch (e) {
10372
- const err = e as { stderr?: Buffer | string };
10373
- const stderrLines = String(err.stderr ?? '').split('\n').map((l) => l.trim()).filter((l) => l !== '');
10374
- // prefer the actual `SyntaxError: …` line over node's trailing version footer.
10375
- const msg = [...stderrLines].reverse().find((l) => l.includes('Error')) ?? stderrLines.at(-1) ?? 'node --check failed';
10376
- return { error: msg.slice(0, 200) };
10656
+ const checked = runWithOneInternalRetry<MutationParseCheckResult>(() => {
10657
+ try {
10658
+ // `node --check` on the file IN PLACE, so the nearest package.json decides the module goal.
10659
+ execFileSync(process.execPath, ['--check', absFile], { stdio: 'pipe' });
10660
+ return {};
10661
+ } catch (e) {
10662
+ const err = e as { code?: unknown; status?: unknown; stderr?: Buffer | string; message?: string };
10663
+ // A launched parser that exits non-zero with a SyntaxError is a parse verdict. A child
10664
+ // launch/internal error (EPERM, ENOENT, Node's thrown internal) is runner infrastructure
10665
+ // and must take the bounded retry → INCONCLUSIVE route instead of masquerading as bad JS.
10666
+ if (typeof err.code === 'string' || typeof err.status !== 'number') throw e;
10667
+ const stderrLines = String(err.stderr ?? '').split('\n').map((line) => line.trim()).filter((line) => line !== '');
10668
+ const msg = [...stderrLines].reverse().find((line) => line.includes('Error'))
10669
+ ?? stderrLines.at(-1)
10670
+ ?? err.message
10671
+ ?? 'node --check failed';
10672
+ return { error: msg.slice(0, 200) };
10673
+ }
10674
+ });
10675
+ if (checked.value === null) {
10676
+ return {
10677
+ internalFailureReason: checked.failureReason ?? 'runner-internal-error: persistent after 2/2 attempts',
10678
+ internalAttempts: checked.attempts,
10679
+ };
10377
10680
  }
10681
+ return checked.internalRetries === 1
10682
+ ? { ...checked.value, internalAttempts: checked.attempts }
10683
+ : checked.value;
10378
10684
  }
10379
10685
  return { skipped: `no parser for '${ext}' files — parse-check unavailable` };
10380
10686
  } catch (e) {
@@ -10382,7 +10688,13 @@ function parseCheckMutatedFile(absFile: string, text: string): { error?: string;
10382
10688
  }
10383
10689
  }
10384
10690
 
10385
- function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
10691
+ function cmdMutationGate(
10692
+ options: Map<string, string>,
10693
+ flags: Set<string>,
10694
+ cwd: string,
10695
+ write: Write,
10696
+ injectedRunner?: MutationGateRunner,
10697
+ ): number {
10386
10698
  const json = flags.has('json');
10387
10699
  const fail = (what: string): number => {
10388
10700
  write(json ? JSON.stringify({ error: what, exitCode: 2 }) : `dz mutation-gate: ${what}`);
@@ -10453,6 +10765,11 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
10453
10765
  const results: MutationEntryResult[] = [];
10454
10766
  const observations: MutationObservation[] = [];
10455
10767
  const warnings: string[] = [];
10768
+ const internalRetries: {
10769
+ readonly phase: 'baseline' | 'parse-check' | 'mutation' | 'rebaseline' | 'final-rebaseline';
10770
+ readonly entryId?: string;
10771
+ readonly attempts: ReturnType<typeof runWithOneInternalRetry>['attempts'];
10772
+ }[] = [];
10456
10773
  let baseline: ReturnType<typeof classifyBaseline>;
10457
10774
  try {
10458
10775
  if (gitTop !== null && gitTop !== pkgDir && resolve(pkgDir).startsWith(resolve(gitTop) + sep)) {
@@ -10492,7 +10809,11 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
10492
10809
  const realScratchRoot = realpathSync(copyDir);
10493
10810
  const requireCompletionReceipt = parsed.registry.requireCompletionReceipt === true;
10494
10811
 
10495
- const runSuite = (): { exitCode: number | null; output: string; failureReason?: string } => {
10812
+ type SuiteRun = MutationGateRunnerObservation & { readonly internalAttemptLog?: string };
10813
+ const invokeSuite = (): MutationGateRunnerObservation => {
10814
+ if (injectedRunner !== undefined) {
10815
+ return injectedRunner(testCmd, { cwd: copyDir, timeoutMs: timeout });
10816
+ }
10496
10817
  const run = spawnSync(testCmd, {
10497
10818
  cwd: copyDir,
10498
10819
  shell: true,
@@ -10504,6 +10825,12 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
10504
10825
  const errorCode = run.error && 'code' in run.error && typeof run.error.code === 'string'
10505
10826
  ? run.error.code
10506
10827
  : undefined;
10828
+ // Node may populate both `error` and a numeric `status` for an internal spawn failure. The
10829
+ // error wins except for the two already-named resource observations: a status alongside
10830
+ // EPERM/Unreachable-code is not a suite verdict and takes the one-retry internal-error path.
10831
+ if (run.error !== undefined && errorCode !== 'ETIMEDOUT' && errorCode !== 'ENOBUFS') {
10832
+ throw run.error;
10833
+ }
10507
10834
  const signal = typeof run.signal === 'string' ? run.signal : undefined;
10508
10835
  let failureReason: string | undefined;
10509
10836
  if (typeof run.status !== 'number') {
@@ -10515,22 +10842,62 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
10515
10842
  }
10516
10843
  return {
10517
10844
  exitCode: typeof run.status === 'number' ? run.status : null,
10518
- // Receipt markers may be on stderr. Preserve both streams even on exit 0; stdout-only
10519
- // collection would silently lose a green-run marker.
10520
10845
  output: `${String(run.stdout ?? '')}\n${String(run.stderr ?? '')}`,
10521
10846
  ...(failureReason !== undefined ? { failureReason } : {}),
10522
10847
  };
10523
10848
  };
10524
10849
 
10850
+ const runSuite = (
10851
+ phase: 'baseline' | 'mutation' | 'rebaseline' | 'final-rebaseline',
10852
+ entryId?: string,
10853
+ ): SuiteRun => {
10854
+ const retried = runWithOneInternalRetry(invokeSuite);
10855
+ const loggedAttempts = retried.attempts.map((attempt) => {
10856
+ if (attempt.outcome !== 'completed' || retried.value === null) return attempt;
10857
+ const outcome = retried.value.exitCode === null
10858
+ ? `no exit code (${retried.value.failureReason ?? 'unnamed failure'})`
10859
+ : `exit ${retried.value.exitCode}`;
10860
+ return { ...attempt, detail: `attempt ${attempt.attempt}: completed — ${outcome}` };
10861
+ });
10862
+ if (retried.internalRetries === 1) {
10863
+ const record = entryId === undefined
10864
+ ? { phase, attempts: loggedAttempts }
10865
+ : { phase, entryId, attempts: loggedAttempts };
10866
+ internalRetries.push(record);
10867
+ if (!json) write(`mutation-gate: internal retry — ${loggedAttempts.map((attempt) => attempt.detail).join('; ')}`);
10868
+ }
10869
+ const internalAttemptLog = retried.internalRetries === 1
10870
+ ? loggedAttempts.map((attempt) => attempt.detail).join('; ')
10871
+ : undefined;
10872
+ if (retried.value !== null) {
10873
+ return {
10874
+ ...retried.value,
10875
+ ...(internalAttemptLog !== undefined ? { internalAttemptLog } : {}),
10876
+ };
10877
+ }
10878
+ return {
10879
+ exitCode: null,
10880
+ output: '',
10881
+ failureReason: retried.failureReason ?? 'runner-internal-error: persistent after 2/2 attempts',
10882
+ ...(internalAttemptLog !== undefined ? { internalAttemptLog } : {}),
10883
+ };
10884
+ };
10885
+
10525
10886
  // Baseline BEFORE any mutation: a red copy proves nothing, and reading it as a mutation
10526
10887
  // result would be this gate shipping the defect class it exists to catch.
10527
10888
  if (!json) write(`mutation-gate: baseline suite in scratch copy of ${pkgDir} …`);
10528
- const base = runSuite();
10529
- baseline = classifyBaseline(base.exitCode, base.failureReason);
10889
+ const base = runSuite('baseline');
10890
+ baseline = classifyBaseline(
10891
+ base.exitCode,
10892
+ base.failureReason,
10893
+ base.exitCode !== null && base.exitCode !== 0
10894
+ ? attributeBaselineRedness(base.output, entries.map((entry) => entry.file))
10895
+ : undefined,
10896
+ );
10530
10897
  if (!baseline.ok) {
10531
- if (json) { write(JSON.stringify({ packageDir: pkgDir, registryPath, testCommand: testCmd, baseline, results: [], exitCode: 2 }, null, 2)); return 2; }
10898
+ if (json) { write(JSON.stringify({ packageDir: pkgDir, registryPath, testCommand: testCmd, baseline, results: [], internalRetries, exitCode: 1 }, null, 2)); return 1; }
10532
10899
  write(renderMutationReport([], baseline, pkgDir));
10533
- return 2;
10900
+ return 1;
10534
10901
  }
10535
10902
 
10536
10903
  for (const entry of entries) {
@@ -10565,8 +10932,10 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
10565
10932
  return fail(`entry '${entry.id}': ${entry.file} resolves to ${realTarget ?? '<unresolvable>'} — OUTSIDE the scratch copy (${realScratchRoot}). A path component is a symlink escaping the scratch tree, so writing the mutation would mutate the REAL working tree (SPEC rule 3). Refused; nothing was written.`);
10566
10933
  }
10567
10934
  if (!json) write(`mutation-gate: ${entry.id} — mutating ${entry.file}, running suite …`);
10568
- let run: { exitCode: number | null; output: string; failureReason?: string } | null = null;
10935
+ let run: SuiteRun | null = null;
10569
10936
  let parseError: string | undefined;
10937
+ let parseInternalFailureReason: string | undefined;
10938
+ let parseInternalAttemptLog: string | undefined;
10570
10939
  try {
10571
10940
  writeFileSync(filePath, applied.text);
10572
10941
  // Route-a guard: the mutated file must still PARSE — a load failure reddens the whole
@@ -10576,10 +10945,16 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
10576
10945
  warnings.push(`${entry.id}: parse-check SKIPPED — ${check.skipped}`);
10577
10946
  if (!json) write(`mutation-gate: WARNING ${entry.id}: parse-check skipped — ${check.skipped}`);
10578
10947
  }
10948
+ if (check.internalAttempts !== undefined) {
10949
+ internalRetries.push({ phase: 'parse-check', entryId: entry.id, attempts: check.internalAttempts });
10950
+ parseInternalAttemptLog = check.internalAttempts.map((attempt) => attempt.detail).join('; ');
10951
+ if (!json) write(`mutation-gate: internal retry — ${parseInternalAttemptLog}`);
10952
+ }
10953
+ parseInternalFailureReason = check.internalFailureReason;
10579
10954
  if (check.error !== undefined) {
10580
10955
  parseError = check.error; // no suite run: the verdict is MUTATION_UNPARSEABLE regardless
10581
- } else {
10582
- run = runSuite();
10956
+ } else if (parseInternalFailureReason === undefined) {
10957
+ run = runSuite('mutation', entry.id);
10583
10958
  }
10584
10959
  } finally {
10585
10960
  writeFileSync(filePath, sourceText); // restore the COPY so the next entry starts pristine
@@ -10613,13 +10988,26 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
10613
10988
  // those verdicts outrank the rebaseline check, so the extra suite run would buy nothing.
10614
10989
  let rebaselineExitCode: number | null | undefined;
10615
10990
  let rebaselineFailureReason: string | undefined;
10991
+ let rebaselineAttribution: ReturnType<typeof attributeBaselineRedness> | undefined;
10992
+ let rebaselineInternalAttemptLog: string | undefined;
10616
10993
  if (rebaselineMode === 'per-entry' && run !== null && run.exitCode !== null && run.exitCode !== 0
10617
10994
  && fileLoadFailure === undefined && outputUnrecognised === undefined && receiptMismatch === undefined) {
10618
10995
  if (!json) write(`mutation-gate: ${entry.id} — re-baselining the restored tree …`);
10619
- const rebaselineRun = runSuite();
10996
+ const rebaselineRun = runSuite('rebaseline', entry.id);
10620
10997
  rebaselineExitCode = rebaselineRun.exitCode;
10621
10998
  rebaselineFailureReason = rebaselineRun.failureReason;
10999
+ rebaselineInternalAttemptLog = rebaselineRun.internalAttemptLog;
11000
+ if (rebaselineRun.exitCode !== null && rebaselineRun.exitCode !== 0) {
11001
+ rebaselineAttribution = attributeBaselineRedness(
11002
+ rebaselineRun.output,
11003
+ entries.map((candidate) => candidate.file),
11004
+ );
11005
+ }
10622
11006
  }
11007
+ const entryRunFailureReason = run?.failureReason ?? parseInternalFailureReason;
11008
+ const entryInternalAttemptLog = [parseInternalAttemptLog, run?.internalAttemptLog, rebaselineInternalAttemptLog]
11009
+ .filter((log): log is string => log !== undefined)
11010
+ .join('; ');
10623
11011
  const obs: MutationObservation = {
10624
11012
  entry,
10625
11013
  occurrences: 1,
@@ -10629,9 +11017,11 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
10629
11017
  ...(fileLoadFailure !== undefined ? { fileLoadFailure } : {}),
10630
11018
  ...(outputUnrecognised !== undefined ? { outputUnrecognised } : {}),
10631
11019
  ...(receiptMismatch !== undefined ? { receiptMismatch } : {}),
10632
- ...(run?.failureReason !== undefined ? { runFailureReason: run.failureReason } : {}),
11020
+ ...(entryRunFailureReason !== undefined ? { runFailureReason: entryRunFailureReason } : {}),
11021
+ ...(entryInternalAttemptLog !== '' ? { internalAttemptLog: entryInternalAttemptLog } : {}),
10633
11022
  ...(rebaselineExitCode !== undefined ? { rebaselineExitCode } : {}),
10634
11023
  ...(rebaselineFailureReason !== undefined ? { rebaselineFailureReason } : {}),
11024
+ ...(rebaselineAttribution !== undefined ? { rebaselineAttribution } : {}),
10635
11025
  };
10636
11026
  observations.push(obs);
10637
11027
  results.push(classifyMutationOutcome(obs));
@@ -10644,7 +11034,7 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
10644
11034
  // MUTATION_LOAD_FATAL / RECEIPT_MISMATCH untouched.
10645
11035
  if (rebaselineMode === 'final') {
10646
11036
  if (!json) write('mutation-gate: final re-baseline of the restored tree …');
10647
- const finalRun = runSuite();
11037
+ const finalRun = runSuite('final-rebaseline');
10648
11038
  const finalExit = finalRun.exitCode;
10649
11039
  if (finalExit !== 0) {
10650
11040
  const what = finalExit === null ? `no exit code: ${finalRun.failureReason ?? 'unknown timeout / spawn failure'}` : `exit ${finalExit}`;
@@ -10654,6 +11044,12 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
10654
11044
  ...obs,
10655
11045
  rebaselineExitCode: finalExit,
10656
11046
  ...(finalRun.failureReason !== undefined ? { rebaselineFailureReason: finalRun.failureReason } : {}),
11047
+ ...(finalRun.internalAttemptLog !== undefined
11048
+ ? { internalAttemptLog: [obs.internalAttemptLog, finalRun.internalAttemptLog].filter((log): log is string => log !== undefined).join('; ') }
11049
+ : {}),
11050
+ ...(finalExit !== null && finalExit !== 0
11051
+ ? { rebaselineAttribution: attributeBaselineRedness(finalRun.output, entries.map((entry) => entry.file)) }
11052
+ : {}),
10657
11053
  }));
10658
11054
  results.length = 0;
10659
11055
  results.push(...reclassified);
@@ -10669,7 +11065,7 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
10669
11065
 
10670
11066
  const exitCode = mutationGateExitCode(results, baseline.ok);
10671
11067
  if (json) {
10672
- write(JSON.stringify({ packageDir: pkgDir, registryPath, testCommand: testCmd, rebaselineMode, baseline, results, summary: summarizeMutationResults(results), warnings, exitCode }, null, 2));
11068
+ write(JSON.stringify({ packageDir: pkgDir, registryPath, testCommand: testCmd, rebaselineMode, baseline, results, summary: summarizeMutationResults(results), warnings, internalRetries, exitCode }, null, 2));
10673
11069
  return exitCode;
10674
11070
  }
10675
11071
  write(renderMutationReport(results, baseline, pkgDir));
@@ -12382,6 +12778,13 @@ function cmdFeatureAdrRecord(options: Map<string, string>, flags: Set<string>, c
12382
12778
  targetExists: existsSync(target),
12383
12779
  targetHasPair: flags.has('once') && existsSync(target),
12384
12780
  timestamp: new Date().toISOString(),
12781
+ // WHO ran it: `--runner <id>` when the caller knows, otherwise this host. The workflow cannot
12782
+ // supply it — it has no host inside its sandbox — so the identity is resolved here, at the one
12783
+ // seam that runs outside. hostname() can throw on an exotic setup; an unresolvable runner stays
12784
+ // ABSENT rather than becoming the string 'unknown', which would later join as if it were one.
12785
+ runnerId: (options.get('runner') ?? '').trim() !== ''
12786
+ ? (options.get('runner') ?? '').trim()
12787
+ : (() => { try { return hostname(); } catch { return null; } })(),
12385
12788
  });
12386
12789
  if (decision.line === null) return emit(decision);
12387
12790
 
@@ -13777,6 +14180,145 @@ async function cmdQeBridge(options: Map<string, string>, flags: Set<string>, cwd
13777
14180
  }
13778
14181
 
13779
14182
 
14183
+ interface ScoreReceiptFile {
14184
+ readonly path: string;
14185
+ readonly displayPath: string;
14186
+ readonly qeHash: string;
14187
+ }
14188
+
14189
+ function scoreReceiptFiles(root: string): ScoreReceiptFile[] {
14190
+ const featuresDir = join(root, 'features');
14191
+ let features: Dirent[];
14192
+ try {
14193
+ features = readdirSync(featuresDir, { withFileTypes: true });
14194
+ } catch {
14195
+ return [];
14196
+ }
14197
+ const receipts: ScoreReceiptFile[] = [];
14198
+ for (const feature of features) {
14199
+ if (!feature.isDirectory()) continue;
14200
+ const stateDir = join(featuresDir, feature.name, '.fa-state');
14201
+ let entries: Dirent[];
14202
+ try {
14203
+ if (lstatSync(stateDir).isSymbolicLink()) continue;
14204
+ entries = readdirSync(stateDir, { withFileTypes: true });
14205
+ } catch {
14206
+ continue;
14207
+ }
14208
+ for (const entry of entries) {
14209
+ if (!entry.isFile()) continue;
14210
+ const match = /^score-(.+)\.json$/.exec(entry.name);
14211
+ if (match === null || match[1] === undefined || match[1] === '') continue;
14212
+ const path = join(stateDir, entry.name);
14213
+ receipts.push({ path, displayPath: relative(root, path), qeHash: match[1] });
14214
+ }
14215
+ }
14216
+ return receipts.sort((a, b) => a.displayPath < b.displayPath ? -1 : a.displayPath > b.displayPath ? 1 : 0);
14217
+ }
14218
+
14219
+ function scoreAggregateChainLine(text: string): {
14220
+ readonly line: string;
14221
+ readonly verification: ReturnType<typeof verifyEventChainText> | null;
14222
+ readonly defectAges: ReturnType<typeof classifyChainDefects> | null;
14223
+ } {
14224
+ if (text === '') {
14225
+ return { line: 'chain: NOT_PRESENT — no aggregate evidence file was created', verification: null, defectAges: null };
14226
+ }
14227
+ const verification = verifyEventChainText(text);
14228
+ const defectAges = classifyChainDefects(verification, verification.lines);
14229
+ const kinds = new Map<string, number>();
14230
+ for (const defect of verification.defects) kinds.set(defect.kind, (kinds.get(defect.kind) ?? 0) + 1);
14231
+ const kindText = [...kinds.entries()].map(([kind, count]) => `${kind}: ${count}`).join(' · ');
14232
+ const line =
14233
+ `chain: ${verification.ok ? 'OK' : 'FAILED'} · ${verification.chained} chained · ` +
14234
+ `${verification.resets} recorded restart(s) · before-run defects ${defectAges.beforeRun.length} · ` +
14235
+ `in-run defects ${defectAges.inRun.length} · current run ${defectAges.runRecords} record(s)` +
14236
+ (kindText === '' ? '' : ` · ${kindText}`) +
14237
+ ` — ${verification.scope}`;
14238
+ return { line, verification, defectAges };
14239
+ }
14240
+
14241
+ function cmdScoreAll(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
14242
+ const json = flags.has('json');
14243
+ if (options.has('slug')) {
14244
+ write(json
14245
+ ? JSON.stringify({ error: '--all and --slug are mutually exclusive', exitCode: 1 })
14246
+ : 'dz score: --all and --slug are mutually exclusive');
14247
+ return 1;
14248
+ }
14249
+ const root = resolve(cwd, options.get('project') ?? '.');
14250
+ const receiptFiles = scoreReceiptFiles(root);
14251
+ if (receiptFiles.length === 0) {
14252
+ const report = buildScoreAggregateReport([], [], 0);
14253
+ const chain = scoreAggregateChainLine('');
14254
+ if (json) write(JSON.stringify({ ...report, chain: null, aggregatePath: '.dz/feature-adr/scorecards.jsonl', exitCode: 0 }, null, 2));
14255
+ else {
14256
+ write(renderScoreAggregateReport(report));
14257
+ write(chain.line);
14258
+ }
14259
+ return 0;
14260
+ }
14261
+
14262
+ const ts = new Date().toISOString();
14263
+ const rows: ReturnType<typeof scoreReceiptToAggregateRow>[] = [];
14264
+ const unreadableReceipts: string[] = [];
14265
+ for (const receipt of receiptFiles) {
14266
+ try {
14267
+ rows.push(scoreReceiptToAggregateRow({
14268
+ content: readFileSync(receipt.path, 'utf8'),
14269
+ qeHash: receipt.qeHash,
14270
+ ts,
14271
+ }));
14272
+ } catch {
14273
+ unreadableReceipts.push(receipt.displayPath);
14274
+ }
14275
+ }
14276
+
14277
+ const storeDir = join(root, '.dz', 'feature-adr');
14278
+ const aggregatePath = join(storeDir, 'scorecards.jsonl');
14279
+ let finalText = '';
14280
+ let finalRows = rows;
14281
+ let appended = 0;
14282
+ let storeError: string | null = null;
14283
+ try {
14284
+ const result = withNamedLockSync(storeDir, 'scorecards', () => {
14285
+ let existingText = '';
14286
+ try {
14287
+ existingText = readFileSync(aggregatePath, 'utf8');
14288
+ } catch (error) {
14289
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
14290
+ }
14291
+ const fresh = dedupeScoreAggregateRows(rows, readScoreAggregateRows(existingText));
14292
+ const appendText = appendChainedLines(fresh, readTailInfo(existingText));
14293
+ if (appendText !== '') appendFileSync(aggregatePath, appendText, { encoding: 'utf8', mode: 0o600 });
14294
+ const settledText = existingText + appendText;
14295
+ return { text: settledText, rows: readScoreAggregateRows(settledText), appended: fresh.length };
14296
+ });
14297
+ finalText = result.text;
14298
+ finalRows = result.rows;
14299
+ appended = result.appended;
14300
+ } catch (error) {
14301
+ storeError = error instanceof Error ? error.message : String(error);
14302
+ }
14303
+
14304
+ const report = buildScoreAggregateReport(finalRows, unreadableReceipts, appended);
14305
+ const chain = scoreAggregateChainLine(finalText);
14306
+ if (json) {
14307
+ write(JSON.stringify({
14308
+ ...report,
14309
+ aggregatePath: '.dz/feature-adr/scorecards.jsonl',
14310
+ chain: chain.verification === null ? null : { verification: chain.verification, defectAges: chain.defectAges },
14311
+ storeError,
14312
+ exitCode: 0,
14313
+ }, null, 2));
14314
+ } else {
14315
+ write(renderScoreAggregateReport(report));
14316
+ write(chain.line);
14317
+ if (storeError !== null) write(`store error (nothing was claimed appended): ${storeError}`);
14318
+ }
14319
+ return 0;
14320
+ }
14321
+
13780
14322
  function cmdScore(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
13781
14323
  const json = flags.has('json');
13782
14324
  if (flags.has('help')) {
@@ -13784,13 +14326,14 @@ function cmdScore(options: Map<string, string>, flags: Set<string>, cwd: string,
13784
14326
  if (json) write(JSON.stringify({ help: usage, exitCode: 0 })); // --json stays ONE document even for help
13785
14327
  else {
13786
14328
  write(usage);
14329
+ write('dz score --all [--project <dir>] [--json] — sweep immutable score receipts into the append-only chained aggregate');
13787
14330
  write(' disciplines: ADR confirmation · discrimination · cross-model QE · live verification · README-first · learning loop · amendments');
13788
14331
  write(' descriptive-only, never a gate: a low score exits 0');
13789
14332
  }
13790
14333
  return 0;
13791
14334
  }
13792
14335
  for (const flag of flags) {
13793
- if (!new Set(['json', 'help']).has(flag)) {
14336
+ if (!new Set(['json', 'help', 'all']).has(flag)) {
13794
14337
  write(json ? JSON.stringify({ error: `unknown option --${flag}`, exitCode: 1 }) : `dz score: unknown option --${flag}\n allowed: --slug <feature>, --project <dir>, --json`);
13795
14338
  return 1;
13796
14339
  }
@@ -13802,6 +14345,7 @@ function cmdScore(options: Map<string, string>, flags: Set<string>, cwd: string,
13802
14345
  return 1;
13803
14346
  }
13804
14347
  }
14348
+ if (flags.has('all')) return cmdScoreAll(options, flags, cwd, write);
13805
14349
  const slug = options.get('slug') ?? '';
13806
14350
  // The delivery-check traversal lesson, upgraded to a WHITELIST: `.` slipped the blacklist and
13807
14351
  // silently aggregated the entire features/ tree as one "run" (Codex QE #2).
@@ -15582,17 +16126,19 @@ function cmdStats(cwd: string, write: Write): number {
15582
16126
  }
15583
16127
  const dirs = readdirSync(baseDir, { withFileTypes: true }).filter((e) => e.isDirectory());
15584
16128
  const packages = dirs.length;
15585
- let totalSkills = 0;
15586
- let skillPacks = 0;
15587
- for (const dir of dirs) {
15588
- if (dir.name.startsWith('skills-')) {
15589
- skillPacks++;
15590
- const skillDir = join(baseDir, dir.name);
15591
- const skillDirs = readdirSync(skillDir, { withFileTypes: true })
15592
- .filter((e) => e.isDirectory() && existsSync(join(skillDir, e.name, 'SKILL.md')));
15593
- totalSkills += skillDirs.length;
15594
- }
15595
- }
16129
+ // Backlog e160aeee. This used to walk the tree ITSELF, and was wrong in two independent ways:
16130
+ // it counted only packages whose NAME starts with `skills-` (health-advisor, p-replicator,
16131
+ // keysarium and trip-planner were therefore invisible), and it knew only ONE of the three skill
16132
+ // layouts. Result: 203 here against 250 from `dz registry` on the same tree — two counters of one
16133
+ // quantity, each unable to refute the other because neither knew the other existed.
16134
+ //
16135
+ // The fix is structural, not arithmetic: there is now ONE enumerator, and both commands ask it.
16136
+ // Pinned by test/stats-registry-parity.test.ts, whose red half is this exact divergence.
16137
+ // The registry already PUBLISHES these totals; recomputing them from `entries` here would be a
16138
+ // third implementation of the same count, which is the very defect being fixed.
16139
+ const registry = buildRegistry(cwd);
16140
+ const totalSkills = registry.totalSkills;
16141
+ const skillPacks = registry.totalPacks;
15596
16142
  const targets = TARGET_NAMES.length;
15597
16143
  const presets = PRESET_NAMES.length;
15598
16144
  write(`dz stats — DZ Harness Hub`);
@@ -16065,7 +16611,31 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
16065
16611
  return version === 'unknown' ? 1 : 0;
16066
16612
  }
16067
16613
 
16068
- if (command === '' || command === 'help' || flags.has('help')) {
16614
+ // `-h` is the most-typed help flag and is NOT a command: before the unknown-command contract
16615
+ // landed it fell through to the switch and still printed usage; afterwards it would have died
16616
+ // with exit 2 and an empty stdout (measured regression, cross-model QE M1). It belongs beside
16617
+ // `-v` above — an argv-level flag, resolved before command dispatch.
16618
+ if (argv[0] === '-h') {
16619
+ write(USAGE);
16620
+ return 0;
16621
+ }
16622
+ // A bare `--typo` leaves the command empty, so the usage branch reported SUCCESS on a misspelled
16623
+ // FLAG exactly as it used to on a misspelled VERB (cross-model QE M2): `dz --frobnicate` exited 0
16624
+ // with 30 KB of usage. The refusal is deliberately narrowed to the no-command case, because the
16625
+ // warn-don't-refuse decision above is measured and still stands: with a command present, an
16626
+ // unrecognised name may simply be missing from KNOWN_CLI_FLAGS and refusing would break working
16627
+ // invocations. With NO command there is nothing the flag could belong to, so it is a usage error.
16628
+ if (command === '') {
16629
+ const strayNames = unknownFlagNotice(
16630
+ [...flags, ...options.keys()].filter((k) => !k.startsWith('_positional_')),
16631
+ KNOWN_CLI_FLAGS,
16632
+ ).map((n) => n.name);
16633
+ if (strayNames.length > 0) {
16634
+ writeErr(`dz: unknown option --${strayNames[0]} — run 'dz help' for usage`);
16635
+ return 2;
16636
+ }
16637
+ }
16638
+ if (command === '' || command === 'help' || (flags.has('help') && DZ_COMMANDS.includes(command))) {
16069
16639
  write(USAGE);
16070
16640
  return 0;
16071
16641
  }
@@ -16134,6 +16704,8 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
16134
16704
  return cmdStatusline(options, flags, cwd, write, readStdin);
16135
16705
  case 'usage':
16136
16706
  return cmdUsage(options, optionLists, flags, cwd, write);
16707
+ case 'chain':
16708
+ return cmdChain(options, flags, cwd, write);
16137
16709
  case 'claim-check':
16138
16710
  return cmdClaimCheck(options, optionLists, flags, cwd, write);
16139
16711
  case 'lint':
@@ -16155,7 +16727,7 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
16155
16727
  case 'diff':
16156
16728
  return cmdDiff(options, cwd, write);
16157
16729
  case 'recommend':
16158
- return cmdRecommend(options, cwd, write);
16730
+ return cmdRecommend(options, flags, cwd, write);
16159
16731
  case 'upgrade':
16160
16732
  return cmdUpgrade(options, flags, cwd, write, writeErr);
16161
16733
  case 'auto-canonicalize':
@@ -16204,8 +16776,20 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
16204
16776
  return cmdChallenge(options, flags, cwd, write);
16205
16777
  case 'discrimination-check':
16206
16778
  return cmdDiscriminationCheck(options, flags, cwd, write);
16207
- case 'mutation-gate':
16208
- return cmdMutationGate(options, flags, cwd, write);
16779
+ case 'mutation-gate': {
16780
+ try {
16781
+ return cmdMutationGate(options, flags, cwd, write, io.mutationGateRunner);
16782
+ } catch (error) {
16783
+ const raw = error instanceof Error ? error.message : String(error);
16784
+ const head = Array.from(raw.split(/\r?\n/, 1)[0]?.trim() || 'unknown internal error').slice(0, 160).join('');
16785
+ if (flags.has('json')) {
16786
+ write(JSON.stringify({ verdict: 'INCONCLUSIVE', reason: 'runner-internal-error', error: head, exitCode: 1 }));
16787
+ } else {
16788
+ write(`mutation-gate: INTERNAL ERROR (${head}) — verdict INCONCLUSIVE, exit 1`);
16789
+ }
16790
+ return 1;
16791
+ }
16792
+ }
16209
16793
  case 'delivery-check':
16210
16794
  return cmdDeliveryCheck(options, flags, cwd, write);
16211
16795
  case 'skills-verify':
@@ -16259,9 +16843,8 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
16259
16843
  case 'import-ecc':
16260
16844
  return await cmdImportEcc(options, flags, cwd, write);
16261
16845
  default:
16262
- write(`dz: unknown command ${JSON.stringify(command)}`);
16263
- write(USAGE);
16264
- return 1;
16846
+ writeErr(`dz: unknown command ${JSON.stringify(command)} — run 'dz help' for the command list`);
16847
+ return 2;
16265
16848
  }
16266
16849
  } catch (error) {
16267
16850
  // stderr, not stdout: an uncaught failure is a diagnostic, and routing it through