@dzhechkov/harness-cli 0.8.9 → 0.8.15

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
@@ -11,9 +11,10 @@ import { request as httpsRequest } from 'node:https';
11
11
  import { KNOWN_CLI_FLAGS } from './known-flags.js';
12
12
  import { isBooleanFlag } from './boolean-flags.js';
13
13
  import { resolveInstallSpec } from './install-spec.js';
14
+ import { dispatchedCommands, documentedCommands } from './command-inventory.js';
14
15
  import { execFile, execFileSync, execSync, spawn, spawnSync, type ChildProcess } from 'node:child_process';
15
16
  import { createHash, randomBytes } from 'node:crypto';
16
- import { homedir, tmpdir } from 'node:os';
17
+ import { homedir, hostname, tmpdir } from 'node:os';
17
18
  import { createRequire } from 'node:module';
18
19
  import { isDeepStrictEqual } from 'node:util';
19
20
 
@@ -30,6 +31,8 @@ import {
30
31
  runDoctor,
31
32
  runInit,
32
33
  discoverSkillIds,
34
+ resolveSelection,
35
+ formatSelectRefusal,
33
36
  runIntegrationsVerify,
34
37
  resolvePackageSkillRoots,
35
38
  PACKAGE_SKILL_LAYOUTS,
@@ -144,8 +147,17 @@ import {
144
147
  reindexVectorStore,
145
148
  harmonizeVectorStore,
146
149
  importRvfCheckpoint,
150
+ renderFeatureAdrPhaseLine,
147
151
  statuslineData,
152
+ countLearningStoreRowsReadonly,
153
+ readStoreMark,
154
+ writeStoreMark,
155
+ resetStoreMark,
156
+ checkStoreHealth,
157
+ storeGuardPath,
158
+ storeSnapshotPath,
148
159
  writeFeatureAdrState,
160
+ writeFeatureAdrStateDetailed,
149
161
  CHECKPOINT_STAGES,
150
162
  estimateEta,
151
163
  extractStageSamples,
@@ -155,6 +167,9 @@ import {
155
167
  type CheckpointStage,
156
168
  type EtaEstimate,
157
169
  type FeatureAdrState,
170
+ type StoreHealth,
171
+ type StoreMark,
172
+ type StoreCountSnapshot,
158
173
  type RunSegment,
159
174
  type StageSample,
160
175
  computeUsage,
@@ -185,6 +200,9 @@ import {
185
200
  patternRecordId,
186
201
  patternIdentityOf,
187
202
  mergeLessonMatchedForms,
203
+ SWARM_BRIEF_CONTRACT,
204
+ checkSwarmBrief,
205
+ visibleText,
188
206
  loadStoreRecords,
189
207
  recordToPattern,
190
208
  bundleSkills,
@@ -210,11 +228,14 @@ import {
210
228
  readTailInfo,
211
229
  appendChainedLines,
212
230
  verifyEventChainText,
231
+ classifyChainDefects,
232
+ CHAINED_JOURNALS,
213
233
  buildManifest,
214
234
  buildSbom,
215
235
  resolveTrustRoot,
216
236
  decideVerifyPolicy,
217
237
  generateSigningKeypair,
238
+ appendTransition,
218
239
  evaluateGuard,
219
240
  resolveRules,
220
241
  auditRecord,
@@ -255,6 +276,7 @@ import {
255
276
  isInsideTree,
256
277
  signManifest,
257
278
  verifyManifest,
279
+ listPackFiles,
258
280
  listSignablePackFiles,
259
281
  assertKeyOutsideTree,
260
282
  decidePublishGate,
@@ -289,12 +311,14 @@ import {
289
311
  DEFAULT_RAKE_THRESHOLDS,
290
312
  streamSessionEvents,
291
313
  findLatestTranscript,
314
+ resolveScanTailTranscript,
292
315
  detectProcessRakes,
293
316
  buildRetro,
294
317
  renderRetro,
295
318
  retroLessonText,
296
319
  PROCESS_SIGNATURES,
297
320
  RETRO_DOMAIN,
321
+ runRetroTailScan,
298
322
  scanForSetup,
299
323
  buildSetupPlan,
300
324
  scaffoldFromSpec,
@@ -371,6 +395,11 @@ import {
371
395
  type EpochOutcome,
372
396
  scoreRun,
373
397
  readQeGrade,
398
+ scoreReceiptToAggregateRow,
399
+ readScoreAggregateRows,
400
+ dedupeScoreAggregateRows,
401
+ buildScoreAggregateReport,
402
+ renderScoreAggregateReport,
374
403
  recapWindow,
375
404
  decideHorizon,
376
405
  withinWindow,
@@ -478,6 +507,7 @@ import {
478
507
  // Mutation gate (feature ha-mutation-gate) — break each named protection, run the suite, require red.
479
508
  parseMutationRegistry,
480
509
  applyMutationToText,
510
+ attributeBaselineRedness,
481
511
  countFailingTests,
482
512
  detectSuiteCompletionReceipt,
483
513
  detectSuiteReceiptMismatch,
@@ -487,6 +517,7 @@ import {
487
517
  mutationGateExitCode,
488
518
  summarizeMutationResults,
489
519
  renderMutationReport,
520
+ runWithOneInternalRetry,
490
521
  TRACE_BUNDLE_LEDGER_PATH,
491
522
  TRACE_BUNDLE_SCHEMA,
492
523
  TRACE_BUNDLE_RUN_META_FILE,
@@ -496,6 +527,8 @@ import {
496
527
  planImport,
497
528
  decideCheckpointWrite,
498
529
  amendmentSection,
530
+ amendmentSectionCount,
531
+ amendmentDeclarationAmbiguity,
499
532
  planSaysNoAmendments,
500
533
  parseAmendments,
501
534
  resolveAmendments,
@@ -558,11 +591,36 @@ import type { ProvenanceMode, PackVerdict, ClaudeUsageModel, PatternRecord, Reca
558
591
  import { getPreset, PRESET_NAMES } from '@dzhechkov/harness-presets';
559
592
  import { scanGitHub, analyzeRepo, generateReport, deepAnalyze, scanAllSources, ScoutMemory } from '@dzhechkov/scout';
560
593
 
594
+ /**
595
+ * Область, по которой гейт дрейфа собирает факты. ПОЛНАЯ, а не только корни установки.
596
+ *
597
+ * ПОЧЕМУ. Под областью `installs` сравниваются лишь корни установки (`.claude/skills`,
598
+ * `.agents/skills` и далее). Навык, чьи копии лежат в РАЗНЫХ корнях — канон в `packages/`, живая
599
+ * копия в `.claude/skills` — имеет там ОДНУ копию, а одну копию не с чем сравнивать: она
600
+ * отбрасывается как не дублированная. То есть главный класс расхождения был для гейта невидим.
601
+ *
602
+ * ИЗМЕРЕНО 2026-09-03: `brutal-honesty-review` разошёлся ровно так (канон в skills-qe, копия в
603
+ * бандле p-replicator, живая в .claude/skills), и гейт не мог увидеть это В ПРИНЦИПЕ. Я тогда
604
+ * написал в отчёте «гейт разблокирован» — он никогда не был на этом заблокирован.
605
+ *
606
+ * Узкая область давала 19 дублирующихся навыков, полная даёт 211. Безопасность расширения
607
+ * проверена ДО правки: с полной областью и списком исключений дрейфа сегодня НОЛЬ.
608
+ */
609
+ const DRIFT_SWEEP_SCOPE = 'all' as const;
610
+
611
+ /**
612
+ * Базовая дата правила `backlog-covers-features`. Каталоги фич, заведённые ДО неё, правило не
613
+ * трогает: они появились раньше самого правила. ИЗМЕРЕНО 2026-09-03 — без базы правило даёт 236
614
+ * нарушений из 336 каталогов, и проверка, изобретающая полсотни нарушений в первый день, учит
615
+ * людей себя игнорировать. Дата = день, когда правило принято владельцем.
616
+ */
617
+ const BACKLOG_COVERAGE_BASELINE = '2026-09-03';
618
+
561
619
  /** Literal command inventory, pinned against the main dispatch switch by a layer-1 test. */
562
620
  export const DZ_COMMANDS: readonly string[] = [
563
621
  'init', 'verify', 'sync', 'update', 'list', 'create-skill', 'info', 'scout',
564
622
  'workflow', 'workflow-lint', 'workflow-trace', 'migrate', 'doctor', 'install',
565
- 'bundle', 'teach', 'consolidate', 'recall', 'vector', 'brain', 'statusline',
623
+ 'bundle', 'teach', 'consolidate', 'recall', 'vector', 'brain', 'statusline', 'store-guard',
566
624
  'usage', 'claim-check', 'lint', 'sign', 'sbom', 'guard', 'verify-pack', 'setup',
567
625
  'pretrain', 'compose', 'diff', 'recommend', 'upgrade', 'auto-canonicalize',
568
626
  'publish', 'release', 'parity', 'registry', 'benchmark', 'mcp-scan',
@@ -571,9 +629,9 @@ export const DZ_COMMANDS: readonly string[] = [
571
629
  'retro', 'feature-adr-setup', 'challenge', 'discrimination-check',
572
630
  'mutation-gate', 'delivery-check', 'skills-verify', 'compounding', 'deadwood',
573
631
  'epoch-replay', 'score', 'recap', 'cadence', 'qe-rounds', 'restart-advisor', 'tg-post',
574
- 'name-check', 'provenance-check', 'feature-adr-record', 'amendment-check', 'contract-check',
632
+ 'name-check', 'brief-check', 'provenance-check', 'feature-adr-record', 'amendment-check', 'contract-check',
575
633
  'feature-adr-checkpoint', 'profile', 'reqe', 'qe-bridge', 'backlog', 'routing',
576
- 'bto-optimize', 'dashboard', 'roam', 'import-ecc',
634
+ 'bto-optimize', 'dashboard', 'roam', 'import-ecc', 'chain',
577
635
  ];
578
636
 
579
637
  const USAGE = `dz - DZ cross-platform harness CLI
@@ -615,27 +673,29 @@ Usage:
615
673
  dz epoch-replay --judge <filled-work-order.json> [--out <file>] (blind judge prompts from the filled plans)
616
674
  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
675
  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)
676
+ 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
677
  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
678
  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
679
  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")
621
680
  dz restart-advisor --slug <s> [--threshold C|D] [--rounds N] [--json] (read-only advisory decision over features/<slug>/.fa-state/checkpoints.jsonl and .dz/fa-training/<slug>/qe.jsonl. Defaults: threshold D, rounds 2 — both origins are printed. Equal sources corroborate; conflicts, torn/unreadable evidence, gaps, and unsafe paths are NOT ESTABLISHED. RECOMMENDATION ONLY: autoAction=false; never invokes feature-adr, deletes a stage, or writes advisor state. exit 0 established recommendation/no-recommendation / 2 NOT ESTABLISHED or invalid input / 1 unexpected runtime failure)
622
681
  dz tg-post --draft <file.html> [--manifest <sources.json>] [--channel <@name|id>] [--send --yes] [--night] [--preview] [--json] (the sender for an APPROVED channel post, per the accepted genai-tweets-channel ADRs: HTML mode only — never MarkdownV2; link preview OFF by default (x.com previews in Telegram are broken); the 00:00-06:00 MSK quiet window refuses without an explicit --night. DEFAULT IS A DRY-RUN: it validates the draft (tag balance, allowed tags, bare &/<, the 4096 visible-character limit with the overshoot counted) and runs the provenance gate over --manifest IN-PROCESS — a draft with no manifest is refused as unchecked, and anything but ALLOWED refuses. A real send needs --send --yes, stating ADR-004's manual-publishing decision out loud each time. The token comes from TELEGRAM_BOT_TOKEN or telegram.tokenFile in .dz/config.json and is never printed. exit 0 sent or clean dry-run / 1 refused or Telegram error / 2 usage)
623
682
  dz name-check [--command <n>] [--module <basename>] [--export <a,b>] [--project <dir>] [--json] (is this name free, BEFORE a line of code? Scans workspace SOURCE — never dist, because a stale build answers 'free' confidently. Checks a dz command name against the dispatcher AND the help block, a module basename against every package's src/, and exported identifiers against every declaration in the workspace. exit 0 all free / 1 at least one taken, naming where / 2 nothing asked or the scan did not run — an empty sweep is never a clean bill. Honest limit, printed on the passing path: it reads declarations, so a re-export under a different name stays the build's job)
683
+ dz brief-check <file> [--json] (does a swarm brief declare OUTPUT_DIR, UNITS and ASSEMBLY_UNIT? parsed as DATA, refused by name; verifies the brief DECLARED the contract, not that the agent follows it. exit 0 ok / 1 refused / 2 unreadable)
624
684
  dz provenance-check --manifest <sources.json> [--project <dir>] [--json] (nothing goes out citing a source that may not leave this machine. Checks PROVENANCE, not words: every claim names its source, and only a KNOWN kind that resolves safely is cleared. Repo paths go through 'git -C <root> check-ignore' over the RESOLVED path — a symlink into an ignored directory is REFUSED (git classifies the string and never dereferences, MEASURED), and the verdict does not change with your working directory. Store records must be named in the git-TRACKED provenance-public.json, so declaring one public is a reviewable commit rather than a field inside an ignored store. An undeclared kind is refused, never inferred from the path's shape. exit 0 allowed / 1 blocked / 3 NOT ESTABLISHED — an empty manifest, an unreadable one, or an oracle that did not run is never a pass. It proves what was CITED: it cannot see a paraphrase with no citation, nor confidential text pasted by hand into an allowed file)
625
685
  dz project-skills [--project <dir>] [--json] [--stages-json] (polymorphic feature-adr: resolve architecture/project-skills.json — fixed roles product-vision/critic/brand/impl-bar plus an open extra[] — into per-stage guidance. READ-ONLY. --project names the root explicitly, so it works from any cwd; without it the manifest is read from the current repo. No manifest ⇒ a byte-identical generic run)
626
686
  dz discrimination-check --slug <slug> [--base <ref>] [--json] (does the ADR's named test actually DISCRIMINATE? Re-runs it on a worktree at the pre-feature commit, where it MUST go red. A test that passes with the feature removed proves nothing; dz amendment-check proves the test exists, this proves it bites)
627
687
  dz guard [check|promote|init] [--json] [--force] (HARD/SOFT repo rules — readme-first, lockfile-in-sync, claim tagging — run automatically as a pre-flight inside dz publish. HARD blocks, SOFT warns)
628
688
  dz architecture [--check --slug <s> --desc <text>] [--project <dir>] [--revise] (the live product map + vision: --check is the soft Step-0 сверка of a new feature against them, reporting {signal,confidence} rather than blocking)
629
689
  dz sbom [--pack <name>] [--out <file>] (CycloneDX software bill of materials for the workspace, or for one pack with --pack)
630
- dz amendment-check --slug <slug> | --feature-dir <dir> | --all [--json] (the deterministic Step-8 amendment gate: every AM-N row must resolve to a test found INSIDE the file the row names; the PLAN is authoritative when it carries rows, and an ideation amendment the plan drops is a failure. exit 0 pass/skip, 1 fail, 3 NOT-ESTABLISHED — a section that parsed ZERO rows is never a pass. --all is a CENSUS and always exits 0. Does NOT prove non-vacuity — that is dz discrimination-check)
690
+ dz amendment-check --slug <slug> | --feature-dir <dir> | --all [--json] (the deterministic Step-8 amendment gate: every AM-N / AM-CP-N row must resolve to a test found INSIDE the file the row names (the challenge-panel prefix is part of the id: AM-CP-1 is never AM-1); the PLAN is authoritative when it carries rows, and an ideation amendment the plan drops is a failure. exit 0 pass/skip, 1 fail, 3 NOT-ESTABLISHED — a section that parsed ZERO rows is never a pass, UNLESS the plan explicitly declares \"None\"/\"нет\", which is an answer and reports skip. --all is a CENSUS and always exits 0. Does NOT prove non-vacuity — that is dz discrimination-check)
631
691
  dz contract-check --slug <s> [--json] (read-only retrospective feature contract gate: extracts canonical AC-N + ADR Confirmation items, requires one artifact-anchored met|unmet|not-testable verdict per CC-N, and rejects A/B with unmet. exit 0 pass / 1 readable contract or verdict violation / 2 invalid invocation or unreadable/not-established artifacts)
632
692
  dz feature-adr-record --kind ledger|training-pair --stage <s> [--slug <s>] [--row|--pair <json>] [--mark <n>] [--once] [--json] (the witnessed writer for the run-cost ledger and training pairs: the payload arrives as an ARGUMENT, never as shell; a malformed or wrong-kind payload is REFUSED before any write; the timestamp is stamped before serialising; the append is verified by re-reading the tail. exit 0 written|duplicate|skipped, 2 refused, 3 not-verified — a record failure is never blocking)
633
693
  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
694
  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
695
  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)
696
+ 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
697
  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
- 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)
698
+ dz backlog add "<idea>" [--effort 1-5] [--proposal <text>] [--dry-run] [--allow-cold-start] [--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
699
  dz backlog list [--status <s>] [--goal <id>] [--project <dir>] [--json] (list captured ideas, filterable by status/goal)
640
700
  dz backlog show <id> [--project <dir>] [--json] (full record for one idea)
641
701
  dz backlog goals [--validate] [--project <dir>] [--json] (list/validate the compass at .dz/backlog/goals.json)
@@ -649,7 +709,7 @@ Usage:
649
709
  dz backlog jira <id> [--project <dir>] [--json] (draft a Jira issue via the configurable adapter (backlog.jira.adapter: jira-mcp|copilot-mcp|none); none writes an auditable jira-outbox/<id>.json stub)
650
710
  dz backlog harmonize [--apply] [--threshold <0-1>] [--project <dir>] [--json] (batch semantic dedup of the backlog ideas; --dry-run default, --apply snapshots first)
651
711
  dz setup --target <name> [--preset <name>] [--select id,id,...] [--skills-dir <dir>] [--project <dir>] [--memory agentdb] [--no-memory] [--no-hooks] [--no-verify] [--install-driver] [--force] [--enrich] (--target codex ALSO installs + LIVE-verifies the codex hooks; an unverified hook exits non-zero WITHOUT aborting the rest of setup)
652
- dz teach "<pattern>" [--class-form "<template with :slot>"] [--reward <0-1>] [--domain <name>] [--type rule|success-pattern|lesson-learned] [--project <dir>] [--no-mirror] (class form is optional; rejection never blocks the specific write; --project pins the learned store to <dir>/.dz)
712
+ dz teach "<pattern>" [--class-form "<template with :slot>"] [--reward <0-1>] [--domain <name>] [--type rule|success-pattern|lesson-learned] [--project <dir>] [--no-mirror] [--allow-cold-start] (class form is optional; rejection never blocks the specific write; --project pins the learned store to <dir>/.dz)
653
713
  dz teach --from-json <file> [--project <dir>] [--no-mirror] (bulk-import a 'dz recall --all --json' export — share a learned store across machines)
654
714
  dz consolidate [--sessions-dir <dir>] [--project <dir>] [--no-mirror] [--prune-noise [--apply]] [--prune-quarantine [--apply]] (both prunes: DRY-RUN by default; --apply snapshots then deletes; prune-quarantine = expired unproven lessons ONLY, never coupled to noise)
655
715
  dz recall "<query>" [--limit <N>] [--domain <name>] [--semantic | --no-semantic] [--books [--book <slug>]] [--project <dir>] | dz recall --all [--json] | dz recall --usage [--json] | dz recall --forget <dzId>[,<dzId>] [--apply] | dz recall --promote <dzId>[,<dzId>] [--apply] (--domain <name> BOOSTS lessons of that domain without dropping foreign ones — a shared store keeps its cross-domain transfers; forget/promote: dry-run default; forget snapshots before removing; promote lifts lesson-quarantine)
@@ -669,9 +729,11 @@ Usage:
669
729
  dz brain expand <kuId> [--source <slug>] [--json] (full-content lookup for a citation kuId; --json emits the full KU object)
670
730
  dz brain init [--project <dir>] [--k <N>] (wire the grounding hook into .claude/settings.json — opt-in)
671
731
  dz statusline [--json] [--install] [--project <dir>] (live self-learning panel for Claude Code's status bar; reads the CC JSON payload from STDIN)
672
- 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)
732
+ dz store-guard [--status|--reset] [--yes] [--project <dir>] (show the monotonic external high-water mark; --reset is the only lowering path and requires confirmation or --yes)
733
+ dz statusline --fa-record --slug <s> --step "<label>" [--kind <feature-adr|loop>] [--tier <S|M|L|XL>] [--recalled <n>] [--stored <n>] [--mode <m>] (feature-adr: record live per-run learning state + phase → 📐 SECOND-LINE phase panel; a plain "Step <n>" label that goes BACKWARDS against a slot younger than 90 min is absorbed as a stale duplicate — prefix the label with ⛔ or ⏸ to record a legitimate regression)
673
734
  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
735
  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)
736
+ 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
737
  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
738
  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
739
  dz pretrain [--project <dir>]
@@ -696,6 +758,10 @@ Usage:
696
758
  dz dashboard
697
759
  dz roam [--apply] [--slug <slug>]
698
760
  dz import-ecc [--local-path <dir>] [--select id,id,...] [--limit N] [--output <dir>] [--force]
761
+ dz retro [transcript-path] [--json] [--threshold N] [--no-teach] [--project <dir>] [--install-hook] (per-session retrospective + co-learning: mines the session transcript for recurring PROCESS rakes, drills you, and teaches the same lesson to the store)
762
+ dz feature-adr-setup [--plan] [--from-spec <spec.json>] [--guards [--loc-cap <n>]] [--gates [--target <name>]] [--apply] [--json] (scaffold the project-awareness files feature-adr reads — vision / map / testing / project-skills — plus the deterministic project guards and the portable delivery gates; --guards and --gates work STANDALONE or with --from-spec, and --apply writes for all three; without --apply everything is a preview)
763
+ dz mr-rakes [--json] [--candidate N] [--confirmed N] [--teach] [--gen-critic <path> [--apply]] (experimental: mine the review corpus — features' QE reports + REVIEW files — for RECURRING mistakes and close them into self-learning)
764
+ dz bto-optimize --split | --plan | --select | --scope-check | --diff [--json] (experimental: deterministic tune/holdout split, budget plan and holdout-no-regress winner selection behind the /bto-optimize skill)
699
765
  dz help
700
766
 
701
767
  Global: --version | -v [--json] (prints this CLI's own semver on one line, exit 0; "unknown" + exit 1 when unresolvable)
@@ -717,6 +783,17 @@ Workflows: author loop-plan/1 plans with dz workflow init/validate/render; gate
717
783
  Targets: ${TARGET_NAMES.join(', ')}
718
784
  Presets: ${PRESET_NAMES.join(', ')}`;
719
785
 
786
+ export interface MutationGateRunnerObservation {
787
+ readonly exitCode: number | null;
788
+ readonly output: string;
789
+ readonly failureReason?: string;
790
+ }
791
+
792
+ export type MutationGateRunner = (
793
+ command: string,
794
+ options: { readonly cwd: string; readonly timeoutMs: number },
795
+ ) => MutationGateRunnerObservation;
796
+
720
797
  /** Output sink + working directory — injectable so the CLI is testable. */
721
798
  export interface CliIo {
722
799
  readonly cwd?: string;
@@ -761,6 +838,8 @@ export interface CliIo {
761
838
  * offline, hermetically — mirrors the {@link CliIo.releaseRunner} idiom.
762
839
  */
763
840
  readonly installRunner?: (command: string, cwd: string) => void;
841
+ /** Fault seam for proving mutation-gate catches and retries thrown runner internals. */
842
+ readonly mutationGateRunner?: MutationGateRunner;
764
843
  }
765
844
 
766
845
  /** Injected subprocess runner used by `dz release` (see {@link CliIo.releaseRunner}). */
@@ -850,6 +929,12 @@ function discoverSkillsDirs(cwd: string, explicitSkillsDir?: string | undefined)
850
929
 
851
930
  /** Outcome of {@link installSkills}. */
852
931
  interface InstallSkillsResult {
932
+ /**
933
+ * Set when an explicit `--select` named an id no root provides (backlog 9d15b9b6, PR-A). The
934
+ * caller MUST print it and exit non-zero: `0 skill(s)` is not a success, and by the time this is
935
+ * set nothing has been written yet — the refusal is decided before the first byte.
936
+ */
937
+ readonly selectRefusal?: string;
853
938
  readonly results: { id: string; written: number; skipped: number }[];
854
939
  readonly dirsSearched: number;
855
940
  readonly written: number;
@@ -885,11 +970,44 @@ async function installSkills(opts: {
885
970
  noIntegrations?: boolean;
886
971
  noVerify?: boolean;
887
972
  allowIntegrations?: string;
973
+ writeErr?: (line: string) => void;
888
974
  }): Promise<InstallSkillsResult> {
889
975
  const { target, projectRoot, cwd, explicitSkillsDir, select, force, enrich } = opts;
890
976
 
891
977
  const skillsDirs = discoverSkillsDirs(cwd, explicitSkillsDir);
892
978
 
979
+ // PREFLIGHT (backlog 9d15b9b6, PR-A) — resolve the REQUEST once, before anything is written.
980
+ //
981
+ // Two defects lived in asking each root independently instead of resolving the request: a skill
982
+ // present in two roots was installed TWICE and counted twice (the field report's `2 skill(s)` was
983
+ // one skill installed twice), and a skill present in NO root produced a warning and exit 0 —
984
+ // `0 skill(s)` reading as success. Both are gone once the decision happens here.
985
+ //
986
+ // Placement is load-bearing: an exit 1 that arrives after hooks and memory are written leaves a
987
+ // half-configured project, which is worse than either clean outcome. This runs before the loop
988
+ // below and before every target adapter.
989
+ //
990
+ // Dependency closure is deliberately NOT resolved here — that is PR-B. This preflight fixes the
991
+ // count and the exit contract, and gives that work a base it can trust.
992
+ if (select !== undefined) {
993
+ const roots = skillsDirs.map((dir) => ({ dir, ids: discoverSkillIds(dir) }));
994
+ const resolution = resolveSelection(select, roots);
995
+ for (const shadow of resolution.shadowed) {
996
+ opts.writeErr?.(
997
+ `dz: skill '${shadow.id}' is offered by ${shadow.alsoIn.length + 1} roots; ` +
998
+ `installing from ${shadow.chosen} (earlier root wins). Also present in: ${shadow.alsoIn.join(', ')}`,
999
+ );
1000
+ }
1001
+ const refusal = formatSelectRefusal(resolution, roots);
1002
+ if (refusal !== null) {
1003
+ return {
1004
+ selectRefusal: refusal,
1005
+ results: [], dirsSearched: skillsDirs.length, written: 0, skipped: 0,
1006
+ missing: [...resolution.missing], failures: [], applyFailures: [], integrations: [],
1007
+ };
1008
+ }
1009
+ }
1010
+
893
1011
  // agents-md and gemini are FLATTENING single-file targets: each must aggregate
894
1012
  // every selected skill from ALL discovered dirs into ONE root file (AGENTS.md /
895
1013
  // GEMINI.md) in a single merge. A per-dir runInit loop (like the tree targets
@@ -1014,6 +1132,7 @@ async function cmdInit(options: Map<string, string>, flags: Set<string>, cwd: st
1014
1132
  cwd,
1015
1133
  explicitSkillsDir,
1016
1134
  select,
1135
+ writeErr,
1017
1136
  force: flags.has('force'),
1018
1137
  enrich: flags.has('enrich'),
1019
1138
  noHooks: flags.has('no-hooks'),
@@ -1022,6 +1141,13 @@ async function cmdInit(options: Map<string, string>, flags: Set<string>, cwd: st
1022
1141
  ...(options.get('allow-integrations') !== undefined ? { allowIntegrations: options.get('allow-integrations')! } : {}),
1023
1142
  });
1024
1143
 
1144
+ // PR-A: an explicit --select that named a skill no root provides is a REFUSAL, not a warning.
1145
+ // Printed and returned here, before any target adapter runs — nothing has been written yet.
1146
+ if (r.selectRefusal !== undefined) {
1147
+ writeErr(r.selectRefusal);
1148
+ return 1;
1149
+ }
1150
+
1025
1151
  // Codex keeps its established user-registry writer, but its result is normalized into the same
1026
1152
  // two-outcome contract before JSON/human rendering. A write without a live ready observation is
1027
1153
  // a refusal with applied=true, never a second success channel.
@@ -1323,7 +1449,7 @@ async function cmdScout(options: Map<string, string>, flags: Set<string>, cwd: s
1323
1449
 
1324
1450
  try {
1325
1451
  const scanTopics = topicsArg ? topicsArg.split(',').map((t) => t.trim()) : undefined;
1326
- const { results: repos, totalBySource } = await scanAllSources({
1452
+ const { results: repos, totalBySource, statusBySource } = await scanAllSources({
1327
1453
  token,
1328
1454
  topics: scanTopics,
1329
1455
  since,
@@ -1337,10 +1463,16 @@ async function cmdScout(options: Map<string, string>, flags: Set<string>, cwd: s
1337
1463
  .join(', ');
1338
1464
  write(`Sources: ${sourceLines}`);
1339
1465
 
1340
- // Memory: diff with previous scan
1466
+ // Memory: diff with previous scan.
1467
+ //
1468
+ // СОСТОЯНИЕ ИСТОЧНИКОВ ПЕРЕДАЁТСЯ ОБЯЗАТЕЛЬНО. Без него разность не выводит исчезновений
1469
+ // вообще — и это правильно: источник, ответивший кодом ошибки, раньше делал ВСЕ свои записи
1470
+ // «пропавшими» на экране, то есть отчёт печатал факт о нашей сети как факт о мире.
1341
1471
  if (showDiff || memory.size > 0) {
1342
- const diff = memory.diff(repos);
1343
- if (diff.newRepos.length > 0 || diff.goneRepos.length > 0 || diff.changedScore.length > 0) {
1472
+ const health: Record<string, string> = {};
1473
+ for (const [source, status] of Object.entries(statusBySource)) health[source] = status.health;
1474
+ const diff = memory.diff(repos, health);
1475
+ if (diff.newRepos.length > 0 || diff.goneRepos.length > 0 || diff.changedScore.length > 0 || diff.goneOmittedReason !== undefined) {
1344
1476
  write(memory.diffMarkdown(diff));
1345
1477
  } else if (memory.size > 0) {
1346
1478
  write(`\nNo changes since last scan (${memory.size} repos tracked).\n`);
@@ -2143,6 +2275,49 @@ function cmdBundle(options: Map<string, string>, flags: Set<string>, cwd: string
2143
2275
  return 0;
2144
2276
  }
2145
2277
 
2278
+ /**
2279
+ * Команда npm для установки пакета В ЦЕЛЕВОЙ КАТАЛОГ, а не куда решит npm.
2280
+ *
2281
+ * ЗАЧЕМ `--prefix`. Без него npm при отсутствии `package.json` в текущем каталоге поднимается по
2282
+ * дереву до первого найденного и мутирует ЕГО — а `dz` потом ищет пакет в
2283
+ * `<цель>/node_modules` и не находит. Место установки и место проверки были двумя независимыми
2284
+ * предположениями, и совпадали они только по удаче.
2285
+ *
2286
+ * ИЗМЕРЕНО 2026-09-03 (полевой случай владельца): установка в каталог без `package.json`
2287
+ * записала в `/home`, где лежит ЧУЖОЙ проект; ручной откат вернул `package.json`, а запись
2288
+ * `extraneous` в `/home/package-lock.json` пережила откат.
2289
+ *
2290
+ * ПОЧЕМУ НЕ ОТКАЗ (ADR-001, вариант A отвергнут). Отказ запретил бы законный сценарий: проект
2291
+ * внутри монорепо, намеренно не имеющий своего `package.json` и опирающийся на родительский
2292
+ * воркспейс. `--prefix` согласует установку с проверкой ПО ПОСТРОЕНИЮ и сценарий сохраняет.
2293
+ *
2294
+ * ЧИСТАЯ: ни файловой системы, ни запуска npm — проверяется без обоих. Путь экранируется, потому
2295
+ * что каталоги с пробелом в имени встречаются в наших же тестах.
2296
+ */
2297
+ export function buildInstallArgs(npmSpec: string, projectRoot: string): readonly string[] {
2298
+ return ['install', npmSpec, '--prefix', projectRoot, '--save-dev', '--no-fund', '--no-audit'];
2299
+ }
2300
+
2301
+ /**
2302
+ * Та же команда СТРОКОЙ — только для показа человеку и для тестового шва.
2303
+ *
2304
+ * НЕ ДЛЯ ИСПОЛНЕНИЯ, и это не стилистическая оговорка. `JSON.stringify` НЕ является экранированием
2305
+ * для оболочки: внутри двойных кавычек оболочка по-прежнему выполняет `$(...)` и обратные кавычки.
2306
+ * ИЗМЕРЕНО 2026-09-03 — `execSync('echo ' + JSON.stringify('pkg$(touch ФАЙЛ)'))` создал файл.
2307
+ * Прежняя редакция этого комментария утверждала «путь экранируется»; это было неверно, и находку
2308
+ * предъявило кросс-семейное ревью (gpt-5.6-sol), а я подтвердил её пробой.
2309
+ *
2310
+ * Боевой путь исполняется через `execFileSync` массивом аргументов — оболочки в цепочке нет вовсе,
2311
+ * поэтому подставлять некуда. Это структурное лечение, а не более хитрое экранирование.
2312
+ */
2313
+ export function buildInstallCommand(npmSpec: string, projectRoot: string): string {
2314
+ // ЗНАЧЕНИЯ в кавычках, ФЛАГИ без — та же форма, что печаталась до этой фичи, чтобы читатель
2315
+ // (и закреплённые тесты) видели знакомую строку. Кавычки здесь — ЧИТАЕМОСТЬ, а не безопасность:
2316
+ // безопасность даёт отсутствие оболочки на боевом пути.
2317
+ return `npm install ${JSON.stringify(npmSpec)} --prefix ${JSON.stringify(projectRoot)}`
2318
+ + ' --save-dev --no-fund --no-audit';
2319
+ }
2320
+
2146
2321
  async function cmdInstall(
2147
2322
  options: Map<string, string>,
2148
2323
  flags: Set<string>,
@@ -2194,14 +2369,36 @@ async function cmdInstall(
2194
2369
 
2195
2370
  const projectRoot = resolve(cwd, options.get('project') ?? '.');
2196
2371
 
2372
+ // ПРЕДПОСЫЛКА НАЗЫВАЕТСЯ ДО ПОБОЧНОГО ЭФФЕКТА, А НЕ ПОСЛЕ (ADR-001, FR-2).
2373
+ //
2374
+ // Проверка стоит ЗДЕСЬ, до развилки installRunner/execSync, и это не стилистика. Поставить её
2375
+ // внутрь ветки execSync значило бы оставить боевой путь непокрытым при зелёных тестах — ровно
2376
+ // то состояние, из которого фича и родилась.
2377
+ const hasOwnManifest = existsSync(join(projectRoot, 'package.json'));
2378
+ if (!hasOwnManifest) {
2379
+ write(`dz install: ${projectRoot} — не npm-проект (нет своего package.json).`);
2380
+ write(` Ставлю ЛОКАЛЬНО в него: npm получит --prefix, package.json и node_modules появятся здесь.`);
2381
+ write(` Без --prefix npm поднялся бы по дереву и записал в ЧУЖОЙ проект выше — измерено 2026-09-03.`);
2382
+ }
2383
+
2197
2384
  // Step 1: npm install the package (installRunner is the CliIo test seam — unset in production)
2198
2385
  write(`Installing ${specResolution.npmSpec}${specResolution.kind === 'name' ? '' : ` (${specResolution.kind} → node_modules/${specResolution.dirName})`}...`);
2199
- const installCmd = `npm install ${JSON.stringify(specResolution.npmSpec)} --save-dev --no-fund --no-audit`;
2386
+ const installCmd = buildInstallCommand(specResolution.npmSpec, projectRoot);
2200
2387
  try {
2201
2388
  if (installRunner) installRunner(installCmd, projectRoot);
2202
- else execSync(installCmd, { cwd: projectRoot, stdio: 'pipe', encoding: 'utf-8' });
2389
+ // БЕЗ ОБОЛОЧКИ. execFileSync с массивом аргументов не запускает shell, поэтому имя пакета или
2390
+ // путь с `$(...)` подставить нечему. Строка выше — для показа и для тестового шва, не для
2391
+ // исполнения (см. докстринг buildInstallCommand).
2392
+ else execFileSync('npm', [...buildInstallArgs(specResolution.npmSpec, projectRoot)], { cwd: projectRoot, stdio: 'pipe', encoding: 'utf-8' });
2203
2393
  } catch (err) {
2204
2394
  write(`dz install: npm install failed — ${err instanceof Error ? err.message : String(err)}`);
2395
+ // НЕАТОМАРНЫЙ ОТКАЗ НАЗЫВАЕТСЯ ВСЛУХ (ADR-001, FR-4). Названо кросс-семейной проверкой
2396
+ // 2026-09-03: npm мог успеть изменить package.json, файл замков и node_modules и упасть уже
2397
+ // после этого. Отката у нас нет — и молчать об этом хуже, чем не откатывать: пользователь
2398
+ // считает каталог нетронутым. Полевой случай: ручной откат вернул package.json, а запись
2399
+ // extraneous в файле замков пережила его.
2400
+ write(` npm мог успеть изменить файлы ДО падения — проверьте ${join(projectRoot, 'package.json')},`);
2401
+ write(` ${join(projectRoot, 'package-lock.json')} и ${join(projectRoot, 'node_modules')}: отката dz не делает.`);
2205
2402
  return 1;
2206
2403
  }
2207
2404
 
@@ -2462,7 +2659,7 @@ function cmdStatuslineInstall(options: Map<string, string>, cwd: string, write:
2462
2659
  * `--kind <feature-adr|loop>` identifies the producer, defaults to `feature-adr`, and rejects any
2463
2660
  * other value rather than silently weakening panel arbitration.
2464
2661
  */
2465
- function cmdStatuslineFaRecord(options: Map<string, string>, cwd: string, write: Write): number {
2662
+ function cmdStatuslineFaRecord(options: Map<string, string>, cwd: string, write: Write, writeErr: WriteErr): number {
2466
2663
  const slug = (options.get('slug') ?? '').trim();
2467
2664
  const step = (options.get('step') ?? '').trim();
2468
2665
 
@@ -2522,19 +2719,42 @@ function cmdStatuslineFaRecord(options: Map<string, string>, cwd: string, write:
2522
2719
  return 1;
2523
2720
  }
2524
2721
 
2722
+ // fa-phase-statusline (acid A1): --tier drives done/total on the phase line — an invalid tier is
2723
+ // REJECTED before anything is written (nothing slotted, nothing ledgered), never silently dropped.
2724
+ const tierRaw = options.get('tier');
2725
+ const tier = tierRaw?.trim();
2726
+ if (tier !== undefined && tier !== 'S' && tier !== 'M' && tier !== 'L' && tier !== 'XL') {
2727
+ write(`dz statusline --fa-record: --tier must be S, M, L or XL (got "${tierRaw}")`);
2728
+ write(' Example: dz statusline --fa-record --slug add-user-auth --step "Step 7 Code" --tier M');
2729
+ return 1;
2730
+ }
2731
+
2525
2732
  const mode = options.get('mode');
2526
2733
  const projectRoot = resolve(cwd, options.get('project') ?? '.');
2527
- const state = writeFeatureAdrState(projectRoot, {
2734
+ const outcome = writeFeatureAdrStateDetailed(projectRoot, {
2528
2735
  kind: kindRaw, slug, step, recalled, stored,
2529
2736
  ...(reinforced > 0 ? { reinforced } : {}),
2530
2737
  ...(mode !== undefined && mode.trim() !== '' ? { mode: mode.trim() } : {}),
2738
+ ...(tier !== undefined ? { tier } : {}),
2531
2739
  });
2740
+ const state = outcome.state;
2532
2741
 
2533
2742
  if (state === undefined) {
2743
+ // [AM-5] A REFUSED slot write is LOUD. A held `fa-phase-slot` lock or an unwritable `.dz` used
2744
+ // to return a bare `undefined`, and a caller reading silence as success is exactly the
2745
+ // "absence of a receipt is not success" class. The reason goes to stderr — diagnosis, not
2746
+ // data — and the EXIT CODE is unchanged for a refusal, because the panel must never break the
2747
+ // pipeline that is only reporting to it.
2748
+ if (outcome.refused !== undefined) {
2749
+ writeErr(`fa-record: slot write refused (${slug}): ${outcome.refused}`);
2750
+ return 0;
2751
+ }
2534
2752
  write(`dz statusline --fa-record: could not write learning state under ${projectRoot}/.dz/feature-adr/`);
2535
2753
  return 1;
2536
2754
  }
2537
- write(`dz statusline: recorded /feature-adr learning state for "${slug}" (${step}) 🎓 ${state.pool} pool · ↑${state.recalled} used · +${state.stored} new · ↻${state.reinforced ?? 0} reinforced`);
2755
+ // state.step, not the flag: the monotonic guard may have kept a LATER step against a stale
2756
+ // duplicate record (fa-phase-statusline P1) — print what actually stands in the slot.
2757
+ write(`dz statusline: recorded /feature-adr learning state for "${slug}" (${state.step}) — 🎓 ${state.pool} pool · ↑${state.recalled} used · +${state.stored} new · ↻${state.reinforced ?? 0} reinforced`);
2538
2758
  return 0;
2539
2759
  }
2540
2760
 
@@ -2686,8 +2906,12 @@ function statuslineEta(projectRoot: string, state: FeatureAdrState, nowMs: numbe
2686
2906
  * least a minimal `dz` even on total failure.
2687
2907
  *
2688
2908
  * Flags: `--install` wires it into settings.json; `--fa-record` records a live `/feature-adr`
2689
- * learning state (WRITES — see {@link cmdStatuslineFaRecord}); `--json` prints the raw data object;
2690
- * default prints the status line (with a 📐 pipeline segment prepended when a fresh run is in flight).
2909
+ * learning state (WRITES — see {@link cmdStatuslineFaRecord}); `--json` prints the raw data object
2910
+ * (plus `featureAdrLine`, the rendered phase line, when a fresh /feature-adr run is in flight);
2911
+ * default prints the status line, with the 📐 phase panel as its OWN SECOND LINE (format B —
2912
+ * fa-phase-statusline ADR-001 D1; Claude Code renders every stdout line of a statusline command).
2913
+ * The ETA fragment main shipped for that panel rides the SECOND line with it (fa-phase-statusline ADR-001 D1) — the panel
2914
+ * moved, the estimate was not dropped.
2691
2915
  */
2692
2916
  function cmdStatusline(
2693
2917
  options: Map<string, string>,
@@ -2695,12 +2919,14 @@ function cmdStatusline(
2695
2919
  cwd: string,
2696
2920
  write: Write,
2697
2921
  readStdin: () => string,
2922
+ writeErr: WriteErr,
2698
2923
  ): number {
2699
2924
  if (flags.has('install')) return cmdStatuslineInstall(options, cwd, write);
2700
- if (flags.has('fa-record')) return cmdStatuslineFaRecord(options, cwd, write);
2925
+ if (flags.has('fa-record')) return cmdStatuslineFaRecord(options, cwd, write, writeErr);
2701
2926
 
2702
2927
  try {
2703
2928
  const projectRoot = statuslineProjectRoot(readStdin(), options, cwd);
2929
+ warnLearningStoreRead(projectRoot, writeErr, 'dz statusline');
2704
2930
  const data = statuslineData(projectRoot);
2705
2931
  const fa = data.featureAdr;
2706
2932
  let eta: EtaEstimate | undefined;
@@ -2716,27 +2942,55 @@ function cmdStatusline(
2716
2942
  }
2717
2943
  }
2718
2944
 
2945
+ // fa-phase-statusline (ADR-001 D1): the phase line renders from the slot ALONE — a pure
2946
+ // function over data.featureAdr, computed once here for both the plain and --json surfaces.
2947
+ const phaseLine = fa !== undefined ? renderFeatureAdrPhaseLine(fa) : undefined;
2948
+
2719
2949
  if (flags.has('json')) {
2720
- write(JSON.stringify({ ...data, ...(eta !== undefined ? { eta } : {}) }));
2950
+ write(JSON.stringify({
2951
+ ...data,
2952
+ ...(eta !== undefined ? { eta } : {}),
2953
+ ...(phaseLine !== undefined ? { featureAdrLine: phaseLine } : {}),
2954
+ }));
2721
2955
  return 0;
2722
2956
  }
2723
2957
 
2724
- let line = `🎓 dz: ${data.patterns} patterns${data.usedPatterns !== undefined ? ` · ${data.usedPatterns} used` : ''} · 🧠 ${data.brainSources} sources`;
2958
+ const breakdown = data.patternBreakdown;
2959
+ let line = breakdown === undefined
2960
+ ? `🎓 dz: ${data.patterns} patterns`
2961
+ : `🎓 dz: ${data.patterns} (${breakdown.active} актив${breakdown.quarantined > 0
2962
+ ? ` · ${breakdown.quarantined} карантин${breakdown.attention ? ' ⚠' : ''}`
2963
+ : ''})${breakdown.tierDelta !== undefined ? ` ⚠ тиры Δ${breakdown.tierDelta}` : ''}`;
2964
+ if (data.storeHealth?.verdict === 'collapsed') {
2965
+ line += ` ⛔ ОБВАЛ: было ${data.storeHealth.previousMax ?? '?'} · dz store-guard --reset`;
2966
+ } else if (data.storeHealth?.verdict === 'cold-start-over-existing') {
2967
+ line += ` ⛔ СТОР ПУСТ: было ${data.storeHealth.previousMax ?? '?'} · восстановить из снимков ${data.storeHealth.snapshotPath ?? ''}`.trimEnd();
2968
+ } else if (data.storeHealth?.verdict === 'unreadable') {
2969
+ line += ` ⛔ СТОР НЕЧИТАЕМ${data.storeHealth.unreadableFiles !== undefined && data.storeHealth.unreadableFiles.length > 0
2970
+ ? `: ${data.storeHealth.unreadableFiles.join(', ')}` : ''}`;
2971
+ } else if (data.storeHealth?.verdict === 'source-changed') {
2972
+ line += ' ⚠ смена источника хранения';
2973
+ }
2974
+ line += `${data.usedPatterns !== undefined ? ` · ${data.usedPatterns} used` : ''} · 🧠 ${data.brainSources} sources`;
2725
2975
  const branch = statuslineGitBranch(projectRoot);
2726
2976
  if (branch !== undefined) line += ` · ⎇ ${branch}`;
2727
2977
  if (data.consolidatedAgeH !== undefined) line += ` · ⟳ ${data.consolidatedAgeH}h`;
2728
2978
 
2729
- // Live /feature-adr run in flight → PREPEND the pipeline learning segment to the base dz line.
2730
- if (fa !== undefined) {
2731
- // The producer marker reached data and arbitration in a previous round but not this label, so the bar asserted a pipeline that was not running.
2732
- if (fa.kind === 'loop') {
2733
- line = `🔁 loop ${fa.step} · ${line}`;
2734
- } else {
2735
- line = `📐 feature-adr ${fa.step} · ${etaFragment !== undefined ? `${etaFragment} · ` : ''}🎓 ${fa.pool} pool · ↑${fa.recalled} used · +${fa.stored} new · ↻${fa.reinforced ?? 0} reinforced · ${line}`;
2736
- }
2979
+ // Live loop run in flight → PREPEND its segment to the base dz line (unchanged). A live
2980
+ // /feature-adr run no longer glues into line 1: its 📐 segment IS the second line (format B)
2981
+ // Claude Code renders every stdout line of a statusline command (fa-phase-statusline ADR-001 D1).
2982
+ if (fa !== undefined && fa.kind === 'loop') {
2983
+ line = `🔁 loop ${fa.step} · ${line}`;
2737
2984
  }
2738
2985
 
2739
2986
  write(line);
2987
+ // fa-phase-statusline ADR-001 D1: the phase panel moved to line 2 and TOOK main's ETA fragment with it. The move is
2988
+ // the point of format B (ADR-001 D1); dropping the estimate would have been a silent
2989
+ // regression of a feature `main` shipped while this branch was stranded, so it rides here
2990
+ // instead. A phase line that renders (fresh, non-terminal, non-loop slot) is the only gate.
2991
+ if (phaseLine !== undefined) {
2992
+ write(`${phaseLine}${etaFragment !== undefined ? ` · ${etaFragment}` : ''}`);
2993
+ }
2740
2994
  return 0;
2741
2995
  } catch {
2742
2996
  // A garbled status bar is worse than a terse one — print SOMETHING minimal, never throw.
@@ -3359,6 +3613,282 @@ function learningStoreLine(
3359
3613
  ) + (reason ? ' [' + reason + ']' : '');
3360
3614
  }
3361
3615
 
3616
+ function inspectLearningStore(projectRoot: string): {
3617
+ mark: StoreMark | undefined;
3618
+ health: StoreHealth;
3619
+ rows: ReturnType<typeof countLearningStoreRowsReadonly>;
3620
+ } {
3621
+ const mark = readStoreMark(projectRoot);
3622
+ const rows = countLearningStoreRowsReadonly(projectRoot);
3623
+ return { mark, rows, health: checkStoreHealth({ projectRoot, ...rows, mark }) };
3624
+ }
3625
+
3626
+ function shellQuote(value: string): string {
3627
+ return `'${value.replace(/'/g, `'\\''`)}'`;
3628
+ }
3629
+
3630
+ function storeGuardResetCommand(projectRoot: string): string {
3631
+ return `dz store-guard --reset --project ${shellQuote(projectRoot)}`;
3632
+ }
3633
+
3634
+ function lexicalSourceLines(rows: ReturnType<typeof countLearningStoreRowsReadonly>): string[] {
3635
+ return [
3636
+ ` lexical selected: ${rows.lexicalSourcePath} (${rows.lexicalSource}, ${rows.lexicalRows} rows)`,
3637
+ ...(rows.lexicalIgnoredSourcePath === undefined ? [] : [
3638
+ ` lexical ignored: ${rows.lexicalIgnoredSourcePath} (${rows.lexicalIgnoredRows} rows)`,
3639
+ ]),
3640
+ ];
3641
+ }
3642
+
3643
+ function storeGuardRecoveryLines(
3644
+ projectRoot: string,
3645
+ mark: StoreMark | undefined,
3646
+ health: StoreHealth,
3647
+ rows?: ReturnType<typeof countLearningStoreRowsReadonly>,
3648
+ ): string[] {
3649
+ const snapshots = storeSnapshotPath(projectRoot);
3650
+ const markPath = storeGuardPath(projectRoot);
3651
+ let lexicalSnapshot: 'sqlite' | 'jsonl' | undefined;
3652
+ let vectorSnapshot = false;
3653
+ try {
3654
+ const names = existsSync(snapshots) ? readdirSync(snapshots) : [];
3655
+ const hasSqlite = names.some((name) => /^lexical\..+\.sqlite$/.test(name));
3656
+ const hasJsonl = names.some((name) => /^lexical\..+\.jsonl$/.test(name));
3657
+ const preferred = mark?.lexicalSource === 'sqlite' || mark?.lexicalSource === 'jsonl'
3658
+ ? mark.lexicalSource
3659
+ : rows?.lexicalSource;
3660
+ if (preferred === 'jsonl' && hasJsonl) lexicalSnapshot = 'jsonl';
3661
+ else if (preferred === 'sqlite' && hasSqlite) lexicalSnapshot = 'sqlite';
3662
+ else if (hasSqlite) lexicalSnapshot = 'sqlite';
3663
+ else if (hasJsonl) lexicalSnapshot = 'jsonl';
3664
+ vectorSnapshot = names.some((name) => /^vector\..+\.sqlite$/.test(name));
3665
+ } catch {
3666
+ lexicalSnapshot = undefined;
3667
+ vectorSnapshot = false;
3668
+ }
3669
+ const lines = [
3670
+ `dz store guard: REFUSED — ${health.reason}`,
3671
+ ` mark: ${markPath}`,
3672
+ ...(mark === undefined ? [] : [` recorded rows: lexical=${mark.lexicalMax} (${mark.lexicalSource}), vector=${mark.vectorMax}`]),
3673
+ ...(rows === undefined ? [] : lexicalSourceLines(rows)),
3674
+ ];
3675
+ if (lexicalSnapshot !== undefined && vectorSnapshot) {
3676
+ const lexicalDestination = lexicalSnapshot === 'sqlite'
3677
+ ? join(projectRoot, '.dz', 'memory', 'patterns.sqlite')
3678
+ : join(projectRoot, '.dz', 'patterns.jsonl');
3679
+ lines.push(
3680
+ ` snapshots: ${snapshots}/`,
3681
+ ` restore: mkdir -p ${shellQuote(dirname(lexicalDestination))} && cp ${shellQuote(join(snapshots, `lexical.<timestamp>.${lexicalSnapshot}`))} ${shellQuote(lexicalDestination)} && cp ${shellQuote(join(snapshots, 'vector.<timestamp>.sqlite'))} ${shellQuote(join(projectRoot, '.dz', 'agentdb.db'))}`,
3682
+ );
3683
+ } else {
3684
+ lines.push(
3685
+ ` snapshots: none found in ${snapshots}/`,
3686
+ ` create one manually: scripts/dz-store-snapshot.sh --project ${shellQuote(projectRoot)}`,
3687
+ );
3688
+ }
3689
+ lines.push(
3690
+ ` accept current counts: ${storeGuardResetCommand(projectRoot)}`,
3691
+ ' continue intentionally: set DZ_ALLOW_COLD_START=1 or pass --allow-cold-start',
3692
+ );
3693
+ return lines;
3694
+ }
3695
+
3696
+ function observedRows(
3697
+ rows: ReturnType<typeof countLearningStoreRowsReadonly>,
3698
+ ): StoreCountSnapshot | undefined {
3699
+ return typeof rows.lexicalRows === 'number' && typeof rows.vectorRows === 'number'
3700
+ ? { lexicalRows: rows.lexicalRows, vectorRows: rows.vectorRows, lexicalSource: rows.lexicalSource }
3701
+ : undefined;
3702
+ }
3703
+
3704
+ interface MarkRefreshOptions {
3705
+ readonly reader?: boolean;
3706
+ }
3707
+
3708
+ /** Mark maintenance is diagnostic: the store operation already completed and must keep its exit code. */
3709
+ function refreshLearningStoreMark(
3710
+ projectRoot: string,
3711
+ writeErr: WriteErr,
3712
+ command: string,
3713
+ options: MarkRefreshOptions = {},
3714
+ ): void {
3715
+ try {
3716
+ const rows = countLearningStoreRowsReadonly(projectRoot);
3717
+ const counts = observedRows(rows);
3718
+ if (counts === undefined) {
3719
+ writeErr(`⚠ dz store guard: ${options.reader ? 'reader observation' : 'store operation'} completed but the external mark was not updated — a store tier is unreadable`);
3720
+ return;
3721
+ }
3722
+ writeStoreMark(projectRoot, {
3723
+ ...counts,
3724
+ observedAt: new Date().toISOString(),
3725
+ command,
3726
+ }, options.reader ? { timeoutMs: 0 } : {});
3727
+ } catch (error) {
3728
+ if (options.reader && error instanceof NamedLockTimeoutError) return;
3729
+ writeErr(`⚠ dz store guard: ${options.reader ? 'reader observation' : 'store operation'} completed but the external mark could not be updated — ${error instanceof Error ? error.message : String(error)}`);
3730
+ }
3731
+ }
3732
+
3733
+ function storeGuardResetReminder(projectRoot: string, writeErr: WriteErr, command: string): void {
3734
+ try {
3735
+ const rows = countLearningStoreRowsReadonly(projectRoot);
3736
+ const mark = readStoreMark(projectRoot);
3737
+ writeErr(`⚠ DZ STORE GUARD — ${command}: store now lexical=${rows.lexicalRows}, vector=${rows.vectorRows}; maximum remains lexical=${mark?.lexicalMax ?? 'none'}, vector=${mark?.vectorMax ?? 'none'}; reconcile explicitly: ${storeGuardResetCommand(projectRoot)}`);
3738
+ for (const line of lexicalSourceLines(rows)) writeErr(line);
3739
+ } catch (error) {
3740
+ writeErr(`⚠ DZ STORE GUARD — ${command}: store changed; inspect it and reconcile explicitly with ${storeGuardResetCommand(projectRoot)} (${error instanceof Error ? error.message : String(error)})`);
3741
+ }
3742
+ }
3743
+
3744
+ /** Fail closed for store writers, except for an explicit per-process/per-command override. */
3745
+ function allowLearningStoreWrite(
3746
+ projectRoot: string,
3747
+ flags: Set<string>,
3748
+ writeErr: WriteErr,
3749
+ command: string,
3750
+ ): boolean {
3751
+ let inspection: ReturnType<typeof inspectLearningStore>;
3752
+ try {
3753
+ inspection = inspectLearningStore(projectRoot);
3754
+ } catch (error) {
3755
+ const health: StoreHealth = {
3756
+ verdict: 'unreadable',
3757
+ reason: `cannot read the external mark (${error instanceof Error ? error.message : String(error)})`,
3758
+ };
3759
+ for (const line of storeGuardRecoveryLines(projectRoot, undefined, health)) writeErr(line);
3760
+ return false;
3761
+ }
3762
+ if (inspection.health.verdict === 'no-mark' || inspection.health.verdict === 'ok') {
3763
+ // The successful write path records the resulting counts. Refreshing here as
3764
+ // well would emit the same telemetry failure twice when the external mark is
3765
+ // unavailable, and would receipt a source transition before the command's
3766
+ // own row had landed.
3767
+ return true;
3768
+ }
3769
+ if (inspection.health.verdict === 'source-changed') {
3770
+ const counts = observedRows(inspection.rows);
3771
+ if (counts === undefined) return false;
3772
+ writeErr(`⚠ DZ STORE GUARD WARNING — SOURCE CHANGED: ${inspection.health.reason}; ${storeGuardResetCommand(projectRoot)}`);
3773
+ for (const line of lexicalSourceLines(inspection.rows)) writeErr(line);
3774
+ try {
3775
+ // Consume the single migration allowance BEFORE the store write. If the
3776
+ // following command fails, the safe result is a consumed allowance that
3777
+ // requires an explicit reset, never a silently reusable permission.
3778
+ writeStoreMark(projectRoot, { ...counts, observedAt: new Date().toISOString(), command }, {
3779
+ expectedPreviousLexicalSource: inspection.mark?.lexicalSource ?? 'unknown',
3780
+ });
3781
+ return true;
3782
+ } catch (error) {
3783
+ writeErr(`dz store guard: REFUSED — source-change allowance could not be recorded: ${error instanceof Error ? error.message : String(error)}`);
3784
+ return false;
3785
+ }
3786
+ }
3787
+
3788
+ const allowed = process.env.DZ_ALLOW_COLD_START === '1' || flags.has('allow-cold-start');
3789
+ if (allowed) {
3790
+ const label = inspection.health.verdict.replaceAll('-', ' ').toUpperCase();
3791
+ writeErr(`⚠ DZ STORE GUARD WARNING — ${label}: ${inspection.health.reason}; explicit cold-start override accepted`);
3792
+ return true;
3793
+ }
3794
+ for (const line of storeGuardRecoveryLines(projectRoot, inspection.mark, inspection.health, inspection.rows)) writeErr(line);
3795
+ return false;
3796
+ }
3797
+
3798
+ /** Readers warn on damage and bootstrap/refresh a healthy non-empty store mark. */
3799
+ function warnLearningStoreRead(projectRoot: string, writeErr: WriteErr, command: string): void {
3800
+ try {
3801
+ const { health, rows, mark } = inspectLearningStore(projectRoot);
3802
+ if (health.verdict === 'collapsed' || health.verdict === 'cold-start-over-existing' || health.verdict === 'unreadable'
3803
+ || health.verdict === 'source-changed') {
3804
+ writeErr(`⚠ DZ STORE GUARD WARNING — ${health.verdict.replaceAll('-', ' ').toUpperCase()}: ${health.reason}`);
3805
+ for (const line of lexicalSourceLines(rows)) writeErr(line);
3806
+ return;
3807
+ }
3808
+ const counts = observedRows(rows);
3809
+ if (counts !== undefined && counts.lexicalRows + counts.vectorRows > 0
3810
+ && (mark === undefined || mark.lexicalLast !== counts.lexicalRows || mark.vectorLast !== counts.vectorRows
3811
+ || mark.lexicalSource !== counts.lexicalSource
3812
+ || mark.lexicalMax < counts.lexicalRows || mark.vectorMax < counts.vectorRows)) {
3813
+ refreshLearningStoreMark(projectRoot, writeErr, command, { reader: true });
3814
+ }
3815
+ } catch (error) {
3816
+ writeErr(`⚠ DZ STORE GUARD WARNING — external mark unreadable: ${error instanceof Error ? error.message : String(error)}`);
3817
+ }
3818
+ }
3819
+
3820
+ async function cmdStoreGuard(
3821
+ options: Map<string, string>, flags: Set<string>, cwd: string, write: Write, writeErr: WriteErr,
3822
+ stdinText: string | undefined, interactive: boolean,
3823
+ ): Promise<number> {
3824
+ const projectRoot = resolve(cwd, options.get('project') ?? '.');
3825
+ const path = storeGuardPath(projectRoot);
3826
+ const reset = flags.has('reset');
3827
+ const status = flags.has('status') || options.has('status');
3828
+ if (reset && status) {
3829
+ writeErr('dz store-guard: --status and --reset are mutually exclusive');
3830
+ return 2;
3831
+ }
3832
+ if (reset) {
3833
+ const rows = countLearningStoreRowsReadonly(projectRoot);
3834
+ const counts = observedRows(rows);
3835
+ if (counts === undefined) {
3836
+ writeErr(`dz store-guard: REFUSED — cannot reset from an unreadable store; mark: ${path}`);
3837
+ return 1;
3838
+ }
3839
+ try {
3840
+ const previous = readStoreMark(projectRoot);
3841
+ const beforeLexical = previous?.lexicalMax ?? counts.lexicalRows;
3842
+ const beforeVector = previous?.vectorMax ?? counts.vectorRows;
3843
+ writeErr('⚠ dz store-guard --reset: manual operator decision required; this lowers the recorded high-water evidence');
3844
+ writeErr(` old maximum: lexical=${beforeLexical}, vector=${beforeVector}`);
3845
+ writeErr(` new observed: lexical=${counts.lexicalRows} (${counts.lexicalSource}), vector=${counts.vectorRows}`);
3846
+ for (const line of lexicalSourceLines(rows)) writeErr(line);
3847
+ let answer = stdinText?.trim().split(/\r?\n/, 1)[0]?.trim().toLowerCase() ?? '';
3848
+ if (!flags.has('yes') && answer === '' && interactive && process.stdin.isTTY) {
3849
+ const { createInterface } = await import('node:readline/promises');
3850
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
3851
+ try { answer = (await rl.question(' type yes to continue: ')).trim().toLowerCase(); }
3852
+ finally { rl.close(); }
3853
+ }
3854
+ if (!flags.has('yes') && !['y', 'yes', 'да'].includes(answer)) {
3855
+ writeErr(`dz store-guard: REFUSED — reset was not confirmed; re-run with --yes or answer yes`);
3856
+ return 1;
3857
+ }
3858
+ const current = countLearningStoreRowsReadonly(projectRoot);
3859
+ const currentCounts = observedRows(current);
3860
+ if (currentCounts === undefined || !isDeepStrictEqual(currentCounts, counts)) {
3861
+ writeErr('dz store-guard: REFUSED — store counts changed after confirmation; inspect and confirm again');
3862
+ return 1;
3863
+ }
3864
+ const mark = resetStoreMark(projectRoot, {
3865
+ ...counts,
3866
+ observedAt: new Date().toISOString(),
3867
+ command: 'dz store-guard --reset',
3868
+ });
3869
+ write(`dz store-guard: RESET — accepted lexical=${mark.lexicalMax}, vector=${mark.vectorMax}`);
3870
+ write(` mark: ${path}`);
3871
+ write(` receipt: ${mark.resetAt?.at} — ${mark.resetAt?.reason}`);
3872
+ return 0;
3873
+ } catch (error) {
3874
+ writeErr(`dz store-guard: reset failed — ${error instanceof Error ? error.message : String(error)}; mark: ${path}`);
3875
+ return 1;
3876
+ }
3877
+ }
3878
+ try {
3879
+ const inspection = inspectLearningStore(projectRoot);
3880
+ write(`dz store-guard: ${inspection.health.verdict.toUpperCase()} — ${inspection.health.reason}`);
3881
+ write(` mark: ${path}`);
3882
+ write(` current rows: lexical=${inspection.rows.lexicalRows}, vector=${inspection.rows.vectorRows}`);
3883
+ for (const line of lexicalSourceLines(inspection.rows)) write(line);
3884
+ write(` recorded: ${inspection.mark === undefined ? 'none' : JSON.stringify(inspection.mark)}`);
3885
+ return 0;
3886
+ } catch (error) {
3887
+ writeErr(`dz store-guard: mark unreadable — ${error instanceof Error ? error.message : String(error)}; mark: ${path}`);
3888
+ return 1;
3889
+ }
3890
+ }
3891
+
3362
3892
  async function runTeachGuardReinforcement(
3363
3893
  projectRoot: string,
3364
3894
  dzId: string,
@@ -3392,6 +3922,12 @@ async function cmdTeach(
3392
3922
  // repo's own store holds 361 records written under that behaviour, and every other user's store
3393
3923
  // is the same. Only an explicit choice moves it.
3394
3924
  const { storeRoot, target: teachTarget } = resolved;
3925
+ const teachWillWrite = flags.has('harmonize')
3926
+ ? false
3927
+ : options.has('from-json')
3928
+ || (options.get('reinforce') ?? '').trim() !== ''
3929
+ || (options.get('_positional_0') ?? '').trim() !== '';
3930
+ if (teachWillWrite && !allowLearningStoreWrite(storeRoot, flags, writeErr, 'dz teach')) return 1;
3395
3931
  // The verb is per OUTCOME, not per command: a harmonize dry-run and a failed --reinforce READ
3396
3932
  // the store and change nothing, so saying "written" there is a false claim about what happened
3397
3933
  // (cross-family QE round 2, 2026-08-27).
@@ -3428,10 +3964,11 @@ async function cmdTeach(
3428
3964
  // Suppressed under --json: this line ahead of the report made stdout unparseable, which is a
3429
3965
  // worse defect than the invisibility it was closing (measured live, cross-family QE round 2).
3430
3966
  if (!flags.has('json')) write(storeLine(flags.has('apply') ? 'written' : 'read'));
3431
- return runHarmonize(storeRoot, options, flags, write, {
3967
+ const code = await runHarmonize(storeRoot, options, flags, write, writeErr, {
3432
3968
  store: join(storeRoot, '.dz'),
3433
3969
  storeChosenBy: teachTarget.reason,
3434
3970
  });
3971
+ return code;
3435
3972
  }
3436
3973
 
3437
3974
  // Bulk import: `dz teach --from-json <file>` ingests a `dz recall --all --json`
@@ -3527,6 +4064,7 @@ async function cmdTeach(
3527
4064
  const report = await harmonizeVectorStore(storeRoot, {});
3528
4065
  write(` ℹ ${imported} imported — ${report.clusters.length} near-duplicate cluster(s): review with dz vector harmonize (dry-run); merge with dz vector harmonize --apply after backup`);
3529
4066
  }
4067
+ if (imported > 0) refreshLearningStoreMark(storeRoot, writeErr, 'dz teach --from-json');
3530
4068
  return 0;
3531
4069
  }
3532
4070
 
@@ -3547,6 +4085,7 @@ async function cmdTeach(
3547
4085
  const clearedQ = clearAgentdbQuarantine(storeRoot, [reinforce]);
3548
4086
  if (clearedQ.cleared > 0) write(` ↳ promoted out of quarantine (mirror updated)`);
3549
4087
  write(storeLine('written'));
4088
+ refreshLearningStoreMark(storeRoot, writeErr, 'dz teach --reinforce');
3550
4089
  return 0;
3551
4090
  }
3552
4091
  // HIGH-fix: a no-match must NOT auto-teach the raw argument — callers pass dzIds or truncated
@@ -3594,6 +4133,7 @@ async function cmdTeach(
3594
4133
  write(`↳ reinforced existing pattern ${verdict.dzId} (cos=${verdict.cosine.toFixed(2)}) — not re-added`);
3595
4134
  const clearedQ = clearAgentdbQuarantine(storeRoot, [verdict.dzId]);
3596
4135
  if (clearedQ.cleared > 0) write(' ↳ promoted out of quarantine (mirror updated)');
4136
+ refreshLearningStoreMark(storeRoot, writeErr, 'dz teach --guard');
3597
4137
  return 0;
3598
4138
  }
3599
4139
  write(`dz teach --guard: reinforce of ${verdict.dzId} did not flush (backend off or write failure) — teaching the lesson normally instead`);
@@ -3707,10 +4247,16 @@ async function cmdTeach(
3707
4247
  }
3708
4248
  // The lexical write above is durable — the vector mirror is strictly best-effort (I-3).
3709
4249
  await emitMirrorQ(storeRoot, recordsToMirror, 'dz-teach', quarantineOn);
4250
+ if (stored.records.length > 0) {
4251
+ write(` ID: ${stored.records.map((record) => patternRecordId(record)).join(', ')}`);
4252
+ refreshLearningStoreMark(storeRoot, writeErr, 'dz teach');
4253
+ }
3710
4254
  return commandFailed ? 1 : 0;
3711
4255
  }
3712
4256
 
3713
- async function cmdConsolidate(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): Promise<number> {
4257
+ async function cmdConsolidate(
4258
+ options: Map<string, string>, flags: Set<string>, cwd: string, write: Write, writeErr: WriteErr,
4259
+ ): Promise<number> {
3714
4260
  const projectRoot = resolve(cwd, options.get('project') ?? '.');
3715
4261
  const sessionsDirOpt = options.get('sessions-dir');
3716
4262
  const pruneNoise = flags.has('prune-noise');
@@ -3729,6 +4275,8 @@ async function cmdConsolidate(options: Map<string, string>, flags: Set<string>,
3729
4275
  if (res.error !== undefined) { write(`dz consolidate --prune-quarantine: ${res.error}`); return 1; }
3730
4276
  write(`dz consolidate --prune-quarantine: removed ${res.removed} expired quarantined lesson(s)`);
3731
4277
  if (res.snapshot !== undefined) write(` snapshot: ${res.snapshot}`);
4278
+ refreshLearningStoreMark(projectRoot, writeErr, 'dz consolidate --prune-quarantine --apply');
4279
+ storeGuardResetReminder(projectRoot, writeErr, 'dz consolidate --prune-quarantine --apply');
3732
4280
  return 0;
3733
4281
  }
3734
4282
 
@@ -3826,6 +4374,10 @@ async function cmdConsolidate(options: Map<string, string>, flags: Set<string>,
3826
4374
  }
3827
4375
  } catch { /* best-effort — the ranking is advisory, never fails the consolidate */ }
3828
4376
 
4377
+ refreshLearningStoreMark(projectRoot, writeErr, 'dz consolidate');
4378
+ if (pruneNoise && applyPrune) {
4379
+ storeGuardResetReminder(projectRoot, writeErr, 'dz consolidate --prune-noise --apply');
4380
+ }
3829
4381
  return 0;
3830
4382
  }
3831
4383
 
@@ -3938,6 +4490,7 @@ async function cmdRecallForget(
3938
4490
  flags: Set<string>,
3939
4491
  projectRoot: string,
3940
4492
  write: Write,
4493
+ writeErr: WriteErr,
3941
4494
  ): Promise<number> {
3942
4495
  const raw = options.get('forget') ?? '';
3943
4496
  const ids = new Set(raw.split(',').map((s) => s.trim()).filter((s) => s !== ''));
@@ -3966,7 +4519,7 @@ async function cmdRecallForget(
3966
4519
  return 0;
3967
4520
  }
3968
4521
 
3969
- const dest = join(projectRoot, '.dz', `patterns-pre-forget-${Date.now()}.json`);
4522
+ const dest = join(storeSnapshotPath(projectRoot), `forget-${Date.now()}.json`);
3970
4523
  const snap = snapshotStore(projectRoot, dest);
3971
4524
  if (snap.error !== undefined) {
3972
4525
  write(`dz recall --forget: snapshot failed (${snap.error}) — nothing removed; the store is not versioned`);
@@ -3977,6 +4530,8 @@ async function cmdRecallForget(
3977
4530
  write(` snapshot: ${snap.path} (${snap.count} record(s))`);
3978
4531
  if (result.error !== undefined) write(` ⚠ ${result.error}`);
3979
4532
  write(' the vector mirror still holds them — run `dz vector reindex` to resync');
4533
+ refreshLearningStoreMark(projectRoot, writeErr, 'dz recall --forget --apply');
4534
+ storeGuardResetReminder(projectRoot, writeErr, 'dz recall --forget --apply');
3980
4535
  return 0;
3981
4536
  }
3982
4537
 
@@ -4068,10 +4623,15 @@ async function cmdRecall(
4068
4623
  classMatcher?: RecallPatternsOptions['classMatcher'],
4069
4624
  ): Promise<number> {
4070
4625
  const projectRoot = resolve(cwd, options.get('project') ?? '.');
4626
+ warnLearningStoreRead(projectRoot, writeErr, 'dz recall');
4627
+ const globalRootForGuard = globalStoreRoot();
4628
+ if (!sameStore(projectRoot, globalRootForGuard) && existsSync(join(globalRootForGuard, '.dz', 'memory'))) {
4629
+ warnLearningStoreRead(globalRootForGuard, writeErr, 'dz recall');
4630
+ }
4071
4631
  const asJson = flags.has('json');
4072
4632
  const all = flags.has('all');
4073
4633
  if (flags.has('usage')) return cmdRecallUsage(options, flags, projectRoot, write);
4074
- if (options.has('forget')) return cmdRecallForget(options, flags, projectRoot, write);
4634
+ if (options.has('forget')) return cmdRecallForget(options, flags, projectRoot, write, writeErr);
4075
4635
  if (options.has('promote')) return cmdRecallPromote(options, flags, projectRoot, write);
4076
4636
 
4077
4637
  // --all: dump the entire learned store (backend-agnostic, via loadStorePatternsSync).
@@ -4633,7 +5193,7 @@ function renderHarmonize(report: HarmonizeReport, write: Write): void {
4633
5193
  * `--apply` + `--dry-run` together is rejected; `--threshold` must be in `(0, 1]`; no flag ⇒ dry-run.
4634
5194
  */
4635
5195
  async function runHarmonize(
4636
- projectRoot: string, options: Map<string, string>, flags: Set<string>, write: Write,
5196
+ projectRoot: string, options: Map<string, string>, flags: Set<string>, write: Write, writeErr: WriteErr,
4637
5197
  /**
4638
5198
  * Where this harmonize is pointed and what chose it. Under `--json` the human store line is
4639
5199
  * suppressed to keep stdout ONE document, so the destination has to travel INSIDE that document
@@ -4657,6 +5217,10 @@ async function runHarmonize(
4657
5217
  }
4658
5218
  }
4659
5219
  const report = await harmonizeVectorStore(projectRoot, { apply, ...(threshold !== undefined ? { threshold } : {}) });
5220
+ if (apply && report.error === undefined) {
5221
+ refreshLearningStoreMark(projectRoot, writeErr, 'dz vector harmonize --apply');
5222
+ storeGuardResetReminder(projectRoot, writeErr, 'dz vector harmonize --apply');
5223
+ }
4660
5224
  if (flags.has('json')) {
4661
5225
  write(JSON.stringify(storeAnnotation !== undefined ? { ...report, ...storeAnnotation } : report));
4662
5226
  return report.error !== undefined ? 1 : 0;
@@ -4665,7 +5229,9 @@ async function runHarmonize(
4665
5229
  return report.error !== undefined ? 1 : 0;
4666
5230
  }
4667
5231
 
4668
- async function cmdVector(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): Promise<number> {
5232
+ async function cmdVector(
5233
+ options: Map<string, string>, flags: Set<string>, cwd: string, write: Write, writeErr: WriteErr,
5234
+ ): Promise<number> {
4669
5235
  const projectRoot = resolve(cwd, options.get('project') ?? '.');
4670
5236
  const sub = options.get('_positional_0');
4671
5237
 
@@ -4720,6 +5286,10 @@ async function cmdVector(options: Map<string, string>, flags: Set<string>, cwd:
4720
5286
  if (sub === 'reindex') {
4721
5287
  const report = await reindexVectorStore(projectRoot);
4722
5288
  if (flags.has('json')) {
5289
+ if (report.error === undefined) {
5290
+ refreshLearningStoreMark(projectRoot, writeErr, 'dz vector reindex');
5291
+ storeGuardResetReminder(projectRoot, writeErr, 'dz vector reindex');
5292
+ }
4723
5293
  write(JSON.stringify(report));
4724
5294
  return report.error !== undefined ? 1 : 0;
4725
5295
  }
@@ -4737,6 +5307,8 @@ async function cmdVector(options: Map<string, string>, flags: Set<string>, cwd:
4737
5307
  write(` ⚠ still in the previous embedding space: ${report.staleTaskTypes.join(', ')}`);
4738
5308
  if (report.staleTaskTypes.includes('book-knowledge')) write(' run \`dz brain reindex\` to rebuild the brain\'s book vectors');
4739
5309
  }
5310
+ refreshLearningStoreMark(projectRoot, writeErr, 'dz vector reindex');
5311
+ storeGuardResetReminder(projectRoot, writeErr, 'dz vector reindex');
4740
5312
  return 0;
4741
5313
  }
4742
5314
 
@@ -4793,7 +5365,7 @@ async function cmdVector(options: Map<string, string>, flags: Set<string>, cwd:
4793
5365
  // harmonize (alias: dz teach --harmonize) — SEMANTIC dedup of the learned store, NON-DESTRUCTIVE:
4794
5366
  // dry-run by default (previews clusters, writes nothing); --apply drops after a restorable backup.
4795
5367
  if (sub === 'harmonize') {
4796
- return runHarmonize(projectRoot, options, flags, write);
5368
+ return runHarmonize(projectRoot, options, flags, write, writeErr);
4797
5369
  }
4798
5370
 
4799
5371
  // import <file.rvf> — the missing HALF of the RVF cycle: UPSERT-BY-dzId, never overwrites.
@@ -4805,6 +5377,7 @@ async function cmdVector(options: Map<string, string>, flags: Set<string>, cwd:
4805
5377
  }
4806
5378
  const report = await importRvfCheckpoint(projectRoot, resolve(cwd, src), {});
4807
5379
  if (flags.has('json')) {
5380
+ if (report.error === undefined) refreshLearningStoreMark(projectRoot, writeErr, 'dz vector import');
4808
5381
  write(JSON.stringify(report));
4809
5382
  return report.error !== undefined ? 1 : 0;
4810
5383
  }
@@ -4817,6 +5390,7 @@ async function cmdVector(options: Map<string, string>, flags: Set<string>, cwd:
4817
5390
  if (report.skippedOrphans > 0) {
4818
5391
  write(' ↳ orphan vectors have no local pattern — import the text first: dz teach --from-json <recall-export.json>, then re-run dz vector import');
4819
5392
  }
5393
+ refreshLearningStoreMark(projectRoot, writeErr, 'dz vector import');
4820
5394
  return 0;
4821
5395
  }
4822
5396
 
@@ -5471,6 +6045,26 @@ async function cmdSetup(options: Map<string, string>, flags: Set<string>, cwd: s
5471
6045
  const projectRoot = resolve(cwd, options.get('project') ?? '.');
5472
6046
  const presetName = options.get('preset');
5473
6047
 
6048
+ // PREFLIGHT BEFORE THE FIRST WRITE (backlog 9d15b9b6, PR-A). Step 3 configures the learning
6049
+ // environment and step 4 installs skills, so refusing at step 4 would leave a project that has
6050
+ // memory and hooks but not the skills the operator asked for — a half-configured state worse than
6051
+ // either clean outcome. The request is therefore resolved HERE, before the banner's first step.
6052
+ //
6053
+ // Only an EXPLICIT --select is refused. A preset names skills the package itself ships, so a gap
6054
+ // there is our packaging defect, not the operator's typo, and it is reported by the existing
6055
+ // missing-list rather than by refusing the whole run.
6056
+ const setupSelectRaw = options.get('select');
6057
+ if (setupSelectRaw !== undefined) {
6058
+ const requested = setupSelectRaw.split(',').map((x) => x.trim()).filter((x) => x.length > 0);
6059
+ const roots = discoverSkillsDirs(cwd, options.get('skills-dir')).map((dir) => ({ dir, ids: discoverSkillIds(dir) }));
6060
+ const resolution = resolveSelection(requested, roots);
6061
+ for (const shadow of resolution.shadowed) {
6062
+ 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(', ')}`);
6063
+ }
6064
+ const refusal = formatSelectRefusal(resolution, roots);
6065
+ if (refusal !== null) { writeErr(refusal); return 1; }
6066
+ }
6067
+
5474
6068
  write(`\n╔══════════════════════════════════════════════════════╗`);
5475
6069
  write(`║ DZ SETUP — Full Environment ║`);
5476
6070
  write(`╠══════════════════════════════════════════════════════╣`);
@@ -5622,7 +6216,7 @@ function cmdPretrain(options: Map<string, string>, cwd: string, write: Write): n
5622
6216
  return 0;
5623
6217
  }
5624
6218
 
5625
- function cmdRecommend(options: Map<string, string>, cwd: string, write: Write): number {
6219
+ function cmdRecommend(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
5626
6220
  const task = options.get('_positional_0');
5627
6221
  if (!task) {
5628
6222
  write('dz recommend: task description required');
@@ -5632,17 +6226,41 @@ function cmdRecommend(options: Map<string, string>, cwd: string, write: Write):
5632
6226
 
5633
6227
  const registry = buildRegistry(cwd);
5634
6228
  const report = recommend(task, registry, cwd);
6229
+ if (flags.has('json')) {
6230
+ write(JSON.stringify(report, null, 2));
6231
+ return 0;
6232
+ }
5635
6233
 
5636
6234
  write(`\n╔══════════════════════════════════════════════════════════════╗`);
5637
6235
  write(`║ DZ RECOMMEND — Task Advisor ║`);
5638
6236
  write(`╠══════════════════════════════════════════════════════════════╣`);
5639
6237
  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)}║`);
6238
+ if (report.topicSource === 'task') {
6239
+ write(`║ Topics: ${report.topics.join(', ').slice(0, 50).padEnd(50)}║`);
6240
+ } else if (report.topicSource === 'project-stack') {
6241
+ write(`║ Topics: ${'not matched in the question'.padEnd(50)}║`);
6242
+ } else {
6243
+ write(`║ Topics: ${'not recognized — no recommendations'.padEnd(50)}║`);
6244
+ }
5642
6245
  write(`╠══════════════════════════════════════════════════════════════╣`);
5643
6246
 
6247
+ if (report.topicSource === 'project-stack') {
6248
+ write(`⚠ Тема запроса не распознана — подбор ниже сделан по СТЕКУ ПРОЕКТА, не по вашему вопросу.`);
6249
+ write(` (topic not recognized — recommendations reflect the project stack, not the question)`);
6250
+ write(`PROJECT-STACK SUGGESTIONS`);
6251
+ } else if (report.topicSource === 'none') {
6252
+ write(`Тема запроса не распознана; рекомендаций нет.`);
6253
+ write(`Переформулируйте задачу или используйте dz registry search <слово> / /skill-advisor.`);
6254
+ write(`╚══════════════════════════════════════════════════════════════╝`);
6255
+ return 0;
6256
+ }
6257
+
6258
+ const stackDerived = report.topicSource === 'project-stack';
6259
+
5644
6260
  if (report.presets.length > 0) {
5645
- write(`║ RECOMMENDED PRESETS ║`);
6261
+ write(stackDerived
6262
+ ? `║ PROJECT-STACK PRESETS ║`
6263
+ : `║ RECOMMENDED PRESETS ║`);
5646
6264
  for (const p of report.presets) {
5647
6265
  const matched = p.matchedSkills.length > 0 ? ` (${p.matchedSkills.slice(0, 3).join(', ')})` : '';
5648
6266
  write(`║ ${p.name.padEnd(15)} ${String(p.skills).padStart(2)} skills coverage: ${String(p.coverage).padStart(2)} topics${matched.padEnd(15)}║`);
@@ -5651,7 +6269,9 @@ function cmdRecommend(options: Map<string, string>, cwd: string, write: Write):
5651
6269
  }
5652
6270
 
5653
6271
  if (report.skills.length > 0) {
5654
- write(`║ RECOMMENDED SKILLS (top ${Math.min(report.skills.length, 8)})${' '.repeat(35)}║`);
6272
+ write(stackDerived
6273
+ ? `║ PROJECT-STACK SKILLS (top ${Math.min(report.skills.length, 8)})${' '.repeat(35)}║`
6274
+ : `║ RECOMMENDED SKILLS (top ${Math.min(report.skills.length, 8)})${' '.repeat(35)}║`);
5655
6275
  for (const s of report.skills.slice(0, 8)) {
5656
6276
  const desc = s.description.length > 35 ? s.description.slice(0, 32) + '...' : s.description;
5657
6277
  write(`║ ${s.id.padEnd(24)} ${desc.padEnd(36)}║`);
@@ -5660,7 +6280,9 @@ function cmdRecommend(options: Map<string, string>, cwd: string, write: Write):
5660
6280
  }
5661
6281
 
5662
6282
  if (report.toolkits.length > 0) {
5663
- write(`║ FULL PIPELINE (npx toolkits) ║`);
6283
+ write(stackDerived
6284
+ ? `║ PROJECT-STACK PIPELINE (npx toolkits) ║`
6285
+ : `║ FULL PIPELINE (npx toolkits) ║`);
5664
6286
  for (const tk of report.toolkits) {
5665
6287
  const desc = tk.description.length > 44 ? tk.description.slice(0, 41) + '...' : tk.description;
5666
6288
  write(`║ ${tk.name.padEnd(16)} ${desc.padEnd(44)}║`);
@@ -5671,13 +6293,17 @@ function cmdRecommend(options: Map<string, string>, cwd: string, write: Write):
5671
6293
  }
5672
6294
 
5673
6295
  write(`╠══════════════════════════════════════════════════════════════╣`);
5674
- write(`║ STEP-BY-STEP PLAN ║`);
6296
+ write(stackDerived
6297
+ ? `║ PROJECT-STACK PLAN ║`
6298
+ : `║ STEP-BY-STEP PLAN ║`);
5675
6299
  for (const step of report.plan) {
5676
6300
  const line = step.length > 60 ? step.slice(0, 57) + '...' : step;
5677
6301
  write(`║ ${line.padEnd(58)}║`);
5678
6302
  }
5679
6303
  write(`╠══════════════════════════════════════════════════════════════╣`);
5680
- write(`║ QUICK INSTALL ║`);
6304
+ write(stackDerived
6305
+ ? `║ PROJECT-STACK QUICK INSTALL ║`
6306
+ : `║ QUICK INSTALL ║`);
5681
6307
  const cmd = report.installCommand.length > 58 ? report.installCommand.slice(0, 55) + '...' : report.installCommand;
5682
6308
  write(`║ ${cmd.padEnd(58)}║`);
5683
6309
  write(`╚══════════════════════════════════════════════════════════════╝`);
@@ -6214,6 +6840,19 @@ function cmdPublish(options: Map<string, string>, flags: Set<string>, cwd: strin
6214
6840
  }
6215
6841
  }
6216
6842
 
6843
+ const filterStr = options.get('filter');
6844
+ // SAFETY: trim + drop empty segments (mirrors --select at the top of cmdInit).
6845
+ // Parse before the guard pre-flight so its packed-secret scan uses the SAME scoped package set
6846
+ // that publishPackages receives below; an empty resulting list remains an explicit error.
6847
+ let filter: string[] | undefined;
6848
+ if (filterStr !== undefined) {
6849
+ filter = filterStr.split(',').map((s) => s.trim()).filter((s) => s.length > 0);
6850
+ if (filter.length === 0) {
6851
+ write('dz publish: --filter requires a non-empty comma-separated list of package-name substrings');
6852
+ return 1;
6853
+ }
6854
+ }
6855
+
6217
6856
  // dz guard pre-flight (ADR-002 option A): publish is the most dangerous, least-reversible self-mutation, so
6218
6857
  // it ALWAYS runs the declarative guard first. A HARD violation refuses the publish; `--no-guard "<reason>"`
6219
6858
  // is the logged escape hatch (the override lands in .dz/guard-audit.jsonl — visible, never silent).
@@ -6225,7 +6864,7 @@ function cmdPublish(options: Map<string, string>, flags: Set<string>, cwd: strin
6225
6864
  write('dz publish: --no-guard requires a reason (it is logged): --no-guard "hotfix, guard re-run after"');
6226
6865
  return 1;
6227
6866
  }
6228
- const guardResult = runGuardEvaluation(guardRoot, 'publish', undefined, noGuard);
6867
+ const guardResult = runGuardEvaluation(guardRoot, 'publish', undefined, noGuard, filter);
6229
6868
  if (guardResult.verdict === 'block' && noGuard === undefined) {
6230
6869
  write('dz publish: ✗ BLOCKED by dz guard (HARD invariant violated):');
6231
6870
  for (const v of guardResult.violations.filter((x) => x.severity === 'hard')) write(` [BLOCK] ${v.rule}: ${v.detail}`);
@@ -6266,21 +6905,6 @@ function cmdPublish(options: Map<string, string>, flags: Set<string>, cwd: strin
6266
6905
  const claimCheckOpt = (claimCheckRaw as 'off' | 'warn' | 'error' | undefined) ?? 'warn';
6267
6906
 
6268
6907
  const bumpOnly = flags.has('bump-only');
6269
- const filterStr = options.get('filter');
6270
- // SAFETY: trim + drop empty segments (mirrors --select at the top of cmdInit).
6271
- // Without this, `--filter ""` (e.g. an unset shell var) or a stray comma yields
6272
- // [''] / ['', 'core'], and publishPackages matches with name.includes(''), which
6273
- // is true for EVERY package — silently turning a scoped publish into a
6274
- // whole-monorepo publish. An empty resulting list is an explicit error, never
6275
- // "match all".
6276
- let filter: string[] | undefined;
6277
- if (filterStr !== undefined) {
6278
- filter = filterStr.split(',').map((s) => s.trim()).filter((s) => s.length > 0);
6279
- if (filter.length === 0) {
6280
- write('dz publish: --filter requires a non-empty comma-separated list of package-name substrings');
6281
- return 1;
6282
- }
6283
- }
6284
6908
 
6285
6909
  // SAFETY: dry-run is the DEFAULT. A real publish requires an EXPLICIT opt-in
6286
6910
  // via --yes, --confirm, or --no-dry-run. Without one, we never bump or publish.
@@ -6474,6 +7098,13 @@ function cmdPublish(options: Map<string, string>, flags: Set<string>, cwd: strin
6474
7098
  if (pkg.claimCheck && pkg.claimCheck.findings > 0 && pkg.status !== 'error') {
6475
7099
  write(` ⚠ claim-check: ${pkg.claimCheck.findings} finding(s) (${pkg.claimCheck.high} high) in README.md`);
6476
7100
  }
7101
+ // A dry run stops before build/sign/pack, so it says NOTHING about the gates below that line.
7102
+ // Printing what it did not check is what keeps a clean preview from reading as a clean publish
7103
+ // (measured 2026-09-02: a clean dry run preceded a RED real gate).
7104
+ if (pkg.notVerified && pkg.notVerified.length > 0) {
7105
+ write(` ⓘ холостой прогон НЕ проверял (${pkg.notVerified.length}):`);
7106
+ for (const item of pkg.notVerified) write(` · ${item}`);
7107
+ }
6477
7108
  }
6478
7109
  return report.errors > 0 ? 1 : 0;
6479
7110
  }
@@ -7426,6 +8057,84 @@ function looksBinaryText(text: string): boolean {
7426
8057
  * per-file findings (each enriched with its `file`), and applies the exit-code contract.
7427
8058
  * `--json` ALWAYS emits valid JSON `{ok, findings, scanned}`, even on the failure path.
7428
8059
  */
8060
+ /**
8061
+ * `dz chain` — verify EVERY hash-chained journal in one command (W0-chain, backlog bc4ee35c).
8062
+ *
8063
+ * The machinery to verify a chain has worked for weeks. What was missing is the ABILITY TO ASK:
8064
+ * verification lived inside two consumers, each carrying its own hardcoded list of which files are
8065
+ * chained, so a journal could be given a chain and still be checked by nobody. Coverage here is
8066
+ * DERIVED from CHAINED_JOURNALS, never typed — adding a journal to the registry adds it to this
8067
+ * report by construction.
8068
+ *
8069
+ * An ABSENT journal is reported as `absent`, not omitted. Omission and cleanliness are
8070
+ * indistinguishable in a report, and that indistinguishability is how the original blind spot
8071
+ * survived; the same reason `broken` exits NON-ZERO rather than merely printing — a verifier that
8072
+ * reports damage and exits 0 is one no automation can act on, and this verb exists to run unattended.
8073
+ *
8074
+ * A journal that exists but carries NO chained records is `unchained`, which is legal (a log may
8075
+ * predate the chain) and therefore does not fail the command. Calling it a defect would train the
8076
+ * reader to ignore the output — the failure mode already measured once on the doctor's own line.
8077
+ */
8078
+ function cmdChain(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
8079
+ const root = options.get('project') ?? cwd;
8080
+ const journals = CHAINED_JOURNALS.map((journal) => {
8081
+ const path = join(root, journal.rel);
8082
+ if (!existsSync(path)) {
8083
+ return { rel: journal.rel, decides: journal.decides, status: 'absent' as const, chained: 0, defects: 0, detail: 'file not present' };
8084
+ }
8085
+ let text = '';
8086
+ try {
8087
+ text = readFileSync(path, 'utf-8');
8088
+ } catch {
8089
+ // Unreadable is NOT clean. It is the one outcome that must never be quietly folded into
8090
+ // "nothing to report": we did not look, so we know nothing.
8091
+ return { rel: journal.rel, decides: journal.decides, status: 'unreadable' as const, chained: 0, defects: 0, detail: 'file could not be read' };
8092
+ }
8093
+ const v = verifyEventChainText(text);
8094
+ if (v.chained === 0) {
8095
+ 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)' };
8096
+ }
8097
+ const total = text.split('\n').filter((l) => l.trim() !== '').length;
8098
+ const age = classifyChainDefects(v, total);
8099
+ if (v.ok) {
8100
+ 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)` };
8101
+ }
8102
+ // A break the current unbroken run has already outlived does not make TODAY's records unsound.
8103
+ // Reporting both alike is what made the doctor's equivalent line permanently red for four weeks.
8104
+ const historical = age.inRun.length === 0 && age.runRecords > 0;
8105
+ return {
8106
+ rel: journal.rel,
8107
+ decides: journal.decides,
8108
+ status: historical ? ('healed' as const) : ('broken' as const),
8109
+ chained: v.chained,
8110
+ defects: v.defects.length,
8111
+ detail: historical
8112
+ ? `${v.defects.length} defect(s), all BEFORE the current run — the last ${age.runRecords} record(s) are unbroken, so verdicts over those are sound`
8113
+ : `${v.defects.length} defect(s) with NO sound records after them: verdicts computed from this log are unsafe`,
8114
+ };
8115
+ });
8116
+
8117
+ const failed = journals.filter((j) => j.status === 'broken' || j.status === 'unreadable');
8118
+ const ok = failed.length === 0;
8119
+
8120
+ if (flags.has('json')) {
8121
+ write(JSON.stringify({ ok, root, journals }, null, 2));
8122
+ return ok ? 0 : 1;
8123
+ }
8124
+
8125
+ write(`dz chain — ${journals.length} registered journal(s) under ${root}`);
8126
+ write('');
8127
+ const MARK: Record<string, string> = { ok: '\u2713', healed: '\u2713', unchained: '\u00b7', absent: '\u00b7', broken: '\u2717', unreadable: '\u2717' };
8128
+ for (const j of journals) {
8129
+ write(` ${MARK[j.status] ?? '?'} ${j.rel} — ${j.status}`);
8130
+ write(` ${j.detail}`);
8131
+ write(` decides: ${j.decides}`);
8132
+ }
8133
+ write('');
8134
+ write(ok ? ' all registered journals are sound for present verdicts' : ` ${failed.length} journal(s) UNSAFE — see above`);
8135
+ return ok ? 0 : 1;
8136
+ }
8137
+
7429
8138
  function cmdClaimCheck(
7430
8139
  options: Map<string, string>,
7431
8140
  _optionLists: Map<string, string[]>,
@@ -8154,7 +8863,18 @@ function cmdAgentsSync(
8154
8863
  const effect = flags.has('check') ? 'AGENTS.md would change' : 'AGENTS.md was not rewritten';
8155
8864
  writeErr(`dz agents-sync: DRIFT — ${drifted.length} stale/missing section(s); ${effect}`);
8156
8865
  for (const finding of drifted) writeErr(` ${finding.id}: ${finding.file} (${finding.status})`);
8157
- if (drifted.some((finding) => finding.id === 'dz:policies')) {
8866
+ const unregistered = drifted.filter((finding) => finding.status === 'unregistered-section');
8867
+ if (unregistered.length > 0) {
8868
+ // ПОДСКАЗКА, ВЕДУЩАЯ НЕ ТУДА, ХУЖЕ ОТСУТСТВУЮЩЕЙ. Повторный `dz agents-sync` эту находку
8869
+ // НЕ лечит: реестр POLICY_SOURCES ведётся руками, и секция, не вписанная в него, не
8870
+ // попадёт в проекцию сколько ни синхронизируй. Раньше здесь печаталась общая подсказка —
8871
+ // читатель прогнал бы её и снова увидел ту же ошибку.
8872
+ writeErr('→ heal with: объяви секцию в POLICY_SOURCES (packages/@dzhechkov/harness-core/src/agents-policy.ts):');
8873
+ for (const finding of unregistered) {
8874
+ writeErr(` { id: '${finding.id}', file: '${finding.file}', heading: '…', why: '…', operativeClause: '…' }`);
8875
+ }
8876
+ writeErr(' затем: dz agents-sync');
8877
+ } else if (drifted.some((finding) => finding.id === 'dz:policies')) {
8158
8878
  writeErr('→ heal with: repair duplicate/unmatched dz:policies markers, then run dz agents-sync');
8159
8879
  } else {
8160
8880
  writeErr('→ heal with: dz agents-sync');
@@ -8245,8 +8965,11 @@ const DEFAULT_STORE_CAP = 5000;
8245
8965
  */
8246
8966
  const MAX_STUB_SCAN_FILES = 400;
8247
8967
 
8248
- /** Read the optional `.dz/guard.json` `{ rules?: [...], storeCap?: number, stubWaivers?: [...] }`. Missing/broken ⇒ defaults. */
8249
- function loadGuardConfig(root: string): { rules?: unknown[]; storeCap?: number; stubWaivers?: unknown[]; reviewRound?: { minGrade?: unknown } } {
8968
+ /** Maximum packed-file size read by the publish secret scan. */
8969
+ const SECRET_SCAN_MAX_BYTES = 512 * 1024;
8970
+
8971
+ /** Read the optional `.dz/guard.json` — `{ rules?, storeCap?, stubWaivers?, secretWaivers? }`. Missing/broken ⇒ defaults. */
8972
+ function loadGuardConfig(root: string): { rules?: unknown[]; storeCap?: number; stubWaivers?: unknown[]; secretWaivers?: unknown[]; reviewRound?: { minGrade?: unknown } } {
8250
8973
  const p = join(root, '.dz', 'guard.json');
8251
8974
  if (!existsSync(p)) return {};
8252
8975
  try {
@@ -8272,6 +8995,51 @@ function gatherReadmeCounts(root: string): { label: string; a: number; b: number
8272
8995
  // (target repo without sitedoc — missing-evidence contract). But a file that EXISTS and no longer
8273
8996
  // matches its anchored pattern emits a MISMATCH pair (a: -1) — silent non-extraction is the exact
8274
8997
  // disease this contour cures (AM-1 applies to the guard path too, not only the CI test).
8998
+ // ЗНАЧКИ ПРОТИВ ДЕРЕВА, а не только README против README (бэклог 9cb30764).
8999
+ //
9000
+ // Прежде правило сверяло только числа МЕЖДУ документами: два согласованных документа могли
9001
+ // хором утверждать одно и то же неверное число, и правило молчало. Значок «пакетов: 52» стоял
9002
+ // при 55 публикуемых на диске — ИЗМЕРЕНО 2026-09-03. Меню обещает 32 блюда, официант
9003
+ // перечисляет 30, кухня готовит 38, и никто в ресторане не знает правду.
9004
+ //
9005
+ // Сверяются только числа, ВЫЧИСЛИМЫЕ ИЗ ДЕРЕВА. Значок «опубликовано в npm» сюда НЕ входит и
9006
+ // это сказано вслух: его источник — реестр, а не рабочая копия, и пара, которая делает вид, что
9007
+ // проверила его, была бы хуже отсутствующей.
9008
+ const badge = (s: string, name: string): number | null => {
9009
+ const m = s.match(new RegExp(`img\\.shields\\.io/badge/${name}-(\\d+)`));
9010
+ return m && m[1] ? Number(m[1]) : null;
9011
+ };
9012
+ const publishablePackages = ((): number | null => {
9013
+ const dir = join(root, 'packages', '@dzhechkov');
9014
+ if (!existsSync(dir)) return null;
9015
+ let n = 0;
9016
+ for (const name of readdirSync(dir)) {
9017
+ const pj = join(dir, name, 'package.json');
9018
+ if (!existsSync(pj)) continue;
9019
+ try {
9020
+ const j = JSON.parse(readFileSync(pj, 'utf8')) as { private?: unknown };
9021
+ if (j.private !== true) n += 1;
9022
+ } catch { /* нечитаемый манифест — не считается ни в одну сторону */ }
9023
+ }
9024
+ return n;
9025
+ })();
9026
+ const pkgBadge = badge(rootMd, 'packages');
9027
+ if (pkgBadge !== null && publishablePackages !== null) {
9028
+ pairs.push({ label: 'packages (root badge vs publishable package.json on disk)', a: pkgBadge, b: publishablePackages });
9029
+ }
9030
+ const presetBadge = badge(rootMd, 'presets');
9031
+ if (presetBadge !== null) {
9032
+ pairs.push({ label: 'presets (root badge vs PRESET_NAMES in the build)', a: presetBadge, b: PRESET_NAMES.length });
9033
+ }
9034
+ const targetBadge = badge(rootMd, 'targets');
9035
+ if (targetBadge !== null) {
9036
+ pairs.push({ label: 'targets (root badge vs TARGET_NAMES in the build)', a: targetBadge, b: TARGET_NAMES.length });
9037
+ }
9038
+ const cmdBadge = badge(rootMd, 'CLI%20commands');
9039
+ if (cmdBadge !== null && cliAll !== null) {
9040
+ pairs.push({ label: 'commands (root badge vs cli All Commands)', a: cmdBadge, b: cliAll });
9041
+ }
9042
+
8275
9043
  const sitePair = (rel: string, re: RegExp, label: string): void => {
8276
9044
  if (cliAll === null || !existsSync(join(root, rel))) return;
8277
9045
  const found = num(read(rel), re);
@@ -8677,8 +9445,9 @@ function gatherVolumeShadowFacts(
8677
9445
  }
8678
9446
 
8679
9447
  /** Gather the facts one op needs. All I/O is best-effort — a missing signal skips its rule, never crashes. */
8680
- function gatherGuardFacts(op: string, root: string, text: string | undefined, storeCap: number): Record<string, unknown> {
9448
+ function gatherGuardFacts(op: string, root: string, text: string | undefined, storeCap: number, publishFilter?: readonly string[]): Record<string, unknown> {
8681
9449
  const facts: Record<string, unknown> = { op };
9450
+ const publishPackageRoots: string[] = [];
8682
9451
  if (op === 'publish') {
8683
9452
  // Advisory I/O: unreadable telemetry or fed state is absence of evidence, never a fabricated
8684
9453
  // stale finding and never a publish blocker.
@@ -8810,6 +9579,13 @@ function gatherGuardFacts(op: string, root: string, text: string | undefined, st
8810
9579
  } catch { /* not a git repo */ }
8811
9580
  const versionByName = new Map<string, string>();
8812
9581
  for (const m of manifests) if (m.name && typeof m.version === 'string') versionByName.set(m.name, m.version);
9582
+ publishPackageRoots.push(...located
9583
+ .filter(({ dir, m }) => m.private !== true && (
9584
+ publishFilter === undefined
9585
+ || publishFilter.length === 0
9586
+ || publishFilter.some((filter) => (m.name ?? '').includes(filter) || dir.includes(filter))
9587
+ ))
9588
+ .map(({ dir }) => dir));
8813
9589
  const pnpmWorkspace = existsSync(join(root, 'pnpm-workspace.yaml'));
8814
9590
  const packages: { name: string; deps: Record<string, string> }[] = [];
8815
9591
  for (const m of manifests) {
@@ -8823,6 +9599,82 @@ function gatherGuardFacts(op: string, root: string, text: string | undefined, st
8823
9599
  packages.push({ name: m.name ?? '(unnamed)', deps });
8824
9600
  }
8825
9601
  facts['packages'] = packages;
9602
+ // sibling-dep-protocol: сырые спеки, БЕЗ подстановки версии. Подставленная версия выглядела бы
9603
+ // как обычный диапазон, и правило потеряло бы ровно то, что проверяет.
9604
+ const siblingDeps: { name: string; field: string; dep: string; spec: string }[] = [];
9605
+ for (const m of manifests) {
9606
+ if (m.private === true) continue;
9607
+ const fields = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'] as const;
9608
+ for (const field of fields) {
9609
+ const table = (m as unknown as Record<string, unknown>)[field];
9610
+ if (typeof table !== 'object' || table === null) continue;
9611
+ for (const [dep, spec] of Object.entries(table as Record<string, unknown>)) {
9612
+ if (!dep.startsWith('@dzhechkov/') || typeof spec !== 'string') continue;
9613
+ siblingDeps.push({ name: m.name ?? '(unnamed)', field, dep, spec });
9614
+ }
9615
+ }
9616
+ }
9617
+ facts['siblingDeps'] = siblingDeps;
9618
+ // plugin-manifest-audit: каждый `.claude-plugin/plugin.json` в дереве. Обход ограничен по
9619
+ // глубине и не заходит в node_modules/dist — чужие манифесты не наши, и краснеть на них
9620
+ // значило бы отчитываться о том, чего мы не публикуем.
9621
+ const pluginManifests: { path: string; parseError?: string; name?: string; version?: string; description?: string }[] = [];
9622
+ const walkPlugins = (dir: string, depth: number): void => {
9623
+ if (depth > 4) return;
9624
+ let entries: Dirent[];
9625
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
9626
+ for (const e of entries) {
9627
+ // `out/` — СГЕНЕРИРОВАННОЕ публичное зеркало: те же манифесты, скопированные. Дефект в нём
9628
+ // есть дефект генератора, и он уже сообщается по источнику; вторая копия только удвоила бы
9629
+ // одну и ту же находку.
9630
+ if (!e.isDirectory() || e.name === 'node_modules' || e.name === 'dist' || e.name === 'out') continue;
9631
+ const full = join(dir, e.name);
9632
+ if (e.name === '.claude-plugin') {
9633
+ const manifest = join(full, 'plugin.json');
9634
+ if (!existsSync(manifest)) continue;
9635
+ try {
9636
+ const j = JSON.parse(readFileSync(manifest, 'utf8')) as Record<string, unknown>;
9637
+ const pick = (k: string): string | undefined => (typeof j[k] === 'string' ? j[k] as string : undefined);
9638
+ // Состав навыков — ОБЕ стороны инвентаризации, и ТОЛЬКО для коробки ОДНОГО ПАКЕТА.
9639
+ //
9640
+ // Корневая витрина сюда НЕ входит, и это не упрощение: её состав собирается из всего
9641
+ // монорепозитория через реестр, «что лежит на складе» для неё — не обход одного дерева,
9642
+ // а весь реестр, и ровно это уже проверяет `marketplace-parity` регенерацией. Первая
9643
+ // редакция этой проверки обошла корень с ограничением глубины и выдала 27 ЛОЖНЫХ
9644
+ // «объявлено, но не найдено» — навыки лежали глубже границы обхода (ИЗМЕРЕНО 2026-09-04,
9645
+ // поймано до коммита прогоном стража на этом же дереве).
9646
+ //
9647
+ // Сравниваются ПУТИ, как их объявил манифест, а не имена: два навыка с одинаковым
9648
+ // именем в разных подкаталогах — законная вещь, и сведение к имени их бы склеило.
9649
+ const boxRoot = dirname(full);
9650
+ const isRepoRoot = resolve(boxRoot) === resolve(root);
9651
+ const declaredSkills = Array.isArray(j['skills']) && !isRepoRoot
9652
+ ? (j['skills'] as unknown[]).filter((x): x is string => typeof x === 'string')
9653
+ .map((rel) => rel.replace(/^\.\//, '').replace(/\/+$/, '')).filter(Boolean)
9654
+ : undefined;
9655
+ const skillsOnDisk = declaredSkills === undefined ? undefined : findSkillDirs(boxRoot);
9656
+ pluginManifests.push({
9657
+ path: relative(root, manifest),
9658
+ ...(pick('name') !== undefined ? { name: pick('name')! } : {}),
9659
+ ...(pick('version') !== undefined ? { version: pick('version')! } : {}),
9660
+ ...(pick('description') !== undefined ? { description: pick('description')! } : {}),
9661
+ ...(declaredSkills !== undefined ? { declaredSkills } : {}),
9662
+ ...(skillsOnDisk !== undefined ? { skillsOnDisk } : {}),
9663
+ });
9664
+ } catch (error) {
9665
+ pluginManifests.push({
9666
+ path: relative(root, manifest),
9667
+ parseError: (error instanceof Error ? error.message : String(error)).replace(/\s+/g, ' ').slice(0, 200),
9668
+ });
9669
+ }
9670
+ continue;
9671
+ }
9672
+ if (e.name.startsWith('.')) continue;
9673
+ walkPlugins(full, depth + 1);
9674
+ }
9675
+ };
9676
+ walkPlugins(root, 0);
9677
+ facts['pluginManifests'] = pluginManifests;
8826
9678
  facts['volume'] = gatherVolumeShadowFacts(root, located.map(({ dir, m }) => ({
8827
9679
  dir,
8828
9680
  name: m.name ?? dir,
@@ -8850,7 +9702,43 @@ function gatherGuardFacts(op: string, root: string, text: string | undefined, st
8850
9702
  }
8851
9703
  facts['licenceHold'] = holds;
8852
9704
  } catch { /* unreadable tree — the rule reports nothing rather than inventing a violation */ }
8853
- try { facts['drift'] = sweepSkillDrift(root, { scope: 'installs', allowlist: readDriftAllowlist(root) }).drifted.map((d) => d.name); } catch { /* skip */ }
9705
+ try { facts['drift'] = sweepSkillDrift(root, { scope: DRIFT_SWEEP_SCOPE, allowlist: readDriftAllowlist(root) }).drifted.map((d) => d.name); } catch { /* skip */ }
9706
+ // backlog-covers-features: каталоги фич, дата их ПОЯВЛЕНИЯ В ИСТОРИИ (не mtime — его двигает
9707
+ // любой посторонний процесс), оговорки из README фичи и тексты записей бэклога. Базовая дата
9708
+ // делает правило зелёным на приходе: 336 существующих каталогов заведены до правила.
9709
+ // Нечитаемое дерево ⇒ факт НЕ выставляется ⇒ правило молчит, а не выдумывает вердикт.
9710
+ try {
9711
+ const featDir = join(root, 'features');
9712
+ if (existsSync(featDir)) {
9713
+ const slugs = readdirSync(featDir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
9714
+ const features = slugs.map((slug) => {
9715
+ let createdIso = '';
9716
+ try {
9717
+ createdIso = execSync(`git log --diff-filter=A --format=%aI -1 -- ${JSON.stringify('features/' + slug)}`,
9718
+ { cwd: root, encoding: 'utf-8' }).trim().split('\n').filter(Boolean).pop() ?? '';
9719
+ } catch { /* нет в истории — функция засчитает как новый, это верный дефолт */ }
9720
+ let waiver: string | undefined;
9721
+ for (const f of ['README.md', '07_code_changes/change_manifest.md']) {
9722
+ try {
9723
+ const m = readFileSync(join(featDir, slug, f), 'utf-8')
9724
+ .match(/^\s*Backlog:\s*не заведено\s*[—-]\s*(.+)$/m);
9725
+ if (m && m[1] && m[1].trim() !== '') { waiver = m[1].trim(); break; }
9726
+ } catch { /* нет файла — не оговорка */ }
9727
+ }
9728
+ return { slug, createdIso, waiver };
9729
+ });
9730
+ const backlogTexts: string[] = [];
9731
+ try {
9732
+ for (const line of readFileSync(join(root, '.dz', 'backlog', 'ideas.jsonl'), 'utf-8').split('\n')) {
9733
+ if (line.trim() === '') continue;
9734
+ try { const o = JSON.parse(line) as { text?: unknown }; if (typeof o.text === 'string') backlogTexts.push(o.text); } catch { /* рваная строка */ }
9735
+ }
9736
+ } catch { /* стора нет */ }
9737
+ if (backlogTexts.length > 0) {
9738
+ facts['featureBacklog'] = { baseline: BACKLOG_COVERAGE_BASELINE, features, backlogTexts };
9739
+ }
9740
+ }
9741
+ } catch { /* нечитаемо — правило молчит */ }
8854
9742
  facts['counts'] = gatherReadmeCounts(root);
8855
9743
  // readme-first: from the WORKING-TREE diff (publishes happen pre-commit here), per package: does the
8856
9744
  // change set contain its package.json (the version-bump signal) without its README.md?
@@ -9010,11 +9898,39 @@ function gatherGuardFacts(op: string, root: string, text: string | undefined, st
9010
9898
 
9011
9899
  // no-stubs config waivers: `.dz/guard.json` `stubWaivers: [{path, reason}]` — path-keyed, reason
9012
9900
  // MANDATORY (the feature-adr-setup --guards shape; the pure checker refuses a reasonless entry).
9013
- const stubWaivers = loadGuardConfig(root).stubWaivers;
9901
+ const guardConfig = loadGuardConfig(root);
9902
+ const stubWaivers = guardConfig.stubWaivers;
9014
9903
  if (Array.isArray(stubWaivers)) facts['stubWaivers'] = stubWaivers;
9904
+ const secretWaivers = guardConfig.secretWaivers;
9905
+ if (Array.isArray(secretWaivers)) facts['secretWaivers'] = secretWaivers;
9015
9906
  }
9016
9907
  if (op === 'consolidate') {
9017
- try { facts['drift'] = sweepSkillDrift(root, { scope: 'installs', allowlist: readDriftAllowlist(root) }).drifted.map((d) => d.name); } catch { /* skip */ }
9908
+ try { facts['drift'] = sweepSkillDrift(root, { scope: DRIFT_SWEEP_SCOPE, allowlist: readDriftAllowlist(root) }).drifted.map((d) => d.name); } catch { /* skip */ }
9909
+ }
9910
+ if (op === 'publish') {
9911
+ const secretTargets: { label: string; text: string }[] = [];
9912
+ let skipped = 0;
9913
+ for (const dir of publishPackageRoots) {
9914
+ const packageRoot = join(root, dir);
9915
+ let packed: string[];
9916
+ try { packed = listPackFiles(packageRoot); }
9917
+ catch { skipped++; continue; }
9918
+ for (const rel of packed) {
9919
+ const absolute = join(packageRoot, rel);
9920
+ try {
9921
+ const stat = lstatSync(absolute);
9922
+ if (!stat.isFile() || stat.size > SECRET_SCAN_MAX_BYTES) { skipped++; continue; }
9923
+ const content = readFileSync(absolute);
9924
+ if (content.subarray(0, 8 * 1024).includes(0)) { skipped++; continue; }
9925
+ secretTargets.push({
9926
+ label: relative(root, absolute).split(sep).join('/'),
9927
+ text: content.toString('utf8'),
9928
+ });
9929
+ } catch { skipped++; }
9930
+ }
9931
+ }
9932
+ if (secretTargets.length > 0) facts['secretTargets'] = secretTargets;
9933
+ if (skipped > 0) facts['secretScan'] = { skipped };
9018
9934
  }
9019
9935
  if (op === 'teach' || op === 'consolidate') {
9020
9936
  if (op === 'teach' && text) facts['secretTargets'] = [{ label: 'lesson', text }];
@@ -9030,13 +9946,13 @@ function gatherGuardFacts(op: string, root: string, text: string | undefined, st
9030
9946
  * shared by `dz guard check` and the `dz publish` pre-flight (ADR-002 option A) so they can never disagree.
9031
9947
  * `overrideReason` (when the caller forces through a block) is logged, never silent.
9032
9948
  */
9033
- function runGuardEvaluation(root: string, op: string, text: string | undefined, overrideReason: string | undefined): ReturnType<typeof evaluateGuard> {
9949
+ function runGuardEvaluation(root: string, op: string, text: string | undefined, overrideReason: string | undefined, publishFilter?: readonly string[]): ReturnType<typeof evaluateGuard> {
9034
9950
  const cfg = loadGuardConfig(root);
9035
9951
  // Number.isFinite, not just > 0: a config `storeCap: 1e400` parses to Infinity, passes `> 0`, and would
9036
9952
  // silently DISABLE the cap (count <= Infinity always). Non-finite ⇒ fall back to the default.
9037
9953
  const storeCap = typeof cfg.storeCap === 'number' && Number.isFinite(cfg.storeCap) && cfg.storeCap > 0 ? cfg.storeCap : DEFAULT_STORE_CAP;
9038
9954
  const rules = resolveRules(Array.isArray(cfg.rules) ? (cfg.rules as never[]) : undefined);
9039
- const facts = gatherGuardFacts(op, root, text, storeCap);
9955
+ const facts = gatherGuardFacts(op, root, text, storeCap, publishFilter);
9040
9956
  const result = evaluateGuard(facts as never, rules);
9041
9957
  // audit (append-only). ts is real time here (a CLI, not the sandboxed workflow).
9042
9958
  try {
@@ -9835,15 +10751,52 @@ async function cmdMrRakes(options: Map<string, string>, flags: Set<string>, cwd:
9835
10751
  * --threshold N drill threshold (default 2 — anti-noise: a first-seen rake accrues, never drills)
9836
10752
  * --no-teach drill only; do NOT write the store (skip the agent side)
9837
10753
  * --project <dir> pin the teach ledger
9838
- * --install-hook print the opt-in SessionEnd hook to add (non-destructive)
10754
+ * --install-hook print the opt-in hook set to add (non-destructive): Stop scan-tail +
10755
+ * PreCompact/SessionEnd full retro (feature narrated-error-must-be-taught)
10756
+ * --scan-tail per-turn Stop-hook mode: incremental admission-debt scan, O(new bytes) —
10757
+ * writes/clears .dz/retro-pending.json; no ledger, no teach, no git subprocess
10758
+ * --transcript <p> the transcript --scan-tail must read. Without it the Stop hook's own stdin
10759
+ * payload (`transcript_path`) is used; with neither, the scan REFUSES
10760
+ * (NOT-ESTABLISHED) rather than guessing the newest file on disk
9839
10761
  */
9840
- async function cmdRetro(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): Promise<number> {
10762
+ async function cmdRetro(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write, readStdin: () => string = () => ''): Promise<number> {
10763
+ if (flags.has('scan-tail')) {
10764
+ // Per-turn primary detector (ADR-001 D2). Cost budget IS the design: no `git rev-parse`
10765
+ // subprocess (hooks run with cwd = project root; CLAUDE_PROJECT_DIR pins it), no store open,
10766
+ // O(new bytes) via the persisted offset. Always exit 0 — a Stop hook must never fail a turn.
10767
+ const root = process.env['CLAUDE_PROJECT_DIR'] ?? cwd;
10768
+ // WHICH transcript. Round 3, P1-3: this used to fall back to `findLatestTranscript(root)`, so with
10769
+ // several sessions and their subagents alive at once it scanned whichever file had the newest
10770
+ // mtime — routinely another session's, advancing that session's offset and never seeing this
10771
+ // turn's admission. The Stop hook hands the exact path on stdin; with no path from any source the
10772
+ // answer is a stated refusal, never a guess. Still exit 0: a Stop hook must never fail a turn.
10773
+ const picked = resolveScanTailTranscript({
10774
+ flag: options.get('transcript'),
10775
+ positional: options.get('_positional_0'),
10776
+ stdin: readStdin(),
10777
+ });
10778
+ if (picked.path === null) {
10779
+ const reason = picked.reason ?? 'no transcript path';
10780
+ if (flags.has('json')) write(JSON.stringify({ status: 'not-established', source: 'none', reason, scannedBytes: 0, offset: 0 }));
10781
+ else write(`retro scan-tail: NOT-ESTABLISHED — ${reason}. Wire the hook as \`dz retro --scan-tail\` (Claude Code pipes the payload on stdin) or pass \`--transcript <path>\`.`);
10782
+ return 0;
10783
+ }
10784
+ const outcome = runRetroTailScan(join(root, '.dz'), picked.path);
10785
+ if (flags.has('json')) write(JSON.stringify({ ...outcome, source: picked.source }));
10786
+ else if (outcome.status === 'pending') write(`retro scan-tail: unpaid admission — .dz/retro-pending.json armed («${(outcome.snippet ?? '').slice(0, 60)}…»)`);
10787
+ return 0;
10788
+ }
10789
+
9841
10790
  let repoRoot = cwd;
9842
10791
  try { repoRoot = execSync('git rev-parse --show-toplevel', { cwd, encoding: 'utf-8' }).trim() || cwd; } catch { /* not git */ }
9843
10792
 
9844
10793
  if (flags.has('install-hook')) {
9845
- write('Add this opt-in SessionEnd hook to .claude/settings.json (runs a retro when a session ends):');
9846
- write(JSON.stringify({ hooks: { SessionEnd: [{ hooks: [{ type: 'command', command: 'dz retro' }] }] } }, null, 2));
10794
+ write('Add these opt-in hooks to .claude/settings.json (per-turn debt scan + retro at compaction AND session end — PreCompact covers the crash/disconnect sessions SessionEnd never sees):');
10795
+ write(JSON.stringify({ hooks: {
10796
+ Stop: [{ hooks: [{ type: 'command', command: 'dz retro --scan-tail', timeout: 10000, continueOnError: true }] }],
10797
+ PreCompact: [{ hooks: [{ type: 'command', command: 'dz retro', timeout: 60000, continueOnError: true }] }],
10798
+ SessionEnd: [{ hooks: [{ type: 'command', command: 'dz retro', timeout: 60000, continueOnError: true }] }],
10799
+ } }, null, 2));
9847
10800
  return 0;
9848
10801
  }
9849
10802
 
@@ -10080,6 +11033,43 @@ function cmdChallenge(options: Map<string, string>, flags: Set<string>, cwd: str
10080
11033
  * decide (dz's rule — a false gate kills trust). Exit code is 0 on a clean run regardless of verdict; 2 only on
10081
11034
  * a usage/setup error, so a caller distinguishes "gate ran" from "gate could not run".
10082
11035
  */
11036
+ /**
11037
+ * Есть ли в этом каталоге НЕЗАКОММИЧЕННЫЕ правки. Это и отличает «фича ещё в рабочем дереве»
11038
+ * (тогда `HEAD` — законная предфичевая база) от «фича уже закоммичена» (тогда `HEAD` её содержит).
11039
+ *
11040
+ * Не удалось спросить git — возвращается null, и вызывающий обязан считать положение НЕ
11041
+ * УСТАНОВЛЕННЫМ, а не выбрать удобный ответ.
11042
+ */
11043
+ /**
11044
+ * Каталоги с `SKILL.md` внутри коробки ОДНОГО пакета — то, что РЕАЛЬНО лежит на складе.
11045
+ * Возвращаются ПУТИ относительно коробки, как их объявляет манифест, а не имена.
11046
+ */
11047
+ function findSkillDirs(boxRoot: string): string[] {
11048
+ const found = new Set<string>();
11049
+ const walk = (dir: string, depth: number): void => {
11050
+ if (depth > 4) return;
11051
+ let entries: Dirent[];
11052
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
11053
+ for (const e of entries) {
11054
+ if (!e.isDirectory() || e.name === 'node_modules' || e.name === 'dist' || e.name.startsWith('.')) continue;
11055
+ const full = join(dir, e.name);
11056
+ if (existsSync(join(full, 'SKILL.md'))) found.add(relative(boxRoot, full));
11057
+ walk(full, depth + 1);
11058
+ }
11059
+ };
11060
+ walk(boxRoot, 0);
11061
+ return [...found];
11062
+ }
11063
+
11064
+ function hasUncommittedChangesIn(repoRoot: string, dir: string): boolean | null {
11065
+ try {
11066
+ const out = execSync(`git status --porcelain -- ${JSON.stringify(dir)}`, { cwd: repoRoot, encoding: 'utf-8' });
11067
+ return out.split('\n').some((line) => line.trim() !== '');
11068
+ } catch {
11069
+ return null;
11070
+ }
11071
+ }
11072
+
10083
11073
  function cmdDiscriminationCheck(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
10084
11074
  let repoRoot = cwd;
10085
11075
  try { repoRoot = execSync('git rev-parse --show-toplevel', { cwd, encoding: 'utf-8' }).trim() || cwd; } catch { /* not git */ }
@@ -10092,15 +11082,121 @@ function cmdDiscriminationCheck(options: Map<string, string>, flags: Set<string>
10092
11082
  const nameFilter = options.get('name');
10093
11083
  const propertyTests = testArg.split(',').map((s) => s.trim()).filter(Boolean).map((file) =>
10094
11084
  nameFilter !== undefined && nameFilter.trim() !== '' ? { file, name: nameFilter.trim() } : { file });
10095
- const baseRef = options.get('base') ?? 'HEAD';
11085
+ /**
11086
+ * УМОЛЧАНИЕ БАЗЫ РАЗЛИЧАЕТ ДВА РАЗНЫХ ПОЛОЖЕНИЯ, потому что они и правда разные.
11087
+ *
11088
+ * В штатном ходе конвейера правка Шага 7 ЕЩЁ НЕ ЗАКОММИЧЕНА, и тогда `HEAD` — настоящая
11089
+ * предфичевая база; так конвейер и зовёт гейт (`--base HEAD`, причина записана в его тексте).
11090
+ *
11091
+ * Но если фича УЖЕ ЗАКОММИЧЕНА, `HEAD` её содержит, и гейт сравнивает фичу С САМОЙ СОБОЙ.
11092
+ * ИЗМЕРЕНО 2026-09-04: такой прогон дал уверенное `NON_DISCRIMINATING` с советом «усилить тест»
11093
+ * на тестах, которые дискриминируют; тот же прогон с верной базой дал `DISCRIMINATES_VIA_ERROR`.
11094
+ * Находка выглядела как утверждение О ТЕСТАХ, и автор пошёл бы чинить исправное.
11095
+ *
11096
+ * Различить эти положения можно ДЕТЕРМИНИРОВАННО: есть ли незакоммиченные правки в пакете, чьи
11097
+ * тесты названы. Есть — `HEAD` законен и используется как раньше. Нет — база не установлена, и
11098
+ * гейт ОТКАЗЫВАЕТСЯ (exit 3), вместо того чтобы измерять относительно самого себя.
11099
+ */
11100
+ const explicitBase = options.get('base');
10096
11101
  const runnerOpt = options.get('runner');
10097
11102
  // R11: a hung runner is a loud non-answer, never a pass. Same default + parse shape as mutation-gate.
10098
11103
  const timeoutOpt = Number(options.get('timeout') ?? '300000');
10099
11104
  const timeoutMs = Number.isFinite(timeoutOpt) && timeoutOpt > 0 ? timeoutOpt : 300000;
10100
11105
 
10101
- const plan = planDiscriminationCheck(runnerOpt !== undefined ? { baseRef, propertyTests, runner: runnerOpt } : { baseRef, propertyTests });
11106
+ // Runner honesty (feature instrument-honesty, ADR-001): the runner is selected from the TARGET
11107
+ // package's own scripts.test, never from a global default. The package dir is the nearest
11108
+ // ancestor of the FIRST named test that carries a package.json — walked here, at the seam,
11109
+ // because the pure half deliberately takes the script text as data and never touches the fs.
11110
+ let packageTestScript: string | null = null;
11111
+ let packageDevDependencies: string[] = [];
11112
+ let packageDir = repoRoot;
11113
+ {
11114
+ const firstTest = propertyTests[0]?.file;
11115
+ // QE-1 (instrument-honesty, HIGH): this walk runs on the RAW --test argument, BEFORE the
11116
+ // engine's sanitation — a `../` traversal made it read an arbitrary package.json OUTSIDE the
11117
+ // repo and echo its scripts.test verbatim into the JSON output (MEASURED with a planted
11118
+ // marker file). Containment first: a start point outside the repo root never gets walked,
11119
+ // the script stays null, and the engine's own path sanitation then refuses the test path.
11120
+ const walkStart = firstTest !== undefined ? resolve(cwd, dirname(firstTest)) : undefined;
11121
+ if (firstTest !== undefined && walkStart !== undefined
11122
+ && (walkStart === resolve(repoRoot) || walkStart.startsWith(resolve(repoRoot) + sep))) {
11123
+ let probe = walkStart;
11124
+ // walk up to the repo root looking for package.json (bounded by the fs root either way)
11125
+ for (;;) {
11126
+ if (existsSync(join(probe, 'package.json'))) { packageDir = probe; break; }
11127
+ const parent = dirname(probe);
11128
+ if (parent === probe || probe === repoRoot) break;
11129
+ probe = parent;
11130
+ }
11131
+ try {
11132
+ const pkg = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf-8')) as {
11133
+ scripts?: Record<string, string>; devDependencies?: Record<string, string>;
11134
+ };
11135
+ packageTestScript = typeof pkg.scripts?.test === 'string' ? pkg.scripts.test : null;
11136
+ packageDevDependencies = Object.keys(pkg.devDependencies ?? {});
11137
+ } catch { /* unreadable package.json → selection falls through to the honest REFUSE */ }
11138
+ }
11139
+ }
11140
+ // The pure half's path sanitation expects a REPO-RELATIVE package dir ('.'-rooted), not an
11141
+ // absolute one — an absolute path is refused as unsafe-package-dir by design.
11142
+ const packageDirRel = relative(repoRoot, packageDir) || '.';
11143
+
11144
+ // Умолчание базы решается ЗДЕСЬ, потому что только здесь известен пакет, чьи тесты названы.
11145
+ let baseRef: string;
11146
+ if (explicitBase !== undefined && explicitBase.trim() !== '') {
11147
+ baseRef = explicitBase.trim();
11148
+ } else {
11149
+ const dirty = hasUncommittedChangesIn(repoRoot, packageDirRel);
11150
+ if (dirty !== true) {
11151
+ write('dz discrimination-check: NOT-ESTABLISHED — предфичевая база не установлена.');
11152
+ write(dirty === null
11153
+ ? ` Не удалось спросить git о состоянии ${packageDirRel}; выбирать удобный ответ вместо этого нельзя.`
11154
+ : ` В ${packageDirRel} нет незакоммиченных правок, значит фича УЖЕ в HEAD, и сравнение шло бы с самой собой.`);
11155
+ write(' Гейт сравнивает поведение ДО и ПОСЛЕ фичи; без базы сравнивать не с чем, а HEAD здесь');
11156
+ write(' дал бы уверенное NON_DISCRIMINATING на исправных тестах (ИЗМЕРЕНО 2026-09-04).');
11157
+ write(' Передайте --base <коммит перед фичей>, например `<sha коммита фичи>^`.');
11158
+ return 3;
11159
+ }
11160
+ // Штатный ход конвейера: правка Шага 7 ещё в рабочем дереве, HEAD — настоящая предфичевая база.
11161
+ baseRef = 'HEAD';
11162
+ write(`dz discrimination-check: база не задана; в ${packageDirRel} есть незакоммиченные правки, беру HEAD как предфичевую базу.`);
11163
+ }
11164
+
11165
+ // Провенанс базы едет в квитанцию вместе с самой базой: «явно указано» — утверждение о действии
11166
+ // человека, и писать его для HEAD, выбранного инструментом, значит подделывать происхождение
11167
+ // доказательства, на которое сошлются позже.
11168
+ const baseRefSupplied = explicitBase !== undefined && explicitBase.trim() !== '';
11169
+ const planInput = runnerOpt !== undefined
11170
+ ? { baseRef, baseRefSupplied, propertyTests, runner: runnerOpt, packageTestScript, packageDevDependencies, packageDir: packageDirRel }
11171
+ : { baseRef, baseRefSupplied, propertyTests, packageTestScript, packageDevDependencies, packageDir: packageDirRel };
11172
+ const plan = planDiscriminationCheck(planInput);
10102
11173
 
10103
11174
  if (!plan.runnable) {
11175
+ // QE-2 (instrument-honesty, MEDIUM): a runner REFUSE used to be reported through the generic
11176
+ // "no property test to check"/map-a-test framing — the operator-facing surface re-created the
11177
+ // exact "instrument gap misread as test gap" class ADR-001 names as the reason three duplicate
11178
+ // backlog entries existed. The plan's own named reason is the verdict; the generic classify
11179
+ // stays only for the genuinely-empty-target case.
11180
+ const runnerRefusal = typeof plan.reason === 'string' && plan.reason.startsWith('unsupported-runner');
11181
+ if (runnerRefusal) {
11182
+ const refusal = {
11183
+ aggregate: 'CANNOT_ISOLATE',
11184
+ measurementValid: false,
11185
+ primaryAction: plan.primaryAction ?? 'fix-runner-invocation',
11186
+ finding: {
11187
+ severity: 'high',
11188
+ verdict: 'CANNOT_ISOLATE',
11189
+ files: plan.targets.map((t) => t.file),
11190
+ detail: `runner refused: ${plan.reason} — the INSTRUMENT could not run, nothing was measured; `
11191
+ + `declare scripts.test in the target package (or pass --runner) and re-run. `
11192
+ + `This is NOT a statement about the tests.`,
11193
+ },
11194
+ };
11195
+ if (flags.has('json')) { write(JSON.stringify({ plan, results: [], perTest: [], ...refusal }, null, 2)); return 0; }
11196
+ write(`discrimination-check: REFUSED (${plan.reason})`);
11197
+ write(` → ${refusal.finding.detail}`);
11198
+ return 0;
11199
+ }
10104
11200
  // No safe target to run → this is the existing "property untested" finding (empty propertyTests classify).
10105
11201
  const result = classifyDiscrimination({ propertyTests: [], results: [] });
10106
11202
  if (flags.has('json')) { write(JSON.stringify({ plan, results: [], ...result }, null, 2)); return 0; }
@@ -10189,10 +11285,24 @@ function cmdDiscriminationCheck(options: Map<string, string>, flags: Set<string>
10189
11285
 
10190
11286
  // t.file + t.name already passed the engine's strict sanitation (no quotes/metacharacters/leading-dash);
10191
11287
  // still quote + `--` so a path can never be read as a runner option or split a word.
11288
+ // Runner honesty (ADR-001): the run executes FROM the target package dir with a
11289
+ // package-relative path — a root-cwd `npx vitest run packages/...` loads the ROOT config
11290
+ // (none) and reds unclassifiably, which is exactly the CANNOT_ISOLATE artifact this
11291
+ // feature removes. The plan's own commands encode the same cd; this body mirrors it.
11292
+ const pkgRel = plan.packageDir === '.' ? '' : plan.packageDir;
11293
+ const fileInPkg = pkgRel !== '' && t.file.startsWith(pkgRel + '/') ? t.file.slice(pkgRel.length + 1) : t.file;
11294
+ const execDirBase = pkgRel === '' ? worktree : join(worktree, pkgRel);
11295
+ const execDirTip = pkgRel === '' ? repoRoot : join(repoRoot, pkgRel);
10192
11296
  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);
11297
+ // NO `--` before the path: MEASURED 2026-09-02 — `npx vitest run -- 'file'` IGNORES the
11298
+ // filter and runs the whole suite (5269 tests), which is the exact whole-repo artifact
11299
+ // this feature removes (QE ha-intake-archive F5). The path is engine-sanitized (no
11300
+ // leading dash, no metacharacters), so it can never be read as an option.
11301
+ const cmd = `${runner}${nameArg} '${fileInPkg}'`;
11302
+ const base = runCapturedTest(cmd, execDirBase, timeoutMs);
11303
+ // The classifier's targetSeen is a substring probe: the run now prints PACKAGE-relative
11304
+ // paths, so it must be probed with the same form, or every hit reads as target-unseen.
11305
+ const evidence = classifyExecutionEvidence(base.output, base.exitCode, fileInPkg);
10196
11306
  const outcome = discriminationOutcomeOf(base.exitCode, evidence);
10197
11307
  const row: Record<string, unknown> = t.name !== undefined
10198
11308
  ? { file: t.file, name: t.name, outcome, evidence }
@@ -10204,8 +11314,8 @@ function cmdDiscriminationCheck(options: Map<string, string>, flags: Set<string>
10204
11314
  // base rows per the matrix; running it is cheap and only ever on an already-broken path.
10205
11315
  // Do NOT "simplify" this to evidenced-error-only — that silently breaks Confirmation 17.
10206
11316
  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);
11317
+ const tip = runCapturedTest(cmd, execDirTip, timeoutMs);
11318
+ const tipEvidence = classifyExecutionEvidence(tip.output, tip.exitCode, fileInPkg);
10209
11319
  row['tipOutcome'] = discriminationOutcomeOf(tip.exitCode, tipEvidence);
10210
11320
  row['tipEvidence'] = tipEvidence;
10211
11321
  // R15, named honestly: the base run is isolated in a worktree, but the tip runs in the LIVE
@@ -10329,9 +11439,9 @@ function nameFor(t: { file: string; name?: string }, outcome: 'pass' | 'fail' |
10329
11439
  *
10330
11440
  * Exit codes: 0 every entry PROVEN · 1 the gate ran and failed (undefended / not-applied /
10331
11441
  * 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
11442
+ * error (missing registry or an entry whose file RESOLVES outside the scratch copy: a
10334
11443
  * symlink escape is refused before anything is written, SPEC rule 3).
11444
+ * A RED/no-exit baseline is a measured failing verdict (exit 1), never a usage error.
10335
11445
  */
10336
11446
  /**
10337
11447
  * Route-a guard for `dz mutation-gate`: parse-check a MUTATED file as its own language BEFORE the
@@ -10340,7 +11450,14 @@ function nameFor(t: { file: string; name?: string }, outcome: 'pass' | 'fail' |
10340
11450
  * redness says nothing about the named protection. Returns `{error}` when a parser ran and the
10341
11451
  * text does not parse; `{skipped}` (reported loudly, never silently) when no parser is available.
10342
11452
  */
10343
- function parseCheckMutatedFile(absFile: string, text: string): { error?: string; skipped?: string } {
11453
+ interface MutationParseCheckResult {
11454
+ readonly error?: string;
11455
+ readonly skipped?: string;
11456
+ readonly internalFailureReason?: string;
11457
+ readonly internalAttempts?: ReturnType<typeof runWithOneInternalRetry>['attempts'];
11458
+ }
11459
+
11460
+ function parseCheckMutatedFile(absFile: string, text: string): MutationParseCheckResult {
10344
11461
  interface TsLike {
10345
11462
  transpileModule(t: string, o: { reportDiagnostics: boolean; compilerOptions: Record<string, unknown> }): { diagnostics?: { category: number; code: number; messageText: unknown }[] };
10346
11463
  flattenDiagnosticMessageText(m: unknown, s: string): string;
@@ -10364,17 +11481,34 @@ function parseCheckMutatedFile(absFile: string, text: string): { error?: string;
10364
11481
  try { JSON.parse(text); return {}; } catch (e) { return { error: String((e as Error).message).slice(0, 200) }; }
10365
11482
  }
10366
11483
  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) };
11484
+ const checked = runWithOneInternalRetry<MutationParseCheckResult>(() => {
11485
+ try {
11486
+ // `node --check` on the file IN PLACE, so the nearest package.json decides the module goal.
11487
+ execFileSync(process.execPath, ['--check', absFile], { stdio: 'pipe' });
11488
+ return {};
11489
+ } catch (e) {
11490
+ const err = e as { code?: unknown; status?: unknown; stderr?: Buffer | string; message?: string };
11491
+ // A launched parser that exits non-zero with a SyntaxError is a parse verdict. A child
11492
+ // launch/internal error (EPERM, ENOENT, Node's thrown internal) is runner infrastructure
11493
+ // and must take the bounded retry → INCONCLUSIVE route instead of masquerading as bad JS.
11494
+ if (typeof err.code === 'string' || typeof err.status !== 'number') throw e;
11495
+ const stderrLines = String(err.stderr ?? '').split('\n').map((line) => line.trim()).filter((line) => line !== '');
11496
+ const msg = [...stderrLines].reverse().find((line) => line.includes('Error'))
11497
+ ?? stderrLines.at(-1)
11498
+ ?? err.message
11499
+ ?? 'node --check failed';
11500
+ return { error: msg.slice(0, 200) };
11501
+ }
11502
+ });
11503
+ if (checked.value === null) {
11504
+ return {
11505
+ internalFailureReason: checked.failureReason ?? 'runner-internal-error: persistent after 2/2 attempts',
11506
+ internalAttempts: checked.attempts,
11507
+ };
10377
11508
  }
11509
+ return checked.internalRetries === 1
11510
+ ? { ...checked.value, internalAttempts: checked.attempts }
11511
+ : checked.value;
10378
11512
  }
10379
11513
  return { skipped: `no parser for '${ext}' files — parse-check unavailable` };
10380
11514
  } catch (e) {
@@ -10382,7 +11516,13 @@ function parseCheckMutatedFile(absFile: string, text: string): { error?: string;
10382
11516
  }
10383
11517
  }
10384
11518
 
10385
- function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
11519
+ function cmdMutationGate(
11520
+ options: Map<string, string>,
11521
+ flags: Set<string>,
11522
+ cwd: string,
11523
+ write: Write,
11524
+ injectedRunner?: MutationGateRunner,
11525
+ ): number {
10386
11526
  const json = flags.has('json');
10387
11527
  const fail = (what: string): number => {
10388
11528
  write(json ? JSON.stringify({ error: what, exitCode: 2 }) : `dz mutation-gate: ${what}`);
@@ -10453,6 +11593,11 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
10453
11593
  const results: MutationEntryResult[] = [];
10454
11594
  const observations: MutationObservation[] = [];
10455
11595
  const warnings: string[] = [];
11596
+ const internalRetries: {
11597
+ readonly phase: 'baseline' | 'parse-check' | 'mutation' | 'rebaseline' | 'final-rebaseline';
11598
+ readonly entryId?: string;
11599
+ readonly attempts: ReturnType<typeof runWithOneInternalRetry>['attempts'];
11600
+ }[] = [];
10456
11601
  let baseline: ReturnType<typeof classifyBaseline>;
10457
11602
  try {
10458
11603
  if (gitTop !== null && gitTop !== pkgDir && resolve(pkgDir).startsWith(resolve(gitTop) + sep)) {
@@ -10492,7 +11637,11 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
10492
11637
  const realScratchRoot = realpathSync(copyDir);
10493
11638
  const requireCompletionReceipt = parsed.registry.requireCompletionReceipt === true;
10494
11639
 
10495
- const runSuite = (): { exitCode: number | null; output: string; failureReason?: string } => {
11640
+ type SuiteRun = MutationGateRunnerObservation & { readonly internalAttemptLog?: string };
11641
+ const invokeSuite = (): MutationGateRunnerObservation => {
11642
+ if (injectedRunner !== undefined) {
11643
+ return injectedRunner(testCmd, { cwd: copyDir, timeoutMs: timeout });
11644
+ }
10496
11645
  const run = spawnSync(testCmd, {
10497
11646
  cwd: copyDir,
10498
11647
  shell: true,
@@ -10504,6 +11653,12 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
10504
11653
  const errorCode = run.error && 'code' in run.error && typeof run.error.code === 'string'
10505
11654
  ? run.error.code
10506
11655
  : undefined;
11656
+ // Node may populate both `error` and a numeric `status` for an internal spawn failure. The
11657
+ // error wins except for the two already-named resource observations: a status alongside
11658
+ // EPERM/Unreachable-code is not a suite verdict and takes the one-retry internal-error path.
11659
+ if (run.error !== undefined && errorCode !== 'ETIMEDOUT' && errorCode !== 'ENOBUFS') {
11660
+ throw run.error;
11661
+ }
10507
11662
  const signal = typeof run.signal === 'string' ? run.signal : undefined;
10508
11663
  let failureReason: string | undefined;
10509
11664
  if (typeof run.status !== 'number') {
@@ -10515,22 +11670,62 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
10515
11670
  }
10516
11671
  return {
10517
11672
  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
11673
  output: `${String(run.stdout ?? '')}\n${String(run.stderr ?? '')}`,
10521
11674
  ...(failureReason !== undefined ? { failureReason } : {}),
10522
11675
  };
10523
11676
  };
10524
11677
 
11678
+ const runSuite = (
11679
+ phase: 'baseline' | 'mutation' | 'rebaseline' | 'final-rebaseline',
11680
+ entryId?: string,
11681
+ ): SuiteRun => {
11682
+ const retried = runWithOneInternalRetry(invokeSuite);
11683
+ const loggedAttempts = retried.attempts.map((attempt) => {
11684
+ if (attempt.outcome !== 'completed' || retried.value === null) return attempt;
11685
+ const outcome = retried.value.exitCode === null
11686
+ ? `no exit code (${retried.value.failureReason ?? 'unnamed failure'})`
11687
+ : `exit ${retried.value.exitCode}`;
11688
+ return { ...attempt, detail: `attempt ${attempt.attempt}: completed — ${outcome}` };
11689
+ });
11690
+ if (retried.internalRetries === 1) {
11691
+ const record = entryId === undefined
11692
+ ? { phase, attempts: loggedAttempts }
11693
+ : { phase, entryId, attempts: loggedAttempts };
11694
+ internalRetries.push(record);
11695
+ if (!json) write(`mutation-gate: internal retry — ${loggedAttempts.map((attempt) => attempt.detail).join('; ')}`);
11696
+ }
11697
+ const internalAttemptLog = retried.internalRetries === 1
11698
+ ? loggedAttempts.map((attempt) => attempt.detail).join('; ')
11699
+ : undefined;
11700
+ if (retried.value !== null) {
11701
+ return {
11702
+ ...retried.value,
11703
+ ...(internalAttemptLog !== undefined ? { internalAttemptLog } : {}),
11704
+ };
11705
+ }
11706
+ return {
11707
+ exitCode: null,
11708
+ output: '',
11709
+ failureReason: retried.failureReason ?? 'runner-internal-error: persistent after 2/2 attempts',
11710
+ ...(internalAttemptLog !== undefined ? { internalAttemptLog } : {}),
11711
+ };
11712
+ };
11713
+
10525
11714
  // Baseline BEFORE any mutation: a red copy proves nothing, and reading it as a mutation
10526
11715
  // result would be this gate shipping the defect class it exists to catch.
10527
11716
  if (!json) write(`mutation-gate: baseline suite in scratch copy of ${pkgDir} …`);
10528
- const base = runSuite();
10529
- baseline = classifyBaseline(base.exitCode, base.failureReason);
11717
+ const base = runSuite('baseline');
11718
+ baseline = classifyBaseline(
11719
+ base.exitCode,
11720
+ base.failureReason,
11721
+ base.exitCode !== null && base.exitCode !== 0
11722
+ ? attributeBaselineRedness(base.output, entries.map((entry) => entry.file))
11723
+ : undefined,
11724
+ );
10530
11725
  if (!baseline.ok) {
10531
- if (json) { write(JSON.stringify({ packageDir: pkgDir, registryPath, testCommand: testCmd, baseline, results: [], exitCode: 2 }, null, 2)); return 2; }
11726
+ if (json) { write(JSON.stringify({ packageDir: pkgDir, registryPath, testCommand: testCmd, baseline, results: [], internalRetries, exitCode: 1 }, null, 2)); return 1; }
10532
11727
  write(renderMutationReport([], baseline, pkgDir));
10533
- return 2;
11728
+ return 1;
10534
11729
  }
10535
11730
 
10536
11731
  for (const entry of entries) {
@@ -10565,8 +11760,10 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
10565
11760
  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
11761
  }
10567
11762
  if (!json) write(`mutation-gate: ${entry.id} — mutating ${entry.file}, running suite …`);
10568
- let run: { exitCode: number | null; output: string; failureReason?: string } | null = null;
11763
+ let run: SuiteRun | null = null;
10569
11764
  let parseError: string | undefined;
11765
+ let parseInternalFailureReason: string | undefined;
11766
+ let parseInternalAttemptLog: string | undefined;
10570
11767
  try {
10571
11768
  writeFileSync(filePath, applied.text);
10572
11769
  // Route-a guard: the mutated file must still PARSE — a load failure reddens the whole
@@ -10576,10 +11773,16 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
10576
11773
  warnings.push(`${entry.id}: parse-check SKIPPED — ${check.skipped}`);
10577
11774
  if (!json) write(`mutation-gate: WARNING ${entry.id}: parse-check skipped — ${check.skipped}`);
10578
11775
  }
11776
+ if (check.internalAttempts !== undefined) {
11777
+ internalRetries.push({ phase: 'parse-check', entryId: entry.id, attempts: check.internalAttempts });
11778
+ parseInternalAttemptLog = check.internalAttempts.map((attempt) => attempt.detail).join('; ');
11779
+ if (!json) write(`mutation-gate: internal retry — ${parseInternalAttemptLog}`);
11780
+ }
11781
+ parseInternalFailureReason = check.internalFailureReason;
10579
11782
  if (check.error !== undefined) {
10580
11783
  parseError = check.error; // no suite run: the verdict is MUTATION_UNPARSEABLE regardless
10581
- } else {
10582
- run = runSuite();
11784
+ } else if (parseInternalFailureReason === undefined) {
11785
+ run = runSuite('mutation', entry.id);
10583
11786
  }
10584
11787
  } finally {
10585
11788
  writeFileSync(filePath, sourceText); // restore the COPY so the next entry starts pristine
@@ -10613,13 +11816,26 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
10613
11816
  // those verdicts outrank the rebaseline check, so the extra suite run would buy nothing.
10614
11817
  let rebaselineExitCode: number | null | undefined;
10615
11818
  let rebaselineFailureReason: string | undefined;
11819
+ let rebaselineAttribution: ReturnType<typeof attributeBaselineRedness> | undefined;
11820
+ let rebaselineInternalAttemptLog: string | undefined;
10616
11821
  if (rebaselineMode === 'per-entry' && run !== null && run.exitCode !== null && run.exitCode !== 0
10617
11822
  && fileLoadFailure === undefined && outputUnrecognised === undefined && receiptMismatch === undefined) {
10618
11823
  if (!json) write(`mutation-gate: ${entry.id} — re-baselining the restored tree …`);
10619
- const rebaselineRun = runSuite();
11824
+ const rebaselineRun = runSuite('rebaseline', entry.id);
10620
11825
  rebaselineExitCode = rebaselineRun.exitCode;
10621
11826
  rebaselineFailureReason = rebaselineRun.failureReason;
11827
+ rebaselineInternalAttemptLog = rebaselineRun.internalAttemptLog;
11828
+ if (rebaselineRun.exitCode !== null && rebaselineRun.exitCode !== 0) {
11829
+ rebaselineAttribution = attributeBaselineRedness(
11830
+ rebaselineRun.output,
11831
+ entries.map((candidate) => candidate.file),
11832
+ );
11833
+ }
10622
11834
  }
11835
+ const entryRunFailureReason = run?.failureReason ?? parseInternalFailureReason;
11836
+ const entryInternalAttemptLog = [parseInternalAttemptLog, run?.internalAttemptLog, rebaselineInternalAttemptLog]
11837
+ .filter((log): log is string => log !== undefined)
11838
+ .join('; ');
10623
11839
  const obs: MutationObservation = {
10624
11840
  entry,
10625
11841
  occurrences: 1,
@@ -10629,9 +11845,11 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
10629
11845
  ...(fileLoadFailure !== undefined ? { fileLoadFailure } : {}),
10630
11846
  ...(outputUnrecognised !== undefined ? { outputUnrecognised } : {}),
10631
11847
  ...(receiptMismatch !== undefined ? { receiptMismatch } : {}),
10632
- ...(run?.failureReason !== undefined ? { runFailureReason: run.failureReason } : {}),
11848
+ ...(entryRunFailureReason !== undefined ? { runFailureReason: entryRunFailureReason } : {}),
11849
+ ...(entryInternalAttemptLog !== '' ? { internalAttemptLog: entryInternalAttemptLog } : {}),
10633
11850
  ...(rebaselineExitCode !== undefined ? { rebaselineExitCode } : {}),
10634
11851
  ...(rebaselineFailureReason !== undefined ? { rebaselineFailureReason } : {}),
11852
+ ...(rebaselineAttribution !== undefined ? { rebaselineAttribution } : {}),
10635
11853
  };
10636
11854
  observations.push(obs);
10637
11855
  results.push(classifyMutationOutcome(obs));
@@ -10644,7 +11862,7 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
10644
11862
  // MUTATION_LOAD_FATAL / RECEIPT_MISMATCH untouched.
10645
11863
  if (rebaselineMode === 'final') {
10646
11864
  if (!json) write('mutation-gate: final re-baseline of the restored tree …');
10647
- const finalRun = runSuite();
11865
+ const finalRun = runSuite('final-rebaseline');
10648
11866
  const finalExit = finalRun.exitCode;
10649
11867
  if (finalExit !== 0) {
10650
11868
  const what = finalExit === null ? `no exit code: ${finalRun.failureReason ?? 'unknown timeout / spawn failure'}` : `exit ${finalExit}`;
@@ -10654,6 +11872,12 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
10654
11872
  ...obs,
10655
11873
  rebaselineExitCode: finalExit,
10656
11874
  ...(finalRun.failureReason !== undefined ? { rebaselineFailureReason: finalRun.failureReason } : {}),
11875
+ ...(finalRun.internalAttemptLog !== undefined
11876
+ ? { internalAttemptLog: [obs.internalAttemptLog, finalRun.internalAttemptLog].filter((log): log is string => log !== undefined).join('; ') }
11877
+ : {}),
11878
+ ...(finalExit !== null && finalExit !== 0
11879
+ ? { rebaselineAttribution: attributeBaselineRedness(finalRun.output, entries.map((entry) => entry.file)) }
11880
+ : {}),
10657
11881
  }));
10658
11882
  results.length = 0;
10659
11883
  results.push(...reclassified);
@@ -10669,7 +11893,7 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
10669
11893
 
10670
11894
  const exitCode = mutationGateExitCode(results, baseline.ok);
10671
11895
  if (json) {
10672
- write(JSON.stringify({ packageDir: pkgDir, registryPath, testCommand: testCmd, rebaselineMode, baseline, results, summary: summarizeMutationResults(results), warnings, exitCode }, null, 2));
11896
+ write(JSON.stringify({ packageDir: pkgDir, registryPath, testCommand: testCmd, rebaselineMode, baseline, results, summary: summarizeMutationResults(results), warnings, internalRetries, exitCode }, null, 2));
10673
11897
  return exitCode;
10674
11898
  }
10675
11899
  write(renderMutationReport(results, baseline, pkgDir));
@@ -11846,9 +13070,24 @@ function nameCheckScan(repoRoot: string): NameFacts {
11846
13070
  // Command names come from the dispatcher AND from the help block: a name that dispatches but
11847
13071
  // is undocumented is still taken, and so is the reverse.
11848
13072
  if (f.name === 'cli.ts') {
11849
- for (const c of dispatchedCommandsIn(text)) commands.add(c);
11850
- const help = /^\s{2}dz ([a-z][a-z0-9-]*)/gm;
11851
- for (let m = help.exec(text); m !== null; m = help.exec(text)) if (m[1] !== undefined) commands.add(m[1]);
13073
+ // ONE enumeration, every consumer derives (ADR-001, feature command-count-triad). This
13074
+ // scan's question is "is the name TAKEN?", so taken = dispatched ∪ documented, which is
13075
+ // legitimately LARGER than the canonical command count but it must be the SAME parse
13076
+ // the layer-1 parity test uses, not a second private regex that agrees by coincidence.
13077
+ // The any-indent `dispatchedCommandsIn` fallback stays for a cli.ts WITHOUT a main
13078
+ // `switch (command)` (none in this workspace today): a partial sweep would answer "free"
13079
+ // about a taken name, and that is the one answer this command may never give.
13080
+ let mainSwitchParsed = false;
13081
+ try {
13082
+ for (const c of dispatchedCommands(text)) commands.add(c);
13083
+ for (const c of documentedCommands(text)) commands.add(c);
13084
+ mainSwitchParsed = true;
13085
+ } catch { /* no main switch here — fall back to the broad regexes below */ }
13086
+ if (!mainSwitchParsed) {
13087
+ for (const c of dispatchedCommandsIn(text)) commands.add(c);
13088
+ const help = /^\s{2}dz ([a-z][a-z0-9-]*)/gm;
13089
+ for (let m = help.exec(text); m !== null; m = help.exec(text)) if (m[1] !== undefined) commands.add(m[1]);
13090
+ }
11852
13091
  }
11853
13092
  }
11854
13093
  }
@@ -11862,6 +13101,71 @@ function nameCheckScan(repoRoot: string): NameFacts {
11862
13101
  return { commands, modules, exports: exportsFound, scanned: { packages, files, exports: exportsFound.size, commands: commands.size } };
11863
13102
  }
11864
13103
 
13104
+ /**
13105
+ * `dz brief-check <файл>` — проверить бриф роя на контракт вывода (ADR-001 swarm-brief-output-contract).
13106
+ *
13107
+ * Разбирает объявления брифа как ДАННЫЕ и отказывает поимённо: «бриф неверен» не говорит автору,
13108
+ * что чинить, поэтому каждое нарушение называет ключ и причину.
13109
+ *
13110
+ * ЧЕСТНЫЙ ПРЕДЕЛ печатается ВМЕСТЕ С ЗЕЛЁНЫМ ответом: проверено, что бриф ОБЪЯВИЛ каталог и
13111
+ * единицы, а не что агент им последует. Зелёная проверка, читаемая как гарантия поведения, хуже
13112
+ * её отсутствия.
13113
+ */
13114
+ function cmdBriefCheck(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
13115
+ const json = flags.has('json');
13116
+ /**
13117
+ * ВЕТКА «ПРОВЕРИТЬ НЕ УДАЛОСЬ» ТОЖЕ ОБЯЗАНА ОТВЕТИТЬ JSON-ом (находка 11).
13118
+ *
13119
+ * `--json` печатал человеческую строку на несуществующем файле, и потребитель, читающий вывод как
13120
+ * JSON, получал ошибку разбора вместо структурного «не проверено». Признак `checked` — тот же
13121
+ * трихотомический вердикт, что и коды выхода, только для машины: не «бриф плох», а «мы про него
13122
+ * ничего не установили».
13123
+ */
13124
+ const unchecked = (reason: string, detail: string): number => {
13125
+ if (json) write(JSON.stringify({ ok: false, checked: false, reason, detail }));
13126
+ else write(detail);
13127
+ return 2;
13128
+ };
13129
+ // Только позиционный аргумент: `--file` был необъявленным псевдонимом, и страж дрейфа флагов
13130
+ // справедливо на него указал — лишняя поверхность, которой нет в справке.
13131
+ const file = options.get('_positional_0');
13132
+ if (file === undefined || file.trim() === '') {
13133
+ const code = unchecked('no-file', 'dz brief-check: name the brief file — dz brief-check <file> [--json]');
13134
+ if (!json) write(' A brief must declare: ' + SWARM_BRIEF_CONTRACT.map((c: { key: string }) => c.key).join(', '));
13135
+ return code;
13136
+ }
13137
+ let text: string;
13138
+ try {
13139
+ text = readFileSync(resolve(cwd, file), 'utf-8');
13140
+ } catch {
13141
+ // Нечитаемый файл — НЕ «бриф плох»: мы про него ничего не установили. Отдельный код выхода,
13142
+ // чтобы отказ прибора не смешивался с отказом брифа.
13143
+ return unchecked('unreadable', `dz brief-check: cannot read ${visibleText(file)}`);
13144
+ }
13145
+ const result = checkSwarmBrief(text);
13146
+ if (json) {
13147
+ // `checked: true` — вторая половина того же различителя: потребитель отличает «проверено и
13148
+ // отвергнуто» от «проверить не удалось» полем, а не отсутствием поля.
13149
+ write(JSON.stringify({ ...result, checked: true }));
13150
+ return result.ok ? 0 : 1;
13151
+ }
13152
+ if (result.ok) {
13153
+ // ЗНАЧЕНИЯ ИЗ БРИФА ОБЕЗВРЕЖИВАЮТСЯ И В ЗЕЛЁНОЙ СТРОКЕ (находка 10). Отказ их уже обезвредил,
13154
+ // но подделывается ровно эта строка: управляющая последовательность в имени каталога стирает
13155
+ // предыдущий вывод и печатает поверх него подделку.
13156
+ write(`dz brief-check: OK — dir ${visibleText(result.outputDir ?? '')}, ${result.units.length} unit(s), assembly "${visibleText(result.assemblyUnit ?? '')}"`);
13157
+ write(' LIMIT: this verifies the brief DECLARED the contract, not that the agent will follow it —');
13158
+ write(' only comparing the directory against the unit list on an orchestrator tick can show that.');
13159
+ write(' A filled-in template and an unedited one both pass: the parse cannot tell them apart.');
13160
+ return 0;
13161
+ }
13162
+ write(`dz brief-check: REFUSED — ${result.violations.length} violation(s)`);
13163
+ // Ядро уже обезвредило значения в причинах; повтор на слое печати — не суеверие, а граница:
13164
+ // печатающий слой не обязан знать, кто именно из его источников уже почистил текст.
13165
+ for (const v of result.violations) write(` ${v.rule}: ${visibleText(v.detail)}`);
13166
+ return 1;
13167
+ }
13168
+
11865
13169
  function cmdNameCheck(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
11866
13170
  const repoRoot = resolve(options.get('project') ?? cwd);
11867
13171
  const json = flags.has('json');
@@ -12382,6 +13686,13 @@ function cmdFeatureAdrRecord(options: Map<string, string>, flags: Set<string>, c
12382
13686
  targetExists: existsSync(target),
12383
13687
  targetHasPair: flags.has('once') && existsSync(target),
12384
13688
  timestamp: new Date().toISOString(),
13689
+ // WHO ran it: `--runner <id>` when the caller knows, otherwise this host. The workflow cannot
13690
+ // supply it — it has no host inside its sandbox — so the identity is resolved here, at the one
13691
+ // seam that runs outside. hostname() can throw on an exotic setup; an unresolvable runner stays
13692
+ // ABSENT rather than becoming the string 'unknown', which would later join as if it were one.
13693
+ runnerId: (options.get('runner') ?? '').trim() !== ''
13694
+ ? (options.get('runner') ?? '').trim()
13695
+ : (() => { try { return hostname(); } catch { return null; } })(),
12385
13696
  });
12386
13697
  if (decision.line === null) return emit(decision);
12387
13698
 
@@ -12738,12 +14049,24 @@ function cmdAmendmentCheck(options: Map<string, string>, flags: Set<string>, cwd
12738
14049
  // Paths in an amendment row are repo-relative, so they resolve against the repo root — not
12739
14050
  // against the feature directory, and not against wherever the caller happened to stand.
12740
14051
  const resolutions = resolveAmendments(rows, { readFile: (rel) => readOr(resolve(cwd, rel)) });
14052
+ // A document that opens `## Amendments` twice cannot have its first heading answer for the
14053
+ // rest (Codex round 6, P2). Counted per document and taken at its worst — one contradictory
14054
+ // input is enough to make the run inconclusive.
14055
+ const sectionCount = Math.max(
14056
+ ideation === null ? 0 : amendmentSectionCount(ideation),
14057
+ plan === null ? 0 : amendmentSectionCount(plan),
14058
+ );
12741
14059
  const decision = decideAmendmentOutcome({
12742
14060
  sectionPresent,
12743
14061
  rows,
12744
14062
  resolutions,
12745
14063
  planSaysNone: plan !== null && planSaysNoAmendments(plan),
12746
14064
  missingFromPlan,
14065
+ sectionCount,
14066
+ // Fail-closed: measured on the document the rows would have come from (the plan when it has
14067
+ // a section, otherwise the ideation report).
14068
+ ambiguity: (plan !== null ? amendmentDeclarationAmbiguity(plan) : null)
14069
+ ?? (ideation !== null ? amendmentDeclarationAmbiguity(ideation) : null),
12747
14070
  });
12748
14071
  return { slug, decision, resolutions };
12749
14072
  };
@@ -13777,6 +15100,145 @@ async function cmdQeBridge(options: Map<string, string>, flags: Set<string>, cwd
13777
15100
  }
13778
15101
 
13779
15102
 
15103
+ interface ScoreReceiptFile {
15104
+ readonly path: string;
15105
+ readonly displayPath: string;
15106
+ readonly qeHash: string;
15107
+ }
15108
+
15109
+ function scoreReceiptFiles(root: string): ScoreReceiptFile[] {
15110
+ const featuresDir = join(root, 'features');
15111
+ let features: Dirent[];
15112
+ try {
15113
+ features = readdirSync(featuresDir, { withFileTypes: true });
15114
+ } catch {
15115
+ return [];
15116
+ }
15117
+ const receipts: ScoreReceiptFile[] = [];
15118
+ for (const feature of features) {
15119
+ if (!feature.isDirectory()) continue;
15120
+ const stateDir = join(featuresDir, feature.name, '.fa-state');
15121
+ let entries: Dirent[];
15122
+ try {
15123
+ if (lstatSync(stateDir).isSymbolicLink()) continue;
15124
+ entries = readdirSync(stateDir, { withFileTypes: true });
15125
+ } catch {
15126
+ continue;
15127
+ }
15128
+ for (const entry of entries) {
15129
+ if (!entry.isFile()) continue;
15130
+ const match = /^score-(.+)\.json$/.exec(entry.name);
15131
+ if (match === null || match[1] === undefined || match[1] === '') continue;
15132
+ const path = join(stateDir, entry.name);
15133
+ receipts.push({ path, displayPath: relative(root, path), qeHash: match[1] });
15134
+ }
15135
+ }
15136
+ return receipts.sort((a, b) => a.displayPath < b.displayPath ? -1 : a.displayPath > b.displayPath ? 1 : 0);
15137
+ }
15138
+
15139
+ function scoreAggregateChainLine(text: string): {
15140
+ readonly line: string;
15141
+ readonly verification: ReturnType<typeof verifyEventChainText> | null;
15142
+ readonly defectAges: ReturnType<typeof classifyChainDefects> | null;
15143
+ } {
15144
+ if (text === '') {
15145
+ return { line: 'chain: NOT_PRESENT — no aggregate evidence file was created', verification: null, defectAges: null };
15146
+ }
15147
+ const verification = verifyEventChainText(text);
15148
+ const defectAges = classifyChainDefects(verification, verification.lines);
15149
+ const kinds = new Map<string, number>();
15150
+ for (const defect of verification.defects) kinds.set(defect.kind, (kinds.get(defect.kind) ?? 0) + 1);
15151
+ const kindText = [...kinds.entries()].map(([kind, count]) => `${kind}: ${count}`).join(' · ');
15152
+ const line =
15153
+ `chain: ${verification.ok ? 'OK' : 'FAILED'} · ${verification.chained} chained · ` +
15154
+ `${verification.resets} recorded restart(s) · before-run defects ${defectAges.beforeRun.length} · ` +
15155
+ `in-run defects ${defectAges.inRun.length} · current run ${defectAges.runRecords} record(s)` +
15156
+ (kindText === '' ? '' : ` · ${kindText}`) +
15157
+ ` — ${verification.scope}`;
15158
+ return { line, verification, defectAges };
15159
+ }
15160
+
15161
+ function cmdScoreAll(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
15162
+ const json = flags.has('json');
15163
+ if (options.has('slug')) {
15164
+ write(json
15165
+ ? JSON.stringify({ error: '--all and --slug are mutually exclusive', exitCode: 1 })
15166
+ : 'dz score: --all and --slug are mutually exclusive');
15167
+ return 1;
15168
+ }
15169
+ const root = resolve(cwd, options.get('project') ?? '.');
15170
+ const receiptFiles = scoreReceiptFiles(root);
15171
+ if (receiptFiles.length === 0) {
15172
+ const report = buildScoreAggregateReport([], [], 0);
15173
+ const chain = scoreAggregateChainLine('');
15174
+ if (json) write(JSON.stringify({ ...report, chain: null, aggregatePath: '.dz/feature-adr/scorecards.jsonl', exitCode: 0 }, null, 2));
15175
+ else {
15176
+ write(renderScoreAggregateReport(report));
15177
+ write(chain.line);
15178
+ }
15179
+ return 0;
15180
+ }
15181
+
15182
+ const ts = new Date().toISOString();
15183
+ const rows: ReturnType<typeof scoreReceiptToAggregateRow>[] = [];
15184
+ const unreadableReceipts: string[] = [];
15185
+ for (const receipt of receiptFiles) {
15186
+ try {
15187
+ rows.push(scoreReceiptToAggregateRow({
15188
+ content: readFileSync(receipt.path, 'utf8'),
15189
+ qeHash: receipt.qeHash,
15190
+ ts,
15191
+ }));
15192
+ } catch {
15193
+ unreadableReceipts.push(receipt.displayPath);
15194
+ }
15195
+ }
15196
+
15197
+ const storeDir = join(root, '.dz', 'feature-adr');
15198
+ const aggregatePath = join(storeDir, 'scorecards.jsonl');
15199
+ let finalText = '';
15200
+ let finalRows = rows;
15201
+ let appended = 0;
15202
+ let storeError: string | null = null;
15203
+ try {
15204
+ const result = withNamedLockSync(storeDir, 'scorecards', () => {
15205
+ let existingText = '';
15206
+ try {
15207
+ existingText = readFileSync(aggregatePath, 'utf8');
15208
+ } catch (error) {
15209
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
15210
+ }
15211
+ const fresh = dedupeScoreAggregateRows(rows, readScoreAggregateRows(existingText));
15212
+ const appendText = appendChainedLines(fresh, readTailInfo(existingText));
15213
+ if (appendText !== '') appendFileSync(aggregatePath, appendText, { encoding: 'utf8', mode: 0o600 });
15214
+ const settledText = existingText + appendText;
15215
+ return { text: settledText, rows: readScoreAggregateRows(settledText), appended: fresh.length };
15216
+ });
15217
+ finalText = result.text;
15218
+ finalRows = result.rows;
15219
+ appended = result.appended;
15220
+ } catch (error) {
15221
+ storeError = error instanceof Error ? error.message : String(error);
15222
+ }
15223
+
15224
+ const report = buildScoreAggregateReport(finalRows, unreadableReceipts, appended);
15225
+ const chain = scoreAggregateChainLine(finalText);
15226
+ if (json) {
15227
+ write(JSON.stringify({
15228
+ ...report,
15229
+ aggregatePath: '.dz/feature-adr/scorecards.jsonl',
15230
+ chain: chain.verification === null ? null : { verification: chain.verification, defectAges: chain.defectAges },
15231
+ storeError,
15232
+ exitCode: 0,
15233
+ }, null, 2));
15234
+ } else {
15235
+ write(renderScoreAggregateReport(report));
15236
+ write(chain.line);
15237
+ if (storeError !== null) write(`store error (nothing was claimed appended): ${storeError}`);
15238
+ }
15239
+ return 0;
15240
+ }
15241
+
13780
15242
  function cmdScore(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
13781
15243
  const json = flags.has('json');
13782
15244
  if (flags.has('help')) {
@@ -13784,13 +15246,14 @@ function cmdScore(options: Map<string, string>, flags: Set<string>, cwd: string,
13784
15246
  if (json) write(JSON.stringify({ help: usage, exitCode: 0 })); // --json stays ONE document even for help
13785
15247
  else {
13786
15248
  write(usage);
15249
+ write('dz score --all [--project <dir>] [--json] — sweep immutable score receipts into the append-only chained aggregate');
13787
15250
  write(' disciplines: ADR confirmation · discrimination · cross-model QE · live verification · README-first · learning loop · amendments');
13788
15251
  write(' descriptive-only, never a gate: a low score exits 0');
13789
15252
  }
13790
15253
  return 0;
13791
15254
  }
13792
15255
  for (const flag of flags) {
13793
- if (!new Set(['json', 'help']).has(flag)) {
15256
+ if (!new Set(['json', 'help', 'all']).has(flag)) {
13794
15257
  write(json ? JSON.stringify({ error: `unknown option --${flag}`, exitCode: 1 }) : `dz score: unknown option --${flag}\n allowed: --slug <feature>, --project <dir>, --json`);
13795
15258
  return 1;
13796
15259
  }
@@ -13802,6 +15265,7 @@ function cmdScore(options: Map<string, string>, flags: Set<string>, cwd: string,
13802
15265
  return 1;
13803
15266
  }
13804
15267
  }
15268
+ if (flags.has('all')) return cmdScoreAll(options, flags, cwd, write);
13805
15269
  const slug = options.get('slug') ?? '';
13806
15270
  // The delivery-check traversal lesson, upgraded to a WHITELIST: `.` slipped the blacklist and
13807
15271
  // silently aggregated the entire features/ tree as one "run" (Codex QE #2).
@@ -14915,7 +16379,9 @@ function cmdDeliveryCheck(options: Map<string, string>, flags: Set<string>, cwd:
14915
16379
  /* ------------------------------------------------------------------ */
14916
16380
 
14917
16381
  /** Thin dispatcher — ALL logic lives in harness-core/src/backlog.ts (05 architecture: handlers stay dumb). */
14918
- async function cmdBacklog(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): Promise<number> {
16382
+ async function cmdBacklog(
16383
+ options: Map<string, string>, flags: Set<string>, cwd: string, write: Write, writeErr: WriteErr,
16384
+ ): Promise<number> {
14919
16385
  const projectRoot = resolve(cwd, options.get('project') ?? '.');
14920
16386
  const json = flags.has('json');
14921
16387
  const sub = options.get('_positional_0');
@@ -14933,6 +16399,7 @@ async function cmdBacklog(options: Map<string, string>, flags: Set<string>, cwd:
14933
16399
  const eff = parseEffort(options.get('effort'), cfg.roulette.defaultEffort);
14934
16400
  if (eff.adjusted && !json && eff.note !== undefined) write(`dz backlog: ${eff.note}`);
14935
16401
  const dryRun = flags.has('dry-run');
16402
+ if (!dryRun && !allowLearningStoreWrite(projectRoot, flags, writeErr, 'dz backlog add')) return 1;
14936
16403
  // Embed-form migration (register-inflation fix): v1 vectors are FULL-TEXT embeds, v2 queries are
14937
16404
  // bounded excerpts — comparing across the forms is a query-vs-row space split. Re-mirror once
14938
16405
  // (idempotent upsert), before the dedup search. Dry-run writes nothing, so it only WARNS.
@@ -14961,6 +16428,7 @@ async function cmdBacklog(options: Map<string, string>, flags: Set<string>, cwd:
14961
16428
  const ideas = readIdeas(projectRoot);
14962
16429
  const match = ideas.find((i) => i.id === verdict.matchedId);
14963
16430
  let absorbErr: string | undefined;
16431
+ let didWrite = false;
14964
16432
  if (!dryRun && match !== undefined) {
14965
16433
  const snap = snapshotIdeas(projectRoot, join(projectRoot, '.dz', 'backlog', `ideas.pre-merge-${Date.now()}.jsonl`));
14966
16434
  if (snap.error !== undefined) return emitErr(snap.error);
@@ -14976,7 +16444,9 @@ async function cmdBacklog(options: Map<string, string>, flags: Set<string>, cwd:
14976
16444
  }).error;
14977
16445
  match.uses += 1;
14978
16446
  writeIdeas(projectRoot, ideas);
16447
+ didWrite = true;
14979
16448
  }
16449
+ if (didWrite) refreshLearningStoreMark(projectRoot, writeErr, 'dz backlog add');
14980
16450
  if (json) write(JSON.stringify({ action: 'duplicate', matchedId: verdict.matchedId, cosine: verdict.cosine, ...(verdict.containment !== undefined ? { containment: verdict.containment } : {}), ...(verdict.subsetMatch === true ? { subsetMatch: true } : {}), ...(topMatch !== undefined ? { topMatch } : {}), ...(eff.note !== undefined ? { effortNote: eff.note } : {}), ...(dryRun ? {} : { absorbedLogged: absorbErr === undefined, ...(absorbErr !== undefined ? { absorbedLogError: absorbErr } : {}) }), exitCode: 0 }, null, 2));
14981
16451
  else {
14982
16452
  const via = verdict.subsetMatch === true
@@ -15032,6 +16502,7 @@ async function cmdBacklog(options: Map<string, string>, flags: Set<string>, cwd:
15032
16502
  ideas.push(rec);
15033
16503
  writeIdeas(projectRoot, ideas);
15034
16504
  const mirror = await mirrorIdeaVector(projectRoot, rec); // best-effort — never blocks capture
16505
+ refreshLearningStoreMark(projectRoot, writeErr, 'dz backlog add');
15035
16506
  if (json) write(JSON.stringify({ action: verdict.action, idea: rec, related: verdict.relatedIds, ...(verdict.demoted !== undefined ? { demoted: verdict.demoted } : {}), ...(topMatch !== undefined ? { topMatch } : {}), ...(eff.note !== undefined ? { effortNote: eff.note } : {}), gitignore: ignore, exitCode: 0 }, null, 2));
15036
16507
  else {
15037
16508
  write(`dz backlog: ${verdict.action.toUpperCase()} — captured ${rec.id}`);
@@ -15198,8 +16669,18 @@ async function cmdBacklog(options: Map<string, string>, flags: Set<string>, cwd:
15198
16669
  if (commitId !== undefined) {
15199
16670
  // Validated above (safe id + known id) BEFORE any early return.
15200
16671
  const idx = ideas.findIndex((i) => i.id === commitId);
16672
+ // ОТМЕТКА ВРЕМЕНИ И ЖУРНАЛ ставятся ЗДЕСЬ, а не «где-нибудь потом».
16673
+ // Измерено 2026-09-02: `grep -c statusTs cli.ts` давал НОЛЬ — оба пути смены статуса в этом
16674
+ // файле меняли поле и не отмечали, когда. Отсюда 22 терминальные записи без отметки и
16675
+ // невычислимое «сколько идея пробыла в работе».
16676
+ const spinFrom = ideas[idx]!.status;
16677
+ const spinTs = new Date().toISOString();
15201
16678
  ideas[idx]!.status = 'in-progress';
16679
+ ideas[idx]!.statusTs = spinTs;
15202
16680
  writeIdeas(projectRoot, ideas);
16681
+ if (!appendTransition(projectRoot, { id: commitId, from: spinFrom, to: 'in-progress', ts: spinTs, by: 'backlog roulette --commit' })) {
16682
+ write('dz backlog: переход НЕ записан в журнал — наблюдение потеряно (сам статус изменён)');
16683
+ }
15203
16684
  pick = ideas[idx]!;
15204
16685
  committed = true;
15205
16686
  }
@@ -15263,6 +16744,7 @@ async function cmdBacklog(options: Map<string, string>, flags: Set<string>, cwd:
15263
16744
  if (mirror.mirrored > 0 && mirror.error === undefined && clearEmbedStale(projectRoot, report.id)) embed = 'ok';
15264
16745
  else embed = 'stale';
15265
16746
  } else embed = 'stale';
16747
+ refreshLearningStoreMark(projectRoot, writeErr, 'dz backlog edit');
15266
16748
  }
15267
16749
  if (json) {
15268
16750
  write(JSON.stringify({ verb: 'edit', ...report, embed, exitCode: report.ok ? (embed === 'stale' ? 1 : 0) : 1 }, null, 2));
@@ -15290,9 +16772,15 @@ async function cmdBacklog(options: Map<string, string>, flags: Set<string>, cwd:
15290
16772
  const goalMap = readGoalMap(projectRoot);
15291
16773
  const related = ideas.filter((i) => rec.relatedIds.includes(i.id));
15292
16774
  const staging = stageEnrichment(projectRoot, rec, related, goalMap);
16775
+ const enrichFrom = rec.status;
16776
+ const enrichTs = new Date().toISOString();
15293
16777
  rec.status = 'enriched';
16778
+ rec.statusTs = enrichTs;
15294
16779
  rec.enrichedPath = `features/${staging.slug}`;
15295
16780
  writeIdeas(projectRoot, ideas);
16781
+ if (!appendTransition(projectRoot, { id: rec.id, from: enrichFrom, to: 'enriched', ts: enrichTs, by: 'backlog enrich' })) {
16782
+ write('dz backlog: переход НЕ записан в журнал — наблюдение потеряно (сам статус изменён)');
16783
+ }
15296
16784
  if (json) write(JSON.stringify({ slug: staging.slug, scaffoldPath: staging.scaffoldPath, handoff: 'idea2prd-manual', exitCode: 0 }, null, 2));
15297
16785
  else {
15298
16786
  write(`dz backlog enrich: staged ${rec.id} → ${staging.scaffoldPath}`);
@@ -15332,6 +16820,10 @@ async function cmdBacklog(options: Map<string, string>, flags: Set<string>, cwd:
15332
16820
  if (form.action === 'migrated' && !json) write(`dz backlog: re-embedded ${form.remirrored} idea vector(s) into the bounded dedup embed form (v${form.version})`);
15333
16821
  else if (form.action === 'deferred' && !json) write(`dz backlog: ⚠ embed-form migration deferred (${form.error ?? 'unknown error'})`);
15334
16822
  const report = await harmonizeBacklog(projectRoot, { apply, ...(thr !== undefined ? { threshold: Number(thr) } : {}) });
16823
+ if (apply) {
16824
+ refreshLearningStoreMark(projectRoot, writeErr, 'dz backlog harmonize --apply');
16825
+ storeGuardResetReminder(projectRoot, writeErr, 'dz backlog harmonize --apply');
16826
+ }
15335
16827
  if (json) {
15336
16828
  write(JSON.stringify({ ...report, exitCode: 0 }, null, 2));
15337
16829
  return 0;
@@ -15582,17 +17074,19 @@ function cmdStats(cwd: string, write: Write): number {
15582
17074
  }
15583
17075
  const dirs = readdirSync(baseDir, { withFileTypes: true }).filter((e) => e.isDirectory());
15584
17076
  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
- }
17077
+ // Backlog e160aeee. This used to walk the tree ITSELF, and was wrong in two independent ways:
17078
+ // it counted only packages whose NAME starts with `skills-` (health-advisor, p-replicator,
17079
+ // keysarium and trip-planner were therefore invisible), and it knew only ONE of the three skill
17080
+ // layouts. Result: 203 here against 250 from `dz registry` on the same tree — two counters of one
17081
+ // quantity, each unable to refute the other because neither knew the other existed.
17082
+ //
17083
+ // The fix is structural, not arithmetic: there is now ONE enumerator, and both commands ask it.
17084
+ // Pinned by test/stats-registry-parity.test.ts, whose red half is this exact divergence.
17085
+ // The registry already PUBLISHES these totals; recomputing them from `entries` here would be a
17086
+ // third implementation of the same count, which is the very defect being fixed.
17087
+ const registry = buildRegistry(cwd);
17088
+ const totalSkills = registry.totalSkills;
17089
+ const skillPacks = registry.totalPacks;
15596
17090
  const targets = TARGET_NAMES.length;
15597
17091
  const presets = PRESET_NAMES.length;
15598
17092
  write(`dz stats — DZ Harness Hub`);
@@ -16065,7 +17559,31 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
16065
17559
  return version === 'unknown' ? 1 : 0;
16066
17560
  }
16067
17561
 
16068
- if (command === '' || command === 'help' || flags.has('help')) {
17562
+ // `-h` is the most-typed help flag and is NOT a command: before the unknown-command contract
17563
+ // landed it fell through to the switch and still printed usage; afterwards it would have died
17564
+ // with exit 2 and an empty stdout (measured regression, cross-model QE M1). It belongs beside
17565
+ // `-v` above — an argv-level flag, resolved before command dispatch.
17566
+ if (argv[0] === '-h') {
17567
+ write(USAGE);
17568
+ return 0;
17569
+ }
17570
+ // A bare `--typo` leaves the command empty, so the usage branch reported SUCCESS on a misspelled
17571
+ // FLAG exactly as it used to on a misspelled VERB (cross-model QE M2): `dz --frobnicate` exited 0
17572
+ // with 30 KB of usage. The refusal is deliberately narrowed to the no-command case, because the
17573
+ // warn-don't-refuse decision above is measured and still stands: with a command present, an
17574
+ // unrecognised name may simply be missing from KNOWN_CLI_FLAGS and refusing would break working
17575
+ // invocations. With NO command there is nothing the flag could belong to, so it is a usage error.
17576
+ if (command === '') {
17577
+ const strayNames = unknownFlagNotice(
17578
+ [...flags, ...options.keys()].filter((k) => !k.startsWith('_positional_')),
17579
+ KNOWN_CLI_FLAGS,
17580
+ ).map((n) => n.name);
17581
+ if (strayNames.length > 0) {
17582
+ writeErr(`dz: unknown option --${strayNames[0]} — run 'dz help' for usage`);
17583
+ return 2;
17584
+ }
17585
+ }
17586
+ if (command === '' || command === 'help' || (flags.has('help') && DZ_COMMANDS.includes(command))) {
16069
17587
  write(USAGE);
16070
17588
  return 0;
16071
17589
  }
@@ -16123,17 +17641,29 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
16123
17641
  io.teachReinforceRunner ?? runTeachGuardReinforcement,
16124
17642
  );
16125
17643
  case 'consolidate':
16126
- return await cmdConsolidate(options, flags, cwd, write);
17644
+ return await cmdConsolidate(options, flags, cwd, write, writeErr);
16127
17645
  case 'recall':
16128
17646
  return await cmdRecall(options, flags, cwd, write, writeErr, io.classMatcher);
16129
17647
  case 'vector':
16130
- return await cmdVector(options, flags, cwd, write);
17648
+ return await cmdVector(options, flags, cwd, write, writeErr);
16131
17649
  case 'brain':
16132
17650
  return await cmdBrain(options, flags, cwd, write, readStdin);
16133
17651
  case 'statusline':
16134
- return cmdStatusline(options, flags, cwd, write, readStdin);
17652
+ return cmdStatusline(options, flags, cwd, write, readStdin, writeErr);
17653
+ case 'store-guard':
17654
+ return await cmdStoreGuard(
17655
+ options,
17656
+ flags,
17657
+ cwd,
17658
+ write,
17659
+ writeErr,
17660
+ io.stdin,
17661
+ io.interactive ?? process.stdin.isTTY === true,
17662
+ );
16135
17663
  case 'usage':
16136
17664
  return cmdUsage(options, optionLists, flags, cwd, write);
17665
+ case 'chain':
17666
+ return cmdChain(options, flags, cwd, write);
16137
17667
  case 'claim-check':
16138
17668
  return cmdClaimCheck(options, optionLists, flags, cwd, write);
16139
17669
  case 'lint':
@@ -16155,7 +17685,7 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
16155
17685
  case 'diff':
16156
17686
  return cmdDiff(options, cwd, write);
16157
17687
  case 'recommend':
16158
- return cmdRecommend(options, cwd, write);
17688
+ return cmdRecommend(options, flags, cwd, write);
16159
17689
  case 'upgrade':
16160
17690
  return cmdUpgrade(options, flags, cwd, write, writeErr);
16161
17691
  case 'auto-canonicalize':
@@ -16197,15 +17727,27 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
16197
17727
  case 'mr-rakes':
16198
17728
  return await cmdMrRakes(options, flags, cwd, write);
16199
17729
  case 'retro':
16200
- return await cmdRetro(options, flags, cwd, write);
17730
+ return await cmdRetro(options, flags, cwd, write, readStdin);
16201
17731
  case 'feature-adr-setup':
16202
17732
  return cmdFeatureAdrSetup(options, flags, cwd, write, writeErr);
16203
17733
  case 'challenge':
16204
17734
  return cmdChallenge(options, flags, cwd, write);
16205
17735
  case 'discrimination-check':
16206
17736
  return cmdDiscriminationCheck(options, flags, cwd, write);
16207
- case 'mutation-gate':
16208
- return cmdMutationGate(options, flags, cwd, write);
17737
+ case 'mutation-gate': {
17738
+ try {
17739
+ return cmdMutationGate(options, flags, cwd, write, io.mutationGateRunner);
17740
+ } catch (error) {
17741
+ const raw = error instanceof Error ? error.message : String(error);
17742
+ const head = Array.from(raw.split(/\r?\n/, 1)[0]?.trim() || 'unknown internal error').slice(0, 160).join('');
17743
+ if (flags.has('json')) {
17744
+ write(JSON.stringify({ verdict: 'INCONCLUSIVE', reason: 'runner-internal-error', error: head, exitCode: 1 }));
17745
+ } else {
17746
+ write(`mutation-gate: INTERNAL ERROR (${head}) — verdict INCONCLUSIVE, exit 1`);
17747
+ }
17748
+ return 1;
17749
+ }
17750
+ }
16209
17751
  case 'delivery-check':
16210
17752
  return cmdDeliveryCheck(options, flags, cwd, write);
16211
17753
  case 'skills-verify':
@@ -16230,6 +17772,8 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
16230
17772
  return cmdTgPost(options, flags, cwd, write);
16231
17773
  case 'name-check':
16232
17774
  return cmdNameCheck(options, flags, cwd, write);
17775
+ case 'brief-check':
17776
+ return cmdBriefCheck(options, flags, cwd, write);
16233
17777
  case 'provenance-check':
16234
17778
  return cmdProvenanceCheck(options, flags, cwd, write);
16235
17779
  case 'feature-adr-record':
@@ -16247,7 +17791,7 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
16247
17791
  case 'qe-bridge':
16248
17792
  return await cmdQeBridge(options, flags, cwd, write);
16249
17793
  case 'backlog':
16250
- return await cmdBacklog(options, flags, cwd, write);
17794
+ return await cmdBacklog(options, flags, cwd, write, writeErr);
16251
17795
  case 'routing':
16252
17796
  return cmdRouting(options, flags, cwd, write);
16253
17797
  case 'bto-optimize':
@@ -16259,9 +17803,8 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
16259
17803
  case 'import-ecc':
16260
17804
  return await cmdImportEcc(options, flags, cwd, write);
16261
17805
  default:
16262
- write(`dz: unknown command ${JSON.stringify(command)}`);
16263
- write(USAGE);
16264
- return 1;
17806
+ writeErr(`dz: unknown command ${JSON.stringify(command)} — run 'dz help' for the command list`);
17807
+ return 2;
16265
17808
  }
16266
17809
  } catch (error) {
16267
17810
  // stderr, not stdout: an uncaught failure is a diagnostic, and routing it through