@dzhechkov/harness-cli 0.8.6 → 0.8.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.ts CHANGED
@@ -13,7 +13,7 @@ import { isBooleanFlag } from './boolean-flags.js';
13
13
  import { resolveInstallSpec } from './install-spec.js';
14
14
  import { execFile, execFileSync, execSync, spawn, spawnSync, type ChildProcess } from 'node:child_process';
15
15
  import { createHash, randomBytes } from 'node:crypto';
16
- import { homedir, tmpdir } from 'node:os';
16
+ import { homedir, hostname, tmpdir } from 'node:os';
17
17
  import { createRequire } from 'node:module';
18
18
  import { isDeepStrictEqual } from 'node:util';
19
19
 
@@ -29,6 +29,10 @@ import {
29
29
  TARGET_NAMES_SORTED,
30
30
  runDoctor,
31
31
  runInit,
32
+ discoverSkillIds,
33
+ resolveSelection,
34
+ formatSelectRefusal,
35
+ runIntegrationsVerify,
32
36
  resolvePackageSkillRoots,
33
37
  PACKAGE_SKILL_LAYOUTS,
34
38
  benchmarkSkill,
@@ -81,6 +85,7 @@ import {
81
85
  NamedLockTimeoutError,
82
86
  NamedLockCompromisedError,
83
87
  type CodexHooksSyncReport,
88
+ type IntegrationManifestSource,
84
89
  type ParityCell,
85
90
  type ParityFeature,
86
91
  type ParityReportCell,
@@ -116,6 +121,8 @@ import {
116
121
  renderTimelineHtml,
117
122
  importEcc,
118
123
  recordPattern,
124
+ recordLessonForms,
125
+ normalizeLessonForms,
119
126
  resolveLearningBackend,
120
127
  storeStats,
121
128
  consolidateSessions,
@@ -141,6 +148,17 @@ import {
141
148
  importRvfCheckpoint,
142
149
  statuslineData,
143
150
  writeFeatureAdrState,
151
+ CHECKPOINT_STAGES,
152
+ estimateEta,
153
+ extractStageSamples,
154
+ formatEta,
155
+ parseCheckpointLines,
156
+ segmentRun,
157
+ type CheckpointStage,
158
+ type EtaEstimate,
159
+ type FeatureAdrState,
160
+ type RunSegment,
161
+ type StageSample,
144
162
  computeUsage,
145
163
  deriveCostLedger,
146
164
  planLedgerBackfill,
@@ -167,6 +185,8 @@ import {
167
185
  queryBookKnowledge,
168
186
  loadStorePatternsSync,
169
187
  patternRecordId,
188
+ patternIdentityOf,
189
+ mergeLessonMatchedForms,
170
190
  loadStoreRecords,
171
191
  recordToPattern,
172
192
  bundleSkills,
@@ -192,6 +212,8 @@ import {
192
212
  readTailInfo,
193
213
  appendChainedLines,
194
214
  verifyEventChainText,
215
+ classifyChainDefects,
216
+ CHAINED_JOURNALS,
195
217
  buildManifest,
196
218
  buildSbom,
197
219
  resolveTrustRoot,
@@ -204,12 +226,22 @@ import {
204
226
  DEFAULT_RULES,
205
227
  parsePnpmLockImporters,
206
228
  scannableStubPath,
229
+ type FeatureArtifactFact,
230
+ type FeatureTier,
231
+ type FeatureVolumeFact,
232
+ type GuardObservation,
233
+ type TemplateVolumeFileFact,
234
+ type TemplateVolumeTargetFact,
235
+ type VolumeShadowInput,
207
236
  // guard-promotion (feature guard-promotion, scout idea #1)
208
237
  assembleCandidates,
209
238
  renderPromotionReport,
210
239
  renderPromotionAdr,
211
240
  normalizePromotionState,
212
241
  nextPromotionState,
242
+ recordPromotionRunEvidence,
243
+ isLessonRuleContentAnchor,
244
+ isOffsetIsoTimestamp,
213
245
  globMatch,
214
246
  promotionAdrRelPath,
215
247
  DEFAULT_WINDOW_DAYS,
@@ -219,6 +251,10 @@ import {
219
251
  type ChangeSet,
220
252
  type ExistingRuleView,
221
253
  type PromotionReport,
254
+ type PromotionAcceptanceEvidence,
255
+ type PromotionRunEvidence,
256
+ type FunnelEvidenceSource,
257
+ type GuardEvent,
222
258
  decideProvenance,
223
259
  isInsideTree,
224
260
  signManifest,
@@ -339,6 +375,11 @@ import {
339
375
  type EpochOutcome,
340
376
  scoreRun,
341
377
  readQeGrade,
378
+ scoreReceiptToAggregateRow,
379
+ readScoreAggregateRows,
380
+ dedupeScoreAggregateRows,
381
+ buildScoreAggregateReport,
382
+ renderScoreAggregateReport,
342
383
  recapWindow,
343
384
  decideHorizon,
344
385
  withinWindow,
@@ -446,6 +487,7 @@ import {
446
487
  // Mutation gate (feature ha-mutation-gate) — break each named protection, run the suite, require red.
447
488
  parseMutationRegistry,
448
489
  applyMutationToText,
490
+ attributeBaselineRedness,
449
491
  countFailingTests,
450
492
  detectSuiteCompletionReceipt,
451
493
  detectSuiteReceiptMismatch,
@@ -455,6 +497,7 @@ import {
455
497
  mutationGateExitCode,
456
498
  summarizeMutationResults,
457
499
  renderMutationReport,
500
+ runWithOneInternalRetry,
458
501
  TRACE_BUNDLE_LEDGER_PATH,
459
502
  TRACE_BUNDLE_SCHEMA,
460
503
  TRACE_BUNDLE_RUN_META_FILE,
@@ -522,7 +565,7 @@ import type { SetupSpec } from '@dzhechkov/harness-core';
522
565
  import type { LogTail } from '@dzhechkov/harness-core';
523
566
  import type { DeadwoodInventoryItem } from '@dzhechkov/harness-core';
524
567
  import type { ContractDiagnostic, ContractEvidenceReader } from '@dzhechkov/harness-core';
525
- import type { ProvenanceMode, PackVerdict, ClaudeUsageModel, PatternRecord, TargetName, BookKU, HarmonizeReport, UsageCalibrationPlan, ClaimFinding, RecallUsagePatternRow, GateExecution, GateStep, SlopFinding, SlopLintConfig, SlopRegistry } from '@dzhechkov/harness-core';
568
+ import type { ProvenanceMode, PackVerdict, ClaudeUsageModel, PatternRecord, RecallPatternsOptions, TeachGuardResult, TargetName, IntegrationOutcome, BookKU, HarmonizeReport, UsageCalibrationPlan, ClaimFinding, RecallUsagePatternRow, GateExecution, GateStep, SlopFinding, SlopLintConfig, SlopRegistry } from '@dzhechkov/harness-core';
526
569
  import { getPreset, PRESET_NAMES } from '@dzhechkov/harness-presets';
527
570
  import { scanGitHub, analyzeRepo, generateReport, deepAnalyze, scanAllSources, ScoutMemory } from '@dzhechkov/scout';
528
571
 
@@ -534,20 +577,20 @@ export const DZ_COMMANDS: readonly string[] = [
534
577
  'usage', 'claim-check', 'lint', 'sign', 'sbom', 'guard', 'verify-pack', 'setup',
535
578
  'pretrain', 'compose', 'diff', 'recommend', 'upgrade', 'auto-canonicalize',
536
579
  'publish', 'release', 'parity', 'registry', 'benchmark', 'mcp-scan',
537
- 'sync-upstream', 'drift-check', 'hooks-sync', 'agents-sync', 'sync-canonical',
580
+ 'sync-upstream', 'drift-check', 'hooks-sync', 'integrations-verify', 'agents-sync', 'sync-canonical',
538
581
  'plugin', 'downloads', 'stats', 'architecture', 'project-skills', 'mr-rakes',
539
582
  'retro', 'feature-adr-setup', 'challenge', 'discrimination-check',
540
583
  'mutation-gate', 'delivery-check', 'skills-verify', 'compounding', 'deadwood',
541
584
  'epoch-replay', 'score', 'recap', 'cadence', 'qe-rounds', 'restart-advisor', 'tg-post',
542
585
  'name-check', 'provenance-check', 'feature-adr-record', 'amendment-check', 'contract-check',
543
586
  'feature-adr-checkpoint', 'profile', 'reqe', 'qe-bridge', 'backlog', 'routing',
544
- 'bto-optimize', 'dashboard', 'roam', 'import-ecc',
587
+ 'bto-optimize', 'dashboard', 'roam', 'import-ecc', 'chain',
545
588
  ];
546
589
 
547
590
  const USAGE = `dz - DZ cross-platform harness CLI
548
591
 
549
592
  Usage:
550
- dz init --target <name> [--skills-dir <dir>] [--project <dir>] [--preset <name>] [--select id,id,...] [--force] [--enrich] [--no-hooks] [--no-verify] (--target codex ALSO installs the user-global dz veto+recall hooks and LIVE-verifies them (ADR-001 §8); --no-hooks = skills only; --no-verify skips the live probe and can never report ready)
593
+ dz init --target <name> [--skills-dir <dir>] [--project <dir>] [--preset <name>] [--select id,id,...] [--force] [--enrich] [--allow-integrations <sha256:digest>] [--no-integrations] [--no-hooks] [--no-verify] (integration manifests require exact digest consent; --no-integrations = explicit skills-only)
551
594
  dz verify [--skills-dir <dir>] [--target <name>]
552
595
  dz sync [--canonical <dir>] [--project <dir>] [--dry-run] [--force]
553
596
  dz update (alias of sync)
@@ -576,13 +619,14 @@ Usage:
576
619
  dz delivery-check --slug <slug> [--context-only] [--findings <f.json>] [--strict] [--author <model>] [--json] (portable Step-10 Delivery Gate: prints the 4-plane review brief + artifact probes; --findings classifies a fed-back review into a fail-closed ready|blocked hand-off and writes features/<slug>/10_delivery_review.md; --strict exits 1 on blocked)
577
620
  dz challenge --plan <plan.md> [--author <model>] (the deterministic cartridge behind the challenge-panel adversarial plan-gate (R6): assembles the wide brief — plan + architecture/vision.md + testing.md + map.json + degradations.md — and prints the C1-C8 adversary prompt naming the cross-family reviewer to dispatch. exit 0 brief printed / 1 plan missing or empty)
578
621
  dz skills-verify [--dir <project>] [--expect a,b] [--static] [--strict] [--json] (does .claude/skills/ actually REGISTER? --static = instant layout scan for CI; default reads the authoritative system/init listing from a real session. exit 0 pass / 1 fail / 2 inconclusive — never a false pass)
579
- dz compounding [--project <dir>] [--json] (honest learning-loop payoff report: pool write-only ratio, guard repeat-violation trajectory, cold-vs-warm replay readiness, instrumentation health a gate without enough data says INSUFFICIENT_DATA, never a fake verdict)
622
+ dz compounding [--project <dir>] [--json] (honest learning-loop payoff report: pool/replay/guard instrumentation + monthly eligible→attempted→accepted→executions; unavailable is NOT MEASURED, and only a named empty stage after a non-empty predecessor for 3 measured months is a funnel finding)
580
623
  dz deadwood [--weeks <n>] [--json] (advisory zero-usage candidates for human deprecation review; safety-excluded surfaces carry reasons; never deletes or deprecates anything; shallow history says INSUFFICIENT_DATA)
581
624
  dz epoch-replay --mock [--n <N>] [--effect <-1..1>] [--tie-rate <0..1>] [--seed <N>] [--slice <name>] [--json] ($0 synthetic run — exercises the verdict math, NOT evidence)
582
625
  dz epoch-replay --emit [--project <dir>] [--limit <N>] [--seed <N>] [--out <file>] (cold-vs-warm work order: instances + PRE-REGISTERED blind A/B assignment; the runner never calls a model)
583
626
  dz epoch-replay --judge <filled-work-order.json> [--out <file>] (blind judge prompts from the filled plans)
584
627
  dz epoch-replay --score <judgments.json> --work-order <file> [--slice <name>] [--json] (un-blind against the pre-registered assignment → SUPPORTED only when the two 95% Wilson CIs are DISJOINT, else FALSIFIED / INCONCLUSIVE)
585
628
  dz score --slug <feature> [--project <dir>] [--json] (process scorecard for ONE feature-adr run, from its artifacts: ADR confirmation, discrimination, cross-model QE grade, live verification, README-first, learning loop, amendments — descriptive-only, a low score exits 0)
629
+ dz score --all [--project <dir>] [--json] (sweep features/*/.fa-state/score-*.json into the append-only chained scorecards aggregate — descriptive-only, always exits 0)
586
630
  dz recap [--day|--week|--month] [--at <ISO date>] [--project <dir>] [--json] (what was done over a window, from records only: deliveries with the grade an independent review STATED — a report naming two grades is reported ambiguous, never guessed — registry publishes, gate verdicts, knowledge reuse. --quarter/--half-year/--year are RECOGNISED and REFUSED with the real span in days: there is one complete quarter and the longest record is 174 days. Every section carries its own data-start date, and "the source was not read" never prints as zero. Contaminated measures — commit count, lines, tokens, learning-event volume, inventory counts, lesson count — are not computed, and the report says so. exit 0 reported / 2 refused)
587
631
  dz cadence [--window day|week|month|quarter|halfyear|year] [--json] (the WHAT-SHIPPED aggregator: graded-shipment cadence by ISO week + npm-publish cadence (recap cache) + guard repeat decay on the FIXED rule set + recall reuse; a window deeper than 2× the record is REFUSED with the depth named (ADR: a cadence from one point is scale forgery); exit 0 report / 2 refused-window / 1 usage)
588
632
  dz qe-rounds (--slug <feature> | --feature-dir <abs>) [--ceiling <n>] [--project <dir>] [--json] (how many Step-8 review rounds has this feature ALREADY had? Reads what dz qe-bridge already wrote — signoff-<runId>.json and failed-*.json under features/<slug>/.fa-state/qe-bridge — and writes nothing itself, so it can answer for runs already past. A round is a runId, not a file; an attempt with no verdict is counted SEPARATELY and never merged; an unreadable record is NAMED and the count is declared a LOWER BOUND. ONE directory, never a union across checkouts. exit 0 under the ceiling / 1 at-or-over — owner decides, the command does not judge whether the rounds were warranted / 2 NOT ESTABLISHED, which is never "zero rounds")
@@ -601,7 +645,7 @@ Usage:
601
645
  dz feature-adr-checkpoint (--slug <feature> | --feature-dir <abs>) --stage <s> --input-hash <h> --result <json> [--artifact a,b] [--json] (record a pipeline stage ONLY after measuring its artifacts on disk; refuses a null result, an absent artifact, or a stage that declares none — the subagent runs a COMMAND instead of hand-writing durable state)
602
646
  dz profile [init|show|set|sync] [--json] (WHO the assistant is talking to — per-user store at ~/.dz/profile.json (0600, NEVER in a project), delivered as a marked block in ~/.claude/CLAUDE.md so it loads in EVERY project, dz installed or not. init = five questions (language, register, deep/weak domains as comma lists — "networking (CCIE; NSX)" keeps the parenthetical as the note, Enter skips — teaches y/n with one re-ask, never a silent default); show ALWAYS prints the store path + age + drift verdict + the rendered block; set register|language|teaches <v> or set deep|weak add|rm <tag> [note] — register accepts the owner's own words (профи / профи лайт / просто), an unknown value is REFUSED naming the accepted set; sync re-writes the block (runs automatically after init/set; foreign content byte-for-byte, timestamped backup before every modifying write). The register changes FORM, never FACTS, and governs dialogue only — never ADRs/commits/QE reports; both rules are baked into the rendered block at every level. exit 0 done / 1 no profile or failed / 2 refused input)
603
647
  dz reqe [--slug <feature> [--done --report <f>]] [--json] (the re-QE debt ledger: a usage-switched run whose Step-8 QE ran on the coder's OWN family records a debt; list debts, print the cross-family review brief, settle FAIL-CLOSED against a graded report — the settlement lands in 08_qe_report.md)
604
- dz qe-bridge --family claude --slug <feature> [--coder-family codex|claude] [--model <id>] [--files a,b] [--out <f>] [--timeout <s>] [--allow-same-family] [--json] (the REVERSE QE bridge: run an INDEPENDENT Claude reviewer over a feature's Step-8 artifacts from ANY host — a Codex session included, plain shell, no Claude agent plane needed — and land a PARSED signoff. The reviewer runs ISOLATED: an EMPTY temp cwd plus --safe-mode --strict-mcp-config --tools '' --no-session-persistence, so no CLAUDE.md/skills/plugins/hooks/MCP load, and the verdict is read from the --output-format json RESULT ENVELOPE — text a session customization printed onto the same stdout can never become a signoff. Probes the model before trusting it; sends SCOPED extracts with a loud 200k-char ceiling (never silent truncation); the grade must AGREE across three LAST-anchored channels (terminal marker line, fenced qe-bridge-signoff JSON, the report's own GRADE line) AND the marker must be the FINAL content — empty, gradeless, self-contradicting or miscounted output is one of 18 NAMED failures with an audit record under features/<slug>/.fa-state/qe-bridge/ (runId, resolved executable + binOverride, prompt sha256, channel offsets, requestedOut, reportWritten, retained raw stdout; 0600 files in a 0700 dir), never a clean review. A --coder-family that contradicts the recorded reqe debt is refused. Writes features/<slug>/08b_reqe_report.md, which dz reqe --done settles unchanged. DISCLOSURE: the extracts you scope are sent to the Claude runtime; the bridge cannot classify secrets. DZ_QE_BRIDGE_CLAUDE_BIN is a TEST SEAM, not a flag. exit 0 signoff parsed (ANY grade — it reports, it does not gate) / 1 named failure / 2 usage)
648
+ dz qe-bridge --family claude --slug <feature> [--coder-family codex|claude] [--model <id>] [--files a,b] [--out <f>] [--timeout <s>] [--allow-same-family] [--json] (the REVERSE QE bridge: run an INDEPENDENT Claude reviewer over a feature's Step-8 artifacts from ANY host — a Codex session included, plain shell, no Claude agent plane needed — and land a PARSED signoff. The reviewer runs ISOLATED: an EMPTY temp cwd plus --safe-mode --strict-mcp-config --tools '' --no-session-persistence, so no CLAUDE.md/skills/plugins/hooks/MCP load, and the verdict is read from the --output-format json RESULT ENVELOPE — text a session customization printed onto the same stdout can never become a signoff. Probes the model before trusting it; sends SCOPED extracts with a loud 200k-char ceiling (never silent truncation); the grade must AGREE across three LAST-anchored channels (terminal marker line, fenced qe-bridge-signoff JSON, the report's own GRADE line) AND the marker must be the FINAL content — empty, gradeless, self-contradicting or miscounted output is one of 17 NAMED failures with an audit record under features/<slug>/.fa-state/qe-bridge/ (runId, resolved executable + binOverride, prompt sha256, channel offsets, requestedOut, reportWritten, retained raw stdout; 0600 files in a 0700 dir), never a clean review. A --coder-family that contradicts the recorded reqe debt is refused. Writes features/<slug>/08b_reqe_report.md, which dz reqe --done settles unchanged. DISCLOSURE: the extracts you scope are sent to the Claude runtime; the bridge cannot classify secrets. DZ_QE_BRIDGE_CLAUDE_BIN is a TEST SEAM, not a flag. exit 0 signoff parsed (ANY grade — it reports, it does not gate) / 1 named failure / 2 usage)
605
649
  dz mutation-gate [--package <dir>] [--registry <file>] [--test-cmd "<cmd>"] [--only <id[,id]>] [--timeout <ms>] [--rebaseline per-entry|final] [--keep-scratch] [--json] (prove each NAMED protection has a test that DISCRIMINATES: copy the package to a scratch dir, verify the baseline suite is green, apply each registry mutation, run the suite, REQUIRE red, restore. The red must be BEHAVIOURAL: a mutation that no longer parses is MUTATION_UNPARSEABLE; a red run whose OWN output reports a test FILE failing to load (node --test file-level not-ok with exitCode, vitest Failed Suites) is MUTATION_LOAD_FATAL — the signal comes from the same run as the failing count, never from a separate isolated import; red output whose shape matches no known runner is INCONCLUSIVE (a runner-coverage gap, loud, never PROVEN); a count far above the entry's bound is OVER_FAILING; a restored tree that does not reproduce green makes the entry INCONCLUSIVE (flaky). Mutation writes are realpath-contained to the scratch copy: a symlink escape or a node_modules/ target is refused (exit 2), the real tree is never written. A mutation that does not apply, a green suite, or an inconclusive run is a FAILURE — never a skip. exit 0 all proven / 1 gate failed / 2 setup error)
606
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)
607
651
  dz backlog list [--status <s>] [--goal <id>] [--project <dir>] [--json] (list captured ideas, filterable by status/goal)
@@ -617,7 +661,7 @@ Usage:
617
661
  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)
618
662
  dz backlog harmonize [--apply] [--threshold <0-1>] [--project <dir>] [--json] (batch semantic dedup of the backlog ideas; --dry-run default, --apply snapshots first)
619
663
  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)
620
- dz teach "<pattern>" [--reward <0-1>] [--domain <name>] [--type rule|success-pattern|lesson-learned] [--project <dir>] [--no-mirror] (--project pins the learned store to <dir>/.dz, not the cwd — pin to a canonical brain)
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)
621
665
  dz teach --from-json <file> [--project <dir>] [--no-mirror] (bulk-import a 'dz recall --all --json' export — share a learned store across machines)
622
666
  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)
623
667
  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)
@@ -640,6 +684,7 @@ Usage:
640
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)
641
685
  dz usage [--json] [--project <dir>] | dz usage --calibrate --session <pct> --weekly <pct> [--model fable=<pct>] [--project <dir>] (ESTIMATE Claude usage from fixed reset windows; optional per-model weekly binding; exit 0 ALWAYS; pct=null when limits unconfigured)
642
686
  dz usage --by-stage [--run <runId> | --slug <slug>] [--epsilon <0..1>] [--write <file.jsonl>] [--json] (per-stage cost ledger for ONE feature-adr run + the reconciliation invariant: accounted + unaccounted = run total; verdict BALANCED | DEFECT | INSUFFICIENT_DATA; local transcript ESTIMATES — catches ATTRIBUTION errors, not pricing errors)
687
+ dz chain [--project <dir>] [--json] (verify EVERY hash-chained journal in ONE command: coverage is DERIVED from the CHAINED_JOURNALS registry, never typed, so a journal cannot be given a chain and checked by nobody. An ABSENT journal is NAMED absent, never omitted — omission and cleanliness are indistinguishable in a report. Statuses: ok | healed (defects the current unbroken run has outlived — verdicts over present records are sound) | unchained (present, no chained record yet — legal) | absent | broken | unreadable. Exit 1 on broken/unreadable: a verifier that reports damage and exits 0 is one no automation can act on)
643
688
  dz claim-check [paths...] [--json] [--fail-on high|medium|none] [--project <dir>] (enforce the Integrity Rule: flag untagged/overstated accuracy claims; default scan = root README.md + every discovered package's README.md + features/*/08_qe_report.md + docs/**/*.md (historical feature artifacts are NOT scanned — pass paths explicitly); exit 1 only at/above --fail-on, default high)
644
689
  dz lint [paths...] [--json] [--config <file>] [--registry <file>] [--project <dir>] (advisory EN/RU prose-style lint; findings exit 0, incomplete input/policy exits 1, usage exits 2)
645
690
  dz pretrain [--project <dir>]
@@ -656,6 +701,7 @@ Usage:
656
701
  dz drift-check [--json] [--project <dir>] (CI gate: exit 1 if any shared skill drifted between its monorepo copies)
657
702
  dz agents-sync [--project <dir>] [--check] [--json] (sync/verify the always-on policy fence in root AGENTS.md; exit 0 synced/written, 1 drift, 3 inconclusive)
658
703
  dz hooks-sync --target codex [--check] [--verify] [--remove] [--json] (install/verify the dz veto + recall hooks in $CODEX_HOME/hooks.json; exit 0 armed+trusted, 1 not armed/drift, 3 inconclusive)
704
+ dz integrations-verify --target <name> --component <mcp|hooks> [--project <dir>] [--json] (non-executing exact-version registration probe; exit 0 observed / 1 refused)
659
705
  dz sync-canonical <skill> [--check] [--from <dir>] [--auto] [--project <dir>] (heal every copy from skills-meta/<skill> or --from; no canonical + --check = compare copies to each other (exit 1 on drift); no canonical + write = refuse unless --auto (LOUD, picks most-complete copy); --check writes nothing)
660
706
  dz plugin [--version <ver>]
661
707
  dz downloads
@@ -684,6 +730,17 @@ Workflows: author loop-plan/1 plans with dz workflow init/validate/render; gate
684
730
  Targets: ${TARGET_NAMES.join(', ')}
685
731
  Presets: ${PRESET_NAMES.join(', ')}`;
686
732
 
733
+ export interface MutationGateRunnerObservation {
734
+ readonly exitCode: number | null;
735
+ readonly output: string;
736
+ readonly failureReason?: string;
737
+ }
738
+
739
+ export type MutationGateRunner = (
740
+ command: string,
741
+ options: { readonly cwd: string; readonly timeoutMs: number },
742
+ ) => MutationGateRunnerObservation;
743
+
687
744
  /** Output sink + working directory — injectable so the CLI is testable. */
688
745
  export interface CliIo {
689
746
  readonly cwd?: string;
@@ -706,6 +763,14 @@ export interface CliIo {
706
763
  * command that needs it (`brain ground`), and never when stdin is a TTY (nothing piped).
707
764
  */
708
765
  readonly stdin?: string;
766
+ /** Human-terminal rendering seam; production defaults to stdout TTY detection. */
767
+ readonly interactive?: boolean;
768
+ /** Fault seam proving that class-form recall degrades to specific recall with a stderr receipt. */
769
+ readonly classMatcher?: RecallPatternsOptions['classMatcher'];
770
+ /** Guard decision seam; production always uses the real vector-backed teach guard. */
771
+ readonly teachGuardRunner?: (projectRoot: string, text: string, opts: { readonly reward?: number }) => Promise<TeachGuardResult>;
772
+ /** Reinforcement flush seam paired with `teachGuardRunner`; production uses the configured backend. */
773
+ readonly teachReinforceRunner?: (projectRoot: string, dzId: string, reward: number) => Promise<{ readonly flushed: number }>;
709
774
  /**
710
775
  * Test seam for `dz release`: overrides subprocess execution for gate steps and the
711
776
  * gh/git side channels (production leaves it unset → real `execSync`, stdio piped).
@@ -720,6 +785,8 @@ export interface CliIo {
720
785
  * offline, hermetically — mirrors the {@link CliIo.releaseRunner} idiom.
721
786
  */
722
787
  readonly installRunner?: (command: string, cwd: string) => void;
788
+ /** Fault seam for proving mutation-gate catches and retries thrown runner internals. */
789
+ readonly mutationGateRunner?: MutationGateRunner;
723
790
  }
724
791
 
725
792
  /** Injected subprocess runner used by `dz release` (see {@link CliIo.releaseRunner}). */
@@ -809,6 +876,12 @@ function discoverSkillsDirs(cwd: string, explicitSkillsDir?: string | undefined)
809
876
 
810
877
  /** Outcome of {@link installSkills}. */
811
878
  interface InstallSkillsResult {
879
+ /**
880
+ * Set when an explicit `--select` named an id no root provides (backlog 9d15b9b6, PR-A). The
881
+ * caller MUST print it and exit non-zero: `0 skill(s)` is not a success, and by the time this is
882
+ * set nothing has been written yet — the refusal is decided before the first byte.
883
+ */
884
+ readonly selectRefusal?: string;
812
885
  readonly results: { id: string; written: number; skipped: number }[];
813
886
  readonly dirsSearched: number;
814
887
  readonly written: number;
@@ -823,6 +896,8 @@ interface InstallSkillsResult {
823
896
  * the source `SKILL.md`.
824
897
  */
825
898
  readonly applyFailures: SkillApplyFailure[];
899
+ readonly integrations: readonly IntegrationOutcome[];
900
+ readonly integrationDigest?: string;
826
901
  }
827
902
 
828
903
  /**
@@ -838,11 +913,48 @@ async function installSkills(opts: {
838
913
  select?: readonly string[] | undefined;
839
914
  force: boolean;
840
915
  enrich: boolean;
916
+ noHooks?: boolean;
917
+ noIntegrations?: boolean;
918
+ noVerify?: boolean;
919
+ allowIntegrations?: string;
920
+ writeErr?: (line: string) => void;
841
921
  }): Promise<InstallSkillsResult> {
842
922
  const { target, projectRoot, cwd, explicitSkillsDir, select, force, enrich } = opts;
843
923
 
844
924
  const skillsDirs = discoverSkillsDirs(cwd, explicitSkillsDir);
845
925
 
926
+ // PREFLIGHT (backlog 9d15b9b6, PR-A) — resolve the REQUEST once, before anything is written.
927
+ //
928
+ // Two defects lived in asking each root independently instead of resolving the request: a skill
929
+ // present in two roots was installed TWICE and counted twice (the field report's `2 skill(s)` was
930
+ // one skill installed twice), and a skill present in NO root produced a warning and exit 0 —
931
+ // `0 skill(s)` reading as success. Both are gone once the decision happens here.
932
+ //
933
+ // Placement is load-bearing: an exit 1 that arrives after hooks and memory are written leaves a
934
+ // half-configured project, which is worse than either clean outcome. This runs before the loop
935
+ // below and before every target adapter.
936
+ //
937
+ // Dependency closure is deliberately NOT resolved here — that is PR-B. This preflight fixes the
938
+ // count and the exit contract, and gives that work a base it can trust.
939
+ if (select !== undefined) {
940
+ const roots = skillsDirs.map((dir) => ({ dir, ids: discoverSkillIds(dir) }));
941
+ const resolution = resolveSelection(select, roots);
942
+ for (const shadow of resolution.shadowed) {
943
+ opts.writeErr?.(
944
+ `dz: skill '${shadow.id}' is offered by ${shadow.alsoIn.length + 1} roots; ` +
945
+ `installing from ${shadow.chosen} (earlier root wins). Also present in: ${shadow.alsoIn.join(', ')}`,
946
+ );
947
+ }
948
+ const refusal = formatSelectRefusal(resolution, roots);
949
+ if (refusal !== null) {
950
+ return {
951
+ selectRefusal: refusal,
952
+ results: [], dirsSearched: skillsDirs.length, written: 0, skipped: 0,
953
+ missing: [...resolution.missing], failures: [], applyFailures: [], integrations: [],
954
+ };
955
+ }
956
+ }
957
+
846
958
  // agents-md and gemini are FLATTENING single-file targets: each must aggregate
847
959
  // every selected skill from ALL discovered dirs into ONE root file (AGENTS.md /
848
960
  // GEMINI.md) in a single merge. A per-dir runInit loop (like the tree targets
@@ -850,8 +962,8 @@ async function installSkills(opts: {
850
962
  // route them through one aggregation.
851
963
  if (target === 'agents-md' || target === 'gemini') {
852
964
  const report = target === 'gemini'
853
- ? runInitGeminiMd({ skillsDirs, projectRoot, ...(select !== undefined ? { select } : {}) })
854
- : runInitAgentsMd({ skillsDirs, projectRoot, ...(select !== undefined ? { select } : {}) });
965
+ ? runInitGeminiMd({ skillsDirs, projectRoot, ...(select !== undefined ? { select } : {}), ...(opts.noHooks !== undefined ? { noHooks: opts.noHooks } : {}), ...(opts.noIntegrations !== undefined ? { noIntegrations: opts.noIntegrations } : {}) })
966
+ : runInitAgentsMd({ skillsDirs, projectRoot, ...(select !== undefined ? { select } : {}), ...(opts.noHooks !== undefined ? { noHooks: opts.noHooks } : {}), ...(opts.noIntegrations !== undefined ? { noIntegrations: opts.noIntegrations } : {}) });
855
967
  const results = report.skills.map((s) => ({
856
968
  id: s.id,
857
969
  written: s.written.length,
@@ -860,13 +972,21 @@ async function installSkills(opts: {
860
972
  let written = 0;
861
973
  let skipped = 0;
862
974
  for (const s of results) { written += s.written; skipped += s.skipped; }
863
- return { results, dirsSearched: skillsDirs.length, written, skipped, missing: [...report.missing], failures: [...report.failures], applyFailures: [...report.applyFailures] };
975
+ return { results, dirsSearched: skillsDirs.length, written, skipped, missing: [...report.missing], failures: [...report.failures], applyFailures: [...report.applyFailures], integrations: [...report.integrations], ...(report.integrationDigest !== undefined ? { integrationDigest: report.integrationDigest } : {}) };
864
976
  }
865
977
 
866
978
  const results: { id: string; written: number; skipped: number }[] = [];
867
979
  const failures: SkillLoadFailure[] = [];
868
980
  const applyFailures: SkillApplyFailure[] = [];
869
- for (const skillsDir of skillsDirs) {
981
+ let integrations: readonly IntegrationOutcome[] = [];
982
+ let integrationDigest: string | undefined;
983
+ const integrationManifestSources: readonly IntegrationManifestSource[] = skillsDirs.flatMap((skillsDir) => {
984
+ const discovered = discoverSkillIds(skillsDir);
985
+ return discovered
986
+ .filter((id) => select === undefined || select.includes(id))
987
+ .map((skillId) => ({ skillId, skillDir: skillsDir }));
988
+ });
989
+ for (const [dirIndex, skillsDir] of skillsDirs.entries()) {
870
990
  const r = await runInit({
871
991
  target,
872
992
  skillsDir,
@@ -874,12 +994,24 @@ async function installSkills(opts: {
874
994
  force,
875
995
  enrich,
876
996
  ...(select !== undefined ? { select } : {}),
997
+ ...(opts.noHooks !== undefined ? { noHooks: opts.noHooks } : {}),
998
+ ...(dirIndex === 0
999
+ ? {
1000
+ integrationManifestSources,
1001
+ ...(opts.noIntegrations !== undefined ? { noIntegrations: opts.noIntegrations } : {}),
1002
+ }
1003
+ : { noIntegrations: true }),
1004
+ ...(opts.noVerify !== undefined ? { noVerify: opts.noVerify } : {}),
1005
+ ...(opts.allowIntegrations !== undefined ? { allowIntegrations: opts.allowIntegrations } : {}),
877
1006
  });
878
1007
  for (const skill of r.skills) {
879
1008
  results.push({ id: skill.id, written: skill.written.length, skipped: skill.skipped.length });
880
1009
  }
881
1010
  failures.push(...r.failures);
882
1011
  applyFailures.push(...r.applyFailures);
1012
+ if (r.integrations.some((row) => row.status !== 'not-requested')) integrations = [...r.integrations];
1013
+ else if (integrations.length === 0) integrations = [...r.integrations];
1014
+ if (r.integrationDigest !== undefined) integrationDigest = r.integrationDigest;
883
1015
  }
884
1016
 
885
1017
  let written = 0;
@@ -887,7 +1019,7 @@ async function installSkills(opts: {
887
1019
  for (const s of results) { written += s.written; skipped += s.skipped; }
888
1020
  const installed = new Set(results.map((s) => s.id));
889
1021
  const missing = select !== undefined ? [...select].filter((id) => !installed.has(id)) : [];
890
- return { results, dirsSearched: skillsDirs.length, written, skipped, missing, failures, applyFailures };
1022
+ return { results, dirsSearched: skillsDirs.length, written, skipped, missing, failures, applyFailures, integrations, ...(integrationDigest !== undefined ? { integrationDigest } : {}) };
891
1023
  }
892
1024
 
893
1025
  /** Warn about preset/select ids that weren't found in any installed pack. */
@@ -947,28 +1079,56 @@ async function cmdInit(options: Map<string, string>, flags: Set<string>, cwd: st
947
1079
  cwd,
948
1080
  explicitSkillsDir,
949
1081
  select,
1082
+ writeErr,
950
1083
  force: flags.has('force'),
951
1084
  enrich: flags.has('enrich'),
1085
+ noHooks: flags.has('no-hooks'),
1086
+ noIntegrations: flags.has('no-integrations'),
1087
+ noVerify: flags.has('no-verify'),
1088
+ ...(options.get('allow-integrations') !== undefined ? { allowIntegrations: options.get('allow-integrations')! } : {}),
952
1089
  });
953
1090
 
954
- write(`dz init --target ${target}: ${r.results.length} skill(s), ${r.written} file(s) written, ${r.skipped} skipped`);
955
- if (r.dirsSearched > 1) {
956
- write(` (searched ${r.dirsSearched} skill directories)`);
1091
+ // PR-A: an explicit --select that named a skill no root provides is a REFUSAL, not a warning.
1092
+ // Printed and returned here, before any target adapter runs — nothing has been written yet.
1093
+ if (r.selectRefusal !== undefined) {
1094
+ writeErr(r.selectRefusal);
1095
+ return 1;
957
1096
  }
958
- writeMissingSkillsHint(write, r.missing, presetName);
959
1097
 
960
- // Codex-targeted init DELIVERS the hooks and verifies them (ADR-001 §8). Skills alone are not the
961
- // target's harness: the veto + recall legs are what `--target codex` promises.
962
- // `--no-hooks` is the documented escape for "skills only" (the same flag `dz setup` already
963
- // carries): hook delivery writes USER-GLOBAL config, so a command that only wants skills compiled
964
- // must be able to say so — and every test that is about skills says it.
1098
+ // Codex keeps its established user-registry writer, but its result is normalized into the same
1099
+ // two-outcome contract before JSON/human rendering. A write without a live ready observation is
1100
+ // a refusal with applied=true, never a second success channel.
1101
+ let integrationOutcomes: readonly IntegrationOutcome[] = r.integrations;
965
1102
  let codexHooksOk = true;
966
1103
  if (target === 'codex' && !flags.has('no-hooks')) {
967
1104
  const delivery = deliverCodexHooks({ project: projectRoot, verify: !flags.has('no-verify') }, undefined, 'dz init');
968
1105
  codexHooksOk = delivery.ok;
969
1106
  for (const line of delivery.stdout) write(line);
970
1107
  for (const line of delivery.stderr) writeErr(line);
1108
+ const hookIndex = integrationOutcomes.findIndex((row) => row.component === 'hooks' && row.status !== 'not-requested');
1109
+ if (hookIndex !== -1) {
1110
+ const hook = normalizeCodexHookOutcome(integrationOutcomes[hookIndex]!, delivery, flags.has('no-verify'));
1111
+ integrationOutcomes = integrationOutcomes.map((row, index) => index === hookIndex ? hook : row);
1112
+ }
1113
+ }
1114
+
1115
+ if (flags.has('json')) {
1116
+ write(JSON.stringify({ target, skills: { count: r.results.length, written: r.written, skipped: r.skipped }, integrations: integrationOutcomes, integrationDigest: r.integrationDigest ?? null }));
1117
+ } else {
1118
+ write(`dz init --target ${target}: ${r.results.length} skill(s), ${r.written} file(s) written, ${r.skipped} skipped`);
1119
+ if (r.dirsSearched > 1) write(` (searched ${r.dirsSearched} skill directories)`);
1120
+ for (const outcome of integrationOutcomes) {
1121
+ const label = outcome.component === 'mcp' ? 'MCP' : 'Hooks';
1122
+ const detail = outcome.status === 'refused'
1123
+ ? `${outcome.reasonCode ?? 'LIVE_PROBE_FAILED'}${outcome.remediation ? ` — ${outcome.remediation}` : ''}`
1124
+ : outcome.status === 'emitted'
1125
+ ? `${outcome.carrier?.path ?? 'registered'}${outcome.registrations.some((row) => row.approval === 'pending') ? '; Pending approval; ready=false' : ''}`
1126
+ : 'explicitly not requested';
1127
+ write(`${label}: ${outcome.status.toUpperCase()} (${detail})`);
1128
+ }
971
1129
  }
1130
+ writeMissingSkillsHint(write, r.missing, presetName);
1131
+
972
1132
  // Skip-and-collect must not become skip-and-SILENCE: a skill that failed to load is
973
1133
  // named on stderr and the command exits 1 (it exited 1 before too — by throwing).
974
1134
  if (r.failures.length > 0 || r.applyFailures.length > 0) {
@@ -987,7 +1147,35 @@ async function cmdInit(options: Map<string, string>, flags: Set<string>, cwd: st
987
1147
  for (const line of formatSkillApplyFailures(r.applyFailures)) writeErr(line);
988
1148
  return 1;
989
1149
  }
990
- return codexHooksOk ? 0 : 1;
1150
+ const integrationOk = !integrationOutcomes.some((row) => row.status === 'refused');
1151
+ return codexHooksOk && integrationOk ? 0 : 1;
1152
+ }
1153
+
1154
+ function cmdIntegrationsVerify(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write, writeErr: WriteErr): number {
1155
+ const targetInput = options.get('target');
1156
+ const component = options.get('component');
1157
+ if (targetInput === undefined || (component !== 'mcp' && component !== 'hooks')) {
1158
+ writeErr('dz integrations-verify: requires --target <name> --component <mcp|hooks>');
1159
+ return 1;
1160
+ }
1161
+ const resolution = resolveTargetName(targetInput);
1162
+ if (resolution.kind === 'unknown') {
1163
+ for (const line of formatTargetProblem('dz integrations-verify', resolution)) writeErr(line);
1164
+ return 1;
1165
+ }
1166
+ if (resolution.via === 'alias') writeErr(formatTargetAliasNote('dz integrations-verify', targetInput, resolution.target));
1167
+ const result = runIntegrationsVerify({
1168
+ target: resolution.target,
1169
+ component,
1170
+ projectRoot: resolve(cwd, options.get('project') ?? '.'),
1171
+ });
1172
+ if (flags.has('json')) write(JSON.stringify(result));
1173
+ else if (result.ok) {
1174
+ write(`dz integrations-verify: ${resolution.target}/${component} registered (runtime ${result.runtimeVersion ?? 'unknown'}; ready=${result.registrations.every((row) => row.ready === true)})`);
1175
+ } else {
1176
+ writeErr(`dz integrations-verify: ${resolution.target}/${component} REFUSED (${result.reasonCode ?? 'LIVE_PROBE_FAILED'}) — ${result.remediation ?? 'no qualifying receipt'}`);
1177
+ }
1178
+ return result.ok ? 0 : 1;
991
1179
  }
992
1180
 
993
1181
  async function cmdVerify(options: Map<string, string>, cwd: string, write: Write, writeErr: WriteErr): Promise<number> {
@@ -2423,6 +2611,146 @@ function cmdStatuslineFaRecord(options: Map<string, string>, cwd: string, write:
2423
2611
  return 0;
2424
2612
  }
2425
2613
 
2614
+ interface EtaCorpusRead {
2615
+ readonly history: StageSample[];
2616
+ readonly currentRunSamples: StageSample[];
2617
+ readonly currentTier: string | undefined;
2618
+ readonly activeStages: CheckpointStage[];
2619
+ readonly lastCheckpointTsMs: number | undefined;
2620
+ readonly hasCurrentCheckpoints: boolean;
2621
+ }
2622
+
2623
+ function statuslineEtaStage(step: string): CheckpointStage | undefined {
2624
+ const match = step.match(/\bStep\s*-?\s*(0|[1-9](?:\.5)?)(?:\b|\s)/i);
2625
+ if (match === null) return undefined;
2626
+ const value = Number(match[1]);
2627
+ if (value === 0) return 'router';
2628
+ if (value > 0 && value < 6) return 'design';
2629
+ if (value === 6) return 'plan';
2630
+ if (value === 7) return 'code';
2631
+ if (value === 8) return 'qe';
2632
+ if (value === 9) return 'fleet';
2633
+ return undefined;
2634
+ }
2635
+
2636
+ function routerMetadata(router: RunSegment['router']): { tier?: string; activeSteps?: number[] } {
2637
+ const result = router?.result;
2638
+ if (result === null || typeof result !== 'object') return {};
2639
+ const record = result as Record<string, unknown>;
2640
+ const tier = typeof record['tier'] === 'string' && record['tier'].length > 0 ? record['tier'] : undefined;
2641
+ const activeSteps = Array.isArray(record['activeSteps'])
2642
+ ? record['activeSteps'].filter((value): value is number => typeof value === 'number' && Number.isFinite(value))
2643
+ : undefined;
2644
+ return {
2645
+ ...(tier !== undefined ? { tier } : {}),
2646
+ ...(activeSteps !== undefined ? { activeSteps } : {}),
2647
+ };
2648
+ }
2649
+
2650
+ function activeCheckpointStages(tier: string | undefined, activeSteps: readonly number[] | undefined): CheckpointStage[] {
2651
+ const stages = new Set<CheckpointStage>();
2652
+ if (activeSteps !== undefined) {
2653
+ for (const step of activeSteps) {
2654
+ if (step === 0) stages.add('router');
2655
+ else if (step > 0 && step < 6) stages.add('design');
2656
+ else if (step === 6) stages.add('plan');
2657
+ else if (step === 7) stages.add('code');
2658
+ else if (step === 8) stages.add('qe');
2659
+ else if (step === 9) stages.add('fleet');
2660
+ }
2661
+ } else if (tier !== undefined) {
2662
+ for (const stage of CHECKPOINT_STAGES) {
2663
+ if (stage !== 'fleet' || tier === 'L' || tier === 'XL') stages.add(stage);
2664
+ }
2665
+ }
2666
+ return CHECKPOINT_STAGES.filter((stage) => stages.has(stage));
2667
+ }
2668
+
2669
+ /** All checkpoint I/O for ETA lives in this CLI-only wrapper and degrades per file. */
2670
+ function readEtaCorpus(projectRoot: string, currentSlug: string): EtaCorpusRead {
2671
+ const trace = process.env['DZ_ETA_TRACE'];
2672
+ if (trace !== undefined && trace.length > 0) {
2673
+ try {
2674
+ appendFileSync(trace, `${JSON.stringify({ projectRoot, currentSlug, ts: new Date().toISOString() })}\n`);
2675
+ } catch { /* a test/debug receipt can never take down the statusline */ }
2676
+ }
2677
+
2678
+ const samples: StageSample[] = [];
2679
+ let currentSegments: RunSegment[] = [];
2680
+ let hasCurrentCheckpoints = false;
2681
+ let entries: Dirent[] = [];
2682
+ try {
2683
+ entries = readdirSync(join(projectRoot, 'features'), { withFileTypes: true });
2684
+ } catch {
2685
+ return {
2686
+ history: [], currentRunSamples: [], currentTier: undefined, activeStages: [],
2687
+ lastCheckpointTsMs: undefined, hasCurrentCheckpoints: false,
2688
+ };
2689
+ }
2690
+
2691
+ for (const entry of entries) {
2692
+ if (!entry.isDirectory()) continue;
2693
+ const checkpointPath = join(projectRoot, 'features', entry.name, '.fa-state', 'checkpoints.jsonl');
2694
+ try {
2695
+ const text = readFileSync(checkpointPath, 'utf8');
2696
+ const observations = parseCheckpointLines(text, entry.name);
2697
+ const segments = segmentRun(observations);
2698
+ samples.push(...extractStageSamples(segments));
2699
+ if (entry.name === currentSlug) {
2700
+ hasCurrentCheckpoints = observations.length > 0;
2701
+ currentSegments = segments;
2702
+ }
2703
+ } catch {
2704
+ // Missing, unreadable, or racing append: this run contributes no evidence.
2705
+ }
2706
+ }
2707
+
2708
+ const lastSegment = currentSegments.at(-1);
2709
+ const currentRouter = lastSegment?.router;
2710
+ const invocationSegments = currentRouter === undefined
2711
+ ? []
2712
+ : currentSegments.filter((segment) => segment.router === currentRouter);
2713
+ const invocationRunIds = new Set(invocationSegments.map((segment) => segment.runId));
2714
+ const currentRunSamples = extractStageSamples(invocationSegments);
2715
+ const metadata = routerMetadata(currentRouter);
2716
+ const currentTimestamps = invocationSegments
2717
+ .flatMap((segment) => segment.observations)
2718
+ .flatMap((observation) => observation.tsMs === undefined ? [] : [observation.tsMs]);
2719
+
2720
+ return {
2721
+ history: samples.filter((sample) => !invocationRunIds.has(sample.runId)),
2722
+ currentRunSamples,
2723
+ currentTier: metadata.tier,
2724
+ activeStages: activeCheckpointStages(metadata.tier, metadata.activeSteps),
2725
+ lastCheckpointTsMs: currentTimestamps.length > 0 ? Math.max(...currentTimestamps) : undefined,
2726
+ hasCurrentCheckpoints,
2727
+ };
2728
+ }
2729
+
2730
+ function statuslineEta(projectRoot: string, state: FeatureAdrState, nowMs: number): EtaEstimate | undefined {
2731
+ const currentStage = statuslineEtaStage(state.step);
2732
+ if (currentStage === undefined) return undefined;
2733
+ const corpus = readEtaCorpus(projectRoot, state.slug);
2734
+ const currentIndex = CHECKPOINT_STAGES.indexOf(currentStage);
2735
+ const remainingStages = corpus.activeStages.filter((stage) => CHECKPOINT_STAGES.indexOf(stage) >= currentIndex && stage !== 'router');
2736
+ // Preserve honest absence as a machine-readable union member. The text formatter intentionally
2737
+ // omits both variants, but `--json` must still distinguish no file from a file with no tier.
2738
+ const stagesForEstimate = remainingStages.length > 0
2739
+ ? remainingStages
2740
+ : (currentStage === 'router' ? [] : [currentStage]);
2741
+ if (stagesForEstimate.length === 0 && corpus.hasCurrentCheckpoints && corpus.currentTier !== undefined) return undefined;
2742
+ return estimateEta({
2743
+ samples: corpus.history,
2744
+ currentTier: corpus.currentTier,
2745
+ currentStage,
2746
+ remainingStages: stagesForEstimate,
2747
+ currentRunSamples: corpus.currentRunSamples,
2748
+ nowMs,
2749
+ ...(corpus.lastCheckpointTsMs !== undefined ? { lastCheckpointTsMs: corpus.lastCheckpointTsMs } : {}),
2750
+ hasCurrentCheckpoints: corpus.hasCurrentCheckpoints,
2751
+ });
2752
+ }
2753
+
2426
2754
  /**
2427
2755
  * `dz statusline` — render dz's OWN self-learning counts as one compact, emoji-tagged line for
2428
2756
  * Claude Code's status bar (modeled on agentic-qe's "🎓 12 patterns"). Claude Code pipes a JSON
@@ -2447,9 +2775,22 @@ function cmdStatusline(
2447
2775
  try {
2448
2776
  const projectRoot = statuslineProjectRoot(readStdin(), options, cwd);
2449
2777
  const data = statuslineData(projectRoot);
2778
+ const fa = data.featureAdr;
2779
+ let eta: EtaEstimate | undefined;
2780
+ let etaFragment: string | undefined;
2781
+ if (fa !== undefined && fa.kind !== 'loop') {
2782
+ try {
2783
+ eta = statuslineEta(projectRoot, fa, Date.now());
2784
+ if (eta !== undefined) etaFragment = formatEta(eta);
2785
+ } catch {
2786
+ // ETA is advisory: any corpus/resource failure omits only this fragment.
2787
+ eta = undefined;
2788
+ etaFragment = undefined;
2789
+ }
2790
+ }
2450
2791
 
2451
2792
  if (flags.has('json')) {
2452
- write(JSON.stringify(data));
2793
+ write(JSON.stringify({ ...data, ...(eta !== undefined ? { eta } : {}) }));
2453
2794
  return 0;
2454
2795
  }
2455
2796
 
@@ -2459,13 +2800,12 @@ function cmdStatusline(
2459
2800
  if (data.consolidatedAgeH !== undefined) line += ` · ⟳ ${data.consolidatedAgeH}h`;
2460
2801
 
2461
2802
  // Live /feature-adr run in flight → PREPEND the pipeline learning segment to the base dz line.
2462
- const fa = data.featureAdr;
2463
2803
  if (fa !== undefined) {
2464
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.
2465
2805
  if (fa.kind === 'loop') {
2466
2806
  line = `🔁 loop ${fa.step} · ${line}`;
2467
2807
  } else {
2468
- line = `📐 feature-adr ${fa.step} · 🎓 ${fa.pool} pool · ↑${fa.recalled} used · +${fa.stored} new · ↻${fa.reinforced ?? 0} reinforced · ${line}`;
2808
+ line = `📐 feature-adr ${fa.step} · ${etaFragment !== undefined ? `${etaFragment} · ` : ''}🎓 ${fa.pool} pool · ↑${fa.recalled} used · +${fa.stored} new · ↻${fa.reinforced ?? 0} reinforced · ${line}`;
2469
2809
  }
2470
2810
  }
2471
2811
 
@@ -3092,7 +3432,22 @@ function learningStoreLine(
3092
3432
  ) + (reason ? ' [' + reason + ']' : '');
3093
3433
  }
3094
3434
 
3095
- async function cmdTeach(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): Promise<number> {
3435
+ async function runTeachGuardReinforcement(
3436
+ projectRoot: string,
3437
+ dzId: string,
3438
+ reward: number,
3439
+ ): Promise<{ readonly flushed: number }> {
3440
+ const backend = resolveLearningBackend(projectRoot);
3441
+ backend.addSample({ dzId, kind: 'reinforce', reward, ts: new Date().toISOString() });
3442
+ return backend.train();
3443
+ }
3444
+
3445
+ async function cmdTeach(
3446
+ options: Map<string, string>, flags: Set<string>, cwd: string, write: Write,
3447
+ writeErr: WriteErr = (line) => { console.error(line); }, interactive = false,
3448
+ guardRunner: (projectRoot: string, text: string, opts: { readonly reward?: number }) => Promise<TeachGuardResult> = teachGuard,
3449
+ reinforceRunner: (projectRoot: string, dzId: string, reward: number) => Promise<{ readonly flushed: number }> = runTeachGuardReinforcement,
3450
+ ): Promise<number> {
3096
3451
  // WHICH store this lesson belongs to, and WHO decided (teach-chooses-its-store).
3097
3452
  // `--to` → `DZ_LEARN` → `.dz/config.json` learning.teachTo → project. The owner asked for a
3098
3453
  // per-session choice; for a CLI every invocation is a fresh process, so the only honest session
@@ -3176,26 +3531,39 @@ async function cmdTeach(options: Map<string, string>, flags: Set<string>, cwd: s
3176
3531
  write('dz teach --from-json: expected a JSON array (produced by `dz recall --all --json`)');
3177
3532
  return 1;
3178
3533
  }
3179
- const existing = new Set(loadStorePatternsSync(storeRoot).map((p) => p.pattern));
3534
+ const importedKey = (p: PatternRecord): string => p.lessonForm !== undefined && p.lessonPairId !== undefined
3535
+ ? `${p.pattern}\u0000${p.lessonForm}\u0000${p.lessonPairId}`
3536
+ : `legacy\u0000${p.pattern}`;
3537
+ const existing = new Set(loadStorePatternsSync(storeRoot).map(importedKey));
3180
3538
  let imported = 0;
3181
3539
  let skipped = 0;
3182
3540
  const importedRecs: PatternRecord[] = [];
3183
3541
  for (const item of parsed) {
3184
3542
  const p = item as Partial<PatternRecord>;
3185
- if (!p || typeof p.pattern !== 'string' || p.pattern.trim() === '' || existing.has(p.pattern)) {
3543
+ if (!p || typeof p !== 'object') {
3186
3544
  skipped += 1;
3187
3545
  continue;
3188
3546
  }
3189
- const rec: PatternRecord = {
3190
- pattern: p.pattern,
3547
+ const pair = (p.lessonForm === 'specific' || p.lessonForm === 'class')
3548
+ && typeof p.lessonPairId === 'string' && p.lessonPairId !== ''
3549
+ ? { lessonForm: p.lessonForm, lessonPairId: p.lessonPairId }
3550
+ : {};
3551
+ const candidate = {
3552
+ pattern: typeof p.pattern === 'string' ? p.pattern : '',
3191
3553
  type: (typeof p.type === 'string' ? p.type : 'lesson-learned') as PatternRecord['type'],
3192
3554
  reward: typeof p.reward === 'number' ? Math.max(0, Math.min(1, p.reward)) : 0.8,
3193
3555
  domain: typeof p.domain === 'string' ? p.domain : 'general',
3194
3556
  ts: typeof p.ts === 'string' ? p.ts : new Date().toISOString(),
3195
3557
  source: 'dz-teach-import',
3196
- };
3558
+ ...pair,
3559
+ } satisfies PatternRecord;
3560
+ if (candidate.pattern.trim() === '' || existing.has(importedKey(candidate))) {
3561
+ skipped += 1;
3562
+ continue;
3563
+ }
3564
+ const rec: PatternRecord = candidate;
3197
3565
  await recordPattern(storeRoot, rec);
3198
- existing.add(p.pattern);
3566
+ existing.add(importedKey(rec));
3199
3567
  importedRecs.push(rec);
3200
3568
  imported += 1;
3201
3569
  }
@@ -3279,23 +3647,30 @@ async function cmdTeach(options: Map<string, string>, flags: Set<string>, cwd: s
3279
3647
 
3280
3648
  const reward = parseFloat(options.get('reward') ?? '0.8');
3281
3649
  const domain = options.get('domain') ?? 'general';
3650
+ const classWasRequested = options.has('class-form') || flags.has('class-form');
3651
+ const lessonForms = normalizeLessonForms(pattern, options.get('class-form'));
3652
+ let guardedExisting: { readonly dzId: string; readonly cosine: number; readonly pattern: PatternRecord } | undefined;
3282
3653
 
3283
3654
  if (flags.has('guard')) {
3284
- const verdict = await teachGuard(storeRoot, pattern, { reward: Math.max(0, Math.min(1, reward)) });
3655
+ const verdict = await guardRunner(storeRoot, pattern, { reward: Math.max(0, Math.min(1, reward)) });
3285
3656
  if (verdict.action === 'reinforce') {
3286
- const backend = resolveLearningBackend(storeRoot);
3287
- backend.addSample({ dzId: verdict.dzId, kind: 'reinforce', reward: Math.max(0, Math.min(1, reward)), ts: new Date().toISOString() });
3288
- const trained = await backend.train();
3289
- // HIGH-fix: only claim success when the reinforce actually FLUSHED. With backend 'off'
3290
- // (NoopLearningBackend) or a flush failure, flushed === 0 — falling through to the plain
3291
- // teach below so the lesson is NEVER silently discarded (the exact silent-drop the ADR forbids).
3292
- if (trained.flushed > 0) {
3293
- write(`↳ reinforced existing pattern ${verdict.dzId} (cos=${verdict.cosine.toFixed(2)}) — not re-added`);
3294
- const clearedQ = clearAgentdbQuarantine(storeRoot, [verdict.dzId]);
3295
- if (clearedQ.cleared > 0) write(' ↳ promoted out of quarantine (mirror updated)');
3296
- return 0;
3657
+ if (lessonForms.classForm !== undefined) {
3658
+ const existing = loadStoreRecords(storeRoot).find((record) => record.id === verdict.dzId);
3659
+ if (existing !== undefined) {
3660
+ guardedExisting = { dzId: verdict.dzId, cosine: verdict.cosine, pattern: recordToPattern(existing) };
3661
+ } else {
3662
+ write(`dz teach --guard: matched pattern ${verdict.dzId} was not found in the lexical store — teaching the lesson normally`);
3663
+ }
3664
+ } else {
3665
+ const trained = await reinforceRunner(storeRoot, verdict.dzId, Math.max(0, Math.min(1, reward)));
3666
+ if (trained.flushed > 0) {
3667
+ write(`↳ reinforced existing pattern ${verdict.dzId} (cos=${verdict.cosine.toFixed(2)}) — not re-added`);
3668
+ const clearedQ = clearAgentdbQuarantine(storeRoot, [verdict.dzId]);
3669
+ if (clearedQ.cleared > 0) write(' ↳ promoted out of quarantine (mirror updated)');
3670
+ return 0;
3671
+ }
3672
+ write(`dz teach --guard: reinforce of ${verdict.dzId} did not flush (backend off or write failure) — teaching the lesson normally instead`);
3297
3673
  }
3298
- write(`dz teach --guard: reinforce of ${verdict.dzId} did not flush (backend off or write failure) — teaching the lesson normally instead`);
3299
3674
  }
3300
3675
  }
3301
3676
 
@@ -3310,24 +3685,63 @@ async function cmdTeach(options: Map<string, string>, flags: Set<string>, cwd: s
3310
3685
  // Typed through the shared schema (harness-core owns PatternRecord) so the
3311
3686
  // write side and the read side (recommend's loadPatterns) can never drift —
3312
3687
  // a field rename here is a compile error, not a silently re-muted loop (audit #2).
3313
- const entry: PatternRecord = {
3314
- pattern,
3315
- type: type as PatternRecord['type'],
3316
- reward: Math.max(0, Math.min(1, reward)),
3317
- domain,
3318
- ts: new Date().toISOString(),
3319
- source: 'dz-teach',
3320
- };
3688
+ const entry: PatternRecord = guardedExisting?.pattern ?? {
3689
+ pattern: lessonForms.specific,
3690
+ type: type as PatternRecord['type'],
3691
+ reward: Math.max(0, Math.min(1, reward)),
3692
+ domain,
3693
+ ts: new Date().toISOString(),
3694
+ source: 'dz-teach',
3695
+ };
3321
3696
 
3322
3697
  // Tier-2 (ADR-005): persist through the unified @dzhechkov/memory store. recordPattern
3323
3698
  // folds any legacy .dz/patterns.jsonl into the backend (idempotent) and returns the
3324
3699
  // total count. The lossy `npx agentdb add` dual-write was removed in Tier-1 (audit #6).
3325
3700
  // lesson-quarantine (opt-in): a fresh lesson is a HYPOTHESIS until it earns promotion.
3326
3701
  const quarantineOn = readMemoryLearningConfig(storeRoot).quarantine;
3327
- const count = await recordPattern(storeRoot, entry, quarantineOn ? { quarantine: true } : {});
3702
+ const stored = await recordLessonForms(
3703
+ storeRoot,
3704
+ entry,
3705
+ lessonForms.classForm,
3706
+ quarantineOn ? { quarantine: true } : {},
3707
+ );
3708
+ let recordsToMirror = stored.records;
3709
+ let commandFailed = stored.class === 'failed';
3710
+ let reinforced = false;
3711
+ if (guardedExisting !== undefined) {
3712
+ let reinforceId = guardedExisting.dzId;
3713
+ if (stored.class === 'stored') {
3714
+ const specificRow = stored.records.find((row) => row.lessonForm === 'specific');
3715
+ if (specificRow !== undefined) reinforceId = patternRecordId(specificRow);
3716
+ if (reinforceId !== guardedExisting.dzId) {
3717
+ const removed = removePatternsByIds(storeRoot, new Set([guardedExisting.dzId]));
3718
+ if (removed.error !== undefined || removed.removed === 0) {
3719
+ commandFailed = true;
3720
+ writeErr(`dz teach --guard: class pair stored, but old pattern cleanup failed${removed.error === undefined ? '' : ` — ${removed.error}`}`);
3721
+ }
3722
+ }
3723
+ } else if (stored.class === 'failed') {
3724
+ const partialSpecific = stored.records.find((row) => row.lessonForm === 'specific');
3725
+ if (partialSpecific !== undefined && patternRecordId(partialSpecific) !== guardedExisting.dzId) {
3726
+ const rolledBack = removePatternsByIds(storeRoot, new Set([patternRecordId(partialSpecific)]));
3727
+ if (rolledBack.error !== undefined) writeErr(`dz teach --guard: partial enrichment rollback failed — ${rolledBack.error}`);
3728
+ }
3729
+ recordsToMirror = [];
3730
+ reinforceId = guardedExisting.dzId;
3731
+ }
3732
+ if (!commandFailed) {
3733
+ const trained = await reinforceRunner(storeRoot, reinforceId, Math.max(0, Math.min(1, reward)));
3734
+ reinforced = trained.flushed > 0;
3735
+ if (reinforced) {
3736
+ const clearedQ = clearAgentdbQuarantine(storeRoot, [reinforceId]);
3737
+ if (clearedQ.cleared > 0) write(' ↳ promoted out of quarantine (mirror updated)');
3738
+ }
3739
+ }
3740
+ }
3741
+ const count = guardedExisting === undefined ? stored.count : loadStorePatternsSync(storeRoot).length;
3328
3742
 
3329
- write(`Learned: "${pattern.slice(0, 60)}${pattern.length > 60 ? '...' : ''}"`);
3330
- write(` Domain: ${domain} Reward: ${reward} Backend: memory (@dzhechkov/memory)`);
3743
+ write(`${guardedExisting !== undefined && stored.class === 'stored' ? 'Enriched' : 'Learned'}: "${pattern.slice(0, 60)}${pattern.length > 60 ? '...' : ''}"`);
3744
+ write(` Domain: ${entry.domain} Reward: ${entry.reward} Backend: memory (@dzhechkov/memory)`);
3331
3745
  write(` Total patterns: ${count}`);
3332
3746
  // WHERE the write landed. MEASURED before this line existed: teach printed the pattern, the
3333
3747
  // domain, the reward and the backend — and not one word about the path, so a store written to
@@ -3337,6 +3751,20 @@ async function cmdTeach(options: Map<string, string>, flags: Set<string>, cwd: s
3337
3751
  // sees a path, cannot tell what chose it, and has no reason to question it. `default` adds
3338
3752
  // nothing, so the line stays byte-identical for everyone who set nothing.
3339
3753
  write(storeLine('written'));
3754
+ if (stored.class === 'stored') write(' Class form: stored separately and linked to the specific lesson');
3755
+ if (classWasRequested && stored.class === 'absent') write(' Class form: skipped; the specific lesson was saved');
3756
+ if (stored.class === 'rejected') writeErr(`dz teach: class form rejected — ${stored.reason ?? 'invalid class form'}; the specific lesson was saved`);
3757
+ if (stored.class === 'failed') writeErr(`dz teach: specific stored; class failed — ${stored.reason ?? 'unknown storage failure'}`);
3758
+ if (guardedExisting !== undefined && stored.class === 'stored') {
3759
+ write(commandFailed
3760
+ ? ' Guard: class enrichment stored; reinforcement skipped because old-pattern cleanup failed'
3761
+ : reinforced
3762
+ ? ` Guard: enriched existing pattern (cos=${guardedExisting.cosine.toFixed(2)}), then reinforced the linked specific form`
3763
+ : ' Guard: class enrichment stored; reinforcement did not flush');
3764
+ }
3765
+ if (!classWasRequested && interactive) {
3766
+ write(' Rule of one place or of a class? Optional: add --class-form when a future reader needs the why for structure, interfaces, or maintainability; skip when scope, risk, time, and cost are low or standards, policy, or documentation already cover it.');
3767
+ }
3340
3768
  // ADVICE, not a gate. Someone putting medical lessons in a shared store owns both
3341
3769
  // directories and this binary; refusing would be defending a user against themselves,
3342
3770
  // which this design does not attempt. Making the choice INFORMED is the part that is
@@ -3351,8 +3779,8 @@ async function cmdTeach(options: Map<string, string>, flags: Set<string>, cwd: s
3351
3779
  write(' ⚠ quarantined: excluded from auto-inject, damped in recall — promote by confirming it (dz teach --reinforce "<text>") or dz recall --promote <dzId> --apply');
3352
3780
  }
3353
3781
  // The lexical write above is durable — the vector mirror is strictly best-effort (I-3).
3354
- await emitMirrorQ(storeRoot, [entry], 'dz-teach', quarantineOn);
3355
- return 0;
3782
+ await emitMirrorQ(storeRoot, recordsToMirror, 'dz-teach', quarantineOn);
3783
+ return commandFailed ? 1 : 0;
3356
3784
  }
3357
3785
 
3358
3786
  async function cmdConsolidate(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): Promise<number> {
@@ -3704,7 +4132,14 @@ async function cmdRecallPromote(
3704
4132
  return 0;
3705
4133
  }
3706
4134
 
3707
- async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): Promise<number> {
4135
+ async function cmdRecall(
4136
+ options: Map<string, string>,
4137
+ flags: Set<string>,
4138
+ cwd: string,
4139
+ write: Write,
4140
+ writeErr: WriteErr,
4141
+ classMatcher?: RecallPatternsOptions['classMatcher'],
4142
+ ): Promise<number> {
3708
4143
  const projectRoot = resolve(cwd, options.get('project') ?? '.');
3709
4144
  const asJson = flags.has('json');
3710
4145
  const all = flags.has('all');
@@ -3723,7 +4158,17 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
3723
4158
  // review could not make correct: a tag set by the writer is decidable, prose is not.
3724
4159
  const allPatterns = loadStorePatternsSync(projectRoot);
3725
4160
  const holdout = applyExportHoldout(allPatterns, heldOutAfterOptIn(options.get('include-domain')));
3726
- const patterns = holdout.exported;
4161
+ const classByPair = new Map(
4162
+ holdout.exported
4163
+ .filter((row) => row.lessonForm === 'class' && row.lessonPairId !== undefined)
4164
+ .map((row) => [row.lessonPairId as string, row.pattern]),
4165
+ );
4166
+ const patterns = holdout.exported.map((row) => {
4167
+ const classForm = row.lessonForm === 'specific' && row.lessonPairId !== undefined
4168
+ ? classByPair.get(row.lessonPairId)
4169
+ : undefined;
4170
+ return classForm === undefined ? row : { ...row, classForm };
4171
+ });
3727
4172
  const holdoutNote = renderHoldoutNote(holdout);
3728
4173
  // The opt-in is honoured without argument — and named out loud. A flag that silently
3729
4174
  // includes medical lessons in a portable export is a flag whose consequence the user
@@ -3757,7 +4202,15 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
3757
4202
  const perDomain = Object.fromEntries(
3758
4203
  Object.entries(rawStats.perDomain).filter(([d]) => !holdout.domains.includes(canonicalDomainKey(d))),
3759
4204
  );
3760
- const stats = { ...rawStats, topUses: topHoldout.exported, perDomain };
4205
+ const generalizedPairs = new Set(patterns.flatMap((p) => p.lessonPairId === undefined ? [] : [p.lessonPairId]));
4206
+ const unpairedLessons = patterns.filter((p) => p.lessonPairId === undefined).length;
4207
+ const stats = {
4208
+ ...rawStats,
4209
+ topUses: topHoldout.exported,
4210
+ perDomain,
4211
+ generalized: generalizedPairs.size,
4212
+ logicalLessons: generalizedPairs.size + unpairedLessons,
4213
+ };
3761
4214
  const backendStats = resolveLearningBackend(projectRoot).getStats();
3762
4215
  if (asJson) {
3763
4216
  write(JSON.stringify({ patterns, stats, learning: backendStats, withheld: holdout.withheld.length, withheldDomains: holdout.domains }));
@@ -3769,6 +4222,7 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
3769
4222
  write(` backend: ${backendStats.backend}${backendStats.advisory !== undefined ? ` (${backendStats.advisory})` : ''}`);
3770
4223
  write(` domains: ${Object.entries(stats.perDomain).map(([k, v]) => `${k}=${v}`).join(', ') || 'none'}`);
3771
4224
  write(` exact-dup groups: ${stats.exactDupGroups}`);
4225
+ write(` generalized: ${stats.generalized} of ${stats.logicalLessons} lessons`);
3772
4226
  write(` re-teach trend: ${stats.teachEvents} teach event(s), ${stats.reinforceEvents} reinforce event(s)`);
3773
4227
  write(' top uses:');
3774
4228
  for (const row of stats.topUses) write(` ${row.uses}× [${row.reward.toFixed(2)}] (${row.domain}) ${row.pattern.slice(0, 80)}`);
@@ -3889,6 +4343,10 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
3889
4343
  ? ('lexical' as const)
3890
4344
  : ('hybrid' as const);
3891
4345
  const wantedDomain = options.get('domain');
4346
+ const classRecallOptions = {
4347
+ onClassDegraded: writeErr,
4348
+ ...(classMatcher === undefined ? {} : { classMatcher }),
4349
+ };
3892
4350
  // OVER-FETCH before boosting (Codex QE #5): the boost used to run on hits ALREADY
3893
4351
  // truncated to `limit`, so an exact-domain lesson sitting at rank limit+1 could
3894
4352
  // never receive its promised lift — the feature was weakest in exactly the case it
@@ -3911,8 +4369,8 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
3911
4369
  // second occurrence. Foreign stdout is routed to stderr for the duration of the engine call — our
3912
4370
  // own output is written after it returns.
3913
4371
  const result = asJson
3914
- ? await withForeignStdoutOnStderr(() => recallHybrid(projectRoot, query, { limit: fetchLimit, mode, deferExposures: true, ...(wantedDomain !== undefined ? { domain: wantedDomain } : {}) }))
3915
- : await recallHybrid(projectRoot, query, { limit: fetchLimit, mode, deferExposures: true, ...(wantedDomain !== undefined ? { domain: wantedDomain } : {}) });
4372
+ ? await withForeignStdoutOnStderr(() => recallHybrid(projectRoot, query, { limit: fetchLimit, mode, deferExposures: true, ...classRecallOptions, ...(wantedDomain !== undefined ? { domain: wantedDomain } : {}) }))
4373
+ : await recallHybrid(projectRoot, query, { limit: fetchLimit, mode, deferExposures: true, ...classRecallOptions, ...(wantedDomain !== undefined ? { domain: wantedDomain } : {}) });
3916
4374
 
3917
4375
  if (mode === 'semantic' && result.vectorEngine === 'none') {
3918
4376
  // --semantic is an explicit ask — degrading it silently would be dishonest (FR-3).
@@ -3948,13 +4406,26 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
3948
4406
  let globalHits: typeof result.hits = [];
3949
4407
  if (readGlobal) {
3950
4408
  const g = asJson
3951
- ? await withForeignStdoutOnStderr(() => recallHybrid(globalRoot, query, { limit: fetchLimit, mode, deferExposures: true, ...(wantedDomain !== undefined ? { domain: wantedDomain } : {}) }))
3952
- : await recallHybrid(globalRoot, query, { limit: fetchLimit, mode, deferExposures: true, ...(wantedDomain !== undefined ? { domain: wantedDomain } : {}) });
4409
+ ? await withForeignStdoutOnStderr(() => recallHybrid(globalRoot, query, { limit: fetchLimit, mode, deferExposures: true, ...classRecallOptions, ...(wantedDomain !== undefined ? { domain: wantedDomain } : {}) }))
4410
+ : await recallHybrid(globalRoot, query, { limit: fetchLimit, mode, deferExposures: true, ...classRecallOptions, ...(wantedDomain !== undefined ? { domain: wantedDomain } : {}) });
3953
4411
  globalHits = g.hits;
3954
4412
  }
4413
+ const projectHits = boost ? boost.hits : result.hits;
4414
+ // TWO different keys on purpose, and they must not be unified. The cross-store MERGE key is the
4415
+ // lesson TEXT: the same lesson taught into the project store and the global store is one lesson
4416
+ // shown once, labelled `both`, and those two records legitimately differ in timestamp, reward and
4417
+ // domain. The matchedForm LOOKUP key may be narrower. Folding the merge key into
4418
+ // patternIdentityOf (which includes ts) made two `dz teach` calls of identical text stop
4419
+ // collapsing — it reddened the pre-existing P3 case in recall-reads-both-stores.test.ts.
4420
+ const mergeKey = (hit: (typeof projectHits)[number]): string => hit.pattern.pattern;
4421
+ const globalByIdentity = new Map(globalHits.map((hit) => [mergeKey(hit), hit]));
3955
4422
  const merged = readGlobal
3956
- ? mergeStoreHits(boost ? boost.hits : result.hits, globalHits, (h) => h.pattern.pattern)
3957
- : (boost ? boost.hits : result.hits);
4423
+ ? mergeStoreHits(projectHits, globalHits, mergeKey).map((hit) => {
4424
+ if (hit.origin !== 'both') return hit;
4425
+ const matchedForm = mergeLessonMatchedForms(hit.matchedForm, globalByIdentity.get(mergeKey(hit))?.matchedForm);
4426
+ return matchedForm === undefined ? hit : { ...hit, matchedForm };
4427
+ })
4428
+ : projectHits;
3958
4429
  const hits = merged.slice(0, limit);
3959
4430
  // Computed ONCE, honoured by EVERY return path. It used to live only on the text tail, so the two
3960
4431
  // paths that return earlier — `--json` and the zero-hits branch — still reported success. That
@@ -4002,6 +4473,7 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
4002
4473
  // version emitted the number while its own comment promised null (found by independent review).
4003
4474
  write(JSON.stringify(hits.map((h) => ({
4004
4475
  ...h.pattern,
4476
+ ...(h.matchedForm === undefined ? {} : { matchedForm: h.matchedForm }),
4005
4477
  relevance: boost === null && 'score' in h && typeof h.score === 'number' ? h.score : null,
4006
4478
  // The TRUE cosine, as a second companion key — the design named it and the first ship missed
4007
4479
  // it, so a scripted consumer STILL could not threshold (found while recalibrating the floors:
@@ -4066,6 +4538,10 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
4066
4538
  // guessing which second one answered.
4067
4539
  write(` store (read): ${join(globalRoot, '.dz')} [cross-project]`);
4068
4540
  }
4541
+ const classMatches = hits.filter((h) => h.matchedForm === 'class' || h.matchedForm === 'both').length;
4542
+ if (hits.some((h) => h.pattern.classForm !== undefined)) {
4543
+ write(` class-form matches: ${classMatches} of ${hits.length}`);
4544
+ }
4069
4545
  let sawQuarantined = false;
4070
4546
  for (const h of hits) {
4071
4547
  const backendTag = vectorOn ? ` ⟨${h.backend}⟩` : '';
@@ -4090,9 +4566,21 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
4090
4566
  // 160, not 80: at 80 characters the evidence a reader needs to judge relevance sits in the
4091
4567
  // hidden remainder, and the cosine then appears to describe the visible fragment rather than
4092
4568
  // the whole lesson. `--full` prints it all, still on one line.
4093
- const width = flags.has('full') ? Number.POSITIVE_INFINITY : 160;
4094
- const oneLined = oneLine(h.pattern.pattern);
4095
- const shown = width === Number.POSITIVE_INFINITY ? oneLined : oneLined.slice(0, width);
4569
+ const specificText = oneLine(h.pattern.pattern);
4570
+ const classText = h.pattern.classForm === undefined ? undefined : oneLine(h.pattern.classForm);
4571
+ let shown = specificText;
4572
+ if (classText !== undefined) {
4573
+ const suffix = ` [match: ${h.matchedForm ?? 'specific'}]`;
4574
+ if (flags.has('full')) {
4575
+ shown = `specific: ${specificText} · class: ${classText}${suffix}`;
4576
+ } else {
4577
+ const contentWidth = Math.max(2, 160 - 'specific: '.length - ' · class: '.length - suffix.length);
4578
+ const specificWidth = Math.floor(contentWidth / 2);
4579
+ shown = `specific: ${specificText.slice(0, specificWidth)} · class: ${classText.slice(0, contentWidth - specificWidth)}${suffix}`;
4580
+ }
4581
+ } else if (!flags.has('full')) {
4582
+ shown = shown.slice(0, 160);
4583
+ }
4096
4584
  // WHICH store this hit came from. A merged list that does not say re-creates the fragmentation
4097
4585
  // blindness the store-location line just removed, one level down: the reader would see more
4098
4586
  // results and have no way to tell whether the global store is even connected.
@@ -5056,6 +5544,26 @@ async function cmdSetup(options: Map<string, string>, flags: Set<string>, cwd: s
5056
5544
  const projectRoot = resolve(cwd, options.get('project') ?? '.');
5057
5545
  const presetName = options.get('preset');
5058
5546
 
5547
+ // PREFLIGHT BEFORE THE FIRST WRITE (backlog 9d15b9b6, PR-A). Step 3 configures the learning
5548
+ // environment and step 4 installs skills, so refusing at step 4 would leave a project that has
5549
+ // memory and hooks but not the skills the operator asked for — a half-configured state worse than
5550
+ // either clean outcome. The request is therefore resolved HERE, before the banner's first step.
5551
+ //
5552
+ // Only an EXPLICIT --select is refused. A preset names skills the package itself ships, so a gap
5553
+ // there is our packaging defect, not the operator's typo, and it is reported by the existing
5554
+ // missing-list rather than by refusing the whole run.
5555
+ const setupSelectRaw = options.get('select');
5556
+ if (setupSelectRaw !== undefined) {
5557
+ const requested = setupSelectRaw.split(',').map((x) => x.trim()).filter((x) => x.length > 0);
5558
+ const roots = discoverSkillsDirs(cwd, options.get('skills-dir')).map((dir) => ({ dir, ids: discoverSkillIds(dir) }));
5559
+ const resolution = resolveSelection(requested, roots);
5560
+ for (const shadow of resolution.shadowed) {
5561
+ writeErr(`dz: skill '${shadow.id}' is offered by ${shadow.alsoIn.length + 1} roots; installing from ${shadow.chosen} (earlier root wins). Also present in: ${shadow.alsoIn.join(', ')}`);
5562
+ }
5563
+ const refusal = formatSelectRefusal(resolution, roots);
5564
+ if (refusal !== null) { writeErr(refusal); return 1; }
5565
+ }
5566
+
5059
5567
  write(`\n╔══════════════════════════════════════════════════════╗`);
5060
5568
  write(`║ DZ SETUP — Full Environment ║`);
5061
5569
  write(`╠══════════════════════════════════════════════════════╣`);
@@ -5096,7 +5604,13 @@ async function cmdSetup(options: Map<string, string>, flags: Set<string>, cwd: s
5096
5604
  ? selectArg.split(',').map((s) => s.trim()).filter((s) => s.length > 0)
5097
5605
  : getPreset(preset)?.skills;
5098
5606
  const install = select !== undefined && select.length > 0
5099
- ? await installSkills({ target, projectRoot, cwd, explicitSkillsDir: options.get('skills-dir'), select, force: flags.has('force'), enrich: flags.has('enrich') })
5607
+ ? await installSkills({
5608
+ target, projectRoot, cwd, explicitSkillsDir: options.get('skills-dir'), select,
5609
+ force: flags.has('force'), enrich: flags.has('enrich'), noHooks: flags.has('no-hooks'),
5610
+ noIntegrations: flags.has('no-integrations'),
5611
+ noVerify: flags.has('no-verify'),
5612
+ ...(options.get('allow-integrations') !== undefined ? { allowIntegrations: options.get('allow-integrations')! } : {}),
5613
+ })
5100
5614
  : undefined;
5101
5615
  if (install) {
5102
5616
  write(`║ ${String(install.results.length).padStart(2)} skill(s), ${String(install.written).padStart(3)} file(s) written${' '.repeat(15)}║`);
@@ -5106,6 +5620,7 @@ async function cmdSetup(options: Map<string, string>, flags: Set<string>, cwd: s
5106
5620
 
5107
5621
  // Step 5 (ADR-001 §8): DELIVER the codex hooks and verify them live. Non-aborting — the rest of
5108
5622
  // setup has already run and the summary still prints; only the exit code carries the failure.
5623
+ let setupIntegrationOutcomes: readonly IntegrationOutcome[] = install?.integrations ?? [];
5109
5624
  let codexHooksOk = true;
5110
5625
  if (target === 'codex' && !flags.has('no-hooks')) {
5111
5626
  write(`║ 5. Delivering codex hooks (live verify)... ║`);
@@ -5113,6 +5628,11 @@ async function cmdSetup(options: Map<string, string>, flags: Set<string>, cwd: s
5113
5628
  codexHooksOk = delivery.ok;
5114
5629
  for (const line of delivery.stdout) write(line);
5115
5630
  for (const line of delivery.stderr) writeErr(line);
5631
+ const hookIndex = setupIntegrationOutcomes.findIndex((row) => row.component === 'hooks' && row.status !== 'not-requested');
5632
+ if (hookIndex !== -1) {
5633
+ const hook = normalizeCodexHookOutcome(setupIntegrationOutcomes[hookIndex]!, delivery, flags.has('no-verify'));
5634
+ setupIntegrationOutcomes = setupIntegrationOutcomes.map((row, index) => index === hookIndex ? hook : row);
5635
+ }
5116
5636
  }
5117
5637
 
5118
5638
  write(`╠══════════════════════════════════════════════════════╣`);
@@ -5132,6 +5652,9 @@ async function cmdSetup(options: Map<string, string>, flags: Set<string>, cwd: s
5132
5652
  if (install) {
5133
5653
  write(`dz setup: ${install.results.length} skill(s), ${install.written} file(s) written, ${install.skipped} skipped`
5134
5654
  + (install.dirsSearched > 1 ? ` (searched ${install.dirsSearched} skill dirs)` : ''));
5655
+ for (const outcome of setupIntegrationOutcomes) {
5656
+ write(`dz setup integration ${outcome.component}: ${outcome.status.toUpperCase()}${outcome.reasonCode ? ` (${outcome.reasonCode})` : ''}`);
5657
+ }
5135
5658
  writeMissingSkillsHint(write, install.missing, selectArg !== undefined ? undefined : preset);
5136
5659
  }
5137
5660
  // A hook that was written but never witnessed firing is NOT a completed setup (ADR-002 §5): the
@@ -5146,7 +5669,8 @@ async function cmdSetup(options: Map<string, string>, flags: Set<string>, cwd: s
5146
5669
  if (erroredSteps.length > 0) {
5147
5670
  write(`\n✗ setup reported ${erroredSteps.length} failed step(s): ${erroredSteps.join(', ')} — exit 1`);
5148
5671
  }
5149
- return codexHooksOk && erroredSteps.length === 0 ? 0 : 1;
5672
+ const integrationsOk = !setupIntegrationOutcomes.some((row) => row.status === 'refused');
5673
+ return codexHooksOk && erroredSteps.length === 0 && integrationsOk ? 0 : 1;
5150
5674
  }
5151
5675
 
5152
5676
  function cmdPretrain(options: Map<string, string>, cwd: string, write: Write): number {
@@ -5191,7 +5715,7 @@ function cmdPretrain(options: Map<string, string>, cwd: string, write: Write): n
5191
5715
  return 0;
5192
5716
  }
5193
5717
 
5194
- function cmdRecommend(options: Map<string, string>, cwd: string, write: Write): number {
5718
+ function cmdRecommend(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
5195
5719
  const task = options.get('_positional_0');
5196
5720
  if (!task) {
5197
5721
  write('dz recommend: task description required');
@@ -5201,17 +5725,41 @@ function cmdRecommend(options: Map<string, string>, cwd: string, write: Write):
5201
5725
 
5202
5726
  const registry = buildRegistry(cwd);
5203
5727
  const report = recommend(task, registry, cwd);
5728
+ if (flags.has('json')) {
5729
+ write(JSON.stringify(report, null, 2));
5730
+ return 0;
5731
+ }
5204
5732
 
5205
5733
  write(`\n╔══════════════════════════════════════════════════════════════╗`);
5206
5734
  write(`║ DZ RECOMMEND — Task Advisor ║`);
5207
5735
  write(`╠══════════════════════════════════════════════════════════════╣`);
5208
5736
  write(`║ Task: ${report.task.slice(0, 52).padEnd(52)}║`);
5209
- const topicSuffix = report.pretrainFallback ? ' (via pretrain)' : '';
5210
- write(`║ Topics: ${(report.topics.join(', ') + topicSuffix).slice(0, 50).padEnd(50)}║`);
5737
+ if (report.topicSource === 'task') {
5738
+ write(`║ Topics: ${report.topics.join(', ').slice(0, 50).padEnd(50)}║`);
5739
+ } else if (report.topicSource === 'project-stack') {
5740
+ write(`║ Topics: ${'not matched in the question'.padEnd(50)}║`);
5741
+ } else {
5742
+ write(`║ Topics: ${'not recognized — no recommendations'.padEnd(50)}║`);
5743
+ }
5211
5744
  write(`╠══════════════════════════════════════════════════════════════╣`);
5212
5745
 
5746
+ if (report.topicSource === 'project-stack') {
5747
+ write(`⚠ Тема запроса не распознана — подбор ниже сделан по СТЕКУ ПРОЕКТА, не по вашему вопросу.`);
5748
+ write(` (topic not recognized — recommendations reflect the project stack, not the question)`);
5749
+ write(`PROJECT-STACK SUGGESTIONS`);
5750
+ } else if (report.topicSource === 'none') {
5751
+ write(`Тема запроса не распознана; рекомендаций нет.`);
5752
+ write(`Переформулируйте задачу или используйте dz registry search <слово> / /skill-advisor.`);
5753
+ write(`╚══════════════════════════════════════════════════════════════╝`);
5754
+ return 0;
5755
+ }
5756
+
5757
+ const stackDerived = report.topicSource === 'project-stack';
5758
+
5213
5759
  if (report.presets.length > 0) {
5214
- write(`║ RECOMMENDED PRESETS ║`);
5760
+ write(stackDerived
5761
+ ? `║ PROJECT-STACK PRESETS ║`
5762
+ : `║ RECOMMENDED PRESETS ║`);
5215
5763
  for (const p of report.presets) {
5216
5764
  const matched = p.matchedSkills.length > 0 ? ` (${p.matchedSkills.slice(0, 3).join(', ')})` : '';
5217
5765
  write(`║ ${p.name.padEnd(15)} ${String(p.skills).padStart(2)} skills coverage: ${String(p.coverage).padStart(2)} topics${matched.padEnd(15)}║`);
@@ -5220,7 +5768,9 @@ function cmdRecommend(options: Map<string, string>, cwd: string, write: Write):
5220
5768
  }
5221
5769
 
5222
5770
  if (report.skills.length > 0) {
5223
- write(`║ RECOMMENDED SKILLS (top ${Math.min(report.skills.length, 8)})${' '.repeat(35)}║`);
5771
+ write(stackDerived
5772
+ ? `║ PROJECT-STACK SKILLS (top ${Math.min(report.skills.length, 8)})${' '.repeat(35)}║`
5773
+ : `║ RECOMMENDED SKILLS (top ${Math.min(report.skills.length, 8)})${' '.repeat(35)}║`);
5224
5774
  for (const s of report.skills.slice(0, 8)) {
5225
5775
  const desc = s.description.length > 35 ? s.description.slice(0, 32) + '...' : s.description;
5226
5776
  write(`║ ${s.id.padEnd(24)} ${desc.padEnd(36)}║`);
@@ -5229,7 +5779,9 @@ function cmdRecommend(options: Map<string, string>, cwd: string, write: Write):
5229
5779
  }
5230
5780
 
5231
5781
  if (report.toolkits.length > 0) {
5232
- write(`║ FULL PIPELINE (npx toolkits) ║`);
5782
+ write(stackDerived
5783
+ ? `║ PROJECT-STACK PIPELINE (npx toolkits) ║`
5784
+ : `║ FULL PIPELINE (npx toolkits) ║`);
5233
5785
  for (const tk of report.toolkits) {
5234
5786
  const desc = tk.description.length > 44 ? tk.description.slice(0, 41) + '...' : tk.description;
5235
5787
  write(`║ ${tk.name.padEnd(16)} ${desc.padEnd(44)}║`);
@@ -5240,13 +5792,17 @@ function cmdRecommend(options: Map<string, string>, cwd: string, write: Write):
5240
5792
  }
5241
5793
 
5242
5794
  write(`╠══════════════════════════════════════════════════════════════╣`);
5243
- write(`║ STEP-BY-STEP PLAN ║`);
5795
+ write(stackDerived
5796
+ ? `║ PROJECT-STACK PLAN ║`
5797
+ : `║ STEP-BY-STEP PLAN ║`);
5244
5798
  for (const step of report.plan) {
5245
5799
  const line = step.length > 60 ? step.slice(0, 57) + '...' : step;
5246
5800
  write(`║ ${line.padEnd(58)}║`);
5247
5801
  }
5248
5802
  write(`╠══════════════════════════════════════════════════════════════╣`);
5249
- write(`║ QUICK INSTALL ║`);
5803
+ write(stackDerived
5804
+ ? `║ PROJECT-STACK QUICK INSTALL ║`
5805
+ : `║ QUICK INSTALL ║`);
5250
5806
  const cmd = report.installCommand.length > 58 ? report.installCommand.slice(0, 55) + '...' : report.installCommand;
5251
5807
  write(`║ ${cmd.padEnd(58)}║`);
5252
5808
  write(`╚══════════════════════════════════════════════════════════════╝`);
@@ -5804,6 +6360,9 @@ function cmdPublish(options: Map<string, string>, flags: Set<string>, cwd: strin
5804
6360
  if (guardResult.verdict === 'block') write(`dz publish: ⚠ guard BLOCK overridden via --no-guard: ${noGuard} (logged)`);
5805
6361
  else if (guardResult.verdict === 'warn') for (const v of guardResult.violations) write(`dz publish: ⚠ guard warn — ${v.rule}: ${v.detail}`);
5806
6362
  else write('dz publish: ✓ guard pre-flight passed');
6363
+ for (const observation of guardResult.observations ?? []) {
6364
+ write(`dz publish: ℹ guard observation — ${observation.rule} ${observation.scope}: ${observation.detail} [${observation.status}]`);
6365
+ }
5807
6366
  for (const n of guardResult.notes ?? []) write(`dz publish: ℹ guard note — ${n}`); // FN-7: on the record, never blocking
5808
6367
  }
5809
6368
 
@@ -6992,6 +7551,84 @@ function looksBinaryText(text: string): boolean {
6992
7551
  * per-file findings (each enriched with its `file`), and applies the exit-code contract.
6993
7552
  * `--json` ALWAYS emits valid JSON `{ok, findings, scanned}`, even on the failure path.
6994
7553
  */
7554
+ /**
7555
+ * `dz chain` — verify EVERY hash-chained journal in one command (W0-chain, backlog bc4ee35c).
7556
+ *
7557
+ * The machinery to verify a chain has worked for weeks. What was missing is the ABILITY TO ASK:
7558
+ * verification lived inside two consumers, each carrying its own hardcoded list of which files are
7559
+ * chained, so a journal could be given a chain and still be checked by nobody. Coverage here is
7560
+ * DERIVED from CHAINED_JOURNALS, never typed — adding a journal to the registry adds it to this
7561
+ * report by construction.
7562
+ *
7563
+ * An ABSENT journal is reported as `absent`, not omitted. Omission and cleanliness are
7564
+ * indistinguishable in a report, and that indistinguishability is how the original blind spot
7565
+ * survived; the same reason `broken` exits NON-ZERO rather than merely printing — a verifier that
7566
+ * reports damage and exits 0 is one no automation can act on, and this verb exists to run unattended.
7567
+ *
7568
+ * A journal that exists but carries NO chained records is `unchained`, which is legal (a log may
7569
+ * predate the chain) and therefore does not fail the command. Calling it a defect would train the
7570
+ * reader to ignore the output — the failure mode already measured once on the doctor's own line.
7571
+ */
7572
+ function cmdChain(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
7573
+ const root = options.get('project') ?? cwd;
7574
+ const journals = CHAINED_JOURNALS.map((journal) => {
7575
+ const path = join(root, journal.rel);
7576
+ if (!existsSync(path)) {
7577
+ return { rel: journal.rel, decides: journal.decides, status: 'absent' as const, chained: 0, defects: 0, detail: 'file not present' };
7578
+ }
7579
+ let text = '';
7580
+ try {
7581
+ text = readFileSync(path, 'utf-8');
7582
+ } catch {
7583
+ // Unreadable is NOT clean. It is the one outcome that must never be quietly folded into
7584
+ // "nothing to report": we did not look, so we know nothing.
7585
+ return { rel: journal.rel, decides: journal.decides, status: 'unreadable' as const, chained: 0, defects: 0, detail: 'file could not be read' };
7586
+ }
7587
+ const v = verifyEventChainText(text);
7588
+ if (v.chained === 0) {
7589
+ return { rel: journal.rel, decides: journal.decides, status: 'unchained' as const, chained: 0, defects: 0, detail: 'present, but no record carries a chain (legal — the log predates chaining)' };
7590
+ }
7591
+ const total = text.split('\n').filter((l) => l.trim() !== '').length;
7592
+ const age = classifyChainDefects(v, total);
7593
+ if (v.ok) {
7594
+ return { rel: journal.rel, decides: journal.decides, status: 'ok' as const, chained: v.chained, defects: 0, detail: `${v.chained} chained record(s), ${v.resets} recorded restart(s)` };
7595
+ }
7596
+ // A break the current unbroken run has already outlived does not make TODAY's records unsound.
7597
+ // Reporting both alike is what made the doctor's equivalent line permanently red for four weeks.
7598
+ const historical = age.inRun.length === 0 && age.runRecords > 0;
7599
+ return {
7600
+ rel: journal.rel,
7601
+ decides: journal.decides,
7602
+ status: historical ? ('healed' as const) : ('broken' as const),
7603
+ chained: v.chained,
7604
+ defects: v.defects.length,
7605
+ detail: historical
7606
+ ? `${v.defects.length} defect(s), all BEFORE the current run — the last ${age.runRecords} record(s) are unbroken, so verdicts over those are sound`
7607
+ : `${v.defects.length} defect(s) with NO sound records after them: verdicts computed from this log are unsafe`,
7608
+ };
7609
+ });
7610
+
7611
+ const failed = journals.filter((j) => j.status === 'broken' || j.status === 'unreadable');
7612
+ const ok = failed.length === 0;
7613
+
7614
+ if (flags.has('json')) {
7615
+ write(JSON.stringify({ ok, root, journals }, null, 2));
7616
+ return ok ? 0 : 1;
7617
+ }
7618
+
7619
+ write(`dz chain — ${journals.length} registered journal(s) under ${root}`);
7620
+ write('');
7621
+ const MARK: Record<string, string> = { ok: '\u2713', healed: '\u2713', unchained: '\u00b7', absent: '\u00b7', broken: '\u2717', unreadable: '\u2717' };
7622
+ for (const j of journals) {
7623
+ write(` ${MARK[j.status] ?? '?'} ${j.rel} — ${j.status}`);
7624
+ write(` ${j.detail}`);
7625
+ write(` decides: ${j.decides}`);
7626
+ }
7627
+ write('');
7628
+ write(ok ? ' all registered journals are sound for present verdicts' : ` ${failed.length} journal(s) UNSAFE — see above`);
7629
+ return ok ? 0 : 1;
7630
+ }
7631
+
6995
7632
  function cmdClaimCheck(
6996
7633
  options: Map<string, string>,
6997
7634
  _optionLists: Map<string, string[]>,
@@ -7503,6 +8140,31 @@ export interface CodexHooksSummary {
7503
8140
  readonly stderr: readonly string[];
7504
8141
  }
7505
8142
 
8143
+ /** Map the retained Codex hook writer's one live verdict into the common integration contract. */
8144
+ export function normalizeCodexHookOutcome(
8145
+ base: IntegrationOutcome,
8146
+ delivery: CodexHooksSummary & { readonly report: CodexHooksSyncReport },
8147
+ noVerify = false,
8148
+ ): IntegrationOutcome {
8149
+ if (delivery.report.ready) {
8150
+ return {
8151
+ target: 'codex', component: 'hooks', status: 'emitted',
8152
+ registrations: [{ id: 'dz-codex-hooks', scope: 'user', registered: delivery.report.installed, approval: delivery.report.trust === 'trusted' ? 'approved' : 'unknown', ready: true }],
8153
+ carrier: { scope: 'user', path: '$CODEX_HOME/hooks.json' },
8154
+ ...(delivery.report.codexVersion !== null ? { runtimeVersion: delivery.report.codexVersion } : {}),
8155
+ evidenceVersion: 'codex-hooks-live-v1',
8156
+ };
8157
+ }
8158
+ return {
8159
+ ...base,
8160
+ target: 'codex', component: 'hooks', status: 'refused',
8161
+ registrations: [{ id: 'dz-codex-hooks', scope: 'user', registered: delivery.report.installed, approval: delivery.report.trust === 'trusted' ? 'approved' : 'unknown', ready: false }],
8162
+ reasonCode: 'CURRENT_LIVE_CHECK_FAILED',
8163
+ remediation: noVerify ? '--no-verify cannot establish ready; rerun with live verification' : 'approve the managed hooks and rerun the live verification',
8164
+ applied: delivery.report.written || delivery.report.installed,
8165
+ };
8166
+ }
8167
+
7506
8168
  /**
7507
8169
  * What the user is told about a sync report — the ONE place the success word can be printed.
7508
8170
  *
@@ -7831,6 +8493,392 @@ function gatherReadmeCounts(root: string): { label: string; a: number; b: number
7831
8493
  return pairs;
7832
8494
  }
7833
8495
 
8496
+ const MAX_VOLUME_FILES_PER_SCOPE = 512;
8497
+ const MAX_VOLUME_BYTES_PER_SCOPE = 32 * 1024 * 1024;
8498
+ const CYRILLIC_CHARACTER = /\p{Script=Cyrillic}/u;
8499
+
8500
+ function volumePathInside(root: string, candidate: string): boolean {
8501
+ return candidate === root || candidate.startsWith(root + sep);
8502
+ }
8503
+
8504
+ function cyrillicUtf8Bytes(buffer: Buffer): number {
8505
+ let bytes = 0;
8506
+ for (const character of buffer.toString('utf8')) {
8507
+ if (CYRILLIC_CHARACTER.test(character)) bytes += Buffer.byteLength(character, 'utf8');
8508
+ }
8509
+ return bytes;
8510
+ }
8511
+
8512
+ function gatherTemplateVolumeTarget(
8513
+ packageDir: string,
8514
+ target: string,
8515
+ ): TemplateVolumeTargetFact | undefined {
8516
+ const templateRoot = join(packageDir, 'templates', '.claude');
8517
+ if (!existsSync(templateRoot)) return undefined;
8518
+ const files: TemplateVolumeFileFact[] = [];
8519
+ const realFiles = new Set<string>();
8520
+ let totalBytes = 0;
8521
+ let failure: { reason: string; detail: string } | undefined;
8522
+ let packageReal: string;
8523
+ let templateReal: string;
8524
+ try {
8525
+ packageReal = realpathSync(packageDir);
8526
+ templateReal = realpathSync(templateRoot);
8527
+ if (!volumePathInside(packageReal, templateReal)) {
8528
+ failure = { reason: 'template-root-escape', detail: templateRoot };
8529
+ }
8530
+ } catch (error) {
8531
+ return {
8532
+ target,
8533
+ files,
8534
+ collection: { complete: false, reason: 'template-root-unreadable', detail: error instanceof Error ? error.message : String(error) },
8535
+ };
8536
+ }
8537
+
8538
+ const fail = (reason: string, detail: string): void => {
8539
+ failure ??= { reason, detail: detail.replace(/\s+/g, ' ').slice(0, 240) };
8540
+ };
8541
+ const addFile = (absolute: string, kind: 'rules' | 'commands' | 'skills'): void => {
8542
+ if (failure !== undefined) return;
8543
+ if (files.length >= MAX_VOLUME_FILES_PER_SCOPE) {
8544
+ fail('template-file-cap-exceeded', `${MAX_VOLUME_FILES_PER_SCOPE} files`);
8545
+ return;
8546
+ }
8547
+ try {
8548
+ const stat = lstatSync(absolute);
8549
+ if (!stat.isFile()) { fail('template-input-not-regular', absolute); return; }
8550
+ const real = realpathSync(absolute);
8551
+ if (!volumePathInside(templateReal, real)) { fail('template-input-escape', absolute); return; }
8552
+ if (realFiles.has(real)) { fail('template-input-duplicate', absolute); return; }
8553
+ if (stat.size <= 0 || stat.size > MAX_VOLUME_BYTES_PER_SCOPE - totalBytes) {
8554
+ fail(stat.size <= 0 ? 'template-input-empty' : 'template-byte-cap-exceeded', absolute);
8555
+ return;
8556
+ }
8557
+ const content = readFileSync(absolute);
8558
+ if (content.byteLength <= 0 || content.byteLength > MAX_VOLUME_BYTES_PER_SCOPE - totalBytes) {
8559
+ fail(content.byteLength <= 0 ? 'template-input-empty' : 'template-byte-cap-exceeded', absolute);
8560
+ return;
8561
+ }
8562
+ realFiles.add(real);
8563
+ totalBytes += content.byteLength;
8564
+ files.push({
8565
+ path: relative(packageDir, absolute).split(sep).join('/'),
8566
+ kind,
8567
+ bytes: content.byteLength,
8568
+ cyrillicUtf8Bytes: cyrillicUtf8Bytes(content),
8569
+ });
8570
+ } catch (error) {
8571
+ fail('template-input-unreadable', `${absolute}: ${error instanceof Error ? error.message : String(error)}`);
8572
+ }
8573
+ };
8574
+ const scanFlat = (name: 'rules' | 'commands'): void => {
8575
+ const dir = join(templateRoot, name);
8576
+ if (!existsSync(dir) || failure !== undefined) return;
8577
+ try {
8578
+ for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
8579
+ if (!entry.name.endsWith('.md')) continue;
8580
+ addFile(join(dir, entry.name), name);
8581
+ }
8582
+ } catch (error) {
8583
+ fail('template-directory-unreadable', `${dir}: ${error instanceof Error ? error.message : String(error)}`);
8584
+ }
8585
+ };
8586
+ const scanSkills = (dir: string): void => {
8587
+ if (!existsSync(dir) || failure !== undefined) return;
8588
+ let entries: Dirent[];
8589
+ try {
8590
+ entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
8591
+ } catch (error) {
8592
+ fail('template-directory-unreadable', `${dir}: ${error instanceof Error ? error.message : String(error)}`);
8593
+ return;
8594
+ }
8595
+ for (const entry of entries) {
8596
+ if (failure !== undefined) return;
8597
+ const absolute = join(dir, entry.name);
8598
+ if (entry.isSymbolicLink()) { fail('template-input-not-regular', absolute); return; }
8599
+ if (entry.isDirectory()) scanSkills(absolute);
8600
+ else if (entry.name === 'SKILL.md') addFile(absolute, 'skills');
8601
+ }
8602
+ };
8603
+
8604
+ scanFlat('rules');
8605
+ scanFlat('commands');
8606
+ scanSkills(join(templateRoot, 'skills'));
8607
+ files.sort((a, b) => a.path.localeCompare(b.path));
8608
+ return {
8609
+ target,
8610
+ files,
8611
+ collection: failure === undefined
8612
+ ? { complete: true }
8613
+ : { complete: false, reason: failure.reason, detail: failure.detail },
8614
+ };
8615
+ }
8616
+
8617
+ function volumeGitText(root: string, args: readonly string[], allowedStatuses: readonly number[] = [0]): string {
8618
+ const result = spawnSync('git', [...args], {
8619
+ cwd: root,
8620
+ encoding: 'utf8',
8621
+ maxBuffer: 32 * 1024 * 1024,
8622
+ });
8623
+ if (result.status === null || !allowedStatuses.includes(result.status)) {
8624
+ throw result.error ?? new Error(`git ${args[0] ?? ''} exited ${String(result.status)}`);
8625
+ }
8626
+ return result.stdout ?? '';
8627
+ }
8628
+
8629
+ function parseGuardStatusPaths(root: string): { readonly code: string; readonly path: string }[] {
8630
+ const text = volumeGitText(root, ['status', '--porcelain', '-uall']);
8631
+ return text.split('\n').flatMap((line) => {
8632
+ if (line.length < 4) return [];
8633
+ const code = line.slice(0, 2);
8634
+ const raw = line.slice(3).trim();
8635
+ const path = (raw.includes(' -> ') ? raw.split(' -> ')[1] : raw)?.trim();
8636
+ return path ? [{ code, path }] : [];
8637
+ });
8638
+ }
8639
+
8640
+ function parseFeatureTier(text: string): FeatureTier | undefined {
8641
+ const match = text.match(/^##\s+Tier:\s*(S|M|L|XL)\s*$/m);
8642
+ return match?.[1] as FeatureTier | undefined;
8643
+ }
8644
+
8645
+ function parseFeatureActiveSteps(text: string): (number | string)[] | undefined {
8646
+ const section = text.match(/^## Active steps[^\n]*\n([\s\S]*?)(?=^## |(?![\s\S]))/mi)?.[1];
8647
+ if (section === undefined) return undefined;
8648
+ const steps = [...section.matchAll(/\b(?:10|[0-9](?:\.5)?)\b/g)].map((match) => match[0]!);
8649
+ return [...new Set(steps)];
8650
+ }
8651
+
8652
+ function featureLifecycle(root: string, slug: string, assessment: string | undefined): FeatureVolumeFact['lifecycle'] {
8653
+ if (assessment !== undefined && /^Lifecycle:\s*complete\s*$/mi.test(assessment)) return { phase: 'complete' };
8654
+ try {
8655
+ const state = JSON.parse(readFileSync(join(root, '.dz', 'feature-adr', 'learning-state', `${slug}.json`), 'utf8')) as { step?: unknown };
8656
+ const match = typeof state.step === 'string' ? state.step.match(/\bStep\s+(\d+(?:\.5)?)/i) : null;
8657
+ if (!match?.[1]) return undefined;
8658
+ const current = Number(match[1]);
8659
+ if (!Number.isFinite(current)) return undefined;
8660
+ return { phase: 'in-progress', completedThroughStep: current <= 0 ? 0 : Math.ceil(current) - 1 };
8661
+ } catch {
8662
+ return undefined;
8663
+ }
8664
+ }
8665
+
8666
+ interface GatheredFeatureVolume {
8667
+ readonly fact: FeatureVolumeFact;
8668
+ readonly manifestText?: string;
8669
+ }
8670
+
8671
+ function gatherFeatureVolumeFact(root: string, slug: string): GatheredFeatureVolume {
8672
+ const featureDir = join(root, 'features', slug);
8673
+ const artifacts: FeatureArtifactFact[] = [];
8674
+ const contents = new Map<string, Buffer>();
8675
+ let totalBytes = 0;
8676
+ let failure: { reason: string; detail: string } | undefined;
8677
+ let rootReal: string;
8678
+ let featureReal: string;
8679
+ try {
8680
+ rootReal = realpathSync(root);
8681
+ featureReal = realpathSync(featureDir);
8682
+ if (!volumePathInside(rootReal, featureReal)) failure = { reason: 'feature-root-escape', detail: featureDir };
8683
+ } catch (error) {
8684
+ return { fact: {
8685
+ slug,
8686
+ artifacts,
8687
+ collection: { complete: false, reason: 'feature-root-unreadable', detail: error instanceof Error ? error.message : String(error) },
8688
+ } };
8689
+ }
8690
+ const fail = (reason: string, detail: string): void => {
8691
+ failure ??= { reason, detail: detail.replace(/\s+/g, ' ').slice(0, 240) };
8692
+ };
8693
+ const addArtifact = (absolute: string): void => {
8694
+ if (failure !== undefined) return;
8695
+ if (artifacts.length >= MAX_VOLUME_FILES_PER_SCOPE) {
8696
+ fail('feature-file-cap-exceeded', `${MAX_VOLUME_FILES_PER_SCOPE} files`);
8697
+ return;
8698
+ }
8699
+ try {
8700
+ const stat = lstatSync(absolute);
8701
+ if (!stat.isFile()) { fail('feature-input-not-regular', absolute); return; }
8702
+ const real = realpathSync(absolute);
8703
+ if (!volumePathInside(featureReal, real)) { fail('feature-input-escape', absolute); return; }
8704
+ if (stat.size <= 0 || stat.size > MAX_VOLUME_BYTES_PER_SCOPE - totalBytes) {
8705
+ fail(stat.size <= 0 ? 'feature-input-empty' : 'feature-byte-cap-exceeded', absolute);
8706
+ return;
8707
+ }
8708
+ const content = readFileSync(absolute);
8709
+ if (content.byteLength <= 0 || content.byteLength > MAX_VOLUME_BYTES_PER_SCOPE - totalBytes) {
8710
+ fail(content.byteLength <= 0 ? 'feature-input-empty' : 'feature-byte-cap-exceeded', absolute);
8711
+ return;
8712
+ }
8713
+ const path = relative(featureDir, absolute).split(sep).join('/');
8714
+ totalBytes += content.byteLength;
8715
+ contents.set(path, content);
8716
+ artifacts.push({ path, bytes: content.byteLength });
8717
+ } catch (error) {
8718
+ fail('feature-input-unreadable', `${absolute}: ${error instanceof Error ? error.message : String(error)}`);
8719
+ }
8720
+ };
8721
+ const scanNested = (dir: string): void => {
8722
+ if (!existsSync(dir) || failure !== undefined) return;
8723
+ let entries: Dirent[];
8724
+ try {
8725
+ entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
8726
+ } catch (error) {
8727
+ fail('feature-directory-unreadable', `${dir}: ${error instanceof Error ? error.message : String(error)}`);
8728
+ return;
8729
+ }
8730
+ for (const entry of entries) {
8731
+ if (failure !== undefined) return;
8732
+ const absolute = join(dir, entry.name);
8733
+ if (entry.isSymbolicLink()) { fail('feature-input-not-regular', absolute); return; }
8734
+ if (entry.isDirectory()) scanNested(absolute);
8735
+ else addArtifact(absolute);
8736
+ }
8737
+ };
8738
+ try {
8739
+ const entries = readdirSync(featureDir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
8740
+ for (const entry of entries) {
8741
+ if (failure !== undefined) break;
8742
+ const absolute = join(featureDir, entry.name);
8743
+ if (entry.name === '03_adr' || entry.name === '07_code_changes') {
8744
+ if (entry.isSymbolicLink() || !entry.isDirectory()) fail('feature-input-not-regular', absolute);
8745
+ else scanNested(absolute);
8746
+ } else if (entry.name === 'README.md' || /^0[0-9](?:[_.-][^/]*)?\.md$/.test(entry.name)) {
8747
+ addArtifact(absolute);
8748
+ }
8749
+ }
8750
+ } catch (error) {
8751
+ fail('feature-directory-unreadable', `${featureDir}: ${error instanceof Error ? error.message : String(error)}`);
8752
+ }
8753
+ artifacts.sort((a, b) => a.path.localeCompare(b.path));
8754
+ const assessment = contents.get('00_complexity_assessment.md')?.toString('utf8');
8755
+ const tier = assessment !== undefined ? parseFeatureTier(assessment) : undefined;
8756
+ const activeSteps = assessment !== undefined ? parseFeatureActiveSteps(assessment) : undefined;
8757
+ const lifecycle = featureLifecycle(root, slug, assessment);
8758
+ const fact: FeatureVolumeFact = {
8759
+ slug,
8760
+ ...(tier !== undefined ? { tier } : {}),
8761
+ ...(activeSteps !== undefined ? { activeSteps } : {}),
8762
+ namedConsumers: [],
8763
+ ...(lifecycle !== undefined ? { lifecycle } : {}),
8764
+ artifacts,
8765
+ collection: failure === undefined
8766
+ ? { complete: true }
8767
+ : { complete: false, reason: failure.reason, detail: failure.detail },
8768
+ };
8769
+ const manifestText = contents.get('07_code_changes/change_manifest.md')?.toString('utf8');
8770
+ return { fact, ...(manifestText !== undefined ? { manifestText } : {}) };
8771
+ }
8772
+
8773
+ function manifestPaths(text: string, slug: string): string[] {
8774
+ const paths = new Set<string>();
8775
+ for (const line of text.split('\n')) {
8776
+ const match = line.match(/^\s*-\s+`?([^`]+?)`?(?:\s+[—-]\s+.*)?$/);
8777
+ if (!match?.[1]) continue;
8778
+ const path = match[1].trim().replace(/\\/g, '/').replace(/^\.\//, '');
8779
+ if (path === '' || path.startsWith('/') || path.split('/').some((part) => part === '..')) continue;
8780
+ if (path.startsWith(`features/${slug}/`)) continue;
8781
+ paths.add(path);
8782
+ }
8783
+ return [...paths].sort();
8784
+ }
8785
+
8786
+ function attributableDiff(
8787
+ root: string,
8788
+ slug: string,
8789
+ feature: GatheredFeatureVolume,
8790
+ status: readonly { readonly code: string; readonly path: string }[],
8791
+ changedSlugs: readonly string[],
8792
+ ): NonNullable<FeatureVolumeFact['diff']> {
8793
+ if (changedSlugs.length !== 1 || changedSlugs[0] !== slug) {
8794
+ return { attributable: false, reason: changedSlugs.length > 1 ? 'ambiguous-feature-attribution' : 'feature-not-attributable' };
8795
+ }
8796
+ if (feature.manifestText === undefined) return { attributable: false, reason: 'change-manifest-unavailable' };
8797
+ const listed = manifestPaths(feature.manifestText, slug);
8798
+ const changedByPath = new Map(status.map((item) => [item.path, item.code]));
8799
+ const changed = listed.filter((path) => changedByPath.has(path));
8800
+ if (changed.length === 0) return { attributable: true, bytes: 0, base: 'HEAD', head: 'working-tree', method: 'git-unified-diff-bytes/v1', excludedFeaturePath: `features/${slug}/**` };
8801
+ const tracked: string[] = [];
8802
+ const untracked: string[] = [];
8803
+ for (const path of changed) {
8804
+ const absolute = resolve(root, path);
8805
+ if (!volumePathInside(root, absolute)) return { attributable: false, reason: 'manifest-path-escape' };
8806
+ if (changedByPath.get(path) === '??') {
8807
+ try {
8808
+ const stat = lstatSync(absolute);
8809
+ const real = realpathSync(absolute);
8810
+ if (!stat.isFile() || !volumePathInside(root, real) || stat.size > MAX_VOLUME_BYTES_PER_SCOPE) {
8811
+ return { attributable: false, reason: 'untracked-diff-input-refused' };
8812
+ }
8813
+ } catch { return { attributable: false, reason: 'untracked-diff-input-unreadable' }; }
8814
+ untracked.push(path);
8815
+ } else {
8816
+ tracked.push(path);
8817
+ }
8818
+ }
8819
+ let bytes = 0;
8820
+ try {
8821
+ if (tracked.length > 0) {
8822
+ const diff = volumeGitText(root, ['diff', '--binary', '--no-ext-diff', 'HEAD', '--', ...tracked]);
8823
+ bytes += Buffer.byteLength(diff, 'utf8');
8824
+ }
8825
+ for (const path of untracked) {
8826
+ const diff = volumeGitText(root, ['diff', '--no-index', '--binary', '--', '/dev/null', resolve(root, path)], [0, 1]);
8827
+ bytes += Buffer.byteLength(diff, 'utf8');
8828
+ if (bytes > MAX_VOLUME_BYTES_PER_SCOPE) return { attributable: false, reason: 'diff-byte-cap-exceeded' };
8829
+ }
8830
+ } catch {
8831
+ return { attributable: false, reason: 'git-diff-failed' };
8832
+ }
8833
+ return {
8834
+ attributable: true,
8835
+ bytes,
8836
+ base: 'HEAD',
8837
+ head: 'working-tree',
8838
+ method: 'git-unified-diff-bytes/v1',
8839
+ excludedFeaturePath: `features/${slug}/**`,
8840
+ };
8841
+ }
8842
+
8843
+ function gatherVolumeShadowFacts(
8844
+ root: string,
8845
+ packages: readonly { readonly dir: string; readonly name: string; readonly privateFlag: boolean }[],
8846
+ ): VolumeShadowInput {
8847
+ const templates = packages
8848
+ .filter((item) => !item.privateFlag)
8849
+ .sort((a, b) => a.name.localeCompare(b.name) || a.dir.localeCompare(b.dir))
8850
+ .flatMap((item) => {
8851
+ const fact = gatherTemplateVolumeTarget(join(root, item.dir), item.name);
8852
+ return fact === undefined ? [] : [fact];
8853
+ });
8854
+ let status: { code: string; path: string }[];
8855
+ try { status = parseGuardStatusPaths(root); }
8856
+ catch { return templates.length > 0 ? { templates } : {}; }
8857
+ const slugs = [...new Set(status.flatMap((item) => {
8858
+ const match = item.path.match(/^features\/([^/]+)\//);
8859
+ return match?.[1] ? [match[1]] : [];
8860
+ }))].sort();
8861
+ const gathered = slugs.slice(0, 32).map((slug) => gatherFeatureVolumeFact(root, slug));
8862
+ const measuredFeatures = gathered.map((feature) => ({
8863
+ ...feature.fact,
8864
+ diff: attributableDiff(root, feature.fact.slug, feature, status, slugs),
8865
+ }));
8866
+ const cappedFeatures: FeatureVolumeFact[] = slugs.slice(32).map((slug) => ({
8867
+ slug,
8868
+ artifacts: [],
8869
+ collection: {
8870
+ complete: false,
8871
+ reason: 'feature-scope-cap-exceeded',
8872
+ detail: 'only the first 32 lexicographically sorted changed feature scopes were traversed',
8873
+ },
8874
+ }));
8875
+ const features = [...measuredFeatures, ...cappedFeatures];
8876
+ return {
8877
+ ...(templates.length > 0 ? { templates } : {}),
8878
+ ...(features.length > 0 ? { features } : {}),
8879
+ };
8880
+ }
8881
+
7834
8882
  /** Gather the facts one op needs. All I/O is best-effort — a missing signal skips its rule, never crashes. */
7835
8883
  function gatherGuardFacts(op: string, root: string, text: string | undefined, storeCap: number): Record<string, unknown> {
7836
8884
  const facts: Record<string, unknown> = { op };
@@ -7954,7 +9002,7 @@ function gatherGuardFacts(op: string, root: string, text: string | undefined, st
7954
9002
  const manifests: Manifest[] = [];
7955
9003
  const located: { dir: string; m: Manifest }[] = [];
7956
9004
  try {
7957
- const out = execSync('git ls-files "packages/@dzhechkov/*/package.json"', { cwd: root, encoding: 'utf-8' });
9005
+ const out = volumeGitText(root, ['ls-files', 'packages/@dzhechkov/*/package.json']);
7958
9006
  for (const rel of out.split('\n').map((s) => s.trim()).filter(Boolean)) {
7959
9007
  try {
7960
9008
  const m = JSON.parse(readFileSync(join(root, rel), 'utf8')) as Manifest;
@@ -7978,6 +9026,11 @@ function gatherGuardFacts(op: string, root: string, text: string | undefined, st
7978
9026
  packages.push({ name: m.name ?? '(unnamed)', deps });
7979
9027
  }
7980
9028
  facts['packages'] = packages;
9029
+ facts['volume'] = gatherVolumeShadowFacts(root, located.map(({ dir, m }) => ({
9030
+ dir,
9031
+ name: m.name ?? dir,
9032
+ privateFlag: m.private === true,
9033
+ })));
7981
9034
 
7982
9035
  // licence-hold (ADR-001, hermes-claude-adaptation): for each pack DECLARING a hold via a
7983
9036
  // `licenseHold` field, hand the raw evidence to the pure checker. Best-effort: an unreadable
@@ -8202,6 +9255,11 @@ function runGuardEvaluation(root: string, op: string, text: string | undefined,
8202
9255
  return result;
8203
9256
  }
8204
9257
 
9258
+ function renderGuardObservation(observation: GuardObservation): string {
9259
+ const tag = observation.status === 'unknown' ? 'note' : 'observe';
9260
+ return ` [${tag}] ${observation.rule} ${observation.scope}: ${observation.detail} [${observation.status}]`;
9261
+ }
9262
+
8205
9263
  /**
8206
9264
  * The tail facts of an append-only log, read from its END — O(1) in the file size, which is what
8207
9265
  * lets the chain be extended on every append without a full-file scan (FR-2). Anything unreadable
@@ -8542,7 +9600,8 @@ function cmdGuardPromote(options: Map<string, string>, flags: Set<string>, root:
8542
9600
  ...next,
8543
9601
  entries: Object.fromEntries(Object.entries(next.entries).map(([k, v]) => (applied.includes(k) ? [k, { ...v, appliedTs: nowTs }] : [k, v]))),
8544
9602
  };
8545
- try { writeJsonAtomic(join(root, PROMOTION_STATE_FILE), withApplied); } catch { /* best-effort */ }
9603
+ const withEvidence = recordPromotionRunEvidence(withApplied, report, nowTs);
9604
+ try { writeJsonAtomic(join(root, PROMOTION_STATE_FILE), withEvidence); } catch { /* best-effort */ }
8546
9605
  }
8547
9606
 
8548
9607
  // A refused conflict means the requested apply did NOT fully happen — exit non-zero rather than
@@ -8634,6 +9693,7 @@ function cmdGuard(options: Map<string, string>, flags: Set<string>, cwd: string,
8634
9693
 
8635
9694
  const glyph = result.verdict === 'block' ? '✗' : result.verdict === 'warn' ? '⚠' : '✓';
8636
9695
  write(`dz guard (${op}): ${glyph} ${result.verdict.toUpperCase()} [checked: ${result.checked.join(', ') || 'no rules for this op'}]`);
9696
+ for (const observation of result.observations ?? []) write(renderGuardObservation(observation));
8637
9697
  for (const v of result.violations) write(` [${v.severity === 'hard' ? 'BLOCK' : 'warn'}] ${v.rule}: ${v.detail}`);
8638
9698
  for (const n of result.notes ?? []) write(` [note] ${n}`); // information, never a verdict input (FN-7)
8639
9699
  if (result.verdict === 'block' && forced) write(` → forced through: ${force} (logged to .dz/guard-audit.jsonl)`);
@@ -9241,9 +10301,74 @@ function cmdDiscriminationCheck(options: Map<string, string>, flags: Set<string>
9241
10301
  const timeoutOpt = Number(options.get('timeout') ?? '300000');
9242
10302
  const timeoutMs = Number.isFinite(timeoutOpt) && timeoutOpt > 0 ? timeoutOpt : 300000;
9243
10303
 
9244
- const plan = planDiscriminationCheck(runnerOpt !== undefined ? { baseRef, propertyTests, runner: runnerOpt } : { baseRef, propertyTests });
10304
+ // Runner honesty (feature instrument-honesty, ADR-001): the runner is selected from the TARGET
10305
+ // package's own scripts.test, never from a global default. The package dir is the nearest
10306
+ // ancestor of the FIRST named test that carries a package.json — walked here, at the seam,
10307
+ // because the pure half deliberately takes the script text as data and never touches the fs.
10308
+ let packageTestScript: string | null = null;
10309
+ let packageDevDependencies: string[] = [];
10310
+ let packageDir = repoRoot;
10311
+ {
10312
+ const firstTest = propertyTests[0]?.file;
10313
+ // QE-1 (instrument-honesty, HIGH): this walk runs on the RAW --test argument, BEFORE the
10314
+ // engine's sanitation — a `../` traversal made it read an arbitrary package.json OUTSIDE the
10315
+ // repo and echo its scripts.test verbatim into the JSON output (MEASURED with a planted
10316
+ // marker file). Containment first: a start point outside the repo root never gets walked,
10317
+ // the script stays null, and the engine's own path sanitation then refuses the test path.
10318
+ const walkStart = firstTest !== undefined ? resolve(cwd, dirname(firstTest)) : undefined;
10319
+ if (firstTest !== undefined && walkStart !== undefined
10320
+ && (walkStart === resolve(repoRoot) || walkStart.startsWith(resolve(repoRoot) + sep))) {
10321
+ let probe = walkStart;
10322
+ // walk up to the repo root looking for package.json (bounded by the fs root either way)
10323
+ for (;;) {
10324
+ if (existsSync(join(probe, 'package.json'))) { packageDir = probe; break; }
10325
+ const parent = dirname(probe);
10326
+ if (parent === probe || probe === repoRoot) break;
10327
+ probe = parent;
10328
+ }
10329
+ try {
10330
+ const pkg = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf-8')) as {
10331
+ scripts?: Record<string, string>; devDependencies?: Record<string, string>;
10332
+ };
10333
+ packageTestScript = typeof pkg.scripts?.test === 'string' ? pkg.scripts.test : null;
10334
+ packageDevDependencies = Object.keys(pkg.devDependencies ?? {});
10335
+ } catch { /* unreadable package.json → selection falls through to the honest REFUSE */ }
10336
+ }
10337
+ }
10338
+ // The pure half's path sanitation expects a REPO-RELATIVE package dir ('.'-rooted), not an
10339
+ // absolute one — an absolute path is refused as unsafe-package-dir by design.
10340
+ const packageDirRel = relative(repoRoot, packageDir) || '.';
10341
+ const planInput = runnerOpt !== undefined
10342
+ ? { baseRef, propertyTests, runner: runnerOpt, packageTestScript, packageDevDependencies, packageDir: packageDirRel }
10343
+ : { baseRef, propertyTests, packageTestScript, packageDevDependencies, packageDir: packageDirRel };
10344
+ const plan = planDiscriminationCheck(planInput);
9245
10345
 
9246
10346
  if (!plan.runnable) {
10347
+ // QE-2 (instrument-honesty, MEDIUM): a runner REFUSE used to be reported through the generic
10348
+ // "no property test to check"/map-a-test framing — the operator-facing surface re-created the
10349
+ // exact "instrument gap misread as test gap" class ADR-001 names as the reason three duplicate
10350
+ // backlog entries existed. The plan's own named reason is the verdict; the generic classify
10351
+ // stays only for the genuinely-empty-target case.
10352
+ const runnerRefusal = typeof plan.reason === 'string' && plan.reason.startsWith('unsupported-runner');
10353
+ if (runnerRefusal) {
10354
+ const refusal = {
10355
+ aggregate: 'CANNOT_ISOLATE',
10356
+ measurementValid: false,
10357
+ primaryAction: plan.primaryAction ?? 'fix-runner-invocation',
10358
+ finding: {
10359
+ severity: 'high',
10360
+ verdict: 'CANNOT_ISOLATE',
10361
+ files: plan.targets.map((t) => t.file),
10362
+ detail: `runner refused: ${plan.reason} — the INSTRUMENT could not run, nothing was measured; `
10363
+ + `declare scripts.test in the target package (or pass --runner) and re-run. `
10364
+ + `This is NOT a statement about the tests.`,
10365
+ },
10366
+ };
10367
+ if (flags.has('json')) { write(JSON.stringify({ plan, results: [], perTest: [], ...refusal }, null, 2)); return 0; }
10368
+ write(`discrimination-check: REFUSED (${plan.reason})`);
10369
+ write(` → ${refusal.finding.detail}`);
10370
+ return 0;
10371
+ }
9247
10372
  // No safe target to run → this is the existing "property untested" finding (empty propertyTests classify).
9248
10373
  const result = classifyDiscrimination({ propertyTests: [], results: [] });
9249
10374
  if (flags.has('json')) { write(JSON.stringify({ plan, results: [], ...result }, null, 2)); return 0; }
@@ -9332,10 +10457,24 @@ function cmdDiscriminationCheck(options: Map<string, string>, flags: Set<string>
9332
10457
 
9333
10458
  // t.file + t.name already passed the engine's strict sanitation (no quotes/metacharacters/leading-dash);
9334
10459
  // still quote + `--` so a path can never be read as a runner option or split a word.
10460
+ // Runner honesty (ADR-001): the run executes FROM the target package dir with a
10461
+ // package-relative path — a root-cwd `npx vitest run packages/...` loads the ROOT config
10462
+ // (none) and reds unclassifiably, which is exactly the CANNOT_ISOLATE artifact this
10463
+ // feature removes. The plan's own commands encode the same cd; this body mirrors it.
10464
+ const pkgRel = plan.packageDir === '.' ? '' : plan.packageDir;
10465
+ const fileInPkg = pkgRel !== '' && t.file.startsWith(pkgRel + '/') ? t.file.slice(pkgRel.length + 1) : t.file;
10466
+ const execDirBase = pkgRel === '' ? worktree : join(worktree, pkgRel);
10467
+ const execDirTip = pkgRel === '' ? repoRoot : join(repoRoot, pkgRel);
9335
10468
  const nameArg = t.name ? ` -t '${t.name}'` : '';
9336
- const cmd = `${runner}${nameArg} -- '${t.file}'`;
9337
- const base = runCapturedTest(cmd, worktree, timeoutMs);
9338
- const evidence = classifyExecutionEvidence(base.output, base.exitCode, t.file);
10469
+ // NO `--` before the path: MEASURED 2026-09-02 — `npx vitest run -- 'file'` IGNORES the
10470
+ // filter and runs the whole suite (5269 tests), which is the exact whole-repo artifact
10471
+ // this feature removes (QE ha-intake-archive F5). The path is engine-sanitized (no
10472
+ // leading dash, no metacharacters), so it can never be read as an option.
10473
+ const cmd = `${runner}${nameArg} '${fileInPkg}'`;
10474
+ const base = runCapturedTest(cmd, execDirBase, timeoutMs);
10475
+ // The classifier's targetSeen is a substring probe: the run now prints PACKAGE-relative
10476
+ // paths, so it must be probed with the same form, or every hit reads as target-unseen.
10477
+ const evidence = classifyExecutionEvidence(base.output, base.exitCode, fileInPkg);
9339
10478
  const outcome = discriminationOutcomeOf(base.exitCode, evidence);
9340
10479
  const row: Record<string, unknown> = t.name !== undefined
9341
10480
  ? { file: t.file, name: t.name, outcome, evidence }
@@ -9347,8 +10486,8 @@ function cmdDiscriminationCheck(options: Map<string, string>, flags: Set<string>
9347
10486
  // base rows per the matrix; running it is cheap and only ever on an already-broken path.
9348
10487
  // Do NOT "simplify" this to evidenced-error-only — that silently breaks Confirmation 17.
9349
10488
  if (base.exitCode !== null && base.exitCode !== 0 && evidence.failureKind !== 'assertions') {
9350
- const tip = runCapturedTest(cmd, repoRoot, timeoutMs);
9351
- const tipEvidence = classifyExecutionEvidence(tip.output, tip.exitCode, t.file);
10489
+ const tip = runCapturedTest(cmd, execDirTip, timeoutMs);
10490
+ const tipEvidence = classifyExecutionEvidence(tip.output, tip.exitCode, fileInPkg);
9352
10491
  row['tipOutcome'] = discriminationOutcomeOf(tip.exitCode, tipEvidence);
9353
10492
  row['tipEvidence'] = tipEvidence;
9354
10493
  // R15, named honestly: the base run is isolated in a worktree, but the tip runs in the LIVE
@@ -9472,9 +10611,9 @@ function nameFor(t: { file: string; name?: string }, outcome: 'pass' | 'fail' |
9472
10611
  *
9473
10612
  * Exit codes: 0 every entry PROVEN · 1 the gate ran and failed (undefended / not-applied /
9474
10613
  * below-min / unparseable / load-fatal / over-failing / inconclusive entry) · 2 usage or setup
9475
- * error (missing registry, red BASELINE a red unmutated copy proves nothing and must not be
9476
- * read as a mutation result — or an entry whose file RESOLVES outside the scratch copy: a
10614
+ * error (missing registry or an entry whose file RESOLVES outside the scratch copy: a
9477
10615
  * symlink escape is refused before anything is written, SPEC rule 3).
10616
+ * A RED/no-exit baseline is a measured failing verdict (exit 1), never a usage error.
9478
10617
  */
9479
10618
  /**
9480
10619
  * Route-a guard for `dz mutation-gate`: parse-check a MUTATED file as its own language BEFORE the
@@ -9483,7 +10622,14 @@ function nameFor(t: { file: string; name?: string }, outcome: 'pass' | 'fail' |
9483
10622
  * redness says nothing about the named protection. Returns `{error}` when a parser ran and the
9484
10623
  * text does not parse; `{skipped}` (reported loudly, never silently) when no parser is available.
9485
10624
  */
9486
- function parseCheckMutatedFile(absFile: string, text: string): { error?: string; skipped?: string } {
10625
+ interface MutationParseCheckResult {
10626
+ readonly error?: string;
10627
+ readonly skipped?: string;
10628
+ readonly internalFailureReason?: string;
10629
+ readonly internalAttempts?: ReturnType<typeof runWithOneInternalRetry>['attempts'];
10630
+ }
10631
+
10632
+ function parseCheckMutatedFile(absFile: string, text: string): MutationParseCheckResult {
9487
10633
  interface TsLike {
9488
10634
  transpileModule(t: string, o: { reportDiagnostics: boolean; compilerOptions: Record<string, unknown> }): { diagnostics?: { category: number; code: number; messageText: unknown }[] };
9489
10635
  flattenDiagnosticMessageText(m: unknown, s: string): string;
@@ -9507,17 +10653,34 @@ function parseCheckMutatedFile(absFile: string, text: string): { error?: string;
9507
10653
  try { JSON.parse(text); return {}; } catch (e) { return { error: String((e as Error).message).slice(0, 200) }; }
9508
10654
  }
9509
10655
  if (ext === '.js' || ext === '.cjs' || ext === '.mjs' || ext === '') {
9510
- try {
9511
- // `node --check` on the file IN PLACE, so the nearest package.json decides the module goal.
9512
- execFileSync(process.execPath, ['--check', absFile], { stdio: 'pipe' });
9513
- return {};
9514
- } catch (e) {
9515
- const err = e as { stderr?: Buffer | string };
9516
- const stderrLines = String(err.stderr ?? '').split('\n').map((l) => l.trim()).filter((l) => l !== '');
9517
- // prefer the actual `SyntaxError: …` line over node's trailing version footer.
9518
- const msg = [...stderrLines].reverse().find((l) => l.includes('Error')) ?? stderrLines.at(-1) ?? 'node --check failed';
9519
- return { error: msg.slice(0, 200) };
10656
+ const checked = runWithOneInternalRetry<MutationParseCheckResult>(() => {
10657
+ try {
10658
+ // `node --check` on the file IN PLACE, so the nearest package.json decides the module goal.
10659
+ execFileSync(process.execPath, ['--check', absFile], { stdio: 'pipe' });
10660
+ return {};
10661
+ } catch (e) {
10662
+ const err = e as { code?: unknown; status?: unknown; stderr?: Buffer | string; message?: string };
10663
+ // A launched parser that exits non-zero with a SyntaxError is a parse verdict. A child
10664
+ // launch/internal error (EPERM, ENOENT, Node's thrown internal) is runner infrastructure
10665
+ // and must take the bounded retry → INCONCLUSIVE route instead of masquerading as bad JS.
10666
+ if (typeof err.code === 'string' || typeof err.status !== 'number') throw e;
10667
+ const stderrLines = String(err.stderr ?? '').split('\n').map((line) => line.trim()).filter((line) => line !== '');
10668
+ const msg = [...stderrLines].reverse().find((line) => line.includes('Error'))
10669
+ ?? stderrLines.at(-1)
10670
+ ?? err.message
10671
+ ?? 'node --check failed';
10672
+ return { error: msg.slice(0, 200) };
10673
+ }
10674
+ });
10675
+ if (checked.value === null) {
10676
+ return {
10677
+ internalFailureReason: checked.failureReason ?? 'runner-internal-error: persistent after 2/2 attempts',
10678
+ internalAttempts: checked.attempts,
10679
+ };
9520
10680
  }
10681
+ return checked.internalRetries === 1
10682
+ ? { ...checked.value, internalAttempts: checked.attempts }
10683
+ : checked.value;
9521
10684
  }
9522
10685
  return { skipped: `no parser for '${ext}' files — parse-check unavailable` };
9523
10686
  } catch (e) {
@@ -9525,7 +10688,13 @@ function parseCheckMutatedFile(absFile: string, text: string): { error?: string;
9525
10688
  }
9526
10689
  }
9527
10690
 
9528
- function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
10691
+ function cmdMutationGate(
10692
+ options: Map<string, string>,
10693
+ flags: Set<string>,
10694
+ cwd: string,
10695
+ write: Write,
10696
+ injectedRunner?: MutationGateRunner,
10697
+ ): number {
9529
10698
  const json = flags.has('json');
9530
10699
  const fail = (what: string): number => {
9531
10700
  write(json ? JSON.stringify({ error: what, exitCode: 2 }) : `dz mutation-gate: ${what}`);
@@ -9596,6 +10765,11 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
9596
10765
  const results: MutationEntryResult[] = [];
9597
10766
  const observations: MutationObservation[] = [];
9598
10767
  const warnings: string[] = [];
10768
+ const internalRetries: {
10769
+ readonly phase: 'baseline' | 'parse-check' | 'mutation' | 'rebaseline' | 'final-rebaseline';
10770
+ readonly entryId?: string;
10771
+ readonly attempts: ReturnType<typeof runWithOneInternalRetry>['attempts'];
10772
+ }[] = [];
9599
10773
  let baseline: ReturnType<typeof classifyBaseline>;
9600
10774
  try {
9601
10775
  if (gitTop !== null && gitTop !== pkgDir && resolve(pkgDir).startsWith(resolve(gitTop) + sep)) {
@@ -9635,7 +10809,11 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
9635
10809
  const realScratchRoot = realpathSync(copyDir);
9636
10810
  const requireCompletionReceipt = parsed.registry.requireCompletionReceipt === true;
9637
10811
 
9638
- const runSuite = (): { exitCode: number | null; output: string; failureReason?: string } => {
10812
+ type SuiteRun = MutationGateRunnerObservation & { readonly internalAttemptLog?: string };
10813
+ const invokeSuite = (): MutationGateRunnerObservation => {
10814
+ if (injectedRunner !== undefined) {
10815
+ return injectedRunner(testCmd, { cwd: copyDir, timeoutMs: timeout });
10816
+ }
9639
10817
  const run = spawnSync(testCmd, {
9640
10818
  cwd: copyDir,
9641
10819
  shell: true,
@@ -9647,6 +10825,12 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
9647
10825
  const errorCode = run.error && 'code' in run.error && typeof run.error.code === 'string'
9648
10826
  ? run.error.code
9649
10827
  : undefined;
10828
+ // Node may populate both `error` and a numeric `status` for an internal spawn failure. The
10829
+ // error wins except for the two already-named resource observations: a status alongside
10830
+ // EPERM/Unreachable-code is not a suite verdict and takes the one-retry internal-error path.
10831
+ if (run.error !== undefined && errorCode !== 'ETIMEDOUT' && errorCode !== 'ENOBUFS') {
10832
+ throw run.error;
10833
+ }
9650
10834
  const signal = typeof run.signal === 'string' ? run.signal : undefined;
9651
10835
  let failureReason: string | undefined;
9652
10836
  if (typeof run.status !== 'number') {
@@ -9658,22 +10842,62 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
9658
10842
  }
9659
10843
  return {
9660
10844
  exitCode: typeof run.status === 'number' ? run.status : null,
9661
- // Receipt markers may be on stderr. Preserve both streams even on exit 0; stdout-only
9662
- // collection would silently lose a green-run marker.
9663
10845
  output: `${String(run.stdout ?? '')}\n${String(run.stderr ?? '')}`,
9664
10846
  ...(failureReason !== undefined ? { failureReason } : {}),
9665
10847
  };
9666
10848
  };
9667
10849
 
10850
+ const runSuite = (
10851
+ phase: 'baseline' | 'mutation' | 'rebaseline' | 'final-rebaseline',
10852
+ entryId?: string,
10853
+ ): SuiteRun => {
10854
+ const retried = runWithOneInternalRetry(invokeSuite);
10855
+ const loggedAttempts = retried.attempts.map((attempt) => {
10856
+ if (attempt.outcome !== 'completed' || retried.value === null) return attempt;
10857
+ const outcome = retried.value.exitCode === null
10858
+ ? `no exit code (${retried.value.failureReason ?? 'unnamed failure'})`
10859
+ : `exit ${retried.value.exitCode}`;
10860
+ return { ...attempt, detail: `attempt ${attempt.attempt}: completed — ${outcome}` };
10861
+ });
10862
+ if (retried.internalRetries === 1) {
10863
+ const record = entryId === undefined
10864
+ ? { phase, attempts: loggedAttempts }
10865
+ : { phase, entryId, attempts: loggedAttempts };
10866
+ internalRetries.push(record);
10867
+ if (!json) write(`mutation-gate: internal retry — ${loggedAttempts.map((attempt) => attempt.detail).join('; ')}`);
10868
+ }
10869
+ const internalAttemptLog = retried.internalRetries === 1
10870
+ ? loggedAttempts.map((attempt) => attempt.detail).join('; ')
10871
+ : undefined;
10872
+ if (retried.value !== null) {
10873
+ return {
10874
+ ...retried.value,
10875
+ ...(internalAttemptLog !== undefined ? { internalAttemptLog } : {}),
10876
+ };
10877
+ }
10878
+ return {
10879
+ exitCode: null,
10880
+ output: '',
10881
+ failureReason: retried.failureReason ?? 'runner-internal-error: persistent after 2/2 attempts',
10882
+ ...(internalAttemptLog !== undefined ? { internalAttemptLog } : {}),
10883
+ };
10884
+ };
10885
+
9668
10886
  // Baseline BEFORE any mutation: a red copy proves nothing, and reading it as a mutation
9669
10887
  // result would be this gate shipping the defect class it exists to catch.
9670
10888
  if (!json) write(`mutation-gate: baseline suite in scratch copy of ${pkgDir} …`);
9671
- const base = runSuite();
9672
- baseline = classifyBaseline(base.exitCode, base.failureReason);
10889
+ const base = runSuite('baseline');
10890
+ baseline = classifyBaseline(
10891
+ base.exitCode,
10892
+ base.failureReason,
10893
+ base.exitCode !== null && base.exitCode !== 0
10894
+ ? attributeBaselineRedness(base.output, entries.map((entry) => entry.file))
10895
+ : undefined,
10896
+ );
9673
10897
  if (!baseline.ok) {
9674
- if (json) { write(JSON.stringify({ packageDir: pkgDir, registryPath, testCommand: testCmd, baseline, results: [], exitCode: 2 }, null, 2)); return 2; }
10898
+ if (json) { write(JSON.stringify({ packageDir: pkgDir, registryPath, testCommand: testCmd, baseline, results: [], internalRetries, exitCode: 1 }, null, 2)); return 1; }
9675
10899
  write(renderMutationReport([], baseline, pkgDir));
9676
- return 2;
10900
+ return 1;
9677
10901
  }
9678
10902
 
9679
10903
  for (const entry of entries) {
@@ -9708,8 +10932,10 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
9708
10932
  return fail(`entry '${entry.id}': ${entry.file} resolves to ${realTarget ?? '<unresolvable>'} — OUTSIDE the scratch copy (${realScratchRoot}). A path component is a symlink escaping the scratch tree, so writing the mutation would mutate the REAL working tree (SPEC rule 3). Refused; nothing was written.`);
9709
10933
  }
9710
10934
  if (!json) write(`mutation-gate: ${entry.id} — mutating ${entry.file}, running suite …`);
9711
- let run: { exitCode: number | null; output: string; failureReason?: string } | null = null;
10935
+ let run: SuiteRun | null = null;
9712
10936
  let parseError: string | undefined;
10937
+ let parseInternalFailureReason: string | undefined;
10938
+ let parseInternalAttemptLog: string | undefined;
9713
10939
  try {
9714
10940
  writeFileSync(filePath, applied.text);
9715
10941
  // Route-a guard: the mutated file must still PARSE — a load failure reddens the whole
@@ -9719,10 +10945,16 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
9719
10945
  warnings.push(`${entry.id}: parse-check SKIPPED — ${check.skipped}`);
9720
10946
  if (!json) write(`mutation-gate: WARNING ${entry.id}: parse-check skipped — ${check.skipped}`);
9721
10947
  }
10948
+ if (check.internalAttempts !== undefined) {
10949
+ internalRetries.push({ phase: 'parse-check', entryId: entry.id, attempts: check.internalAttempts });
10950
+ parseInternalAttemptLog = check.internalAttempts.map((attempt) => attempt.detail).join('; ');
10951
+ if (!json) write(`mutation-gate: internal retry — ${parseInternalAttemptLog}`);
10952
+ }
10953
+ parseInternalFailureReason = check.internalFailureReason;
9722
10954
  if (check.error !== undefined) {
9723
10955
  parseError = check.error; // no suite run: the verdict is MUTATION_UNPARSEABLE regardless
9724
- } else {
9725
- run = runSuite();
10956
+ } else if (parseInternalFailureReason === undefined) {
10957
+ run = runSuite('mutation', entry.id);
9726
10958
  }
9727
10959
  } finally {
9728
10960
  writeFileSync(filePath, sourceText); // restore the COPY so the next entry starts pristine
@@ -9756,13 +10988,26 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
9756
10988
  // those verdicts outrank the rebaseline check, so the extra suite run would buy nothing.
9757
10989
  let rebaselineExitCode: number | null | undefined;
9758
10990
  let rebaselineFailureReason: string | undefined;
10991
+ let rebaselineAttribution: ReturnType<typeof attributeBaselineRedness> | undefined;
10992
+ let rebaselineInternalAttemptLog: string | undefined;
9759
10993
  if (rebaselineMode === 'per-entry' && run !== null && run.exitCode !== null && run.exitCode !== 0
9760
10994
  && fileLoadFailure === undefined && outputUnrecognised === undefined && receiptMismatch === undefined) {
9761
10995
  if (!json) write(`mutation-gate: ${entry.id} — re-baselining the restored tree …`);
9762
- const rebaselineRun = runSuite();
10996
+ const rebaselineRun = runSuite('rebaseline', entry.id);
9763
10997
  rebaselineExitCode = rebaselineRun.exitCode;
9764
10998
  rebaselineFailureReason = rebaselineRun.failureReason;
10999
+ rebaselineInternalAttemptLog = rebaselineRun.internalAttemptLog;
11000
+ if (rebaselineRun.exitCode !== null && rebaselineRun.exitCode !== 0) {
11001
+ rebaselineAttribution = attributeBaselineRedness(
11002
+ rebaselineRun.output,
11003
+ entries.map((candidate) => candidate.file),
11004
+ );
11005
+ }
9765
11006
  }
11007
+ const entryRunFailureReason = run?.failureReason ?? parseInternalFailureReason;
11008
+ const entryInternalAttemptLog = [parseInternalAttemptLog, run?.internalAttemptLog, rebaselineInternalAttemptLog]
11009
+ .filter((log): log is string => log !== undefined)
11010
+ .join('; ');
9766
11011
  const obs: MutationObservation = {
9767
11012
  entry,
9768
11013
  occurrences: 1,
@@ -9772,9 +11017,11 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
9772
11017
  ...(fileLoadFailure !== undefined ? { fileLoadFailure } : {}),
9773
11018
  ...(outputUnrecognised !== undefined ? { outputUnrecognised } : {}),
9774
11019
  ...(receiptMismatch !== undefined ? { receiptMismatch } : {}),
9775
- ...(run?.failureReason !== undefined ? { runFailureReason: run.failureReason } : {}),
11020
+ ...(entryRunFailureReason !== undefined ? { runFailureReason: entryRunFailureReason } : {}),
11021
+ ...(entryInternalAttemptLog !== '' ? { internalAttemptLog: entryInternalAttemptLog } : {}),
9776
11022
  ...(rebaselineExitCode !== undefined ? { rebaselineExitCode } : {}),
9777
11023
  ...(rebaselineFailureReason !== undefined ? { rebaselineFailureReason } : {}),
11024
+ ...(rebaselineAttribution !== undefined ? { rebaselineAttribution } : {}),
9778
11025
  };
9779
11026
  observations.push(obs);
9780
11027
  results.push(classifyMutationOutcome(obs));
@@ -9787,7 +11034,7 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
9787
11034
  // MUTATION_LOAD_FATAL / RECEIPT_MISMATCH untouched.
9788
11035
  if (rebaselineMode === 'final') {
9789
11036
  if (!json) write('mutation-gate: final re-baseline of the restored tree …');
9790
- const finalRun = runSuite();
11037
+ const finalRun = runSuite('final-rebaseline');
9791
11038
  const finalExit = finalRun.exitCode;
9792
11039
  if (finalExit !== 0) {
9793
11040
  const what = finalExit === null ? `no exit code: ${finalRun.failureReason ?? 'unknown timeout / spawn failure'}` : `exit ${finalExit}`;
@@ -9797,6 +11044,12 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
9797
11044
  ...obs,
9798
11045
  rebaselineExitCode: finalExit,
9799
11046
  ...(finalRun.failureReason !== undefined ? { rebaselineFailureReason: finalRun.failureReason } : {}),
11047
+ ...(finalRun.internalAttemptLog !== undefined
11048
+ ? { internalAttemptLog: [obs.internalAttemptLog, finalRun.internalAttemptLog].filter((log): log is string => log !== undefined).join('; ') }
11049
+ : {}),
11050
+ ...(finalExit !== null && finalExit !== 0
11051
+ ? { rebaselineAttribution: attributeBaselineRedness(finalRun.output, entries.map((entry) => entry.file)) }
11052
+ : {}),
9800
11053
  }));
9801
11054
  results.length = 0;
9802
11055
  results.push(...reclassified);
@@ -9812,7 +11065,7 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
9812
11065
 
9813
11066
  const exitCode = mutationGateExitCode(results, baseline.ok);
9814
11067
  if (json) {
9815
- write(JSON.stringify({ packageDir: pkgDir, registryPath, testCommand: testCmd, rebaselineMode, baseline, results, summary: summarizeMutationResults(results), warnings, exitCode }, null, 2));
11068
+ write(JSON.stringify({ packageDir: pkgDir, registryPath, testCommand: testCmd, rebaselineMode, baseline, results, summary: summarizeMutationResults(results), warnings, internalRetries, exitCode }, null, 2));
9816
11069
  return exitCode;
9817
11070
  }
9818
11071
  write(renderMutationReport(results, baseline, pkgDir));
@@ -11525,6 +12778,13 @@ function cmdFeatureAdrRecord(options: Map<string, string>, flags: Set<string>, c
11525
12778
  targetExists: existsSync(target),
11526
12779
  targetHasPair: flags.has('once') && existsSync(target),
11527
12780
  timestamp: new Date().toISOString(),
12781
+ // WHO ran it: `--runner <id>` when the caller knows, otherwise this host. The workflow cannot
12782
+ // supply it — it has no host inside its sandbox — so the identity is resolved here, at the one
12783
+ // seam that runs outside. hostname() can throw on an exotic setup; an unresolvable runner stays
12784
+ // ABSENT rather than becoming the string 'unknown', which would later join as if it were one.
12785
+ runnerId: (options.get('runner') ?? '').trim() !== ''
12786
+ ? (options.get('runner') ?? '').trim()
12787
+ : (() => { try { return hostname(); } catch { return null; } })(),
11528
12788
  });
11529
12789
  if (decision.line === null) return emit(decision);
11530
12790
 
@@ -12920,6 +14180,145 @@ async function cmdQeBridge(options: Map<string, string>, flags: Set<string>, cwd
12920
14180
  }
12921
14181
 
12922
14182
 
14183
+ interface ScoreReceiptFile {
14184
+ readonly path: string;
14185
+ readonly displayPath: string;
14186
+ readonly qeHash: string;
14187
+ }
14188
+
14189
+ function scoreReceiptFiles(root: string): ScoreReceiptFile[] {
14190
+ const featuresDir = join(root, 'features');
14191
+ let features: Dirent[];
14192
+ try {
14193
+ features = readdirSync(featuresDir, { withFileTypes: true });
14194
+ } catch {
14195
+ return [];
14196
+ }
14197
+ const receipts: ScoreReceiptFile[] = [];
14198
+ for (const feature of features) {
14199
+ if (!feature.isDirectory()) continue;
14200
+ const stateDir = join(featuresDir, feature.name, '.fa-state');
14201
+ let entries: Dirent[];
14202
+ try {
14203
+ if (lstatSync(stateDir).isSymbolicLink()) continue;
14204
+ entries = readdirSync(stateDir, { withFileTypes: true });
14205
+ } catch {
14206
+ continue;
14207
+ }
14208
+ for (const entry of entries) {
14209
+ if (!entry.isFile()) continue;
14210
+ const match = /^score-(.+)\.json$/.exec(entry.name);
14211
+ if (match === null || match[1] === undefined || match[1] === '') continue;
14212
+ const path = join(stateDir, entry.name);
14213
+ receipts.push({ path, displayPath: relative(root, path), qeHash: match[1] });
14214
+ }
14215
+ }
14216
+ return receipts.sort((a, b) => a.displayPath < b.displayPath ? -1 : a.displayPath > b.displayPath ? 1 : 0);
14217
+ }
14218
+
14219
+ function scoreAggregateChainLine(text: string): {
14220
+ readonly line: string;
14221
+ readonly verification: ReturnType<typeof verifyEventChainText> | null;
14222
+ readonly defectAges: ReturnType<typeof classifyChainDefects> | null;
14223
+ } {
14224
+ if (text === '') {
14225
+ return { line: 'chain: NOT_PRESENT — no aggregate evidence file was created', verification: null, defectAges: null };
14226
+ }
14227
+ const verification = verifyEventChainText(text);
14228
+ const defectAges = classifyChainDefects(verification, verification.lines);
14229
+ const kinds = new Map<string, number>();
14230
+ for (const defect of verification.defects) kinds.set(defect.kind, (kinds.get(defect.kind) ?? 0) + 1);
14231
+ const kindText = [...kinds.entries()].map(([kind, count]) => `${kind}: ${count}`).join(' · ');
14232
+ const line =
14233
+ `chain: ${verification.ok ? 'OK' : 'FAILED'} · ${verification.chained} chained · ` +
14234
+ `${verification.resets} recorded restart(s) · before-run defects ${defectAges.beforeRun.length} · ` +
14235
+ `in-run defects ${defectAges.inRun.length} · current run ${defectAges.runRecords} record(s)` +
14236
+ (kindText === '' ? '' : ` · ${kindText}`) +
14237
+ ` — ${verification.scope}`;
14238
+ return { line, verification, defectAges };
14239
+ }
14240
+
14241
+ function cmdScoreAll(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
14242
+ const json = flags.has('json');
14243
+ if (options.has('slug')) {
14244
+ write(json
14245
+ ? JSON.stringify({ error: '--all and --slug are mutually exclusive', exitCode: 1 })
14246
+ : 'dz score: --all and --slug are mutually exclusive');
14247
+ return 1;
14248
+ }
14249
+ const root = resolve(cwd, options.get('project') ?? '.');
14250
+ const receiptFiles = scoreReceiptFiles(root);
14251
+ if (receiptFiles.length === 0) {
14252
+ const report = buildScoreAggregateReport([], [], 0);
14253
+ const chain = scoreAggregateChainLine('');
14254
+ if (json) write(JSON.stringify({ ...report, chain: null, aggregatePath: '.dz/feature-adr/scorecards.jsonl', exitCode: 0 }, null, 2));
14255
+ else {
14256
+ write(renderScoreAggregateReport(report));
14257
+ write(chain.line);
14258
+ }
14259
+ return 0;
14260
+ }
14261
+
14262
+ const ts = new Date().toISOString();
14263
+ const rows: ReturnType<typeof scoreReceiptToAggregateRow>[] = [];
14264
+ const unreadableReceipts: string[] = [];
14265
+ for (const receipt of receiptFiles) {
14266
+ try {
14267
+ rows.push(scoreReceiptToAggregateRow({
14268
+ content: readFileSync(receipt.path, 'utf8'),
14269
+ qeHash: receipt.qeHash,
14270
+ ts,
14271
+ }));
14272
+ } catch {
14273
+ unreadableReceipts.push(receipt.displayPath);
14274
+ }
14275
+ }
14276
+
14277
+ const storeDir = join(root, '.dz', 'feature-adr');
14278
+ const aggregatePath = join(storeDir, 'scorecards.jsonl');
14279
+ let finalText = '';
14280
+ let finalRows = rows;
14281
+ let appended = 0;
14282
+ let storeError: string | null = null;
14283
+ try {
14284
+ const result = withNamedLockSync(storeDir, 'scorecards', () => {
14285
+ let existingText = '';
14286
+ try {
14287
+ existingText = readFileSync(aggregatePath, 'utf8');
14288
+ } catch (error) {
14289
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
14290
+ }
14291
+ const fresh = dedupeScoreAggregateRows(rows, readScoreAggregateRows(existingText));
14292
+ const appendText = appendChainedLines(fresh, readTailInfo(existingText));
14293
+ if (appendText !== '') appendFileSync(aggregatePath, appendText, { encoding: 'utf8', mode: 0o600 });
14294
+ const settledText = existingText + appendText;
14295
+ return { text: settledText, rows: readScoreAggregateRows(settledText), appended: fresh.length };
14296
+ });
14297
+ finalText = result.text;
14298
+ finalRows = result.rows;
14299
+ appended = result.appended;
14300
+ } catch (error) {
14301
+ storeError = error instanceof Error ? error.message : String(error);
14302
+ }
14303
+
14304
+ const report = buildScoreAggregateReport(finalRows, unreadableReceipts, appended);
14305
+ const chain = scoreAggregateChainLine(finalText);
14306
+ if (json) {
14307
+ write(JSON.stringify({
14308
+ ...report,
14309
+ aggregatePath: '.dz/feature-adr/scorecards.jsonl',
14310
+ chain: chain.verification === null ? null : { verification: chain.verification, defectAges: chain.defectAges },
14311
+ storeError,
14312
+ exitCode: 0,
14313
+ }, null, 2));
14314
+ } else {
14315
+ write(renderScoreAggregateReport(report));
14316
+ write(chain.line);
14317
+ if (storeError !== null) write(`store error (nothing was claimed appended): ${storeError}`);
14318
+ }
14319
+ return 0;
14320
+ }
14321
+
12923
14322
  function cmdScore(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
12924
14323
  const json = flags.has('json');
12925
14324
  if (flags.has('help')) {
@@ -12927,13 +14326,14 @@ function cmdScore(options: Map<string, string>, flags: Set<string>, cwd: string,
12927
14326
  if (json) write(JSON.stringify({ help: usage, exitCode: 0 })); // --json stays ONE document even for help
12928
14327
  else {
12929
14328
  write(usage);
14329
+ write('dz score --all [--project <dir>] [--json] — sweep immutable score receipts into the append-only chained aggregate');
12930
14330
  write(' disciplines: ADR confirmation · discrimination · cross-model QE · live verification · README-first · learning loop · amendments');
12931
14331
  write(' descriptive-only, never a gate: a low score exits 0');
12932
14332
  }
12933
14333
  return 0;
12934
14334
  }
12935
14335
  for (const flag of flags) {
12936
- if (!new Set(['json', 'help']).has(flag)) {
14336
+ if (!new Set(['json', 'help', 'all']).has(flag)) {
12937
14337
  write(json ? JSON.stringify({ error: `unknown option --${flag}`, exitCode: 1 }) : `dz score: unknown option --${flag}\n allowed: --slug <feature>, --project <dir>, --json`);
12938
14338
  return 1;
12939
14339
  }
@@ -12945,6 +14345,7 @@ function cmdScore(options: Map<string, string>, flags: Set<string>, cwd: string,
12945
14345
  return 1;
12946
14346
  }
12947
14347
  }
14348
+ if (flags.has('all')) return cmdScoreAll(options, flags, cwd, write);
12948
14349
  const slug = options.get('slug') ?? '';
12949
14350
  // The delivery-check traversal lesson, upgraded to a WHITELIST: `.` slipped the blacklist and
12950
14351
  // silently aggregated the entire features/ tree as one "run" (Codex QE #2).
@@ -13058,6 +14459,160 @@ function readOptionalText(path: string): string {
13058
14459
  }
13059
14460
  }
13060
14461
 
14462
+ function localEvidenceReadReason(resource: 'promotion-journal' | 'guard-audit', error: unknown): string {
14463
+ const code = error && typeof error === 'object' && 'code' in error ? String((error as { code?: unknown }).code) : '';
14464
+ return code === 'ENOENT' ? `${resource}-missing` : `${resource}-unreadable`;
14465
+ }
14466
+
14467
+ interface PromotionEvidenceRead {
14468
+ readonly source: FunnelEvidenceSource<PromotionRunEvidence>;
14469
+ readonly acceptances: readonly PromotionAcceptanceEvidence[];
14470
+ readonly truncatedPeriods: readonly string[];
14471
+ readonly acceptanceHistoryComplete: boolean;
14472
+ }
14473
+
14474
+ function unavailablePromotionEvidence(reason: string): PromotionEvidenceRead {
14475
+ return { source: { status: 'not-measured', reason }, acceptances: [], truncatedPeriods: [], acceptanceHistoryComplete: false };
14476
+ }
14477
+
14478
+ function readPromotionRunEvidence(root: string): PromotionEvidenceRead {
14479
+ let raw: unknown;
14480
+ try {
14481
+ raw = JSON.parse(readFileSync(join(root, PROMOTION_STATE_FILE), 'utf-8')) as unknown;
14482
+ } catch (error) {
14483
+ const reason = error instanceof SyntaxError
14484
+ ? 'promotion-journal-malformed'
14485
+ : localEvidenceReadReason('promotion-journal', error);
14486
+ return unavailablePromotionEvidence(reason);
14487
+ }
14488
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
14489
+ return unavailablePromotionEvidence('promotion-journal-malformed');
14490
+ }
14491
+ const record = raw as Record<string, unknown>;
14492
+ if (record['version'] !== 1 || !Object.hasOwn(record, 'runs')) {
14493
+ return unavailablePromotionEvidence(
14494
+ record['version'] === 1 ? 'promotion-history-not-recorded' : 'promotion-journal-malformed',
14495
+ );
14496
+ }
14497
+ if (!Array.isArray(record['runs'])) {
14498
+ return unavailablePromotionEvidence('promotion-history-malformed');
14499
+ }
14500
+ const state = normalizePromotionState(raw);
14501
+ if (state.runs === undefined || state.runs.length !== record['runs'].length) {
14502
+ return unavailablePromotionEvidence('promotion-history-malformed');
14503
+ }
14504
+ if (
14505
+ (Object.hasOwn(record, 'acceptances') && (
14506
+ !Array.isArray(record['acceptances']) || state.acceptances?.length !== record['acceptances'].length
14507
+ )) ||
14508
+ (Object.hasOwn(record, 'truncatedRunPeriods') && (
14509
+ !Array.isArray(record['truncatedRunPeriods']) || state.truncatedRunPeriods?.length !== record['truncatedRunPeriods'].length
14510
+ )) ||
14511
+ (Object.hasOwn(record, 'acceptanceHistoryComplete') && typeof record['acceptanceHistoryComplete'] !== 'boolean')
14512
+ ) {
14513
+ return unavailablePromotionEvidence('promotion-history-malformed');
14514
+ }
14515
+ const derivedAcceptances = state.runs.flatMap((run) => run.candidates
14516
+ .filter((candidate) => candidate.verdict === 'promote' && candidate.ruleContentAnchor !== null)
14517
+ .map((candidate) => ({ ruleContentAnchor: candidate.ruleContentAnchor!, acceptedTs: run.ts })));
14518
+ const claimedAcceptanceComplete = state.acceptanceHistoryComplete ??
14519
+ ((state.truncatedRunPeriods?.length ?? 0) === 0 && state.runs.every((run) => run.complete === true));
14520
+ if (claimedAcceptanceComplete && state.acceptances !== undefined && derivedAcceptances.some((derived) =>
14521
+ !state.acceptances!.some((stored) =>
14522
+ stored.ruleContentAnchor === derived.ruleContentAnchor &&
14523
+ Date.parse(stored.acceptedTs) <= Date.parse(derived.acceptedTs),
14524
+ ),
14525
+ )) {
14526
+ return unavailablePromotionEvidence('promotion-history-malformed');
14527
+ }
14528
+ const acceptancesByAnchor = new Map<string, PromotionAcceptanceEvidence>();
14529
+ for (const acceptance of [...(state.acceptances ?? []), ...derivedAcceptances]) {
14530
+ const prior = acceptancesByAnchor.get(acceptance.ruleContentAnchor);
14531
+ if (prior === undefined || Date.parse(acceptance.acceptedTs) < Date.parse(prior.acceptedTs)) {
14532
+ acceptancesByAnchor.set(acceptance.ruleContentAnchor, acceptance);
14533
+ }
14534
+ }
14535
+ return {
14536
+ source: { status: 'measured', rows: state.runs },
14537
+ acceptances: [...acceptancesByAnchor.values()],
14538
+ truncatedPeriods: state.truncatedRunPeriods ?? [],
14539
+ acceptanceHistoryComplete: claimedAcceptanceComplete,
14540
+ };
14541
+ }
14542
+
14543
+ interface GuardAuditEvidenceRead {
14544
+ readonly source: FunnelEvidenceSource<GuardEvent>;
14545
+ readonly rows: readonly GuardEvent[];
14546
+ readonly text: string | null;
14547
+ }
14548
+
14549
+ function readGuardAuditEvidence(root: string): GuardAuditEvidenceRead {
14550
+ let text: string;
14551
+ try {
14552
+ text = readFileSync(join(root, '.dz', 'guard-audit.jsonl'), 'utf-8');
14553
+ } catch (error) {
14554
+ return {
14555
+ source: { status: 'not-measured', reason: localEvidenceReadReason('guard-audit', error) },
14556
+ rows: [],
14557
+ text: null,
14558
+ };
14559
+ }
14560
+ const rows: GuardEvent[] = [];
14561
+ let malformed = false;
14562
+ for (const line of text.split('\n')) {
14563
+ if (line.trim() === '') continue;
14564
+ try {
14565
+ const raw = JSON.parse(line) as Record<string, unknown>;
14566
+ if (
14567
+ !isOffsetIsoTimestamp(raw['ts']) ||
14568
+ !['publish', 'teach', 'consolidate', 'reindex'].includes(String(raw['op'])) ||
14569
+ !['pass', 'warn', 'block'].includes(String(raw['verdict']))
14570
+ ) {
14571
+ malformed = true;
14572
+ continue;
14573
+ }
14574
+ const violations: { rule: string; contentAnchor?: string }[] = [];
14575
+ if (!Array.isArray(raw['violations'])) {
14576
+ malformed = true;
14577
+ continue;
14578
+ }
14579
+ for (const item of raw['violations']) {
14580
+ const rule = item && typeof item === 'object' ? (item as { rule?: unknown }).rule : undefined;
14581
+ if (typeof rule !== 'string' || rule === '' || rule.length > 200) {
14582
+ malformed = true;
14583
+ continue;
14584
+ }
14585
+ const anchor = (item as { contentAnchor?: unknown }).contentAnchor;
14586
+ if (anchor !== undefined && !isLessonRuleContentAnchor(anchor)) {
14587
+ malformed = true;
14588
+ continue;
14589
+ }
14590
+ violations.push({ rule, ...(typeof anchor === 'string' ? { contentAnchor: anchor } : {}) });
14591
+ }
14592
+ const verdict = raw['verdict'] as 'pass' | 'warn' | 'block';
14593
+ if ((verdict === 'pass') !== (violations.length === 0)) malformed = true;
14594
+ rows.push({
14595
+ ts: raw['ts'],
14596
+ op: raw['op'] as 'publish' | 'teach' | 'consolidate' | 'reindex',
14597
+ verdict,
14598
+ rules: violations.map((item) => item.rule),
14599
+ violations,
14600
+ });
14601
+ } catch {
14602
+ malformed = true;
14603
+ }
14604
+ }
14605
+ return {
14606
+ source: malformed
14607
+ ? { status: 'not-measured', reason: 'guard-audit-malformed' }
14608
+ : !verifyEventChainText(text).ok
14609
+ ? { status: 'not-measured', reason: 'guard-audit-chain-corrupt' }
14610
+ : { status: 'measured', rows },
14611
+ rows,
14612
+ text,
14613
+ };
14614
+ }
14615
+
13061
14616
  function deadwoodAllowlistText(): string {
13062
14617
  const require = createRequire(import.meta.url);
13063
14618
  const corePackage = require.resolve('@dzhechkov/harness-core/package.json');
@@ -13143,7 +14698,7 @@ function cmdCompounding(options: Map<string, string>, flags: Set<string>, cwd: s
13143
14698
  const json = flags.has('json');
13144
14699
  if (flags.has('help')) {
13145
14700
  write('dz compounding [--project <dir>] [--json] — honest learning-loop payoff report');
13146
- write(' pool payoff (write-only ratio) · guard repeat-violation trajectory · cold-vs-warm replay readiness · instrumentation health');
14701
+ write(' pool payoff · guard trajectory · replay readiness · instrumentation · monthly eligible→attempted→accepted→executions funnel');
13147
14702
  return 0;
13148
14703
  }
13149
14704
  for (const flag of flags) {
@@ -13175,40 +14730,24 @@ function cmdCompounding(options: Map<string, string>, flags: Set<string>, cwd: s
13175
14730
  // apply-leg usage events (read records only; aggregate rows carry no query by construction)
13176
14731
  const usage = readRecallUsageEvents(root);
13177
14732
 
13178
- // guard audit events
13179
- const guard: { ts: string; verdict: string; rules: string[] }[] = [];
13180
- try {
13181
- const text = readFileSync(join(root, '.dz', 'guard-audit.jsonl'), 'utf-8');
13182
- for (const line of text.split('\n')) {
13183
- if (line.trim() === '') continue;
13184
- try {
13185
- const o = JSON.parse(line) as { ts?: unknown; verdict?: unknown; violations?: { rule?: unknown }[] };
13186
- if (typeof o.ts === 'string' && typeof o.verdict === 'string') {
13187
- guard.push({
13188
- ts: o.ts,
13189
- verdict: o.verdict,
13190
- rules: Array.isArray(o.violations) ? o.violations.map((v) => (typeof v?.rule === 'string' ? v.rule : '')).filter(Boolean) : [],
13191
- });
13192
- }
13193
- } catch {
13194
- /* skip */
13195
- }
13196
- }
13197
- } catch {
13198
- /* no audit yet */
13199
- }
14733
+ const promotionEvidence = readPromotionRunEvidence(root);
14734
+ const guardEvidence = readGuardAuditEvidence(root);
14735
+ const guard = [...guardEvidence.rows];
13200
14736
 
13201
14737
  // The evidence logs themselves, verbatim: the report verifies their hash chains (feature
13202
14738
  // event-chain). Handing over the TEXT rather than a pre-computed verdict keeps one definition of
13203
14739
  // "the chain is intact" — a second copy here is how a gate and its report start disagreeing.
13204
14740
  const evidenceLogs: { log: string; text: string }[] = [];
13205
- for (const rel of ['.dz/recall-usage.jsonl', '.dz/guard-audit.jsonl']) {
14741
+ for (const rel of ['.dz/recall-usage.jsonl']) {
13206
14742
  try {
13207
14743
  evidenceLogs.push({ log: rel, text: readFileSync(join(root, ...rel.split('/')), 'utf-8') });
13208
14744
  } catch {
13209
14745
  /* absent log — reported by its own gate above, not invented here */
13210
14746
  }
13211
14747
  }
14748
+ if (guardEvidence.text !== null) {
14749
+ evidenceLogs.push({ log: '.dz/guard-audit.jsonl', text: guardEvidence.text });
14750
+ }
13212
14751
 
13213
14752
  const now = new Date();
13214
14753
  const report = assembleCompoundingReport({
@@ -13218,6 +14757,13 @@ function cmdCompounding(options: Map<string, string>, flags: Set<string>, cwd: s
13218
14757
  nowTs: now.toISOString(),
13219
14758
  evidenceLogs,
13220
14759
  cmdUsageDepthDays: cmdUsageDepthDays(root, now),
14760
+ lessonToRule: {
14761
+ promotionRuns: promotionEvidence.source,
14762
+ guardAudits: guardEvidence.source,
14763
+ promotionAcceptances: promotionEvidence.acceptances,
14764
+ truncatedPromotionPeriods: promotionEvidence.truncatedPeriods,
14765
+ acceptanceHistoryComplete: promotionEvidence.acceptanceHistoryComplete,
14766
+ },
13221
14767
  });
13222
14768
  // lesson-bandit-rerank §11: the payoff axis joins THIS report rather than growing a private
13223
14769
  // dashboard — the `rewardEvents : exposureEvents` row asks exactly the question this command
@@ -14580,17 +16126,19 @@ function cmdStats(cwd: string, write: Write): number {
14580
16126
  }
14581
16127
  const dirs = readdirSync(baseDir, { withFileTypes: true }).filter((e) => e.isDirectory());
14582
16128
  const packages = dirs.length;
14583
- let totalSkills = 0;
14584
- let skillPacks = 0;
14585
- for (const dir of dirs) {
14586
- if (dir.name.startsWith('skills-')) {
14587
- skillPacks++;
14588
- const skillDir = join(baseDir, dir.name);
14589
- const skillDirs = readdirSync(skillDir, { withFileTypes: true })
14590
- .filter((e) => e.isDirectory() && existsSync(join(skillDir, e.name, 'SKILL.md')));
14591
- totalSkills += skillDirs.length;
14592
- }
14593
- }
16129
+ // Backlog e160aeee. This used to walk the tree ITSELF, and was wrong in two independent ways:
16130
+ // it counted only packages whose NAME starts with `skills-` (health-advisor, p-replicator,
16131
+ // keysarium and trip-planner were therefore invisible), and it knew only ONE of the three skill
16132
+ // layouts. Result: 203 here against 250 from `dz registry` on the same tree — two counters of one
16133
+ // quantity, each unable to refute the other because neither knew the other existed.
16134
+ //
16135
+ // The fix is structural, not arithmetic: there is now ONE enumerator, and both commands ask it.
16136
+ // Pinned by test/stats-registry-parity.test.ts, whose red half is this exact divergence.
16137
+ // The registry already PUBLISHES these totals; recomputing them from `entries` here would be a
16138
+ // third implementation of the same count, which is the very defect being fixed.
16139
+ const registry = buildRegistry(cwd);
16140
+ const totalSkills = registry.totalSkills;
16141
+ const skillPacks = registry.totalPacks;
14594
16142
  const targets = TARGET_NAMES.length;
14595
16143
  const presets = PRESET_NAMES.length;
14596
16144
  write(`dz stats — DZ Harness Hub`);
@@ -15063,7 +16611,31 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
15063
16611
  return version === 'unknown' ? 1 : 0;
15064
16612
  }
15065
16613
 
15066
- if (command === '' || command === 'help' || flags.has('help')) {
16614
+ // `-h` is the most-typed help flag and is NOT a command: before the unknown-command contract
16615
+ // landed it fell through to the switch and still printed usage; afterwards it would have died
16616
+ // with exit 2 and an empty stdout (measured regression, cross-model QE M1). It belongs beside
16617
+ // `-v` above — an argv-level flag, resolved before command dispatch.
16618
+ if (argv[0] === '-h') {
16619
+ write(USAGE);
16620
+ return 0;
16621
+ }
16622
+ // A bare `--typo` leaves the command empty, so the usage branch reported SUCCESS on a misspelled
16623
+ // FLAG exactly as it used to on a misspelled VERB (cross-model QE M2): `dz --frobnicate` exited 0
16624
+ // with 30 KB of usage. The refusal is deliberately narrowed to the no-command case, because the
16625
+ // warn-don't-refuse decision above is measured and still stands: with a command present, an
16626
+ // unrecognised name may simply be missing from KNOWN_CLI_FLAGS and refusing would break working
16627
+ // invocations. With NO command there is nothing the flag could belong to, so it is a usage error.
16628
+ if (command === '') {
16629
+ const strayNames = unknownFlagNotice(
16630
+ [...flags, ...options.keys()].filter((k) => !k.startsWith('_positional_')),
16631
+ KNOWN_CLI_FLAGS,
16632
+ ).map((n) => n.name);
16633
+ if (strayNames.length > 0) {
16634
+ writeErr(`dz: unknown option --${strayNames[0]} — run 'dz help' for usage`);
16635
+ return 2;
16636
+ }
16637
+ }
16638
+ if (command === '' || command === 'help' || (flags.has('help') && DZ_COMMANDS.includes(command))) {
15067
16639
  write(USAGE);
15068
16640
  return 0;
15069
16641
  }
@@ -15110,11 +16682,20 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
15110
16682
  case 'bundle':
15111
16683
  return cmdBundle(options, flags, cwd, write);
15112
16684
  case 'teach':
15113
- return await cmdTeach(options, flags, cwd, write);
16685
+ return await cmdTeach(
16686
+ options,
16687
+ flags,
16688
+ cwd,
16689
+ write,
16690
+ writeErr,
16691
+ io.interactive ?? process.stdout.isTTY === true,
16692
+ io.teachGuardRunner ?? teachGuard,
16693
+ io.teachReinforceRunner ?? runTeachGuardReinforcement,
16694
+ );
15114
16695
  case 'consolidate':
15115
16696
  return await cmdConsolidate(options, flags, cwd, write);
15116
16697
  case 'recall':
15117
- return await cmdRecall(options, flags, cwd, write);
16698
+ return await cmdRecall(options, flags, cwd, write, writeErr, io.classMatcher);
15118
16699
  case 'vector':
15119
16700
  return await cmdVector(options, flags, cwd, write);
15120
16701
  case 'brain':
@@ -15123,6 +16704,8 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
15123
16704
  return cmdStatusline(options, flags, cwd, write, readStdin);
15124
16705
  case 'usage':
15125
16706
  return cmdUsage(options, optionLists, flags, cwd, write);
16707
+ case 'chain':
16708
+ return cmdChain(options, flags, cwd, write);
15126
16709
  case 'claim-check':
15127
16710
  return cmdClaimCheck(options, optionLists, flags, cwd, write);
15128
16711
  case 'lint':
@@ -15144,7 +16727,7 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
15144
16727
  case 'diff':
15145
16728
  return cmdDiff(options, cwd, write);
15146
16729
  case 'recommend':
15147
- return cmdRecommend(options, cwd, write);
16730
+ return cmdRecommend(options, flags, cwd, write);
15148
16731
  case 'upgrade':
15149
16732
  return cmdUpgrade(options, flags, cwd, write, writeErr);
15150
16733
  case 'auto-canonicalize':
@@ -15167,6 +16750,8 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
15167
16750
  return cmdDriftCheck(options, flags, cwd, write);
15168
16751
  case 'hooks-sync':
15169
16752
  return cmdHooksSync(options, flags, cwd, write, writeErr);
16753
+ case 'integrations-verify':
16754
+ return cmdIntegrationsVerify(options, flags, cwd, write, writeErr);
15170
16755
  case 'agents-sync':
15171
16756
  return cmdAgentsSync(options, flags, cwd, write, writeErr);
15172
16757
  case 'sync-canonical':
@@ -15191,8 +16776,20 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
15191
16776
  return cmdChallenge(options, flags, cwd, write);
15192
16777
  case 'discrimination-check':
15193
16778
  return cmdDiscriminationCheck(options, flags, cwd, write);
15194
- case 'mutation-gate':
15195
- return cmdMutationGate(options, flags, cwd, write);
16779
+ case 'mutation-gate': {
16780
+ try {
16781
+ return cmdMutationGate(options, flags, cwd, write, io.mutationGateRunner);
16782
+ } catch (error) {
16783
+ const raw = error instanceof Error ? error.message : String(error);
16784
+ const head = Array.from(raw.split(/\r?\n/, 1)[0]?.trim() || 'unknown internal error').slice(0, 160).join('');
16785
+ if (flags.has('json')) {
16786
+ write(JSON.stringify({ verdict: 'INCONCLUSIVE', reason: 'runner-internal-error', error: head, exitCode: 1 }));
16787
+ } else {
16788
+ write(`mutation-gate: INTERNAL ERROR (${head}) — verdict INCONCLUSIVE, exit 1`);
16789
+ }
16790
+ return 1;
16791
+ }
16792
+ }
15196
16793
  case 'delivery-check':
15197
16794
  return cmdDeliveryCheck(options, flags, cwd, write);
15198
16795
  case 'skills-verify':
@@ -15246,9 +16843,8 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
15246
16843
  case 'import-ecc':
15247
16844
  return await cmdImportEcc(options, flags, cwd, write);
15248
16845
  default:
15249
- write(`dz: unknown command ${JSON.stringify(command)}`);
15250
- write(USAGE);
15251
- return 1;
16846
+ writeErr(`dz: unknown command ${JSON.stringify(command)} — run 'dz help' for the command list`);
16847
+ return 2;
15252
16848
  }
15253
16849
  } catch (error) {
15254
16850
  // stderr, not stdout: an uncaught failure is a diagnostic, and routing it through