@dzhechkov/harness-cli 0.8.10 → 0.8.16

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,6 +11,7 @@ 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
17
  import { homedir, hostname, tmpdir } from 'node:os';
@@ -146,8 +147,17 @@ import {
146
147
  reindexVectorStore,
147
148
  harmonizeVectorStore,
148
149
  importRvfCheckpoint,
150
+ renderFeatureAdrPhaseLine,
149
151
  statuslineData,
152
+ countLearningStoreRowsReadonly,
153
+ readStoreMark,
154
+ writeStoreMark,
155
+ resetStoreMark,
156
+ checkStoreHealth,
157
+ storeGuardPath,
158
+ storeSnapshotPath,
150
159
  writeFeatureAdrState,
160
+ writeFeatureAdrStateDetailed,
151
161
  CHECKPOINT_STAGES,
152
162
  estimateEta,
153
163
  extractStageSamples,
@@ -157,6 +167,9 @@ import {
157
167
  type CheckpointStage,
158
168
  type EtaEstimate,
159
169
  type FeatureAdrState,
170
+ type StoreHealth,
171
+ type StoreMark,
172
+ type StoreCountSnapshot,
160
173
  type RunSegment,
161
174
  type StageSample,
162
175
  computeUsage,
@@ -187,6 +200,9 @@ import {
187
200
  patternRecordId,
188
201
  patternIdentityOf,
189
202
  mergeLessonMatchedForms,
203
+ SWARM_BRIEF_CONTRACT,
204
+ checkSwarmBrief,
205
+ visibleText,
190
206
  loadStoreRecords,
191
207
  recordToPattern,
192
208
  bundleSkills,
@@ -219,6 +235,7 @@ import {
219
235
  resolveTrustRoot,
220
236
  decideVerifyPolicy,
221
237
  generateSigningKeypair,
238
+ appendTransition,
222
239
  evaluateGuard,
223
240
  resolveRules,
224
241
  auditRecord,
@@ -259,6 +276,7 @@ import {
259
276
  isInsideTree,
260
277
  signManifest,
261
278
  verifyManifest,
279
+ listPackFiles,
262
280
  listSignablePackFiles,
263
281
  assertKeyOutsideTree,
264
282
  decidePublishGate,
@@ -293,12 +311,14 @@ import {
293
311
  DEFAULT_RAKE_THRESHOLDS,
294
312
  streamSessionEvents,
295
313
  findLatestTranscript,
314
+ resolveScanTailTranscript,
296
315
  detectProcessRakes,
297
316
  buildRetro,
298
317
  renderRetro,
299
318
  retroLessonText,
300
319
  PROCESS_SIGNATURES,
301
320
  RETRO_DOMAIN,
321
+ runRetroTailScan,
302
322
  scanForSetup,
303
323
  buildSetupPlan,
304
324
  scaffoldFromSpec,
@@ -507,6 +527,8 @@ import {
507
527
  planImport,
508
528
  decideCheckpointWrite,
509
529
  amendmentSection,
530
+ amendmentSectionCount,
531
+ amendmentDeclarationAmbiguity,
510
532
  planSaysNoAmendments,
511
533
  parseAmendments,
512
534
  resolveAmendments,
@@ -569,11 +591,36 @@ import type { ProvenanceMode, PackVerdict, ClaudeUsageModel, PatternRecord, Reca
569
591
  import { getPreset, PRESET_NAMES } from '@dzhechkov/harness-presets';
570
592
  import { scanGitHub, analyzeRepo, generateReport, deepAnalyze, scanAllSources, ScoutMemory } from '@dzhechkov/scout';
571
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
+
572
619
  /** Literal command inventory, pinned against the main dispatch switch by a layer-1 test. */
573
620
  export const DZ_COMMANDS: readonly string[] = [
574
621
  'init', 'verify', 'sync', 'update', 'list', 'create-skill', 'info', 'scout',
575
622
  'workflow', 'workflow-lint', 'workflow-trace', 'migrate', 'doctor', 'install',
576
- 'bundle', 'teach', 'consolidate', 'recall', 'vector', 'brain', 'statusline',
623
+ 'bundle', 'teach', 'consolidate', 'recall', 'vector', 'brain', 'statusline', 'store-guard',
577
624
  'usage', 'claim-check', 'lint', 'sign', 'sbom', 'guard', 'verify-pack', 'setup',
578
625
  'pretrain', 'compose', 'diff', 'recommend', 'upgrade', 'auto-canonicalize',
579
626
  'publish', 'release', 'parity', 'registry', 'benchmark', 'mcp-scan',
@@ -582,7 +629,7 @@ export const DZ_COMMANDS: readonly string[] = [
582
629
  'retro', 'feature-adr-setup', 'challenge', 'discrimination-check',
583
630
  'mutation-gate', 'delivery-check', 'skills-verify', 'compounding', 'deadwood',
584
631
  'epoch-replay', 'score', 'recap', 'cadence', 'qe-rounds', 'restart-advisor', 'tg-post',
585
- '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',
586
633
  'feature-adr-checkpoint', 'profile', 'reqe', 'qe-bridge', 'backlog', 'routing',
587
634
  'bto-optimize', 'dashboard', 'roam', 'import-ecc', 'chain',
588
635
  ];
@@ -633,13 +680,14 @@ Usage:
633
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)
634
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)
635
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)
636
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)
637
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)
638
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)
639
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)
640
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)
641
689
  dz sbom [--pack <name>] [--out <file>] (CycloneDX software bill of materials for the workspace, or for one pack with --pack)
642
- 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)
643
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)
644
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)
645
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)
@@ -647,7 +695,7 @@ Usage:
647
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)
648
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)
649
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)
650
- dz backlog add "<idea>" [--effort 1-5] [--proposal <text>] [--dry-run] [--project <dir>] [--json] (capture an idea: semantic dedup against existing ideas via the Brain vector engine (DUPLICATE>=0.92 merges, RELATED links, NEW creates) + GoalMap alignment; --dry-run classifies without writing)
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)
651
699
  dz backlog list [--status <s>] [--goal <id>] [--project <dir>] [--json] (list captured ideas, filterable by status/goal)
652
700
  dz backlog show <id> [--project <dir>] [--json] (full record for one idea)
653
701
  dz backlog goals [--validate] [--project <dir>] [--json] (list/validate the compass at .dz/backlog/goals.json)
@@ -661,7 +709,7 @@ Usage:
661
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)
662
710
  dz backlog harmonize [--apply] [--threshold <0-1>] [--project <dir>] [--json] (batch semantic dedup of the backlog ideas; --dry-run default, --apply snapshots first)
663
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)
664
- 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)
665
713
  dz teach --from-json <file> [--project <dir>] [--no-mirror] (bulk-import a 'dz recall --all --json' export — share a learned store across machines)
666
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)
667
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)
@@ -681,7 +729,8 @@ Usage:
681
729
  dz brain expand <kuId> [--source <slug>] [--json] (full-content lookup for a citation kuId; --json emits the full KU object)
682
730
  dz brain init [--project <dir>] [--k <N>] (wire the grounding hook into .claude/settings.json — opt-in)
683
731
  dz statusline [--json] [--install] [--project <dir>] (live self-learning panel for Claude Code's status bar; reads the CC JSON payload from STDIN)
684
- dz statusline --fa-record --slug <s> --step "<label>" [--kind <feature-adr|loop>] [--recalled <n>] [--stored <n>] [--mode <m>] (feature-adr: record live per-run learning state 📐 panel segment)
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>] [--run-id <id>] [--recalled <n>] [--stored <n>] [--mode <m>] (feature-adr: record live per-run learning state + phase → 📐 SECOND-LINE phase panel; the monotone guard absorbs a backwards plain "Step <n>" only within the same non-empty run id, while an absent/empty id retains legacy fresh-slot behavior — prefix the label with ⛔ or ⏸ to record a legitimate regression)
685
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)
686
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)
687
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)
@@ -709,6 +758,10 @@ Usage:
709
758
  dz dashboard
710
759
  dz roam [--apply] [--slug <slug>]
711
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)
712
765
  dz help
713
766
 
714
767
  Global: --version | -v [--json] (prints this CLI's own semver on one line, exit 0; "unknown" + exit 1 when unresolvable)
@@ -1396,7 +1449,7 @@ async function cmdScout(options: Map<string, string>, flags: Set<string>, cwd: s
1396
1449
 
1397
1450
  try {
1398
1451
  const scanTopics = topicsArg ? topicsArg.split(',').map((t) => t.trim()) : undefined;
1399
- const { results: repos, totalBySource } = await scanAllSources({
1452
+ const { results: repos, totalBySource, statusBySource } = await scanAllSources({
1400
1453
  token,
1401
1454
  topics: scanTopics,
1402
1455
  since,
@@ -1410,10 +1463,16 @@ async function cmdScout(options: Map<string, string>, flags: Set<string>, cwd: s
1410
1463
  .join(', ');
1411
1464
  write(`Sources: ${sourceLines}`);
1412
1465
 
1413
- // Memory: diff with previous scan
1466
+ // Memory: diff with previous scan.
1467
+ //
1468
+ // СОСТОЯНИЕ ИСТОЧНИКОВ ПЕРЕДАЁТСЯ ОБЯЗАТЕЛЬНО. Без него разность не выводит исчезновений
1469
+ // вообще — и это правильно: источник, ответивший кодом ошибки, раньше делал ВСЕ свои записи
1470
+ // «пропавшими» на экране, то есть отчёт печатал факт о нашей сети как факт о мире.
1414
1471
  if (showDiff || memory.size > 0) {
1415
- const diff = memory.diff(repos);
1416
- 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) {
1417
1476
  write(memory.diffMarkdown(diff));
1418
1477
  } else if (memory.size > 0) {
1419
1478
  write(`\nNo changes since last scan (${memory.size} repos tracked).\n`);
@@ -2216,6 +2275,49 @@ function cmdBundle(options: Map<string, string>, flags: Set<string>, cwd: string
2216
2275
  return 0;
2217
2276
  }
2218
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
+
2219
2321
  async function cmdInstall(
2220
2322
  options: Map<string, string>,
2221
2323
  flags: Set<string>,
@@ -2267,14 +2369,36 @@ async function cmdInstall(
2267
2369
 
2268
2370
  const projectRoot = resolve(cwd, options.get('project') ?? '.');
2269
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
+
2270
2384
  // Step 1: npm install the package (installRunner is the CliIo test seam — unset in production)
2271
2385
  write(`Installing ${specResolution.npmSpec}${specResolution.kind === 'name' ? '' : ` (${specResolution.kind} → node_modules/${specResolution.dirName})`}...`);
2272
- const installCmd = `npm install ${JSON.stringify(specResolution.npmSpec)} --save-dev --no-fund --no-audit`;
2386
+ const installCmd = buildInstallCommand(specResolution.npmSpec, projectRoot);
2273
2387
  try {
2274
2388
  if (installRunner) installRunner(installCmd, projectRoot);
2275
- 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' });
2276
2393
  } catch (err) {
2277
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 не делает.`);
2278
2402
  return 1;
2279
2403
  }
2280
2404
 
@@ -2535,7 +2659,7 @@ function cmdStatuslineInstall(options: Map<string, string>, cwd: string, write:
2535
2659
  * `--kind <feature-adr|loop>` identifies the producer, defaults to `feature-adr`, and rejects any
2536
2660
  * other value rather than silently weakening panel arbitration.
2537
2661
  */
2538
- 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 {
2539
2663
  const slug = (options.get('slug') ?? '').trim();
2540
2664
  const step = (options.get('step') ?? '').trim();
2541
2665
 
@@ -2595,19 +2719,44 @@ function cmdStatuslineFaRecord(options: Map<string, string>, cwd: string, write:
2595
2719
  return 1;
2596
2720
  }
2597
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
+
2598
2732
  const mode = options.get('mode');
2733
+ const runId = options.get('run-id')?.trim();
2599
2734
  const projectRoot = resolve(cwd, options.get('project') ?? '.');
2600
- const state = writeFeatureAdrState(projectRoot, {
2735
+ const outcome = writeFeatureAdrStateDetailed(projectRoot, {
2601
2736
  kind: kindRaw, slug, step, recalled, stored,
2602
2737
  ...(reinforced > 0 ? { reinforced } : {}),
2603
2738
  ...(mode !== undefined && mode.trim() !== '' ? { mode: mode.trim() } : {}),
2739
+ ...(tier !== undefined ? { tier } : {}),
2740
+ ...(runId !== undefined && runId !== '' ? { runId } : {}),
2604
2741
  });
2742
+ const state = outcome.state;
2605
2743
 
2606
2744
  if (state === undefined) {
2745
+ // [AM-5] A REFUSED slot write is LOUD. A held `fa-phase-slot` lock or an unwritable `.dz` used
2746
+ // to return a bare `undefined`, and a caller reading silence as success is exactly the
2747
+ // "absence of a receipt is not success" class. The reason goes to stderr — diagnosis, not
2748
+ // data — and the EXIT CODE is unchanged for a refusal, because the panel must never break the
2749
+ // pipeline that is only reporting to it.
2750
+ if (outcome.refused !== undefined) {
2751
+ writeErr(`fa-record: slot write refused (${slug}): ${outcome.refused}`);
2752
+ return 0;
2753
+ }
2607
2754
  write(`dz statusline --fa-record: could not write learning state under ${projectRoot}/.dz/feature-adr/`);
2608
2755
  return 1;
2609
2756
  }
2610
- write(`dz statusline: recorded /feature-adr learning state for "${slug}" (${step}) 🎓 ${state.pool} pool · ↑${state.recalled} used · +${state.stored} new · ↻${state.reinforced ?? 0} reinforced`);
2757
+ // state.step, not the flag: the monotonic guard may have kept a LATER step against a stale
2758
+ // duplicate record (fa-phase-statusline P1) — print what actually stands in the slot.
2759
+ 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`);
2611
2760
  return 0;
2612
2761
  }
2613
2762
 
@@ -2759,8 +2908,12 @@ function statuslineEta(projectRoot: string, state: FeatureAdrState, nowMs: numbe
2759
2908
  * least a minimal `dz` even on total failure.
2760
2909
  *
2761
2910
  * Flags: `--install` wires it into settings.json; `--fa-record` records a live `/feature-adr`
2762
- * learning state (WRITES — see {@link cmdStatuslineFaRecord}); `--json` prints the raw data object;
2763
- * default prints the status line (with a 📐 pipeline segment prepended when a fresh run is in flight).
2911
+ * learning state (WRITES — see {@link cmdStatuslineFaRecord}); `--json` prints the raw data object
2912
+ * (plus `featureAdrLine`, the rendered phase line, when a fresh /feature-adr run is in flight);
2913
+ * default prints the status line, with the 📐 phase panel as its OWN SECOND LINE (format B —
2914
+ * fa-phase-statusline ADR-001 D1; Claude Code renders every stdout line of a statusline command).
2915
+ * The ETA fragment main shipped for that panel rides the SECOND line with it (fa-phase-statusline ADR-001 D1) — the panel
2916
+ * moved, the estimate was not dropped.
2764
2917
  */
2765
2918
  function cmdStatusline(
2766
2919
  options: Map<string, string>,
@@ -2768,12 +2921,14 @@ function cmdStatusline(
2768
2921
  cwd: string,
2769
2922
  write: Write,
2770
2923
  readStdin: () => string,
2924
+ writeErr: WriteErr,
2771
2925
  ): number {
2772
2926
  if (flags.has('install')) return cmdStatuslineInstall(options, cwd, write);
2773
- if (flags.has('fa-record')) return cmdStatuslineFaRecord(options, cwd, write);
2927
+ if (flags.has('fa-record')) return cmdStatuslineFaRecord(options, cwd, write, writeErr);
2774
2928
 
2775
2929
  try {
2776
2930
  const projectRoot = statuslineProjectRoot(readStdin(), options, cwd);
2931
+ warnLearningStoreRead(projectRoot, writeErr, 'dz statusline');
2777
2932
  const data = statuslineData(projectRoot);
2778
2933
  const fa = data.featureAdr;
2779
2934
  let eta: EtaEstimate | undefined;
@@ -2789,27 +2944,64 @@ function cmdStatusline(
2789
2944
  }
2790
2945
  }
2791
2946
 
2947
+ // fa-phase-statusline (ADR-001 D1): the phase line renders from the slot ALONE — a pure
2948
+ // function over data.featureAdr, computed once here for both the plain and --json surfaces.
2949
+ const phaseLine = fa !== undefined ? renderFeatureAdrPhaseLine(fa) : undefined;
2950
+
2792
2951
  if (flags.has('json')) {
2793
- write(JSON.stringify({ ...data, ...(eta !== undefined ? { eta } : {}) }));
2952
+ write(JSON.stringify({
2953
+ ...data,
2954
+ ...(eta !== undefined ? { eta } : {}),
2955
+ ...(phaseLine !== undefined ? { featureAdrLine: phaseLine } : {}),
2956
+ }));
2794
2957
  return 0;
2795
2958
  }
2796
2959
 
2797
- let line = `🎓 dz: ${data.patterns} patterns${data.usedPatterns !== undefined ? ` · ${data.usedPatterns} used` : ''} · 🧠 ${data.brainSources} sources`;
2960
+ // ТЕКСТ ПАНЕЛИ ПО-АНГЛИЙСКИ по просьбе владельца 2026-09-09: строка статуса узкая,
2961
+ // английские слова в ней короче русских при той же ясности. Комментарии остаются русскими.
2962
+ const breakdown = data.patternBreakdown;
2963
+ let line = breakdown === undefined
2964
+ ? `🎓 dz: ${data.patterns} patterns`
2965
+ : `🎓 dz: ${data.patterns} (${breakdown.active} active${breakdown.quarantined > 0
2966
+ ? ` · ${breakdown.quarantined} quarantined${breakdown.attention ? ' ⚠' : ''}`
2967
+ : ''})${breakdown.tierDelta !== undefined ? ` ⚠ tiers Δ${breakdown.tierDelta}` : ''}`;
2968
+ // Показатель зеркала печатается и здесь: `dz statusline` — та же панель, и показатель,
2969
+ // живущий только во вспомогательном скрипте, для этой поверхности просто не существовал.
2970
+ if (data.patternMirror?.state === 'unavailable') line += ' (mirror unreadable ⚠)';
2971
+ else if (data.patternMirror?.state === 'different') line += ` (mirror ${data.patternMirror.vector} ⚠)`;
2972
+ if (data.storeHealth?.verdict === 'collapsed') {
2973
+ line += ` ⛔ COLLAPSE: was ${data.storeHealth.previousMax ?? '?'} · dz store-guard --reset`;
2974
+ } else if (data.storeHealth?.verdict === 'cold-start-over-existing') {
2975
+ line += ` ⛔ STORE EMPTY: was ${data.storeHealth.previousMax ?? '?'} · restore from snapshots ${data.storeHealth.snapshotPath ?? ''}`.trimEnd();
2976
+ } else if (data.storeHealth?.verdict === 'unreadable') {
2977
+ line += ` ⛔ STORE UNREADABLE${data.storeHealth.unreadableFiles !== undefined && data.storeHealth.unreadableFiles.length > 0
2978
+ ? `: ${data.storeHealth.unreadableFiles.join(', ')}` : ''}`;
2979
+ } else if (data.storeHealth?.verdict === 'source-changed') {
2980
+ line += ' ⚠ store source changed';
2981
+ }
2982
+ // Рядом с числом источников — объём каждого через «/» (просьба владельца 2026-09-09):
2983
+ // четыре источника по 300 единиц и четыре по три — разные корпуса, а число одно и то же.
2984
+ const ku = data.brainKuCounts.length > 0 ? ` (${data.brainKuCounts.join('/')})` : '';
2985
+ line += `${data.usedPatterns !== undefined ? ` · ${data.usedPatterns} used` : ''} · 🧠 ${data.brainSources} sources${ku}`;
2798
2986
  const branch = statuslineGitBranch(projectRoot);
2799
2987
  if (branch !== undefined) line += ` · ⎇ ${branch}`;
2800
2988
  if (data.consolidatedAgeH !== undefined) line += ` · ⟳ ${data.consolidatedAgeH}h`;
2801
2989
 
2802
- // Live /feature-adr run in flight → PREPEND the pipeline learning segment to the base dz line.
2803
- if (fa !== undefined) {
2804
- // 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.
2805
- if (fa.kind === 'loop') {
2806
- line = `🔁 loop ${fa.step} · ${line}`;
2807
- } else {
2808
- line = `📐 feature-adr ${fa.step} · ${etaFragment !== undefined ? `${etaFragment} · ` : ''}🎓 ${fa.pool} pool · ↑${fa.recalled} used · +${fa.stored} new · ↻${fa.reinforced ?? 0} reinforced · ${line}`;
2809
- }
2990
+ // Live loop run in flight → PREPEND its segment to the base dz line (unchanged). A live
2991
+ // /feature-adr run no longer glues into line 1: its 📐 segment IS the second line (format B)
2992
+ // Claude Code renders every stdout line of a statusline command (fa-phase-statusline ADR-001 D1).
2993
+ if (fa !== undefined && fa.kind === 'loop') {
2994
+ line = `🔁 loop ${fa.step} · ${line}`;
2810
2995
  }
2811
2996
 
2812
2997
  write(line);
2998
+ // fa-phase-statusline ADR-001 D1: the phase panel moved to line 2 and TOOK main's ETA fragment with it. The move is
2999
+ // the point of format B (ADR-001 D1); dropping the estimate would have been a silent
3000
+ // regression of a feature `main` shipped while this branch was stranded, so it rides here
3001
+ // instead. A phase line that renders (fresh, non-terminal, non-loop slot) is the only gate.
3002
+ if (phaseLine !== undefined) {
3003
+ write(`${phaseLine}${etaFragment !== undefined ? ` · ${etaFragment}` : ''}`);
3004
+ }
2813
3005
  return 0;
2814
3006
  } catch {
2815
3007
  // A garbled status bar is worse than a terse one — print SOMETHING minimal, never throw.
@@ -3432,6 +3624,282 @@ function learningStoreLine(
3432
3624
  ) + (reason ? ' [' + reason + ']' : '');
3433
3625
  }
3434
3626
 
3627
+ function inspectLearningStore(projectRoot: string): {
3628
+ mark: StoreMark | undefined;
3629
+ health: StoreHealth;
3630
+ rows: ReturnType<typeof countLearningStoreRowsReadonly>;
3631
+ } {
3632
+ const mark = readStoreMark(projectRoot);
3633
+ const rows = countLearningStoreRowsReadonly(projectRoot);
3634
+ return { mark, rows, health: checkStoreHealth({ projectRoot, ...rows, mark }) };
3635
+ }
3636
+
3637
+ function shellQuote(value: string): string {
3638
+ return `'${value.replace(/'/g, `'\\''`)}'`;
3639
+ }
3640
+
3641
+ function storeGuardResetCommand(projectRoot: string): string {
3642
+ return `dz store-guard --reset --project ${shellQuote(projectRoot)}`;
3643
+ }
3644
+
3645
+ function lexicalSourceLines(rows: ReturnType<typeof countLearningStoreRowsReadonly>): string[] {
3646
+ return [
3647
+ ` lexical selected: ${rows.lexicalSourcePath} (${rows.lexicalSource}, ${rows.lexicalRows} rows)`,
3648
+ ...(rows.lexicalIgnoredSourcePath === undefined ? [] : [
3649
+ ` lexical ignored: ${rows.lexicalIgnoredSourcePath} (${rows.lexicalIgnoredRows} rows)`,
3650
+ ]),
3651
+ ];
3652
+ }
3653
+
3654
+ function storeGuardRecoveryLines(
3655
+ projectRoot: string,
3656
+ mark: StoreMark | undefined,
3657
+ health: StoreHealth,
3658
+ rows?: ReturnType<typeof countLearningStoreRowsReadonly>,
3659
+ ): string[] {
3660
+ const snapshots = storeSnapshotPath(projectRoot);
3661
+ const markPath = storeGuardPath(projectRoot);
3662
+ let lexicalSnapshot: 'sqlite' | 'jsonl' | undefined;
3663
+ let vectorSnapshot = false;
3664
+ try {
3665
+ const names = existsSync(snapshots) ? readdirSync(snapshots) : [];
3666
+ const hasSqlite = names.some((name) => /^lexical\..+\.sqlite$/.test(name));
3667
+ const hasJsonl = names.some((name) => /^lexical\..+\.jsonl$/.test(name));
3668
+ const preferred = mark?.lexicalSource === 'sqlite' || mark?.lexicalSource === 'jsonl'
3669
+ ? mark.lexicalSource
3670
+ : rows?.lexicalSource;
3671
+ if (preferred === 'jsonl' && hasJsonl) lexicalSnapshot = 'jsonl';
3672
+ else if (preferred === 'sqlite' && hasSqlite) lexicalSnapshot = 'sqlite';
3673
+ else if (hasSqlite) lexicalSnapshot = 'sqlite';
3674
+ else if (hasJsonl) lexicalSnapshot = 'jsonl';
3675
+ vectorSnapshot = names.some((name) => /^vector\..+\.sqlite$/.test(name));
3676
+ } catch {
3677
+ lexicalSnapshot = undefined;
3678
+ vectorSnapshot = false;
3679
+ }
3680
+ const lines = [
3681
+ `dz store guard: REFUSED — ${health.reason}`,
3682
+ ` mark: ${markPath}`,
3683
+ ...(mark === undefined ? [] : [` recorded rows: lexical=${mark.lexicalMax} (${mark.lexicalSource}), vector=${mark.vectorMax}`]),
3684
+ ...(rows === undefined ? [] : lexicalSourceLines(rows)),
3685
+ ];
3686
+ if (lexicalSnapshot !== undefined && vectorSnapshot) {
3687
+ const lexicalDestination = lexicalSnapshot === 'sqlite'
3688
+ ? join(projectRoot, '.dz', 'memory', 'patterns.sqlite')
3689
+ : join(projectRoot, '.dz', 'patterns.jsonl');
3690
+ lines.push(
3691
+ ` snapshots: ${snapshots}/`,
3692
+ ` 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'))}`,
3693
+ );
3694
+ } else {
3695
+ lines.push(
3696
+ ` snapshots: none found in ${snapshots}/`,
3697
+ ` create one manually: scripts/dz-store-snapshot.sh --project ${shellQuote(projectRoot)}`,
3698
+ );
3699
+ }
3700
+ lines.push(
3701
+ ` accept current counts: ${storeGuardResetCommand(projectRoot)}`,
3702
+ ' continue intentionally: set DZ_ALLOW_COLD_START=1 or pass --allow-cold-start',
3703
+ );
3704
+ return lines;
3705
+ }
3706
+
3707
+ function observedRows(
3708
+ rows: ReturnType<typeof countLearningStoreRowsReadonly>,
3709
+ ): StoreCountSnapshot | undefined {
3710
+ return typeof rows.lexicalRows === 'number' && typeof rows.vectorRows === 'number'
3711
+ ? { lexicalRows: rows.lexicalRows, vectorRows: rows.vectorRows, lexicalSource: rows.lexicalSource }
3712
+ : undefined;
3713
+ }
3714
+
3715
+ interface MarkRefreshOptions {
3716
+ readonly reader?: boolean;
3717
+ }
3718
+
3719
+ /** Mark maintenance is diagnostic: the store operation already completed and must keep its exit code. */
3720
+ function refreshLearningStoreMark(
3721
+ projectRoot: string,
3722
+ writeErr: WriteErr,
3723
+ command: string,
3724
+ options: MarkRefreshOptions = {},
3725
+ ): void {
3726
+ try {
3727
+ const rows = countLearningStoreRowsReadonly(projectRoot);
3728
+ const counts = observedRows(rows);
3729
+ if (counts === undefined) {
3730
+ writeErr(`⚠ dz store guard: ${options.reader ? 'reader observation' : 'store operation'} completed but the external mark was not updated — a store tier is unreadable`);
3731
+ return;
3732
+ }
3733
+ writeStoreMark(projectRoot, {
3734
+ ...counts,
3735
+ observedAt: new Date().toISOString(),
3736
+ command,
3737
+ }, options.reader ? { timeoutMs: 0 } : {});
3738
+ } catch (error) {
3739
+ if (options.reader && error instanceof NamedLockTimeoutError) return;
3740
+ 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)}`);
3741
+ }
3742
+ }
3743
+
3744
+ function storeGuardResetReminder(projectRoot: string, writeErr: WriteErr, command: string): void {
3745
+ try {
3746
+ const rows = countLearningStoreRowsReadonly(projectRoot);
3747
+ const mark = readStoreMark(projectRoot);
3748
+ 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)}`);
3749
+ for (const line of lexicalSourceLines(rows)) writeErr(line);
3750
+ } catch (error) {
3751
+ writeErr(`⚠ DZ STORE GUARD — ${command}: store changed; inspect it and reconcile explicitly with ${storeGuardResetCommand(projectRoot)} (${error instanceof Error ? error.message : String(error)})`);
3752
+ }
3753
+ }
3754
+
3755
+ /** Fail closed for store writers, except for an explicit per-process/per-command override. */
3756
+ function allowLearningStoreWrite(
3757
+ projectRoot: string,
3758
+ flags: Set<string>,
3759
+ writeErr: WriteErr,
3760
+ command: string,
3761
+ ): boolean {
3762
+ let inspection: ReturnType<typeof inspectLearningStore>;
3763
+ try {
3764
+ inspection = inspectLearningStore(projectRoot);
3765
+ } catch (error) {
3766
+ const health: StoreHealth = {
3767
+ verdict: 'unreadable',
3768
+ reason: `cannot read the external mark (${error instanceof Error ? error.message : String(error)})`,
3769
+ };
3770
+ for (const line of storeGuardRecoveryLines(projectRoot, undefined, health)) writeErr(line);
3771
+ return false;
3772
+ }
3773
+ if (inspection.health.verdict === 'no-mark' || inspection.health.verdict === 'ok') {
3774
+ // The successful write path records the resulting counts. Refreshing here as
3775
+ // well would emit the same telemetry failure twice when the external mark is
3776
+ // unavailable, and would receipt a source transition before the command's
3777
+ // own row had landed.
3778
+ return true;
3779
+ }
3780
+ if (inspection.health.verdict === 'source-changed') {
3781
+ const counts = observedRows(inspection.rows);
3782
+ if (counts === undefined) return false;
3783
+ writeErr(`⚠ DZ STORE GUARD WARNING — SOURCE CHANGED: ${inspection.health.reason}; ${storeGuardResetCommand(projectRoot)}`);
3784
+ for (const line of lexicalSourceLines(inspection.rows)) writeErr(line);
3785
+ try {
3786
+ // Consume the single migration allowance BEFORE the store write. If the
3787
+ // following command fails, the safe result is a consumed allowance that
3788
+ // requires an explicit reset, never a silently reusable permission.
3789
+ writeStoreMark(projectRoot, { ...counts, observedAt: new Date().toISOString(), command }, {
3790
+ expectedPreviousLexicalSource: inspection.mark?.lexicalSource ?? 'unknown',
3791
+ });
3792
+ return true;
3793
+ } catch (error) {
3794
+ writeErr(`dz store guard: REFUSED — source-change allowance could not be recorded: ${error instanceof Error ? error.message : String(error)}`);
3795
+ return false;
3796
+ }
3797
+ }
3798
+
3799
+ const allowed = process.env.DZ_ALLOW_COLD_START === '1' || flags.has('allow-cold-start');
3800
+ if (allowed) {
3801
+ const label = inspection.health.verdict.replaceAll('-', ' ').toUpperCase();
3802
+ writeErr(`⚠ DZ STORE GUARD WARNING — ${label}: ${inspection.health.reason}; explicit cold-start override accepted`);
3803
+ return true;
3804
+ }
3805
+ for (const line of storeGuardRecoveryLines(projectRoot, inspection.mark, inspection.health, inspection.rows)) writeErr(line);
3806
+ return false;
3807
+ }
3808
+
3809
+ /** Readers warn on damage and bootstrap/refresh a healthy non-empty store mark. */
3810
+ function warnLearningStoreRead(projectRoot: string, writeErr: WriteErr, command: string): void {
3811
+ try {
3812
+ const { health, rows, mark } = inspectLearningStore(projectRoot);
3813
+ if (health.verdict === 'collapsed' || health.verdict === 'cold-start-over-existing' || health.verdict === 'unreadable'
3814
+ || health.verdict === 'source-changed') {
3815
+ writeErr(`⚠ DZ STORE GUARD WARNING — ${health.verdict.replaceAll('-', ' ').toUpperCase()}: ${health.reason}`);
3816
+ for (const line of lexicalSourceLines(rows)) writeErr(line);
3817
+ return;
3818
+ }
3819
+ const counts = observedRows(rows);
3820
+ if (counts !== undefined && counts.lexicalRows + counts.vectorRows > 0
3821
+ && (mark === undefined || mark.lexicalLast !== counts.lexicalRows || mark.vectorLast !== counts.vectorRows
3822
+ || mark.lexicalSource !== counts.lexicalSource
3823
+ || mark.lexicalMax < counts.lexicalRows || mark.vectorMax < counts.vectorRows)) {
3824
+ refreshLearningStoreMark(projectRoot, writeErr, command, { reader: true });
3825
+ }
3826
+ } catch (error) {
3827
+ writeErr(`⚠ DZ STORE GUARD WARNING — external mark unreadable: ${error instanceof Error ? error.message : String(error)}`);
3828
+ }
3829
+ }
3830
+
3831
+ async function cmdStoreGuard(
3832
+ options: Map<string, string>, flags: Set<string>, cwd: string, write: Write, writeErr: WriteErr,
3833
+ stdinText: string | undefined, interactive: boolean,
3834
+ ): Promise<number> {
3835
+ const projectRoot = resolve(cwd, options.get('project') ?? '.');
3836
+ const path = storeGuardPath(projectRoot);
3837
+ const reset = flags.has('reset');
3838
+ const status = flags.has('status') || options.has('status');
3839
+ if (reset && status) {
3840
+ writeErr('dz store-guard: --status and --reset are mutually exclusive');
3841
+ return 2;
3842
+ }
3843
+ if (reset) {
3844
+ const rows = countLearningStoreRowsReadonly(projectRoot);
3845
+ const counts = observedRows(rows);
3846
+ if (counts === undefined) {
3847
+ writeErr(`dz store-guard: REFUSED — cannot reset from an unreadable store; mark: ${path}`);
3848
+ return 1;
3849
+ }
3850
+ try {
3851
+ const previous = readStoreMark(projectRoot);
3852
+ const beforeLexical = previous?.lexicalMax ?? counts.lexicalRows;
3853
+ const beforeVector = previous?.vectorMax ?? counts.vectorRows;
3854
+ writeErr('⚠ dz store-guard --reset: manual operator decision required; this lowers the recorded high-water evidence');
3855
+ writeErr(` old maximum: lexical=${beforeLexical}, vector=${beforeVector}`);
3856
+ writeErr(` new observed: lexical=${counts.lexicalRows} (${counts.lexicalSource}), vector=${counts.vectorRows}`);
3857
+ for (const line of lexicalSourceLines(rows)) writeErr(line);
3858
+ let answer = stdinText?.trim().split(/\r?\n/, 1)[0]?.trim().toLowerCase() ?? '';
3859
+ if (!flags.has('yes') && answer === '' && interactive && process.stdin.isTTY) {
3860
+ const { createInterface } = await import('node:readline/promises');
3861
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
3862
+ try { answer = (await rl.question(' type yes to continue: ')).trim().toLowerCase(); }
3863
+ finally { rl.close(); }
3864
+ }
3865
+ if (!flags.has('yes') && !['y', 'yes', 'да'].includes(answer)) {
3866
+ writeErr(`dz store-guard: REFUSED — reset was not confirmed; re-run with --yes or answer yes`);
3867
+ return 1;
3868
+ }
3869
+ const current = countLearningStoreRowsReadonly(projectRoot);
3870
+ const currentCounts = observedRows(current);
3871
+ if (currentCounts === undefined || !isDeepStrictEqual(currentCounts, counts)) {
3872
+ writeErr('dz store-guard: REFUSED — store counts changed after confirmation; inspect and confirm again');
3873
+ return 1;
3874
+ }
3875
+ const mark = resetStoreMark(projectRoot, {
3876
+ ...counts,
3877
+ observedAt: new Date().toISOString(),
3878
+ command: 'dz store-guard --reset',
3879
+ });
3880
+ write(`dz store-guard: RESET — accepted lexical=${mark.lexicalMax}, vector=${mark.vectorMax}`);
3881
+ write(` mark: ${path}`);
3882
+ write(` receipt: ${mark.resetAt?.at} — ${mark.resetAt?.reason}`);
3883
+ return 0;
3884
+ } catch (error) {
3885
+ writeErr(`dz store-guard: reset failed — ${error instanceof Error ? error.message : String(error)}; mark: ${path}`);
3886
+ return 1;
3887
+ }
3888
+ }
3889
+ try {
3890
+ const inspection = inspectLearningStore(projectRoot);
3891
+ write(`dz store-guard: ${inspection.health.verdict.toUpperCase()} — ${inspection.health.reason}`);
3892
+ write(` mark: ${path}`);
3893
+ write(` current rows: lexical=${inspection.rows.lexicalRows}, vector=${inspection.rows.vectorRows}`);
3894
+ for (const line of lexicalSourceLines(inspection.rows)) write(line);
3895
+ write(` recorded: ${inspection.mark === undefined ? 'none' : JSON.stringify(inspection.mark)}`);
3896
+ return 0;
3897
+ } catch (error) {
3898
+ writeErr(`dz store-guard: mark unreadable — ${error instanceof Error ? error.message : String(error)}; mark: ${path}`);
3899
+ return 1;
3900
+ }
3901
+ }
3902
+
3435
3903
  async function runTeachGuardReinforcement(
3436
3904
  projectRoot: string,
3437
3905
  dzId: string,
@@ -3465,6 +3933,12 @@ async function cmdTeach(
3465
3933
  // repo's own store holds 361 records written under that behaviour, and every other user's store
3466
3934
  // is the same. Only an explicit choice moves it.
3467
3935
  const { storeRoot, target: teachTarget } = resolved;
3936
+ const teachWillWrite = flags.has('harmonize')
3937
+ ? false
3938
+ : options.has('from-json')
3939
+ || (options.get('reinforce') ?? '').trim() !== ''
3940
+ || (options.get('_positional_0') ?? '').trim() !== '';
3941
+ if (teachWillWrite && !allowLearningStoreWrite(storeRoot, flags, writeErr, 'dz teach')) return 1;
3468
3942
  // The verb is per OUTCOME, not per command: a harmonize dry-run and a failed --reinforce READ
3469
3943
  // the store and change nothing, so saying "written" there is a false claim about what happened
3470
3944
  // (cross-family QE round 2, 2026-08-27).
@@ -3501,10 +3975,11 @@ async function cmdTeach(
3501
3975
  // Suppressed under --json: this line ahead of the report made stdout unparseable, which is a
3502
3976
  // worse defect than the invisibility it was closing (measured live, cross-family QE round 2).
3503
3977
  if (!flags.has('json')) write(storeLine(flags.has('apply') ? 'written' : 'read'));
3504
- return runHarmonize(storeRoot, options, flags, write, {
3978
+ const code = await runHarmonize(storeRoot, options, flags, write, writeErr, {
3505
3979
  store: join(storeRoot, '.dz'),
3506
3980
  storeChosenBy: teachTarget.reason,
3507
3981
  });
3982
+ return code;
3508
3983
  }
3509
3984
 
3510
3985
  // Bulk import: `dz teach --from-json <file>` ingests a `dz recall --all --json`
@@ -3600,6 +4075,7 @@ async function cmdTeach(
3600
4075
  const report = await harmonizeVectorStore(storeRoot, {});
3601
4076
  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`);
3602
4077
  }
4078
+ if (imported > 0) refreshLearningStoreMark(storeRoot, writeErr, 'dz teach --from-json');
3603
4079
  return 0;
3604
4080
  }
3605
4081
 
@@ -3620,6 +4096,7 @@ async function cmdTeach(
3620
4096
  const clearedQ = clearAgentdbQuarantine(storeRoot, [reinforce]);
3621
4097
  if (clearedQ.cleared > 0) write(` ↳ promoted out of quarantine (mirror updated)`);
3622
4098
  write(storeLine('written'));
4099
+ refreshLearningStoreMark(storeRoot, writeErr, 'dz teach --reinforce');
3623
4100
  return 0;
3624
4101
  }
3625
4102
  // HIGH-fix: a no-match must NOT auto-teach the raw argument — callers pass dzIds or truncated
@@ -3667,6 +4144,7 @@ async function cmdTeach(
3667
4144
  write(`↳ reinforced existing pattern ${verdict.dzId} (cos=${verdict.cosine.toFixed(2)}) — not re-added`);
3668
4145
  const clearedQ = clearAgentdbQuarantine(storeRoot, [verdict.dzId]);
3669
4146
  if (clearedQ.cleared > 0) write(' ↳ promoted out of quarantine (mirror updated)');
4147
+ refreshLearningStoreMark(storeRoot, writeErr, 'dz teach --guard');
3670
4148
  return 0;
3671
4149
  }
3672
4150
  write(`dz teach --guard: reinforce of ${verdict.dzId} did not flush (backend off or write failure) — teaching the lesson normally instead`);
@@ -3780,10 +4258,16 @@ async function cmdTeach(
3780
4258
  }
3781
4259
  // The lexical write above is durable — the vector mirror is strictly best-effort (I-3).
3782
4260
  await emitMirrorQ(storeRoot, recordsToMirror, 'dz-teach', quarantineOn);
4261
+ if (stored.records.length > 0) {
4262
+ write(` ID: ${stored.records.map((record) => patternRecordId(record)).join(', ')}`);
4263
+ refreshLearningStoreMark(storeRoot, writeErr, 'dz teach');
4264
+ }
3783
4265
  return commandFailed ? 1 : 0;
3784
4266
  }
3785
4267
 
3786
- async function cmdConsolidate(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): Promise<number> {
4268
+ async function cmdConsolidate(
4269
+ options: Map<string, string>, flags: Set<string>, cwd: string, write: Write, writeErr: WriteErr,
4270
+ ): Promise<number> {
3787
4271
  const projectRoot = resolve(cwd, options.get('project') ?? '.');
3788
4272
  const sessionsDirOpt = options.get('sessions-dir');
3789
4273
  const pruneNoise = flags.has('prune-noise');
@@ -3802,6 +4286,8 @@ async function cmdConsolidate(options: Map<string, string>, flags: Set<string>,
3802
4286
  if (res.error !== undefined) { write(`dz consolidate --prune-quarantine: ${res.error}`); return 1; }
3803
4287
  write(`dz consolidate --prune-quarantine: removed ${res.removed} expired quarantined lesson(s)`);
3804
4288
  if (res.snapshot !== undefined) write(` snapshot: ${res.snapshot}`);
4289
+ refreshLearningStoreMark(projectRoot, writeErr, 'dz consolidate --prune-quarantine --apply');
4290
+ storeGuardResetReminder(projectRoot, writeErr, 'dz consolidate --prune-quarantine --apply');
3805
4291
  return 0;
3806
4292
  }
3807
4293
 
@@ -3899,6 +4385,10 @@ async function cmdConsolidate(options: Map<string, string>, flags: Set<string>,
3899
4385
  }
3900
4386
  } catch { /* best-effort — the ranking is advisory, never fails the consolidate */ }
3901
4387
 
4388
+ refreshLearningStoreMark(projectRoot, writeErr, 'dz consolidate');
4389
+ if (pruneNoise && applyPrune) {
4390
+ storeGuardResetReminder(projectRoot, writeErr, 'dz consolidate --prune-noise --apply');
4391
+ }
3902
4392
  return 0;
3903
4393
  }
3904
4394
 
@@ -4011,6 +4501,7 @@ async function cmdRecallForget(
4011
4501
  flags: Set<string>,
4012
4502
  projectRoot: string,
4013
4503
  write: Write,
4504
+ writeErr: WriteErr,
4014
4505
  ): Promise<number> {
4015
4506
  const raw = options.get('forget') ?? '';
4016
4507
  const ids = new Set(raw.split(',').map((s) => s.trim()).filter((s) => s !== ''));
@@ -4039,7 +4530,7 @@ async function cmdRecallForget(
4039
4530
  return 0;
4040
4531
  }
4041
4532
 
4042
- const dest = join(projectRoot, '.dz', `patterns-pre-forget-${Date.now()}.json`);
4533
+ const dest = join(storeSnapshotPath(projectRoot), `forget-${Date.now()}.json`);
4043
4534
  const snap = snapshotStore(projectRoot, dest);
4044
4535
  if (snap.error !== undefined) {
4045
4536
  write(`dz recall --forget: snapshot failed (${snap.error}) — nothing removed; the store is not versioned`);
@@ -4050,6 +4541,8 @@ async function cmdRecallForget(
4050
4541
  write(` snapshot: ${snap.path} (${snap.count} record(s))`);
4051
4542
  if (result.error !== undefined) write(` ⚠ ${result.error}`);
4052
4543
  write(' the vector mirror still holds them — run `dz vector reindex` to resync');
4544
+ refreshLearningStoreMark(projectRoot, writeErr, 'dz recall --forget --apply');
4545
+ storeGuardResetReminder(projectRoot, writeErr, 'dz recall --forget --apply');
4053
4546
  return 0;
4054
4547
  }
4055
4548
 
@@ -4141,10 +4634,15 @@ async function cmdRecall(
4141
4634
  classMatcher?: RecallPatternsOptions['classMatcher'],
4142
4635
  ): Promise<number> {
4143
4636
  const projectRoot = resolve(cwd, options.get('project') ?? '.');
4637
+ warnLearningStoreRead(projectRoot, writeErr, 'dz recall');
4638
+ const globalRootForGuard = globalStoreRoot();
4639
+ if (!sameStore(projectRoot, globalRootForGuard) && existsSync(join(globalRootForGuard, '.dz', 'memory'))) {
4640
+ warnLearningStoreRead(globalRootForGuard, writeErr, 'dz recall');
4641
+ }
4144
4642
  const asJson = flags.has('json');
4145
4643
  const all = flags.has('all');
4146
4644
  if (flags.has('usage')) return cmdRecallUsage(options, flags, projectRoot, write);
4147
- if (options.has('forget')) return cmdRecallForget(options, flags, projectRoot, write);
4645
+ if (options.has('forget')) return cmdRecallForget(options, flags, projectRoot, write, writeErr);
4148
4646
  if (options.has('promote')) return cmdRecallPromote(options, flags, projectRoot, write);
4149
4647
 
4150
4648
  // --all: dump the entire learned store (backend-agnostic, via loadStorePatternsSync).
@@ -4706,7 +5204,7 @@ function renderHarmonize(report: HarmonizeReport, write: Write): void {
4706
5204
  * `--apply` + `--dry-run` together is rejected; `--threshold` must be in `(0, 1]`; no flag ⇒ dry-run.
4707
5205
  */
4708
5206
  async function runHarmonize(
4709
- projectRoot: string, options: Map<string, string>, flags: Set<string>, write: Write,
5207
+ projectRoot: string, options: Map<string, string>, flags: Set<string>, write: Write, writeErr: WriteErr,
4710
5208
  /**
4711
5209
  * Where this harmonize is pointed and what chose it. Under `--json` the human store line is
4712
5210
  * suppressed to keep stdout ONE document, so the destination has to travel INSIDE that document
@@ -4730,6 +5228,10 @@ async function runHarmonize(
4730
5228
  }
4731
5229
  }
4732
5230
  const report = await harmonizeVectorStore(projectRoot, { apply, ...(threshold !== undefined ? { threshold } : {}) });
5231
+ if (apply && report.error === undefined) {
5232
+ refreshLearningStoreMark(projectRoot, writeErr, 'dz vector harmonize --apply');
5233
+ storeGuardResetReminder(projectRoot, writeErr, 'dz vector harmonize --apply');
5234
+ }
4733
5235
  if (flags.has('json')) {
4734
5236
  write(JSON.stringify(storeAnnotation !== undefined ? { ...report, ...storeAnnotation } : report));
4735
5237
  return report.error !== undefined ? 1 : 0;
@@ -4738,7 +5240,9 @@ async function runHarmonize(
4738
5240
  return report.error !== undefined ? 1 : 0;
4739
5241
  }
4740
5242
 
4741
- async function cmdVector(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): Promise<number> {
5243
+ async function cmdVector(
5244
+ options: Map<string, string>, flags: Set<string>, cwd: string, write: Write, writeErr: WriteErr,
5245
+ ): Promise<number> {
4742
5246
  const projectRoot = resolve(cwd, options.get('project') ?? '.');
4743
5247
  const sub = options.get('_positional_0');
4744
5248
 
@@ -4793,6 +5297,10 @@ async function cmdVector(options: Map<string, string>, flags: Set<string>, cwd:
4793
5297
  if (sub === 'reindex') {
4794
5298
  const report = await reindexVectorStore(projectRoot);
4795
5299
  if (flags.has('json')) {
5300
+ if (report.error === undefined) {
5301
+ refreshLearningStoreMark(projectRoot, writeErr, 'dz vector reindex');
5302
+ storeGuardResetReminder(projectRoot, writeErr, 'dz vector reindex');
5303
+ }
4796
5304
  write(JSON.stringify(report));
4797
5305
  return report.error !== undefined ? 1 : 0;
4798
5306
  }
@@ -4810,6 +5318,8 @@ async function cmdVector(options: Map<string, string>, flags: Set<string>, cwd:
4810
5318
  write(` ⚠ still in the previous embedding space: ${report.staleTaskTypes.join(', ')}`);
4811
5319
  if (report.staleTaskTypes.includes('book-knowledge')) write(' run \`dz brain reindex\` to rebuild the brain\'s book vectors');
4812
5320
  }
5321
+ refreshLearningStoreMark(projectRoot, writeErr, 'dz vector reindex');
5322
+ storeGuardResetReminder(projectRoot, writeErr, 'dz vector reindex');
4813
5323
  return 0;
4814
5324
  }
4815
5325
 
@@ -4866,7 +5376,7 @@ async function cmdVector(options: Map<string, string>, flags: Set<string>, cwd:
4866
5376
  // harmonize (alias: dz teach --harmonize) — SEMANTIC dedup of the learned store, NON-DESTRUCTIVE:
4867
5377
  // dry-run by default (previews clusters, writes nothing); --apply drops after a restorable backup.
4868
5378
  if (sub === 'harmonize') {
4869
- return runHarmonize(projectRoot, options, flags, write);
5379
+ return runHarmonize(projectRoot, options, flags, write, writeErr);
4870
5380
  }
4871
5381
 
4872
5382
  // import <file.rvf> — the missing HALF of the RVF cycle: UPSERT-BY-dzId, never overwrites.
@@ -4878,6 +5388,7 @@ async function cmdVector(options: Map<string, string>, flags: Set<string>, cwd:
4878
5388
  }
4879
5389
  const report = await importRvfCheckpoint(projectRoot, resolve(cwd, src), {});
4880
5390
  if (flags.has('json')) {
5391
+ if (report.error === undefined) refreshLearningStoreMark(projectRoot, writeErr, 'dz vector import');
4881
5392
  write(JSON.stringify(report));
4882
5393
  return report.error !== undefined ? 1 : 0;
4883
5394
  }
@@ -4890,6 +5401,7 @@ async function cmdVector(options: Map<string, string>, flags: Set<string>, cwd:
4890
5401
  if (report.skippedOrphans > 0) {
4891
5402
  write(' ↳ orphan vectors have no local pattern — import the text first: dz teach --from-json <recall-export.json>, then re-run dz vector import');
4892
5403
  }
5404
+ refreshLearningStoreMark(projectRoot, writeErr, 'dz vector import');
4893
5405
  return 0;
4894
5406
  }
4895
5407
 
@@ -6339,6 +6851,19 @@ function cmdPublish(options: Map<string, string>, flags: Set<string>, cwd: strin
6339
6851
  }
6340
6852
  }
6341
6853
 
6854
+ const filterStr = options.get('filter');
6855
+ // SAFETY: trim + drop empty segments (mirrors --select at the top of cmdInit).
6856
+ // Parse before the guard pre-flight so its packed-secret scan uses the SAME scoped package set
6857
+ // that publishPackages receives below; an empty resulting list remains an explicit error.
6858
+ let filter: string[] | undefined;
6859
+ if (filterStr !== undefined) {
6860
+ filter = filterStr.split(',').map((s) => s.trim()).filter((s) => s.length > 0);
6861
+ if (filter.length === 0) {
6862
+ write('dz publish: --filter requires a non-empty comma-separated list of package-name substrings');
6863
+ return 1;
6864
+ }
6865
+ }
6866
+
6342
6867
  // dz guard pre-flight (ADR-002 option A): publish is the most dangerous, least-reversible self-mutation, so
6343
6868
  // it ALWAYS runs the declarative guard first. A HARD violation refuses the publish; `--no-guard "<reason>"`
6344
6869
  // is the logged escape hatch (the override lands in .dz/guard-audit.jsonl — visible, never silent).
@@ -6350,7 +6875,7 @@ function cmdPublish(options: Map<string, string>, flags: Set<string>, cwd: strin
6350
6875
  write('dz publish: --no-guard requires a reason (it is logged): --no-guard "hotfix, guard re-run after"');
6351
6876
  return 1;
6352
6877
  }
6353
- const guardResult = runGuardEvaluation(guardRoot, 'publish', undefined, noGuard);
6878
+ const guardResult = runGuardEvaluation(guardRoot, 'publish', undefined, noGuard, filter);
6354
6879
  if (guardResult.verdict === 'block' && noGuard === undefined) {
6355
6880
  write('dz publish: ✗ BLOCKED by dz guard (HARD invariant violated):');
6356
6881
  for (const v of guardResult.violations.filter((x) => x.severity === 'hard')) write(` [BLOCK] ${v.rule}: ${v.detail}`);
@@ -6391,21 +6916,6 @@ function cmdPublish(options: Map<string, string>, flags: Set<string>, cwd: strin
6391
6916
  const claimCheckOpt = (claimCheckRaw as 'off' | 'warn' | 'error' | undefined) ?? 'warn';
6392
6917
 
6393
6918
  const bumpOnly = flags.has('bump-only');
6394
- const filterStr = options.get('filter');
6395
- // SAFETY: trim + drop empty segments (mirrors --select at the top of cmdInit).
6396
- // Without this, `--filter ""` (e.g. an unset shell var) or a stray comma yields
6397
- // [''] / ['', 'core'], and publishPackages matches with name.includes(''), which
6398
- // is true for EVERY package — silently turning a scoped publish into a
6399
- // whole-monorepo publish. An empty resulting list is an explicit error, never
6400
- // "match all".
6401
- let filter: string[] | undefined;
6402
- if (filterStr !== undefined) {
6403
- filter = filterStr.split(',').map((s) => s.trim()).filter((s) => s.length > 0);
6404
- if (filter.length === 0) {
6405
- write('dz publish: --filter requires a non-empty comma-separated list of package-name substrings');
6406
- return 1;
6407
- }
6408
- }
6409
6919
 
6410
6920
  // SAFETY: dry-run is the DEFAULT. A real publish requires an EXPLICIT opt-in
6411
6921
  // via --yes, --confirm, or --no-dry-run. Without one, we never bump or publish.
@@ -6599,6 +7109,13 @@ function cmdPublish(options: Map<string, string>, flags: Set<string>, cwd: strin
6599
7109
  if (pkg.claimCheck && pkg.claimCheck.findings > 0 && pkg.status !== 'error') {
6600
7110
  write(` ⚠ claim-check: ${pkg.claimCheck.findings} finding(s) (${pkg.claimCheck.high} high) in README.md`);
6601
7111
  }
7112
+ // A dry run stops before build/sign/pack, so it says NOTHING about the gates below that line.
7113
+ // Printing what it did not check is what keeps a clean preview from reading as a clean publish
7114
+ // (measured 2026-09-02: a clean dry run preceded a RED real gate).
7115
+ if (pkg.notVerified && pkg.notVerified.length > 0) {
7116
+ write(` ⓘ холостой прогон НЕ проверял (${pkg.notVerified.length}):`);
7117
+ for (const item of pkg.notVerified) write(` · ${item}`);
7118
+ }
6602
7119
  }
6603
7120
  return report.errors > 0 ? 1 : 0;
6604
7121
  }
@@ -8357,7 +8874,18 @@ function cmdAgentsSync(
8357
8874
  const effect = flags.has('check') ? 'AGENTS.md would change' : 'AGENTS.md was not rewritten';
8358
8875
  writeErr(`dz agents-sync: DRIFT — ${drifted.length} stale/missing section(s); ${effect}`);
8359
8876
  for (const finding of drifted) writeErr(` ${finding.id}: ${finding.file} (${finding.status})`);
8360
- if (drifted.some((finding) => finding.id === 'dz:policies')) {
8877
+ const unregistered = drifted.filter((finding) => finding.status === 'unregistered-section');
8878
+ if (unregistered.length > 0) {
8879
+ // ПОДСКАЗКА, ВЕДУЩАЯ НЕ ТУДА, ХУЖЕ ОТСУТСТВУЮЩЕЙ. Повторный `dz agents-sync` эту находку
8880
+ // НЕ лечит: реестр POLICY_SOURCES ведётся руками, и секция, не вписанная в него, не
8881
+ // попадёт в проекцию сколько ни синхронизируй. Раньше здесь печаталась общая подсказка —
8882
+ // читатель прогнал бы её и снова увидел ту же ошибку.
8883
+ writeErr('→ heal with: объяви секцию в POLICY_SOURCES (packages/@dzhechkov/harness-core/src/agents-policy.ts):');
8884
+ for (const finding of unregistered) {
8885
+ writeErr(` { id: '${finding.id}', file: '${finding.file}', heading: '…', why: '…', operativeClause: '…' }`);
8886
+ }
8887
+ writeErr(' затем: dz agents-sync');
8888
+ } else if (drifted.some((finding) => finding.id === 'dz:policies')) {
8361
8889
  writeErr('→ heal with: repair duplicate/unmatched dz:policies markers, then run dz agents-sync');
8362
8890
  } else {
8363
8891
  writeErr('→ heal with: dz agents-sync');
@@ -8448,8 +8976,11 @@ const DEFAULT_STORE_CAP = 5000;
8448
8976
  */
8449
8977
  const MAX_STUB_SCAN_FILES = 400;
8450
8978
 
8451
- /** Read the optional `.dz/guard.json` `{ rules?: [...], storeCap?: number, stubWaivers?: [...] }`. Missing/broken ⇒ defaults. */
8452
- function loadGuardConfig(root: string): { rules?: unknown[]; storeCap?: number; stubWaivers?: unknown[]; reviewRound?: { minGrade?: unknown } } {
8979
+ /** Maximum packed-file size read by the publish secret scan. */
8980
+ const SECRET_SCAN_MAX_BYTES = 512 * 1024;
8981
+
8982
+ /** Read the optional `.dz/guard.json` — `{ rules?, storeCap?, stubWaivers?, secretWaivers? }`. Missing/broken ⇒ defaults. */
8983
+ function loadGuardConfig(root: string): { rules?: unknown[]; storeCap?: number; stubWaivers?: unknown[]; secretWaivers?: unknown[]; reviewRound?: { minGrade?: unknown } } {
8453
8984
  const p = join(root, '.dz', 'guard.json');
8454
8985
  if (!existsSync(p)) return {};
8455
8986
  try {
@@ -8475,6 +9006,51 @@ function gatherReadmeCounts(root: string): { label: string; a: number; b: number
8475
9006
  // (target repo without sitedoc — missing-evidence contract). But a file that EXISTS and no longer
8476
9007
  // matches its anchored pattern emits a MISMATCH pair (a: -1) — silent non-extraction is the exact
8477
9008
  // disease this contour cures (AM-1 applies to the guard path too, not only the CI test).
9009
+ // ЗНАЧКИ ПРОТИВ ДЕРЕВА, а не только README против README (бэклог 9cb30764).
9010
+ //
9011
+ // Прежде правило сверяло только числа МЕЖДУ документами: два согласованных документа могли
9012
+ // хором утверждать одно и то же неверное число, и правило молчало. Значок «пакетов: 52» стоял
9013
+ // при 55 публикуемых на диске — ИЗМЕРЕНО 2026-09-03. Меню обещает 32 блюда, официант
9014
+ // перечисляет 30, кухня готовит 38, и никто в ресторане не знает правду.
9015
+ //
9016
+ // Сверяются только числа, ВЫЧИСЛИМЫЕ ИЗ ДЕРЕВА. Значок «опубликовано в npm» сюда НЕ входит и
9017
+ // это сказано вслух: его источник — реестр, а не рабочая копия, и пара, которая делает вид, что
9018
+ // проверила его, была бы хуже отсутствующей.
9019
+ const badge = (s: string, name: string): number | null => {
9020
+ const m = s.match(new RegExp(`img\\.shields\\.io/badge/${name}-(\\d+)`));
9021
+ return m && m[1] ? Number(m[1]) : null;
9022
+ };
9023
+ const publishablePackages = ((): number | null => {
9024
+ const dir = join(root, 'packages', '@dzhechkov');
9025
+ if (!existsSync(dir)) return null;
9026
+ let n = 0;
9027
+ for (const name of readdirSync(dir)) {
9028
+ const pj = join(dir, name, 'package.json');
9029
+ if (!existsSync(pj)) continue;
9030
+ try {
9031
+ const j = JSON.parse(readFileSync(pj, 'utf8')) as { private?: unknown };
9032
+ if (j.private !== true) n += 1;
9033
+ } catch { /* нечитаемый манифест — не считается ни в одну сторону */ }
9034
+ }
9035
+ return n;
9036
+ })();
9037
+ const pkgBadge = badge(rootMd, 'packages');
9038
+ if (pkgBadge !== null && publishablePackages !== null) {
9039
+ pairs.push({ label: 'packages (root badge vs publishable package.json on disk)', a: pkgBadge, b: publishablePackages });
9040
+ }
9041
+ const presetBadge = badge(rootMd, 'presets');
9042
+ if (presetBadge !== null) {
9043
+ pairs.push({ label: 'presets (root badge vs PRESET_NAMES in the build)', a: presetBadge, b: PRESET_NAMES.length });
9044
+ }
9045
+ const targetBadge = badge(rootMd, 'targets');
9046
+ if (targetBadge !== null) {
9047
+ pairs.push({ label: 'targets (root badge vs TARGET_NAMES in the build)', a: targetBadge, b: TARGET_NAMES.length });
9048
+ }
9049
+ const cmdBadge = badge(rootMd, 'CLI%20commands');
9050
+ if (cmdBadge !== null && cliAll !== null) {
9051
+ pairs.push({ label: 'commands (root badge vs cli All Commands)', a: cmdBadge, b: cliAll });
9052
+ }
9053
+
8478
9054
  const sitePair = (rel: string, re: RegExp, label: string): void => {
8479
9055
  if (cliAll === null || !existsSync(join(root, rel))) return;
8480
9056
  const found = num(read(rel), re);
@@ -8880,8 +9456,9 @@ function gatherVolumeShadowFacts(
8880
9456
  }
8881
9457
 
8882
9458
  /** Gather the facts one op needs. All I/O is best-effort — a missing signal skips its rule, never crashes. */
8883
- function gatherGuardFacts(op: string, root: string, text: string | undefined, storeCap: number): Record<string, unknown> {
9459
+ function gatherGuardFacts(op: string, root: string, text: string | undefined, storeCap: number, publishFilter?: readonly string[]): Record<string, unknown> {
8884
9460
  const facts: Record<string, unknown> = { op };
9461
+ const publishPackageRoots: string[] = [];
8885
9462
  if (op === 'publish') {
8886
9463
  // Advisory I/O: unreadable telemetry or fed state is absence of evidence, never a fabricated
8887
9464
  // stale finding and never a publish blocker.
@@ -9013,6 +9590,13 @@ function gatherGuardFacts(op: string, root: string, text: string | undefined, st
9013
9590
  } catch { /* not a git repo */ }
9014
9591
  const versionByName = new Map<string, string>();
9015
9592
  for (const m of manifests) if (m.name && typeof m.version === 'string') versionByName.set(m.name, m.version);
9593
+ publishPackageRoots.push(...located
9594
+ .filter(({ dir, m }) => m.private !== true && (
9595
+ publishFilter === undefined
9596
+ || publishFilter.length === 0
9597
+ || publishFilter.some((filter) => (m.name ?? '').includes(filter) || dir.includes(filter))
9598
+ ))
9599
+ .map(({ dir }) => dir));
9016
9600
  const pnpmWorkspace = existsSync(join(root, 'pnpm-workspace.yaml'));
9017
9601
  const packages: { name: string; deps: Record<string, string> }[] = [];
9018
9602
  for (const m of manifests) {
@@ -9026,6 +9610,82 @@ function gatherGuardFacts(op: string, root: string, text: string | undefined, st
9026
9610
  packages.push({ name: m.name ?? '(unnamed)', deps });
9027
9611
  }
9028
9612
  facts['packages'] = packages;
9613
+ // sibling-dep-protocol: сырые спеки, БЕЗ подстановки версии. Подставленная версия выглядела бы
9614
+ // как обычный диапазон, и правило потеряло бы ровно то, что проверяет.
9615
+ const siblingDeps: { name: string; field: string; dep: string; spec: string }[] = [];
9616
+ for (const m of manifests) {
9617
+ if (m.private === true) continue;
9618
+ const fields = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'] as const;
9619
+ for (const field of fields) {
9620
+ const table = (m as unknown as Record<string, unknown>)[field];
9621
+ if (typeof table !== 'object' || table === null) continue;
9622
+ for (const [dep, spec] of Object.entries(table as Record<string, unknown>)) {
9623
+ if (!dep.startsWith('@dzhechkov/') || typeof spec !== 'string') continue;
9624
+ siblingDeps.push({ name: m.name ?? '(unnamed)', field, dep, spec });
9625
+ }
9626
+ }
9627
+ }
9628
+ facts['siblingDeps'] = siblingDeps;
9629
+ // plugin-manifest-audit: каждый `.claude-plugin/plugin.json` в дереве. Обход ограничен по
9630
+ // глубине и не заходит в node_modules/dist — чужие манифесты не наши, и краснеть на них
9631
+ // значило бы отчитываться о том, чего мы не публикуем.
9632
+ const pluginManifests: { path: string; parseError?: string; name?: string; version?: string; description?: string }[] = [];
9633
+ const walkPlugins = (dir: string, depth: number): void => {
9634
+ if (depth > 4) return;
9635
+ let entries: Dirent[];
9636
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
9637
+ for (const e of entries) {
9638
+ // `out/` — СГЕНЕРИРОВАННОЕ публичное зеркало: те же манифесты, скопированные. Дефект в нём
9639
+ // есть дефект генератора, и он уже сообщается по источнику; вторая копия только удвоила бы
9640
+ // одну и ту же находку.
9641
+ if (!e.isDirectory() || e.name === 'node_modules' || e.name === 'dist' || e.name === 'out') continue;
9642
+ const full = join(dir, e.name);
9643
+ if (e.name === '.claude-plugin') {
9644
+ const manifest = join(full, 'plugin.json');
9645
+ if (!existsSync(manifest)) continue;
9646
+ try {
9647
+ const j = JSON.parse(readFileSync(manifest, 'utf8')) as Record<string, unknown>;
9648
+ const pick = (k: string): string | undefined => (typeof j[k] === 'string' ? j[k] as string : undefined);
9649
+ // Состав навыков — ОБЕ стороны инвентаризации, и ТОЛЬКО для коробки ОДНОГО ПАКЕТА.
9650
+ //
9651
+ // Корневая витрина сюда НЕ входит, и это не упрощение: её состав собирается из всего
9652
+ // монорепозитория через реестр, «что лежит на складе» для неё — не обход одного дерева,
9653
+ // а весь реестр, и ровно это уже проверяет `marketplace-parity` регенерацией. Первая
9654
+ // редакция этой проверки обошла корень с ограничением глубины и выдала 27 ЛОЖНЫХ
9655
+ // «объявлено, но не найдено» — навыки лежали глубже границы обхода (ИЗМЕРЕНО 2026-09-04,
9656
+ // поймано до коммита прогоном стража на этом же дереве).
9657
+ //
9658
+ // Сравниваются ПУТИ, как их объявил манифест, а не имена: два навыка с одинаковым
9659
+ // именем в разных подкаталогах — законная вещь, и сведение к имени их бы склеило.
9660
+ const boxRoot = dirname(full);
9661
+ const isRepoRoot = resolve(boxRoot) === resolve(root);
9662
+ const declaredSkills = Array.isArray(j['skills']) && !isRepoRoot
9663
+ ? (j['skills'] as unknown[]).filter((x): x is string => typeof x === 'string')
9664
+ .map((rel) => rel.replace(/^\.\//, '').replace(/\/+$/, '')).filter(Boolean)
9665
+ : undefined;
9666
+ const skillsOnDisk = declaredSkills === undefined ? undefined : findSkillDirs(boxRoot);
9667
+ pluginManifests.push({
9668
+ path: relative(root, manifest),
9669
+ ...(pick('name') !== undefined ? { name: pick('name')! } : {}),
9670
+ ...(pick('version') !== undefined ? { version: pick('version')! } : {}),
9671
+ ...(pick('description') !== undefined ? { description: pick('description')! } : {}),
9672
+ ...(declaredSkills !== undefined ? { declaredSkills } : {}),
9673
+ ...(skillsOnDisk !== undefined ? { skillsOnDisk } : {}),
9674
+ });
9675
+ } catch (error) {
9676
+ pluginManifests.push({
9677
+ path: relative(root, manifest),
9678
+ parseError: (error instanceof Error ? error.message : String(error)).replace(/\s+/g, ' ').slice(0, 200),
9679
+ });
9680
+ }
9681
+ continue;
9682
+ }
9683
+ if (e.name.startsWith('.')) continue;
9684
+ walkPlugins(full, depth + 1);
9685
+ }
9686
+ };
9687
+ walkPlugins(root, 0);
9688
+ facts['pluginManifests'] = pluginManifests;
9029
9689
  facts['volume'] = gatherVolumeShadowFacts(root, located.map(({ dir, m }) => ({
9030
9690
  dir,
9031
9691
  name: m.name ?? dir,
@@ -9053,7 +9713,43 @@ function gatherGuardFacts(op: string, root: string, text: string | undefined, st
9053
9713
  }
9054
9714
  facts['licenceHold'] = holds;
9055
9715
  } catch { /* unreadable tree — the rule reports nothing rather than inventing a violation */ }
9056
- try { facts['drift'] = sweepSkillDrift(root, { scope: 'installs', allowlist: readDriftAllowlist(root) }).drifted.map((d) => d.name); } catch { /* skip */ }
9716
+ try { facts['drift'] = sweepSkillDrift(root, { scope: DRIFT_SWEEP_SCOPE, allowlist: readDriftAllowlist(root) }).drifted.map((d) => d.name); } catch { /* skip */ }
9717
+ // backlog-covers-features: каталоги фич, дата их ПОЯВЛЕНИЯ В ИСТОРИИ (не mtime — его двигает
9718
+ // любой посторонний процесс), оговорки из README фичи и тексты записей бэклога. Базовая дата
9719
+ // делает правило зелёным на приходе: 336 существующих каталогов заведены до правила.
9720
+ // Нечитаемое дерево ⇒ факт НЕ выставляется ⇒ правило молчит, а не выдумывает вердикт.
9721
+ try {
9722
+ const featDir = join(root, 'features');
9723
+ if (existsSync(featDir)) {
9724
+ const slugs = readdirSync(featDir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
9725
+ const features = slugs.map((slug) => {
9726
+ let createdIso = '';
9727
+ try {
9728
+ createdIso = execSync(`git log --diff-filter=A --format=%aI -1 -- ${JSON.stringify('features/' + slug)}`,
9729
+ { cwd: root, encoding: 'utf-8' }).trim().split('\n').filter(Boolean).pop() ?? '';
9730
+ } catch { /* нет в истории — функция засчитает как новый, это верный дефолт */ }
9731
+ let waiver: string | undefined;
9732
+ for (const f of ['README.md', '07_code_changes/change_manifest.md']) {
9733
+ try {
9734
+ const m = readFileSync(join(featDir, slug, f), 'utf-8')
9735
+ .match(/^\s*Backlog:\s*не заведено\s*[—-]\s*(.+)$/m);
9736
+ if (m && m[1] && m[1].trim() !== '') { waiver = m[1].trim(); break; }
9737
+ } catch { /* нет файла — не оговорка */ }
9738
+ }
9739
+ return { slug, createdIso, waiver };
9740
+ });
9741
+ const backlogTexts: string[] = [];
9742
+ try {
9743
+ for (const line of readFileSync(join(root, '.dz', 'backlog', 'ideas.jsonl'), 'utf-8').split('\n')) {
9744
+ if (line.trim() === '') continue;
9745
+ try { const o = JSON.parse(line) as { text?: unknown }; if (typeof o.text === 'string') backlogTexts.push(o.text); } catch { /* рваная строка */ }
9746
+ }
9747
+ } catch { /* стора нет */ }
9748
+ if (backlogTexts.length > 0) {
9749
+ facts['featureBacklog'] = { baseline: BACKLOG_COVERAGE_BASELINE, features, backlogTexts };
9750
+ }
9751
+ }
9752
+ } catch { /* нечитаемо — правило молчит */ }
9057
9753
  facts['counts'] = gatherReadmeCounts(root);
9058
9754
  // readme-first: from the WORKING-TREE diff (publishes happen pre-commit here), per package: does the
9059
9755
  // change set contain its package.json (the version-bump signal) without its README.md?
@@ -9213,11 +9909,39 @@ function gatherGuardFacts(op: string, root: string, text: string | undefined, st
9213
9909
 
9214
9910
  // no-stubs config waivers: `.dz/guard.json` `stubWaivers: [{path, reason}]` — path-keyed, reason
9215
9911
  // MANDATORY (the feature-adr-setup --guards shape; the pure checker refuses a reasonless entry).
9216
- const stubWaivers = loadGuardConfig(root).stubWaivers;
9912
+ const guardConfig = loadGuardConfig(root);
9913
+ const stubWaivers = guardConfig.stubWaivers;
9217
9914
  if (Array.isArray(stubWaivers)) facts['stubWaivers'] = stubWaivers;
9915
+ const secretWaivers = guardConfig.secretWaivers;
9916
+ if (Array.isArray(secretWaivers)) facts['secretWaivers'] = secretWaivers;
9218
9917
  }
9219
9918
  if (op === 'consolidate') {
9220
- try { facts['drift'] = sweepSkillDrift(root, { scope: 'installs', allowlist: readDriftAllowlist(root) }).drifted.map((d) => d.name); } catch { /* skip */ }
9919
+ try { facts['drift'] = sweepSkillDrift(root, { scope: DRIFT_SWEEP_SCOPE, allowlist: readDriftAllowlist(root) }).drifted.map((d) => d.name); } catch { /* skip */ }
9920
+ }
9921
+ if (op === 'publish') {
9922
+ const secretTargets: { label: string; text: string }[] = [];
9923
+ let skipped = 0;
9924
+ for (const dir of publishPackageRoots) {
9925
+ const packageRoot = join(root, dir);
9926
+ let packed: string[];
9927
+ try { packed = listPackFiles(packageRoot); }
9928
+ catch { skipped++; continue; }
9929
+ for (const rel of packed) {
9930
+ const absolute = join(packageRoot, rel);
9931
+ try {
9932
+ const stat = lstatSync(absolute);
9933
+ if (!stat.isFile() || stat.size > SECRET_SCAN_MAX_BYTES) { skipped++; continue; }
9934
+ const content = readFileSync(absolute);
9935
+ if (content.subarray(0, 8 * 1024).includes(0)) { skipped++; continue; }
9936
+ secretTargets.push({
9937
+ label: relative(root, absolute).split(sep).join('/'),
9938
+ text: content.toString('utf8'),
9939
+ });
9940
+ } catch { skipped++; }
9941
+ }
9942
+ }
9943
+ if (secretTargets.length > 0) facts['secretTargets'] = secretTargets;
9944
+ if (skipped > 0) facts['secretScan'] = { skipped };
9221
9945
  }
9222
9946
  if (op === 'teach' || op === 'consolidate') {
9223
9947
  if (op === 'teach' && text) facts['secretTargets'] = [{ label: 'lesson', text }];
@@ -9233,13 +9957,13 @@ function gatherGuardFacts(op: string, root: string, text: string | undefined, st
9233
9957
  * shared by `dz guard check` and the `dz publish` pre-flight (ADR-002 option A) so they can never disagree.
9234
9958
  * `overrideReason` (when the caller forces through a block) is logged, never silent.
9235
9959
  */
9236
- function runGuardEvaluation(root: string, op: string, text: string | undefined, overrideReason: string | undefined): ReturnType<typeof evaluateGuard> {
9960
+ function runGuardEvaluation(root: string, op: string, text: string | undefined, overrideReason: string | undefined, publishFilter?: readonly string[]): ReturnType<typeof evaluateGuard> {
9237
9961
  const cfg = loadGuardConfig(root);
9238
9962
  // Number.isFinite, not just > 0: a config `storeCap: 1e400` parses to Infinity, passes `> 0`, and would
9239
9963
  // silently DISABLE the cap (count <= Infinity always). Non-finite ⇒ fall back to the default.
9240
9964
  const storeCap = typeof cfg.storeCap === 'number' && Number.isFinite(cfg.storeCap) && cfg.storeCap > 0 ? cfg.storeCap : DEFAULT_STORE_CAP;
9241
9965
  const rules = resolveRules(Array.isArray(cfg.rules) ? (cfg.rules as never[]) : undefined);
9242
- const facts = gatherGuardFacts(op, root, text, storeCap);
9966
+ const facts = gatherGuardFacts(op, root, text, storeCap, publishFilter);
9243
9967
  const result = evaluateGuard(facts as never, rules);
9244
9968
  // audit (append-only). ts is real time here (a CLI, not the sandboxed workflow).
9245
9969
  try {
@@ -10038,15 +10762,52 @@ async function cmdMrRakes(options: Map<string, string>, flags: Set<string>, cwd:
10038
10762
  * --threshold N drill threshold (default 2 — anti-noise: a first-seen rake accrues, never drills)
10039
10763
  * --no-teach drill only; do NOT write the store (skip the agent side)
10040
10764
  * --project <dir> pin the teach ledger
10041
- * --install-hook print the opt-in SessionEnd hook to add (non-destructive)
10765
+ * --install-hook print the opt-in hook set to add (non-destructive): Stop scan-tail +
10766
+ * PreCompact/SessionEnd full retro (feature narrated-error-must-be-taught)
10767
+ * --scan-tail per-turn Stop-hook mode: incremental admission-debt scan, O(new bytes) —
10768
+ * writes/clears .dz/retro-pending.json; no ledger, no teach, no git subprocess
10769
+ * --transcript <p> the transcript --scan-tail must read. Without it the Stop hook's own stdin
10770
+ * payload (`transcript_path`) is used; with neither, the scan REFUSES
10771
+ * (NOT-ESTABLISHED) rather than guessing the newest file on disk
10042
10772
  */
10043
- async function cmdRetro(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): Promise<number> {
10773
+ async function cmdRetro(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write, readStdin: () => string = () => ''): Promise<number> {
10774
+ if (flags.has('scan-tail')) {
10775
+ // Per-turn primary detector (ADR-001 D2). Cost budget IS the design: no `git rev-parse`
10776
+ // subprocess (hooks run with cwd = project root; CLAUDE_PROJECT_DIR pins it), no store open,
10777
+ // O(new bytes) via the persisted offset. Always exit 0 — a Stop hook must never fail a turn.
10778
+ const root = process.env['CLAUDE_PROJECT_DIR'] ?? cwd;
10779
+ // WHICH transcript. Round 3, P1-3: this used to fall back to `findLatestTranscript(root)`, so with
10780
+ // several sessions and their subagents alive at once it scanned whichever file had the newest
10781
+ // mtime — routinely another session's, advancing that session's offset and never seeing this
10782
+ // turn's admission. The Stop hook hands the exact path on stdin; with no path from any source the
10783
+ // answer is a stated refusal, never a guess. Still exit 0: a Stop hook must never fail a turn.
10784
+ const picked = resolveScanTailTranscript({
10785
+ flag: options.get('transcript'),
10786
+ positional: options.get('_positional_0'),
10787
+ stdin: readStdin(),
10788
+ });
10789
+ if (picked.path === null) {
10790
+ const reason = picked.reason ?? 'no transcript path';
10791
+ if (flags.has('json')) write(JSON.stringify({ status: 'not-established', source: 'none', reason, scannedBytes: 0, offset: 0 }));
10792
+ 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>\`.`);
10793
+ return 0;
10794
+ }
10795
+ const outcome = runRetroTailScan(join(root, '.dz'), picked.path);
10796
+ if (flags.has('json')) write(JSON.stringify({ ...outcome, source: picked.source }));
10797
+ else if (outcome.status === 'pending') write(`retro scan-tail: unpaid admission — .dz/retro-pending.json armed («${(outcome.snippet ?? '').slice(0, 60)}…»)`);
10798
+ return 0;
10799
+ }
10800
+
10044
10801
  let repoRoot = cwd;
10045
10802
  try { repoRoot = execSync('git rev-parse --show-toplevel', { cwd, encoding: 'utf-8' }).trim() || cwd; } catch { /* not git */ }
10046
10803
 
10047
10804
  if (flags.has('install-hook')) {
10048
- write('Add this opt-in SessionEnd hook to .claude/settings.json (runs a retro when a session ends):');
10049
- write(JSON.stringify({ hooks: { SessionEnd: [{ hooks: [{ type: 'command', command: 'dz retro' }] }] } }, null, 2));
10805
+ 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):');
10806
+ write(JSON.stringify({ hooks: {
10807
+ Stop: [{ hooks: [{ type: 'command', command: 'dz retro --scan-tail', timeout: 10000, continueOnError: true }] }],
10808
+ PreCompact: [{ hooks: [{ type: 'command', command: 'dz retro', timeout: 60000, continueOnError: true }] }],
10809
+ SessionEnd: [{ hooks: [{ type: 'command', command: 'dz retro', timeout: 60000, continueOnError: true }] }],
10810
+ } }, null, 2));
10050
10811
  return 0;
10051
10812
  }
10052
10813
 
@@ -10283,6 +11044,43 @@ function cmdChallenge(options: Map<string, string>, flags: Set<string>, cwd: str
10283
11044
  * decide (dz's rule — a false gate kills trust). Exit code is 0 on a clean run regardless of verdict; 2 only on
10284
11045
  * a usage/setup error, so a caller distinguishes "gate ran" from "gate could not run".
10285
11046
  */
11047
+ /**
11048
+ * Есть ли в этом каталоге НЕЗАКОММИЧЕННЫЕ правки. Это и отличает «фича ещё в рабочем дереве»
11049
+ * (тогда `HEAD` — законная предфичевая база) от «фича уже закоммичена» (тогда `HEAD` её содержит).
11050
+ *
11051
+ * Не удалось спросить git — возвращается null, и вызывающий обязан считать положение НЕ
11052
+ * УСТАНОВЛЕННЫМ, а не выбрать удобный ответ.
11053
+ */
11054
+ /**
11055
+ * Каталоги с `SKILL.md` внутри коробки ОДНОГО пакета — то, что РЕАЛЬНО лежит на складе.
11056
+ * Возвращаются ПУТИ относительно коробки, как их объявляет манифест, а не имена.
11057
+ */
11058
+ function findSkillDirs(boxRoot: string): string[] {
11059
+ const found = new Set<string>();
11060
+ const walk = (dir: string, depth: number): void => {
11061
+ if (depth > 4) return;
11062
+ let entries: Dirent[];
11063
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
11064
+ for (const e of entries) {
11065
+ if (!e.isDirectory() || e.name === 'node_modules' || e.name === 'dist' || e.name.startsWith('.')) continue;
11066
+ const full = join(dir, e.name);
11067
+ if (existsSync(join(full, 'SKILL.md'))) found.add(relative(boxRoot, full));
11068
+ walk(full, depth + 1);
11069
+ }
11070
+ };
11071
+ walk(boxRoot, 0);
11072
+ return [...found];
11073
+ }
11074
+
11075
+ function hasUncommittedChangesIn(repoRoot: string, dir: string): boolean | null {
11076
+ try {
11077
+ const out = execSync(`git status --porcelain -- ${JSON.stringify(dir)}`, { cwd: repoRoot, encoding: 'utf-8' });
11078
+ return out.split('\n').some((line) => line.trim() !== '');
11079
+ } catch {
11080
+ return null;
11081
+ }
11082
+ }
11083
+
10286
11084
  function cmdDiscriminationCheck(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
10287
11085
  let repoRoot = cwd;
10288
11086
  try { repoRoot = execSync('git rev-parse --show-toplevel', { cwd, encoding: 'utf-8' }).trim() || cwd; } catch { /* not git */ }
@@ -10295,7 +11093,22 @@ function cmdDiscriminationCheck(options: Map<string, string>, flags: Set<string>
10295
11093
  const nameFilter = options.get('name');
10296
11094
  const propertyTests = testArg.split(',').map((s) => s.trim()).filter(Boolean).map((file) =>
10297
11095
  nameFilter !== undefined && nameFilter.trim() !== '' ? { file, name: nameFilter.trim() } : { file });
10298
- const baseRef = options.get('base') ?? 'HEAD';
11096
+ /**
11097
+ * УМОЛЧАНИЕ БАЗЫ РАЗЛИЧАЕТ ДВА РАЗНЫХ ПОЛОЖЕНИЯ, потому что они и правда разные.
11098
+ *
11099
+ * В штатном ходе конвейера правка Шага 7 ЕЩЁ НЕ ЗАКОММИЧЕНА, и тогда `HEAD` — настоящая
11100
+ * предфичевая база; так конвейер и зовёт гейт (`--base HEAD`, причина записана в его тексте).
11101
+ *
11102
+ * Но если фича УЖЕ ЗАКОММИЧЕНА, `HEAD` её содержит, и гейт сравнивает фичу С САМОЙ СОБОЙ.
11103
+ * ИЗМЕРЕНО 2026-09-04: такой прогон дал уверенное `NON_DISCRIMINATING` с советом «усилить тест»
11104
+ * на тестах, которые дискриминируют; тот же прогон с верной базой дал `DISCRIMINATES_VIA_ERROR`.
11105
+ * Находка выглядела как утверждение О ТЕСТАХ, и автор пошёл бы чинить исправное.
11106
+ *
11107
+ * Различить эти положения можно ДЕТЕРМИНИРОВАННО: есть ли незакоммиченные правки в пакете, чьи
11108
+ * тесты названы. Есть — `HEAD` законен и используется как раньше. Нет — база не установлена, и
11109
+ * гейт ОТКАЗЫВАЕТСЯ (exit 3), вместо того чтобы измерять относительно самого себя.
11110
+ */
11111
+ const explicitBase = options.get('base');
10299
11112
  const runnerOpt = options.get('runner');
10300
11113
  // R11: a hung runner is a loud non-answer, never a pass. Same default + parse shape as mutation-gate.
10301
11114
  const timeoutOpt = Number(options.get('timeout') ?? '300000');
@@ -10338,9 +11151,35 @@ function cmdDiscriminationCheck(options: Map<string, string>, flags: Set<string>
10338
11151
  // The pure half's path sanitation expects a REPO-RELATIVE package dir ('.'-rooted), not an
10339
11152
  // absolute one — an absolute path is refused as unsafe-package-dir by design.
10340
11153
  const packageDirRel = relative(repoRoot, packageDir) || '.';
11154
+
11155
+ // Умолчание базы решается ЗДЕСЬ, потому что только здесь известен пакет, чьи тесты названы.
11156
+ let baseRef: string;
11157
+ if (explicitBase !== undefined && explicitBase.trim() !== '') {
11158
+ baseRef = explicitBase.trim();
11159
+ } else {
11160
+ const dirty = hasUncommittedChangesIn(repoRoot, packageDirRel);
11161
+ if (dirty !== true) {
11162
+ write('dz discrimination-check: NOT-ESTABLISHED — предфичевая база не установлена.');
11163
+ write(dirty === null
11164
+ ? ` Не удалось спросить git о состоянии ${packageDirRel}; выбирать удобный ответ вместо этого нельзя.`
11165
+ : ` В ${packageDirRel} нет незакоммиченных правок, значит фича УЖЕ в HEAD, и сравнение шло бы с самой собой.`);
11166
+ write(' Гейт сравнивает поведение ДО и ПОСЛЕ фичи; без базы сравнивать не с чем, а HEAD здесь');
11167
+ write(' дал бы уверенное NON_DISCRIMINATING на исправных тестах (ИЗМЕРЕНО 2026-09-04).');
11168
+ write(' Передайте --base <коммит перед фичей>, например `<sha коммита фичи>^`.');
11169
+ return 3;
11170
+ }
11171
+ // Штатный ход конвейера: правка Шага 7 ещё в рабочем дереве, HEAD — настоящая предфичевая база.
11172
+ baseRef = 'HEAD';
11173
+ write(`dz discrimination-check: база не задана; в ${packageDirRel} есть незакоммиченные правки, беру HEAD как предфичевую базу.`);
11174
+ }
11175
+
11176
+ // Провенанс базы едет в квитанцию вместе с самой базой: «явно указано» — утверждение о действии
11177
+ // человека, и писать его для HEAD, выбранного инструментом, значит подделывать происхождение
11178
+ // доказательства, на которое сошлются позже.
11179
+ const baseRefSupplied = explicitBase !== undefined && explicitBase.trim() !== '';
10341
11180
  const planInput = runnerOpt !== undefined
10342
- ? { baseRef, propertyTests, runner: runnerOpt, packageTestScript, packageDevDependencies, packageDir: packageDirRel }
10343
- : { baseRef, propertyTests, packageTestScript, packageDevDependencies, packageDir: packageDirRel };
11181
+ ? { baseRef, baseRefSupplied, propertyTests, runner: runnerOpt, packageTestScript, packageDevDependencies, packageDir: packageDirRel }
11182
+ : { baseRef, baseRefSupplied, propertyTests, packageTestScript, packageDevDependencies, packageDir: packageDirRel };
10344
11183
  const plan = planDiscriminationCheck(planInput);
10345
11184
 
10346
11185
  if (!plan.runnable) {
@@ -10720,13 +11559,33 @@ function cmdMutationGate(
10720
11559
  }
10721
11560
 
10722
11561
  let entries: readonly MutationRegistryEntry[] = parsed.registry.entries;
11562
+ let entryResults: readonly MutationEntryResult[] = parsed.entryResults;
10723
11563
  const only = options.get('only');
10724
11564
  if (only !== undefined) {
10725
11565
  const ids = only.split(',').map((s) => s.trim()).filter(Boolean);
10726
- const known = new Set(entries.map((e) => e.id));
11566
+ const known = new Set([
11567
+ ...entries.map((entry) => entry.id),
11568
+ ...entryResults.map((result) => result.id),
11569
+ ]);
10727
11570
  const unknown = ids.filter((id) => !known.has(id));
10728
11571
  if (unknown.length > 0) return fail(`--only names unknown entry id(s): ${unknown.join(', ')}`);
10729
11572
  entries = entries.filter((e) => ids.includes(e.id));
11573
+ entryResults = entryResults.filter((result) => ids.includes(result.id));
11574
+ }
11575
+
11576
+ if (entries.length === 0) {
11577
+ const scope = only === undefined ? 'registry' : 'selected registry entries';
11578
+ const error = `${scope} has no runnable entries after validation — nothing can be run; the registry is unusable`;
11579
+ const summary = summarizeMutationResults(entryResults);
11580
+ if (json) {
11581
+ write(JSON.stringify({ error, registryPath, results: entryResults, summary, exitCode: 2 }, null, 2));
11582
+ } else {
11583
+ write(`dz mutation-gate: ${error}`);
11584
+ for (const result of entryResults) {
11585
+ write(` - ${result.id}: ${result.verdict} — ${result.detail}`);
11586
+ }
11587
+ }
11588
+ return 2;
10730
11589
  }
10731
11590
 
10732
11591
  const testCmdRaw = options.get('test-cmd') ?? parsed.registry.testCommand ?? 'npm test';
@@ -10762,7 +11621,7 @@ function cmdMutationGate(
10762
11621
  let gitTop: string | null = null;
10763
11622
  try { gitTop = execSync('git rev-parse --show-toplevel', { cwd: pkgDir, stdio: 'pipe', encoding: 'utf-8' }).trim() || null; } catch { /* not in a git repo */ }
10764
11623
  let copyDir = join(scratchParent, 'pkg');
10765
- const results: MutationEntryResult[] = [];
11624
+ const results: MutationEntryResult[] = [...entryResults];
10766
11625
  const observations: MutationObservation[] = [];
10767
11626
  const warnings: string[] = [];
10768
11627
  const internalRetries: {
@@ -10895,8 +11754,8 @@ function cmdMutationGate(
10895
11754
  : undefined,
10896
11755
  );
10897
11756
  if (!baseline.ok) {
10898
- if (json) { write(JSON.stringify({ packageDir: pkgDir, registryPath, testCommand: testCmd, baseline, results: [], internalRetries, exitCode: 1 }, null, 2)); return 1; }
10899
- write(renderMutationReport([], baseline, pkgDir));
11757
+ if (json) { write(JSON.stringify({ packageDir: pkgDir, registryPath, testCommand: testCmd, baseline, results, internalRetries, exitCode: 1 }, null, 2)); return 1; }
11758
+ write(renderMutationReport(results, baseline, pkgDir));
10900
11759
  return 1;
10901
11760
  }
10902
11761
 
@@ -12242,9 +13101,24 @@ function nameCheckScan(repoRoot: string): NameFacts {
12242
13101
  // Command names come from the dispatcher AND from the help block: a name that dispatches but
12243
13102
  // is undocumented is still taken, and so is the reverse.
12244
13103
  if (f.name === 'cli.ts') {
12245
- for (const c of dispatchedCommandsIn(text)) commands.add(c);
12246
- const help = /^\s{2}dz ([a-z][a-z0-9-]*)/gm;
12247
- for (let m = help.exec(text); m !== null; m = help.exec(text)) if (m[1] !== undefined) commands.add(m[1]);
13104
+ // ONE enumeration, every consumer derives (ADR-001, feature command-count-triad). This
13105
+ // scan's question is "is the name TAKEN?", so taken = dispatched ∪ documented, which is
13106
+ // legitimately LARGER than the canonical command count but it must be the SAME parse
13107
+ // the layer-1 parity test uses, not a second private regex that agrees by coincidence.
13108
+ // The any-indent `dispatchedCommandsIn` fallback stays for a cli.ts WITHOUT a main
13109
+ // `switch (command)` (none in this workspace today): a partial sweep would answer "free"
13110
+ // about a taken name, and that is the one answer this command may never give.
13111
+ let mainSwitchParsed = false;
13112
+ try {
13113
+ for (const c of dispatchedCommands(text)) commands.add(c);
13114
+ for (const c of documentedCommands(text)) commands.add(c);
13115
+ mainSwitchParsed = true;
13116
+ } catch { /* no main switch here — fall back to the broad regexes below */ }
13117
+ if (!mainSwitchParsed) {
13118
+ for (const c of dispatchedCommandsIn(text)) commands.add(c);
13119
+ const help = /^\s{2}dz ([a-z][a-z0-9-]*)/gm;
13120
+ for (let m = help.exec(text); m !== null; m = help.exec(text)) if (m[1] !== undefined) commands.add(m[1]);
13121
+ }
12248
13122
  }
12249
13123
  }
12250
13124
  }
@@ -12258,6 +13132,71 @@ function nameCheckScan(repoRoot: string): NameFacts {
12258
13132
  return { commands, modules, exports: exportsFound, scanned: { packages, files, exports: exportsFound.size, commands: commands.size } };
12259
13133
  }
12260
13134
 
13135
+ /**
13136
+ * `dz brief-check <файл>` — проверить бриф роя на контракт вывода (ADR-001 swarm-brief-output-contract).
13137
+ *
13138
+ * Разбирает объявления брифа как ДАННЫЕ и отказывает поимённо: «бриф неверен» не говорит автору,
13139
+ * что чинить, поэтому каждое нарушение называет ключ и причину.
13140
+ *
13141
+ * ЧЕСТНЫЙ ПРЕДЕЛ печатается ВМЕСТЕ С ЗЕЛЁНЫМ ответом: проверено, что бриф ОБЪЯВИЛ каталог и
13142
+ * единицы, а не что агент им последует. Зелёная проверка, читаемая как гарантия поведения, хуже
13143
+ * её отсутствия.
13144
+ */
13145
+ function cmdBriefCheck(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
13146
+ const json = flags.has('json');
13147
+ /**
13148
+ * ВЕТКА «ПРОВЕРИТЬ НЕ УДАЛОСЬ» ТОЖЕ ОБЯЗАНА ОТВЕТИТЬ JSON-ом (находка 11).
13149
+ *
13150
+ * `--json` печатал человеческую строку на несуществующем файле, и потребитель, читающий вывод как
13151
+ * JSON, получал ошибку разбора вместо структурного «не проверено». Признак `checked` — тот же
13152
+ * трихотомический вердикт, что и коды выхода, только для машины: не «бриф плох», а «мы про него
13153
+ * ничего не установили».
13154
+ */
13155
+ const unchecked = (reason: string, detail: string): number => {
13156
+ if (json) write(JSON.stringify({ ok: false, checked: false, reason, detail }));
13157
+ else write(detail);
13158
+ return 2;
13159
+ };
13160
+ // Только позиционный аргумент: `--file` был необъявленным псевдонимом, и страж дрейфа флагов
13161
+ // справедливо на него указал — лишняя поверхность, которой нет в справке.
13162
+ const file = options.get('_positional_0');
13163
+ if (file === undefined || file.trim() === '') {
13164
+ const code = unchecked('no-file', 'dz brief-check: name the brief file — dz brief-check <file> [--json]');
13165
+ if (!json) write(' A brief must declare: ' + SWARM_BRIEF_CONTRACT.map((c: { key: string }) => c.key).join(', '));
13166
+ return code;
13167
+ }
13168
+ let text: string;
13169
+ try {
13170
+ text = readFileSync(resolve(cwd, file), 'utf-8');
13171
+ } catch {
13172
+ // Нечитаемый файл — НЕ «бриф плох»: мы про него ничего не установили. Отдельный код выхода,
13173
+ // чтобы отказ прибора не смешивался с отказом брифа.
13174
+ return unchecked('unreadable', `dz brief-check: cannot read ${visibleText(file)}`);
13175
+ }
13176
+ const result = checkSwarmBrief(text);
13177
+ if (json) {
13178
+ // `checked: true` — вторая половина того же различителя: потребитель отличает «проверено и
13179
+ // отвергнуто» от «проверить не удалось» полем, а не отсутствием поля.
13180
+ write(JSON.stringify({ ...result, checked: true }));
13181
+ return result.ok ? 0 : 1;
13182
+ }
13183
+ if (result.ok) {
13184
+ // ЗНАЧЕНИЯ ИЗ БРИФА ОБЕЗВРЕЖИВАЮТСЯ И В ЗЕЛЁНОЙ СТРОКЕ (находка 10). Отказ их уже обезвредил,
13185
+ // но подделывается ровно эта строка: управляющая последовательность в имени каталога стирает
13186
+ // предыдущий вывод и печатает поверх него подделку.
13187
+ write(`dz brief-check: OK — dir ${visibleText(result.outputDir ?? '')}, ${result.units.length} unit(s), assembly "${visibleText(result.assemblyUnit ?? '')}"`);
13188
+ write(' LIMIT: this verifies the brief DECLARED the contract, not that the agent will follow it —');
13189
+ write(' only comparing the directory against the unit list on an orchestrator tick can show that.');
13190
+ write(' A filled-in template and an unedited one both pass: the parse cannot tell them apart.');
13191
+ return 0;
13192
+ }
13193
+ write(`dz brief-check: REFUSED — ${result.violations.length} violation(s)`);
13194
+ // Ядро уже обезвредило значения в причинах; повтор на слое печати — не суеверие, а граница:
13195
+ // печатающий слой не обязан знать, кто именно из его источников уже почистил текст.
13196
+ for (const v of result.violations) write(` ${v.rule}: ${visibleText(v.detail)}`);
13197
+ return 1;
13198
+ }
13199
+
12261
13200
  function cmdNameCheck(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
12262
13201
  const repoRoot = resolve(options.get('project') ?? cwd);
12263
13202
  const json = flags.has('json');
@@ -13141,12 +14080,24 @@ function cmdAmendmentCheck(options: Map<string, string>, flags: Set<string>, cwd
13141
14080
  // Paths in an amendment row are repo-relative, so they resolve against the repo root — not
13142
14081
  // against the feature directory, and not against wherever the caller happened to stand.
13143
14082
  const resolutions = resolveAmendments(rows, { readFile: (rel) => readOr(resolve(cwd, rel)) });
14083
+ // A document that opens `## Amendments` twice cannot have its first heading answer for the
14084
+ // rest (Codex round 6, P2). Counted per document and taken at its worst — one contradictory
14085
+ // input is enough to make the run inconclusive.
14086
+ const sectionCount = Math.max(
14087
+ ideation === null ? 0 : amendmentSectionCount(ideation),
14088
+ plan === null ? 0 : amendmentSectionCount(plan),
14089
+ );
13144
14090
  const decision = decideAmendmentOutcome({
13145
14091
  sectionPresent,
13146
14092
  rows,
13147
14093
  resolutions,
13148
14094
  planSaysNone: plan !== null && planSaysNoAmendments(plan),
13149
14095
  missingFromPlan,
14096
+ sectionCount,
14097
+ // Fail-closed: measured on the document the rows would have come from (the plan when it has
14098
+ // a section, otherwise the ideation report).
14099
+ ambiguity: (plan !== null ? amendmentDeclarationAmbiguity(plan) : null)
14100
+ ?? (ideation !== null ? amendmentDeclarationAmbiguity(ideation) : null),
13150
14101
  });
13151
14102
  return { slug, decision, resolutions };
13152
14103
  };
@@ -15459,7 +16410,9 @@ function cmdDeliveryCheck(options: Map<string, string>, flags: Set<string>, cwd:
15459
16410
  /* ------------------------------------------------------------------ */
15460
16411
 
15461
16412
  /** Thin dispatcher — ALL logic lives in harness-core/src/backlog.ts (05 architecture: handlers stay dumb). */
15462
- async function cmdBacklog(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): Promise<number> {
16413
+ async function cmdBacklog(
16414
+ options: Map<string, string>, flags: Set<string>, cwd: string, write: Write, writeErr: WriteErr,
16415
+ ): Promise<number> {
15463
16416
  const projectRoot = resolve(cwd, options.get('project') ?? '.');
15464
16417
  const json = flags.has('json');
15465
16418
  const sub = options.get('_positional_0');
@@ -15477,6 +16430,7 @@ async function cmdBacklog(options: Map<string, string>, flags: Set<string>, cwd:
15477
16430
  const eff = parseEffort(options.get('effort'), cfg.roulette.defaultEffort);
15478
16431
  if (eff.adjusted && !json && eff.note !== undefined) write(`dz backlog: ${eff.note}`);
15479
16432
  const dryRun = flags.has('dry-run');
16433
+ if (!dryRun && !allowLearningStoreWrite(projectRoot, flags, writeErr, 'dz backlog add')) return 1;
15480
16434
  // Embed-form migration (register-inflation fix): v1 vectors are FULL-TEXT embeds, v2 queries are
15481
16435
  // bounded excerpts — comparing across the forms is a query-vs-row space split. Re-mirror once
15482
16436
  // (idempotent upsert), before the dedup search. Dry-run writes nothing, so it only WARNS.
@@ -15505,6 +16459,7 @@ async function cmdBacklog(options: Map<string, string>, flags: Set<string>, cwd:
15505
16459
  const ideas = readIdeas(projectRoot);
15506
16460
  const match = ideas.find((i) => i.id === verdict.matchedId);
15507
16461
  let absorbErr: string | undefined;
16462
+ let didWrite = false;
15508
16463
  if (!dryRun && match !== undefined) {
15509
16464
  const snap = snapshotIdeas(projectRoot, join(projectRoot, '.dz', 'backlog', `ideas.pre-merge-${Date.now()}.jsonl`));
15510
16465
  if (snap.error !== undefined) return emitErr(snap.error);
@@ -15520,7 +16475,9 @@ async function cmdBacklog(options: Map<string, string>, flags: Set<string>, cwd:
15520
16475
  }).error;
15521
16476
  match.uses += 1;
15522
16477
  writeIdeas(projectRoot, ideas);
16478
+ didWrite = true;
15523
16479
  }
16480
+ if (didWrite) refreshLearningStoreMark(projectRoot, writeErr, 'dz backlog add');
15524
16481
  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));
15525
16482
  else {
15526
16483
  const via = verdict.subsetMatch === true
@@ -15576,6 +16533,7 @@ async function cmdBacklog(options: Map<string, string>, flags: Set<string>, cwd:
15576
16533
  ideas.push(rec);
15577
16534
  writeIdeas(projectRoot, ideas);
15578
16535
  const mirror = await mirrorIdeaVector(projectRoot, rec); // best-effort — never blocks capture
16536
+ refreshLearningStoreMark(projectRoot, writeErr, 'dz backlog add');
15579
16537
  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));
15580
16538
  else {
15581
16539
  write(`dz backlog: ${verdict.action.toUpperCase()} — captured ${rec.id}`);
@@ -15742,8 +16700,18 @@ async function cmdBacklog(options: Map<string, string>, flags: Set<string>, cwd:
15742
16700
  if (commitId !== undefined) {
15743
16701
  // Validated above (safe id + known id) BEFORE any early return.
15744
16702
  const idx = ideas.findIndex((i) => i.id === commitId);
16703
+ // ОТМЕТКА ВРЕМЕНИ И ЖУРНАЛ ставятся ЗДЕСЬ, а не «где-нибудь потом».
16704
+ // Измерено 2026-09-02: `grep -c statusTs cli.ts` давал НОЛЬ — оба пути смены статуса в этом
16705
+ // файле меняли поле и не отмечали, когда. Отсюда 22 терминальные записи без отметки и
16706
+ // невычислимое «сколько идея пробыла в работе».
16707
+ const spinFrom = ideas[idx]!.status;
16708
+ const spinTs = new Date().toISOString();
15745
16709
  ideas[idx]!.status = 'in-progress';
16710
+ ideas[idx]!.statusTs = spinTs;
15746
16711
  writeIdeas(projectRoot, ideas);
16712
+ if (!appendTransition(projectRoot, { id: commitId, from: spinFrom, to: 'in-progress', ts: spinTs, by: 'backlog roulette --commit' })) {
16713
+ write('dz backlog: переход НЕ записан в журнал — наблюдение потеряно (сам статус изменён)');
16714
+ }
15747
16715
  pick = ideas[idx]!;
15748
16716
  committed = true;
15749
16717
  }
@@ -15807,6 +16775,7 @@ async function cmdBacklog(options: Map<string, string>, flags: Set<string>, cwd:
15807
16775
  if (mirror.mirrored > 0 && mirror.error === undefined && clearEmbedStale(projectRoot, report.id)) embed = 'ok';
15808
16776
  else embed = 'stale';
15809
16777
  } else embed = 'stale';
16778
+ refreshLearningStoreMark(projectRoot, writeErr, 'dz backlog edit');
15810
16779
  }
15811
16780
  if (json) {
15812
16781
  write(JSON.stringify({ verb: 'edit', ...report, embed, exitCode: report.ok ? (embed === 'stale' ? 1 : 0) : 1 }, null, 2));
@@ -15834,9 +16803,15 @@ async function cmdBacklog(options: Map<string, string>, flags: Set<string>, cwd:
15834
16803
  const goalMap = readGoalMap(projectRoot);
15835
16804
  const related = ideas.filter((i) => rec.relatedIds.includes(i.id));
15836
16805
  const staging = stageEnrichment(projectRoot, rec, related, goalMap);
16806
+ const enrichFrom = rec.status;
16807
+ const enrichTs = new Date().toISOString();
15837
16808
  rec.status = 'enriched';
16809
+ rec.statusTs = enrichTs;
15838
16810
  rec.enrichedPath = `features/${staging.slug}`;
15839
16811
  writeIdeas(projectRoot, ideas);
16812
+ if (!appendTransition(projectRoot, { id: rec.id, from: enrichFrom, to: 'enriched', ts: enrichTs, by: 'backlog enrich' })) {
16813
+ write('dz backlog: переход НЕ записан в журнал — наблюдение потеряно (сам статус изменён)');
16814
+ }
15840
16815
  if (json) write(JSON.stringify({ slug: staging.slug, scaffoldPath: staging.scaffoldPath, handoff: 'idea2prd-manual', exitCode: 0 }, null, 2));
15841
16816
  else {
15842
16817
  write(`dz backlog enrich: staged ${rec.id} → ${staging.scaffoldPath}`);
@@ -15876,6 +16851,10 @@ async function cmdBacklog(options: Map<string, string>, flags: Set<string>, cwd:
15876
16851
  if (form.action === 'migrated' && !json) write(`dz backlog: re-embedded ${form.remirrored} idea vector(s) into the bounded dedup embed form (v${form.version})`);
15877
16852
  else if (form.action === 'deferred' && !json) write(`dz backlog: ⚠ embed-form migration deferred (${form.error ?? 'unknown error'})`);
15878
16853
  const report = await harmonizeBacklog(projectRoot, { apply, ...(thr !== undefined ? { threshold: Number(thr) } : {}) });
16854
+ if (apply) {
16855
+ refreshLearningStoreMark(projectRoot, writeErr, 'dz backlog harmonize --apply');
16856
+ storeGuardResetReminder(projectRoot, writeErr, 'dz backlog harmonize --apply');
16857
+ }
15879
16858
  if (json) {
15880
16859
  write(JSON.stringify({ ...report, exitCode: 0 }, null, 2));
15881
16860
  return 0;
@@ -16693,15 +17672,25 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
16693
17672
  io.teachReinforceRunner ?? runTeachGuardReinforcement,
16694
17673
  );
16695
17674
  case 'consolidate':
16696
- return await cmdConsolidate(options, flags, cwd, write);
17675
+ return await cmdConsolidate(options, flags, cwd, write, writeErr);
16697
17676
  case 'recall':
16698
17677
  return await cmdRecall(options, flags, cwd, write, writeErr, io.classMatcher);
16699
17678
  case 'vector':
16700
- return await cmdVector(options, flags, cwd, write);
17679
+ return await cmdVector(options, flags, cwd, write, writeErr);
16701
17680
  case 'brain':
16702
17681
  return await cmdBrain(options, flags, cwd, write, readStdin);
16703
17682
  case 'statusline':
16704
- return cmdStatusline(options, flags, cwd, write, readStdin);
17683
+ return cmdStatusline(options, flags, cwd, write, readStdin, writeErr);
17684
+ case 'store-guard':
17685
+ return await cmdStoreGuard(
17686
+ options,
17687
+ flags,
17688
+ cwd,
17689
+ write,
17690
+ writeErr,
17691
+ io.stdin,
17692
+ io.interactive ?? process.stdin.isTTY === true,
17693
+ );
16705
17694
  case 'usage':
16706
17695
  return cmdUsage(options, optionLists, flags, cwd, write);
16707
17696
  case 'chain':
@@ -16769,7 +17758,7 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
16769
17758
  case 'mr-rakes':
16770
17759
  return await cmdMrRakes(options, flags, cwd, write);
16771
17760
  case 'retro':
16772
- return await cmdRetro(options, flags, cwd, write);
17761
+ return await cmdRetro(options, flags, cwd, write, readStdin);
16773
17762
  case 'feature-adr-setup':
16774
17763
  return cmdFeatureAdrSetup(options, flags, cwd, write, writeErr);
16775
17764
  case 'challenge':
@@ -16814,6 +17803,8 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
16814
17803
  return cmdTgPost(options, flags, cwd, write);
16815
17804
  case 'name-check':
16816
17805
  return cmdNameCheck(options, flags, cwd, write);
17806
+ case 'brief-check':
17807
+ return cmdBriefCheck(options, flags, cwd, write);
16817
17808
  case 'provenance-check':
16818
17809
  return cmdProvenanceCheck(options, flags, cwd, write);
16819
17810
  case 'feature-adr-record':
@@ -16831,7 +17822,7 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
16831
17822
  case 'qe-bridge':
16832
17823
  return await cmdQeBridge(options, flags, cwd, write);
16833
17824
  case 'backlog':
16834
- return await cmdBacklog(options, flags, cwd, write);
17825
+ return await cmdBacklog(options, flags, cwd, write, writeErr);
16835
17826
  case 'routing':
16836
17827
  return cmdRouting(options, flags, cwd, write);
16837
17828
  case 'bto-optimize':