@dzhechkov/harness-cli 0.8.5 → 0.8.9

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
@@ -15,6 +15,7 @@ import { execFile, execFileSync, execSync, spawn, spawnSync, type ChildProcess }
15
15
  import { createHash, randomBytes } from 'node:crypto';
16
16
  import { homedir, tmpdir } from 'node:os';
17
17
  import { createRequire } from 'node:module';
18
+ import { isDeepStrictEqual } from 'node:util';
18
19
 
19
20
  import {
20
21
  createSkill,
@@ -28,6 +29,8 @@ import {
28
29
  TARGET_NAMES_SORTED,
29
30
  runDoctor,
30
31
  runInit,
32
+ discoverSkillIds,
33
+ runIntegrationsVerify,
31
34
  resolvePackageSkillRoots,
32
35
  PACKAGE_SKILL_LAYOUTS,
33
36
  benchmarkSkill,
@@ -80,6 +83,7 @@ import {
80
83
  NamedLockTimeoutError,
81
84
  NamedLockCompromisedError,
82
85
  type CodexHooksSyncReport,
86
+ type IntegrationManifestSource,
83
87
  type ParityCell,
84
88
  type ParityFeature,
85
89
  type ParityReportCell,
@@ -115,6 +119,8 @@ import {
115
119
  renderTimelineHtml,
116
120
  importEcc,
117
121
  recordPattern,
122
+ recordLessonForms,
123
+ normalizeLessonForms,
118
124
  resolveLearningBackend,
119
125
  storeStats,
120
126
  consolidateSessions,
@@ -140,6 +146,17 @@ import {
140
146
  importRvfCheckpoint,
141
147
  statuslineData,
142
148
  writeFeatureAdrState,
149
+ CHECKPOINT_STAGES,
150
+ estimateEta,
151
+ extractStageSamples,
152
+ formatEta,
153
+ parseCheckpointLines,
154
+ segmentRun,
155
+ type CheckpointStage,
156
+ type EtaEstimate,
157
+ type FeatureAdrState,
158
+ type RunSegment,
159
+ type StageSample,
143
160
  computeUsage,
144
161
  deriveCostLedger,
145
162
  planLedgerBackfill,
@@ -166,6 +183,8 @@ import {
166
183
  queryBookKnowledge,
167
184
  loadStorePatternsSync,
168
185
  patternRecordId,
186
+ patternIdentityOf,
187
+ mergeLessonMatchedForms,
169
188
  loadStoreRecords,
170
189
  recordToPattern,
171
190
  bundleSkills,
@@ -203,12 +222,22 @@ import {
203
222
  DEFAULT_RULES,
204
223
  parsePnpmLockImporters,
205
224
  scannableStubPath,
225
+ type FeatureArtifactFact,
226
+ type FeatureTier,
227
+ type FeatureVolumeFact,
228
+ type GuardObservation,
229
+ type TemplateVolumeFileFact,
230
+ type TemplateVolumeTargetFact,
231
+ type VolumeShadowInput,
206
232
  // guard-promotion (feature guard-promotion, scout idea #1)
207
233
  assembleCandidates,
208
234
  renderPromotionReport,
209
235
  renderPromotionAdr,
210
236
  normalizePromotionState,
211
237
  nextPromotionState,
238
+ recordPromotionRunEvidence,
239
+ isLessonRuleContentAnchor,
240
+ isOffsetIsoTimestamp,
212
241
  globMatch,
213
242
  promotionAdrRelPath,
214
243
  DEFAULT_WINDOW_DAYS,
@@ -218,6 +247,10 @@ import {
218
247
  type ChangeSet,
219
248
  type ExistingRuleView,
220
249
  type PromotionReport,
250
+ type PromotionAcceptanceEvidence,
251
+ type PromotionRunEvidence,
252
+ type FunnelEvidenceSource,
253
+ type GuardEvent,
221
254
  decideProvenance,
222
255
  isInsideTree,
223
256
  signManifest,
@@ -283,6 +316,7 @@ import {
283
316
  harvestStageOutcomes,
284
317
  recommendModels,
285
318
  planFeed,
319
+ unfedRuns,
286
320
  GRADE_SUCCESS_FLOOR,
287
321
  COST_LADDER,
288
322
  splitScenarios,
@@ -520,7 +554,7 @@ import type { SetupSpec } from '@dzhechkov/harness-core';
520
554
  import type { LogTail } from '@dzhechkov/harness-core';
521
555
  import type { DeadwoodInventoryItem } from '@dzhechkov/harness-core';
522
556
  import type { ContractDiagnostic, ContractEvidenceReader } from '@dzhechkov/harness-core';
523
- import type { ProvenanceMode, PackVerdict, ClaudeUsageModel, PatternRecord, TargetName, BookKU, HarmonizeReport, UsageCalibrationPlan, ClaimFinding, RecallUsagePatternRow, GateExecution, GateStep, SlopFinding, SlopLintConfig, SlopRegistry } from '@dzhechkov/harness-core';
557
+ import type { ProvenanceMode, PackVerdict, ClaudeUsageModel, PatternRecord, RecallPatternsOptions, TeachGuardResult, TargetName, IntegrationOutcome, BookKU, HarmonizeReport, UsageCalibrationPlan, ClaimFinding, RecallUsagePatternRow, GateExecution, GateStep, SlopFinding, SlopLintConfig, SlopRegistry } from '@dzhechkov/harness-core';
524
558
  import { getPreset, PRESET_NAMES } from '@dzhechkov/harness-presets';
525
559
  import { scanGitHub, analyzeRepo, generateReport, deepAnalyze, scanAllSources, ScoutMemory } from '@dzhechkov/scout';
526
560
 
@@ -532,7 +566,7 @@ export const DZ_COMMANDS: readonly string[] = [
532
566
  'usage', 'claim-check', 'lint', 'sign', 'sbom', 'guard', 'verify-pack', 'setup',
533
567
  'pretrain', 'compose', 'diff', 'recommend', 'upgrade', 'auto-canonicalize',
534
568
  'publish', 'release', 'parity', 'registry', 'benchmark', 'mcp-scan',
535
- 'sync-upstream', 'drift-check', 'hooks-sync', 'agents-sync', 'sync-canonical',
569
+ 'sync-upstream', 'drift-check', 'hooks-sync', 'integrations-verify', 'agents-sync', 'sync-canonical',
536
570
  'plugin', 'downloads', 'stats', 'architecture', 'project-skills', 'mr-rakes',
537
571
  'retro', 'feature-adr-setup', 'challenge', 'discrimination-check',
538
572
  'mutation-gate', 'delivery-check', 'skills-verify', 'compounding', 'deadwood',
@@ -545,7 +579,7 @@ export const DZ_COMMANDS: readonly string[] = [
545
579
  const USAGE = `dz - DZ cross-platform harness CLI
546
580
 
547
581
  Usage:
548
- 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)
582
+ 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)
549
583
  dz verify [--skills-dir <dir>] [--target <name>]
550
584
  dz sync [--canonical <dir>] [--project <dir>] [--dry-run] [--force]
551
585
  dz update (alias of sync)
@@ -574,7 +608,7 @@ Usage:
574
608
  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)
575
609
  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)
576
610
  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)
577
- 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)
611
+ 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)
578
612
  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)
579
613
  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)
580
614
  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)
@@ -609,13 +643,13 @@ Usage:
609
643
  dz backlog ship <id> [<id>…] [--reason <t>] [--dry-run] [--project <dir>] [--json] (mark work DONE: new|enriched|in-progress → shipped, removing it from the roulette pool — run it after finishing a task; short id prefixes ok, ambiguous = loud error)
610
644
  dz backlog drop <id> [<id>…] [--reason <t>] [--dry-run] [--project <dir>] [--json] (retire an idea: new|enriched|in-progress → dropped)
611
645
  dz backlog edit <id> --text "<new>" | --append "<more>" [--dry-run] [--project <dir>] [--json] (rewrite ONE idea's text, preserving every other field; re-embeds the dedup vector, and on a failed re-embed MARKS the record embedStale so dedup refuses to trust it — previous text preserved in .dz/backlog/edits.jsonl)
612
- dz routing recommend [--tier <t>] [--apply] [--json] (per-stage args.models suggestion from REAL telemetry — harness records + imported run-meta sidecars — printed WITH its basis: n runs, window, the grade-floor rule, skip reasons; qe is FORCED cross-family of code; --apply feeds .dz/routing-outcomes.json idempotently by runId)
646
+ dz routing recommend [--tier <t>] [--apply] [--json] (per-stage args.models suggestion from REAL telemetry — harness records + imported run-meta sidecars — printed WITH its basis + current/STALE/UNFED store receipt; qe is FORCED cross-family of code; --apply feeds .dz/routing-outcomes.json idempotently by runId)
613
647
  dz backlog reopen <id> [<id>…] [--reason <t>] [--dry-run] [--project <dir>] [--json] (back to the pool: shipped|dropped|in-progress → new)
614
648
  dz backlog enrich <id> [--project <dir>] [--json] (stage the idea2prd input scaffold in features/<slug>/ and hand off to the idea2prd-manual skill — the CLI never fabricates a PRD)
615
649
  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)
616
650
  dz backlog harmonize [--apply] [--threshold <0-1>] [--project <dir>] [--json] (batch semantic dedup of the backlog ideas; --dry-run default, --apply snapshots first)
617
651
  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)
618
- 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)
652
+ dz teach "<pattern>" [--class-form "<template with :slot>"] [--reward <0-1>] [--domain <name>] [--type rule|success-pattern|lesson-learned] [--project <dir>] [--no-mirror] (class form is optional; rejection never blocks the specific write; --project pins the learned store to <dir>/.dz)
619
653
  dz teach --from-json <file> [--project <dir>] [--no-mirror] (bulk-import a 'dz recall --all --json' export — share a learned store across machines)
620
654
  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)
621
655
  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)
@@ -654,6 +688,7 @@ Usage:
654
688
  dz drift-check [--json] [--project <dir>] (CI gate: exit 1 if any shared skill drifted between its monorepo copies)
655
689
  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)
656
690
  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)
691
+ dz integrations-verify --target <name> --component <mcp|hooks> [--project <dir>] [--json] (non-executing exact-version registration probe; exit 0 observed / 1 refused)
657
692
  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)
658
693
  dz plugin [--version <ver>]
659
694
  dz downloads
@@ -704,6 +739,14 @@ export interface CliIo {
704
739
  * command that needs it (`brain ground`), and never when stdin is a TTY (nothing piped).
705
740
  */
706
741
  readonly stdin?: string;
742
+ /** Human-terminal rendering seam; production defaults to stdout TTY detection. */
743
+ readonly interactive?: boolean;
744
+ /** Fault seam proving that class-form recall degrades to specific recall with a stderr receipt. */
745
+ readonly classMatcher?: RecallPatternsOptions['classMatcher'];
746
+ /** Guard decision seam; production always uses the real vector-backed teach guard. */
747
+ readonly teachGuardRunner?: (projectRoot: string, text: string, opts: { readonly reward?: number }) => Promise<TeachGuardResult>;
748
+ /** Reinforcement flush seam paired with `teachGuardRunner`; production uses the configured backend. */
749
+ readonly teachReinforceRunner?: (projectRoot: string, dzId: string, reward: number) => Promise<{ readonly flushed: number }>;
707
750
  /**
708
751
  * Test seam for `dz release`: overrides subprocess execution for gate steps and the
709
752
  * gh/git side channels (production leaves it unset → real `execSync`, stdio piped).
@@ -821,6 +864,8 @@ interface InstallSkillsResult {
821
864
  * the source `SKILL.md`.
822
865
  */
823
866
  readonly applyFailures: SkillApplyFailure[];
867
+ readonly integrations: readonly IntegrationOutcome[];
868
+ readonly integrationDigest?: string;
824
869
  }
825
870
 
826
871
  /**
@@ -836,6 +881,10 @@ async function installSkills(opts: {
836
881
  select?: readonly string[] | undefined;
837
882
  force: boolean;
838
883
  enrich: boolean;
884
+ noHooks?: boolean;
885
+ noIntegrations?: boolean;
886
+ noVerify?: boolean;
887
+ allowIntegrations?: string;
839
888
  }): Promise<InstallSkillsResult> {
840
889
  const { target, projectRoot, cwd, explicitSkillsDir, select, force, enrich } = opts;
841
890
 
@@ -848,8 +897,8 @@ async function installSkills(opts: {
848
897
  // route them through one aggregation.
849
898
  if (target === 'agents-md' || target === 'gemini') {
850
899
  const report = target === 'gemini'
851
- ? runInitGeminiMd({ skillsDirs, projectRoot, ...(select !== undefined ? { select } : {}) })
852
- : runInitAgentsMd({ skillsDirs, projectRoot, ...(select !== undefined ? { select } : {}) });
900
+ ? runInitGeminiMd({ skillsDirs, projectRoot, ...(select !== undefined ? { select } : {}), ...(opts.noHooks !== undefined ? { noHooks: opts.noHooks } : {}), ...(opts.noIntegrations !== undefined ? { noIntegrations: opts.noIntegrations } : {}) })
901
+ : runInitAgentsMd({ skillsDirs, projectRoot, ...(select !== undefined ? { select } : {}), ...(opts.noHooks !== undefined ? { noHooks: opts.noHooks } : {}), ...(opts.noIntegrations !== undefined ? { noIntegrations: opts.noIntegrations } : {}) });
853
902
  const results = report.skills.map((s) => ({
854
903
  id: s.id,
855
904
  written: s.written.length,
@@ -858,13 +907,21 @@ async function installSkills(opts: {
858
907
  let written = 0;
859
908
  let skipped = 0;
860
909
  for (const s of results) { written += s.written; skipped += s.skipped; }
861
- return { results, dirsSearched: skillsDirs.length, written, skipped, missing: [...report.missing], failures: [...report.failures], applyFailures: [...report.applyFailures] };
910
+ 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 } : {}) };
862
911
  }
863
912
 
864
913
  const results: { id: string; written: number; skipped: number }[] = [];
865
914
  const failures: SkillLoadFailure[] = [];
866
915
  const applyFailures: SkillApplyFailure[] = [];
867
- for (const skillsDir of skillsDirs) {
916
+ let integrations: readonly IntegrationOutcome[] = [];
917
+ let integrationDigest: string | undefined;
918
+ const integrationManifestSources: readonly IntegrationManifestSource[] = skillsDirs.flatMap((skillsDir) => {
919
+ const discovered = discoverSkillIds(skillsDir);
920
+ return discovered
921
+ .filter((id) => select === undefined || select.includes(id))
922
+ .map((skillId) => ({ skillId, skillDir: skillsDir }));
923
+ });
924
+ for (const [dirIndex, skillsDir] of skillsDirs.entries()) {
868
925
  const r = await runInit({
869
926
  target,
870
927
  skillsDir,
@@ -872,12 +929,24 @@ async function installSkills(opts: {
872
929
  force,
873
930
  enrich,
874
931
  ...(select !== undefined ? { select } : {}),
932
+ ...(opts.noHooks !== undefined ? { noHooks: opts.noHooks } : {}),
933
+ ...(dirIndex === 0
934
+ ? {
935
+ integrationManifestSources,
936
+ ...(opts.noIntegrations !== undefined ? { noIntegrations: opts.noIntegrations } : {}),
937
+ }
938
+ : { noIntegrations: true }),
939
+ ...(opts.noVerify !== undefined ? { noVerify: opts.noVerify } : {}),
940
+ ...(opts.allowIntegrations !== undefined ? { allowIntegrations: opts.allowIntegrations } : {}),
875
941
  });
876
942
  for (const skill of r.skills) {
877
943
  results.push({ id: skill.id, written: skill.written.length, skipped: skill.skipped.length });
878
944
  }
879
945
  failures.push(...r.failures);
880
946
  applyFailures.push(...r.applyFailures);
947
+ if (r.integrations.some((row) => row.status !== 'not-requested')) integrations = [...r.integrations];
948
+ else if (integrations.length === 0) integrations = [...r.integrations];
949
+ if (r.integrationDigest !== undefined) integrationDigest = r.integrationDigest;
881
950
  }
882
951
 
883
952
  let written = 0;
@@ -885,7 +954,7 @@ async function installSkills(opts: {
885
954
  for (const s of results) { written += s.written; skipped += s.skipped; }
886
955
  const installed = new Set(results.map((s) => s.id));
887
956
  const missing = select !== undefined ? [...select].filter((id) => !installed.has(id)) : [];
888
- return { results, dirsSearched: skillsDirs.length, written, skipped, missing, failures, applyFailures };
957
+ return { results, dirsSearched: skillsDirs.length, written, skipped, missing, failures, applyFailures, integrations, ...(integrationDigest !== undefined ? { integrationDigest } : {}) };
889
958
  }
890
959
 
891
960
  /** Warn about preset/select ids that weren't found in any installed pack. */
@@ -947,26 +1016,46 @@ async function cmdInit(options: Map<string, string>, flags: Set<string>, cwd: st
947
1016
  select,
948
1017
  force: flags.has('force'),
949
1018
  enrich: flags.has('enrich'),
1019
+ noHooks: flags.has('no-hooks'),
1020
+ noIntegrations: flags.has('no-integrations'),
1021
+ noVerify: flags.has('no-verify'),
1022
+ ...(options.get('allow-integrations') !== undefined ? { allowIntegrations: options.get('allow-integrations')! } : {}),
950
1023
  });
951
1024
 
952
- write(`dz init --target ${target}: ${r.results.length} skill(s), ${r.written} file(s) written, ${r.skipped} skipped`);
953
- if (r.dirsSearched > 1) {
954
- write(` (searched ${r.dirsSearched} skill directories)`);
955
- }
956
- writeMissingSkillsHint(write, r.missing, presetName);
957
-
958
- // Codex-targeted init DELIVERS the hooks and verifies them (ADR-001 §8). Skills alone are not the
959
- // target's harness: the veto + recall legs are what `--target codex` promises.
960
- // `--no-hooks` is the documented escape for "skills only" (the same flag `dz setup` already
961
- // carries): hook delivery writes USER-GLOBAL config, so a command that only wants skills compiled
962
- // must be able to say so — and every test that is about skills says it.
1025
+ // Codex keeps its established user-registry writer, but its result is normalized into the same
1026
+ // two-outcome contract before JSON/human rendering. A write without a live ready observation is
1027
+ // a refusal with applied=true, never a second success channel.
1028
+ let integrationOutcomes: readonly IntegrationOutcome[] = r.integrations;
963
1029
  let codexHooksOk = true;
964
1030
  if (target === 'codex' && !flags.has('no-hooks')) {
965
1031
  const delivery = deliverCodexHooks({ project: projectRoot, verify: !flags.has('no-verify') }, undefined, 'dz init');
966
1032
  codexHooksOk = delivery.ok;
967
1033
  for (const line of delivery.stdout) write(line);
968
1034
  for (const line of delivery.stderr) writeErr(line);
1035
+ const hookIndex = integrationOutcomes.findIndex((row) => row.component === 'hooks' && row.status !== 'not-requested');
1036
+ if (hookIndex !== -1) {
1037
+ const hook = normalizeCodexHookOutcome(integrationOutcomes[hookIndex]!, delivery, flags.has('no-verify'));
1038
+ integrationOutcomes = integrationOutcomes.map((row, index) => index === hookIndex ? hook : row);
1039
+ }
1040
+ }
1041
+
1042
+ if (flags.has('json')) {
1043
+ write(JSON.stringify({ target, skills: { count: r.results.length, written: r.written, skipped: r.skipped }, integrations: integrationOutcomes, integrationDigest: r.integrationDigest ?? null }));
1044
+ } else {
1045
+ write(`dz init --target ${target}: ${r.results.length} skill(s), ${r.written} file(s) written, ${r.skipped} skipped`);
1046
+ if (r.dirsSearched > 1) write(` (searched ${r.dirsSearched} skill directories)`);
1047
+ for (const outcome of integrationOutcomes) {
1048
+ const label = outcome.component === 'mcp' ? 'MCP' : 'Hooks';
1049
+ const detail = outcome.status === 'refused'
1050
+ ? `${outcome.reasonCode ?? 'LIVE_PROBE_FAILED'}${outcome.remediation ? ` — ${outcome.remediation}` : ''}`
1051
+ : outcome.status === 'emitted'
1052
+ ? `${outcome.carrier?.path ?? 'registered'}${outcome.registrations.some((row) => row.approval === 'pending') ? '; Pending approval; ready=false' : ''}`
1053
+ : 'explicitly not requested';
1054
+ write(`${label}: ${outcome.status.toUpperCase()} (${detail})`);
1055
+ }
969
1056
  }
1057
+ writeMissingSkillsHint(write, r.missing, presetName);
1058
+
970
1059
  // Skip-and-collect must not become skip-and-SILENCE: a skill that failed to load is
971
1060
  // named on stderr and the command exits 1 (it exited 1 before too — by throwing).
972
1061
  if (r.failures.length > 0 || r.applyFailures.length > 0) {
@@ -985,7 +1074,35 @@ async function cmdInit(options: Map<string, string>, flags: Set<string>, cwd: st
985
1074
  for (const line of formatSkillApplyFailures(r.applyFailures)) writeErr(line);
986
1075
  return 1;
987
1076
  }
988
- return codexHooksOk ? 0 : 1;
1077
+ const integrationOk = !integrationOutcomes.some((row) => row.status === 'refused');
1078
+ return codexHooksOk && integrationOk ? 0 : 1;
1079
+ }
1080
+
1081
+ function cmdIntegrationsVerify(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write, writeErr: WriteErr): number {
1082
+ const targetInput = options.get('target');
1083
+ const component = options.get('component');
1084
+ if (targetInput === undefined || (component !== 'mcp' && component !== 'hooks')) {
1085
+ writeErr('dz integrations-verify: requires --target <name> --component <mcp|hooks>');
1086
+ return 1;
1087
+ }
1088
+ const resolution = resolveTargetName(targetInput);
1089
+ if (resolution.kind === 'unknown') {
1090
+ for (const line of formatTargetProblem('dz integrations-verify', resolution)) writeErr(line);
1091
+ return 1;
1092
+ }
1093
+ if (resolution.via === 'alias') writeErr(formatTargetAliasNote('dz integrations-verify', targetInput, resolution.target));
1094
+ const result = runIntegrationsVerify({
1095
+ target: resolution.target,
1096
+ component,
1097
+ projectRoot: resolve(cwd, options.get('project') ?? '.'),
1098
+ });
1099
+ if (flags.has('json')) write(JSON.stringify(result));
1100
+ else if (result.ok) {
1101
+ write(`dz integrations-verify: ${resolution.target}/${component} registered (runtime ${result.runtimeVersion ?? 'unknown'}; ready=${result.registrations.every((row) => row.ready === true)})`);
1102
+ } else {
1103
+ writeErr(`dz integrations-verify: ${resolution.target}/${component} REFUSED (${result.reasonCode ?? 'LIVE_PROBE_FAILED'}) — ${result.remediation ?? 'no qualifying receipt'}`);
1104
+ }
1105
+ return result.ok ? 0 : 1;
989
1106
  }
990
1107
 
991
1108
  async function cmdVerify(options: Map<string, string>, cwd: string, write: Write, writeErr: WriteErr): Promise<number> {
@@ -2421,6 +2538,146 @@ function cmdStatuslineFaRecord(options: Map<string, string>, cwd: string, write:
2421
2538
  return 0;
2422
2539
  }
2423
2540
 
2541
+ interface EtaCorpusRead {
2542
+ readonly history: StageSample[];
2543
+ readonly currentRunSamples: StageSample[];
2544
+ readonly currentTier: string | undefined;
2545
+ readonly activeStages: CheckpointStage[];
2546
+ readonly lastCheckpointTsMs: number | undefined;
2547
+ readonly hasCurrentCheckpoints: boolean;
2548
+ }
2549
+
2550
+ function statuslineEtaStage(step: string): CheckpointStage | undefined {
2551
+ const match = step.match(/\bStep\s*-?\s*(0|[1-9](?:\.5)?)(?:\b|\s)/i);
2552
+ if (match === null) return undefined;
2553
+ const value = Number(match[1]);
2554
+ if (value === 0) return 'router';
2555
+ if (value > 0 && value < 6) return 'design';
2556
+ if (value === 6) return 'plan';
2557
+ if (value === 7) return 'code';
2558
+ if (value === 8) return 'qe';
2559
+ if (value === 9) return 'fleet';
2560
+ return undefined;
2561
+ }
2562
+
2563
+ function routerMetadata(router: RunSegment['router']): { tier?: string; activeSteps?: number[] } {
2564
+ const result = router?.result;
2565
+ if (result === null || typeof result !== 'object') return {};
2566
+ const record = result as Record<string, unknown>;
2567
+ const tier = typeof record['tier'] === 'string' && record['tier'].length > 0 ? record['tier'] : undefined;
2568
+ const activeSteps = Array.isArray(record['activeSteps'])
2569
+ ? record['activeSteps'].filter((value): value is number => typeof value === 'number' && Number.isFinite(value))
2570
+ : undefined;
2571
+ return {
2572
+ ...(tier !== undefined ? { tier } : {}),
2573
+ ...(activeSteps !== undefined ? { activeSteps } : {}),
2574
+ };
2575
+ }
2576
+
2577
+ function activeCheckpointStages(tier: string | undefined, activeSteps: readonly number[] | undefined): CheckpointStage[] {
2578
+ const stages = new Set<CheckpointStage>();
2579
+ if (activeSteps !== undefined) {
2580
+ for (const step of activeSteps) {
2581
+ if (step === 0) stages.add('router');
2582
+ else if (step > 0 && step < 6) stages.add('design');
2583
+ else if (step === 6) stages.add('plan');
2584
+ else if (step === 7) stages.add('code');
2585
+ else if (step === 8) stages.add('qe');
2586
+ else if (step === 9) stages.add('fleet');
2587
+ }
2588
+ } else if (tier !== undefined) {
2589
+ for (const stage of CHECKPOINT_STAGES) {
2590
+ if (stage !== 'fleet' || tier === 'L' || tier === 'XL') stages.add(stage);
2591
+ }
2592
+ }
2593
+ return CHECKPOINT_STAGES.filter((stage) => stages.has(stage));
2594
+ }
2595
+
2596
+ /** All checkpoint I/O for ETA lives in this CLI-only wrapper and degrades per file. */
2597
+ function readEtaCorpus(projectRoot: string, currentSlug: string): EtaCorpusRead {
2598
+ const trace = process.env['DZ_ETA_TRACE'];
2599
+ if (trace !== undefined && trace.length > 0) {
2600
+ try {
2601
+ appendFileSync(trace, `${JSON.stringify({ projectRoot, currentSlug, ts: new Date().toISOString() })}\n`);
2602
+ } catch { /* a test/debug receipt can never take down the statusline */ }
2603
+ }
2604
+
2605
+ const samples: StageSample[] = [];
2606
+ let currentSegments: RunSegment[] = [];
2607
+ let hasCurrentCheckpoints = false;
2608
+ let entries: Dirent[] = [];
2609
+ try {
2610
+ entries = readdirSync(join(projectRoot, 'features'), { withFileTypes: true });
2611
+ } catch {
2612
+ return {
2613
+ history: [], currentRunSamples: [], currentTier: undefined, activeStages: [],
2614
+ lastCheckpointTsMs: undefined, hasCurrentCheckpoints: false,
2615
+ };
2616
+ }
2617
+
2618
+ for (const entry of entries) {
2619
+ if (!entry.isDirectory()) continue;
2620
+ const checkpointPath = join(projectRoot, 'features', entry.name, '.fa-state', 'checkpoints.jsonl');
2621
+ try {
2622
+ const text = readFileSync(checkpointPath, 'utf8');
2623
+ const observations = parseCheckpointLines(text, entry.name);
2624
+ const segments = segmentRun(observations);
2625
+ samples.push(...extractStageSamples(segments));
2626
+ if (entry.name === currentSlug) {
2627
+ hasCurrentCheckpoints = observations.length > 0;
2628
+ currentSegments = segments;
2629
+ }
2630
+ } catch {
2631
+ // Missing, unreadable, or racing append: this run contributes no evidence.
2632
+ }
2633
+ }
2634
+
2635
+ const lastSegment = currentSegments.at(-1);
2636
+ const currentRouter = lastSegment?.router;
2637
+ const invocationSegments = currentRouter === undefined
2638
+ ? []
2639
+ : currentSegments.filter((segment) => segment.router === currentRouter);
2640
+ const invocationRunIds = new Set(invocationSegments.map((segment) => segment.runId));
2641
+ const currentRunSamples = extractStageSamples(invocationSegments);
2642
+ const metadata = routerMetadata(currentRouter);
2643
+ const currentTimestamps = invocationSegments
2644
+ .flatMap((segment) => segment.observations)
2645
+ .flatMap((observation) => observation.tsMs === undefined ? [] : [observation.tsMs]);
2646
+
2647
+ return {
2648
+ history: samples.filter((sample) => !invocationRunIds.has(sample.runId)),
2649
+ currentRunSamples,
2650
+ currentTier: metadata.tier,
2651
+ activeStages: activeCheckpointStages(metadata.tier, metadata.activeSteps),
2652
+ lastCheckpointTsMs: currentTimestamps.length > 0 ? Math.max(...currentTimestamps) : undefined,
2653
+ hasCurrentCheckpoints,
2654
+ };
2655
+ }
2656
+
2657
+ function statuslineEta(projectRoot: string, state: FeatureAdrState, nowMs: number): EtaEstimate | undefined {
2658
+ const currentStage = statuslineEtaStage(state.step);
2659
+ if (currentStage === undefined) return undefined;
2660
+ const corpus = readEtaCorpus(projectRoot, state.slug);
2661
+ const currentIndex = CHECKPOINT_STAGES.indexOf(currentStage);
2662
+ const remainingStages = corpus.activeStages.filter((stage) => CHECKPOINT_STAGES.indexOf(stage) >= currentIndex && stage !== 'router');
2663
+ // Preserve honest absence as a machine-readable union member. The text formatter intentionally
2664
+ // omits both variants, but `--json` must still distinguish no file from a file with no tier.
2665
+ const stagesForEstimate = remainingStages.length > 0
2666
+ ? remainingStages
2667
+ : (currentStage === 'router' ? [] : [currentStage]);
2668
+ if (stagesForEstimate.length === 0 && corpus.hasCurrentCheckpoints && corpus.currentTier !== undefined) return undefined;
2669
+ return estimateEta({
2670
+ samples: corpus.history,
2671
+ currentTier: corpus.currentTier,
2672
+ currentStage,
2673
+ remainingStages: stagesForEstimate,
2674
+ currentRunSamples: corpus.currentRunSamples,
2675
+ nowMs,
2676
+ ...(corpus.lastCheckpointTsMs !== undefined ? { lastCheckpointTsMs: corpus.lastCheckpointTsMs } : {}),
2677
+ hasCurrentCheckpoints: corpus.hasCurrentCheckpoints,
2678
+ });
2679
+ }
2680
+
2424
2681
  /**
2425
2682
  * `dz statusline` — render dz's OWN self-learning counts as one compact, emoji-tagged line for
2426
2683
  * Claude Code's status bar (modeled on agentic-qe's "🎓 12 patterns"). Claude Code pipes a JSON
@@ -2445,9 +2702,22 @@ function cmdStatusline(
2445
2702
  try {
2446
2703
  const projectRoot = statuslineProjectRoot(readStdin(), options, cwd);
2447
2704
  const data = statuslineData(projectRoot);
2705
+ const fa = data.featureAdr;
2706
+ let eta: EtaEstimate | undefined;
2707
+ let etaFragment: string | undefined;
2708
+ if (fa !== undefined && fa.kind !== 'loop') {
2709
+ try {
2710
+ eta = statuslineEta(projectRoot, fa, Date.now());
2711
+ if (eta !== undefined) etaFragment = formatEta(eta);
2712
+ } catch {
2713
+ // ETA is advisory: any corpus/resource failure omits only this fragment.
2714
+ eta = undefined;
2715
+ etaFragment = undefined;
2716
+ }
2717
+ }
2448
2718
 
2449
2719
  if (flags.has('json')) {
2450
- write(JSON.stringify(data));
2720
+ write(JSON.stringify({ ...data, ...(eta !== undefined ? { eta } : {}) }));
2451
2721
  return 0;
2452
2722
  }
2453
2723
 
@@ -2457,13 +2727,12 @@ function cmdStatusline(
2457
2727
  if (data.consolidatedAgeH !== undefined) line += ` · ⟳ ${data.consolidatedAgeH}h`;
2458
2728
 
2459
2729
  // Live /feature-adr run in flight → PREPEND the pipeline learning segment to the base dz line.
2460
- const fa = data.featureAdr;
2461
2730
  if (fa !== undefined) {
2462
2731
  // The producer marker reached data and arbitration in a previous round but not this label, so the bar asserted a pipeline that was not running.
2463
2732
  if (fa.kind === 'loop') {
2464
2733
  line = `🔁 loop ${fa.step} · ${line}`;
2465
2734
  } else {
2466
- line = `📐 feature-adr ${fa.step} · 🎓 ${fa.pool} pool · ↑${fa.recalled} used · +${fa.stored} new · ↻${fa.reinforced ?? 0} reinforced · ${line}`;
2735
+ line = `📐 feature-adr ${fa.step} · ${etaFragment !== undefined ? `${etaFragment} · ` : ''}🎓 ${fa.pool} pool · ↑${fa.recalled} used · +${fa.stored} new · ↻${fa.reinforced ?? 0} reinforced · ${line}`;
2467
2736
  }
2468
2737
  }
2469
2738
 
@@ -3090,7 +3359,22 @@ function learningStoreLine(
3090
3359
  ) + (reason ? ' [' + reason + ']' : '');
3091
3360
  }
3092
3361
 
3093
- async function cmdTeach(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): Promise<number> {
3362
+ async function runTeachGuardReinforcement(
3363
+ projectRoot: string,
3364
+ dzId: string,
3365
+ reward: number,
3366
+ ): Promise<{ readonly flushed: number }> {
3367
+ const backend = resolveLearningBackend(projectRoot);
3368
+ backend.addSample({ dzId, kind: 'reinforce', reward, ts: new Date().toISOString() });
3369
+ return backend.train();
3370
+ }
3371
+
3372
+ async function cmdTeach(
3373
+ options: Map<string, string>, flags: Set<string>, cwd: string, write: Write,
3374
+ writeErr: WriteErr = (line) => { console.error(line); }, interactive = false,
3375
+ guardRunner: (projectRoot: string, text: string, opts: { readonly reward?: number }) => Promise<TeachGuardResult> = teachGuard,
3376
+ reinforceRunner: (projectRoot: string, dzId: string, reward: number) => Promise<{ readonly flushed: number }> = runTeachGuardReinforcement,
3377
+ ): Promise<number> {
3094
3378
  // WHICH store this lesson belongs to, and WHO decided (teach-chooses-its-store).
3095
3379
  // `--to` → `DZ_LEARN` → `.dz/config.json` learning.teachTo → project. The owner asked for a
3096
3380
  // per-session choice; for a CLI every invocation is a fresh process, so the only honest session
@@ -3174,26 +3458,39 @@ async function cmdTeach(options: Map<string, string>, flags: Set<string>, cwd: s
3174
3458
  write('dz teach --from-json: expected a JSON array (produced by `dz recall --all --json`)');
3175
3459
  return 1;
3176
3460
  }
3177
- const existing = new Set(loadStorePatternsSync(storeRoot).map((p) => p.pattern));
3461
+ const importedKey = (p: PatternRecord): string => p.lessonForm !== undefined && p.lessonPairId !== undefined
3462
+ ? `${p.pattern}\u0000${p.lessonForm}\u0000${p.lessonPairId}`
3463
+ : `legacy\u0000${p.pattern}`;
3464
+ const existing = new Set(loadStorePatternsSync(storeRoot).map(importedKey));
3178
3465
  let imported = 0;
3179
3466
  let skipped = 0;
3180
3467
  const importedRecs: PatternRecord[] = [];
3181
3468
  for (const item of parsed) {
3182
3469
  const p = item as Partial<PatternRecord>;
3183
- if (!p || typeof p.pattern !== 'string' || p.pattern.trim() === '' || existing.has(p.pattern)) {
3470
+ if (!p || typeof p !== 'object') {
3184
3471
  skipped += 1;
3185
3472
  continue;
3186
3473
  }
3187
- const rec: PatternRecord = {
3188
- pattern: p.pattern,
3474
+ const pair = (p.lessonForm === 'specific' || p.lessonForm === 'class')
3475
+ && typeof p.lessonPairId === 'string' && p.lessonPairId !== ''
3476
+ ? { lessonForm: p.lessonForm, lessonPairId: p.lessonPairId }
3477
+ : {};
3478
+ const candidate = {
3479
+ pattern: typeof p.pattern === 'string' ? p.pattern : '',
3189
3480
  type: (typeof p.type === 'string' ? p.type : 'lesson-learned') as PatternRecord['type'],
3190
3481
  reward: typeof p.reward === 'number' ? Math.max(0, Math.min(1, p.reward)) : 0.8,
3191
3482
  domain: typeof p.domain === 'string' ? p.domain : 'general',
3192
3483
  ts: typeof p.ts === 'string' ? p.ts : new Date().toISOString(),
3193
3484
  source: 'dz-teach-import',
3194
- };
3485
+ ...pair,
3486
+ } satisfies PatternRecord;
3487
+ if (candidate.pattern.trim() === '' || existing.has(importedKey(candidate))) {
3488
+ skipped += 1;
3489
+ continue;
3490
+ }
3491
+ const rec: PatternRecord = candidate;
3195
3492
  await recordPattern(storeRoot, rec);
3196
- existing.add(p.pattern);
3493
+ existing.add(importedKey(rec));
3197
3494
  importedRecs.push(rec);
3198
3495
  imported += 1;
3199
3496
  }
@@ -3277,23 +3574,30 @@ async function cmdTeach(options: Map<string, string>, flags: Set<string>, cwd: s
3277
3574
 
3278
3575
  const reward = parseFloat(options.get('reward') ?? '0.8');
3279
3576
  const domain = options.get('domain') ?? 'general';
3577
+ const classWasRequested = options.has('class-form') || flags.has('class-form');
3578
+ const lessonForms = normalizeLessonForms(pattern, options.get('class-form'));
3579
+ let guardedExisting: { readonly dzId: string; readonly cosine: number; readonly pattern: PatternRecord } | undefined;
3280
3580
 
3281
3581
  if (flags.has('guard')) {
3282
- const verdict = await teachGuard(storeRoot, pattern, { reward: Math.max(0, Math.min(1, reward)) });
3582
+ const verdict = await guardRunner(storeRoot, pattern, { reward: Math.max(0, Math.min(1, reward)) });
3283
3583
  if (verdict.action === 'reinforce') {
3284
- const backend = resolveLearningBackend(storeRoot);
3285
- backend.addSample({ dzId: verdict.dzId, kind: 'reinforce', reward: Math.max(0, Math.min(1, reward)), ts: new Date().toISOString() });
3286
- const trained = await backend.train();
3287
- // HIGH-fix: only claim success when the reinforce actually FLUSHED. With backend 'off'
3288
- // (NoopLearningBackend) or a flush failure, flushed === 0 — falling through to the plain
3289
- // teach below so the lesson is NEVER silently discarded (the exact silent-drop the ADR forbids).
3290
- if (trained.flushed > 0) {
3291
- write(`↳ reinforced existing pattern ${verdict.dzId} (cos=${verdict.cosine.toFixed(2)}) — not re-added`);
3292
- const clearedQ = clearAgentdbQuarantine(storeRoot, [verdict.dzId]);
3293
- if (clearedQ.cleared > 0) write(' ↳ promoted out of quarantine (mirror updated)');
3294
- return 0;
3584
+ if (lessonForms.classForm !== undefined) {
3585
+ const existing = loadStoreRecords(storeRoot).find((record) => record.id === verdict.dzId);
3586
+ if (existing !== undefined) {
3587
+ guardedExisting = { dzId: verdict.dzId, cosine: verdict.cosine, pattern: recordToPattern(existing) };
3588
+ } else {
3589
+ write(`dz teach --guard: matched pattern ${verdict.dzId} was not found in the lexical store — teaching the lesson normally`);
3590
+ }
3591
+ } else {
3592
+ const trained = await reinforceRunner(storeRoot, verdict.dzId, Math.max(0, Math.min(1, reward)));
3593
+ if (trained.flushed > 0) {
3594
+ write(`↳ reinforced existing pattern ${verdict.dzId} (cos=${verdict.cosine.toFixed(2)}) — not re-added`);
3595
+ const clearedQ = clearAgentdbQuarantine(storeRoot, [verdict.dzId]);
3596
+ if (clearedQ.cleared > 0) write(' ↳ promoted out of quarantine (mirror updated)');
3597
+ return 0;
3598
+ }
3599
+ write(`dz teach --guard: reinforce of ${verdict.dzId} did not flush (backend off or write failure) — teaching the lesson normally instead`);
3295
3600
  }
3296
- write(`dz teach --guard: reinforce of ${verdict.dzId} did not flush (backend off or write failure) — teaching the lesson normally instead`);
3297
3601
  }
3298
3602
  }
3299
3603
 
@@ -3308,24 +3612,63 @@ async function cmdTeach(options: Map<string, string>, flags: Set<string>, cwd: s
3308
3612
  // Typed through the shared schema (harness-core owns PatternRecord) so the
3309
3613
  // write side and the read side (recommend's loadPatterns) can never drift —
3310
3614
  // a field rename here is a compile error, not a silently re-muted loop (audit #2).
3311
- const entry: PatternRecord = {
3312
- pattern,
3313
- type: type as PatternRecord['type'],
3314
- reward: Math.max(0, Math.min(1, reward)),
3315
- domain,
3316
- ts: new Date().toISOString(),
3317
- source: 'dz-teach',
3318
- };
3615
+ const entry: PatternRecord = guardedExisting?.pattern ?? {
3616
+ pattern: lessonForms.specific,
3617
+ type: type as PatternRecord['type'],
3618
+ reward: Math.max(0, Math.min(1, reward)),
3619
+ domain,
3620
+ ts: new Date().toISOString(),
3621
+ source: 'dz-teach',
3622
+ };
3319
3623
 
3320
3624
  // Tier-2 (ADR-005): persist through the unified @dzhechkov/memory store. recordPattern
3321
3625
  // folds any legacy .dz/patterns.jsonl into the backend (idempotent) and returns the
3322
3626
  // total count. The lossy `npx agentdb add` dual-write was removed in Tier-1 (audit #6).
3323
3627
  // lesson-quarantine (opt-in): a fresh lesson is a HYPOTHESIS until it earns promotion.
3324
3628
  const quarantineOn = readMemoryLearningConfig(storeRoot).quarantine;
3325
- const count = await recordPattern(storeRoot, entry, quarantineOn ? { quarantine: true } : {});
3629
+ const stored = await recordLessonForms(
3630
+ storeRoot,
3631
+ entry,
3632
+ lessonForms.classForm,
3633
+ quarantineOn ? { quarantine: true } : {},
3634
+ );
3635
+ let recordsToMirror = stored.records;
3636
+ let commandFailed = stored.class === 'failed';
3637
+ let reinforced = false;
3638
+ if (guardedExisting !== undefined) {
3639
+ let reinforceId = guardedExisting.dzId;
3640
+ if (stored.class === 'stored') {
3641
+ const specificRow = stored.records.find((row) => row.lessonForm === 'specific');
3642
+ if (specificRow !== undefined) reinforceId = patternRecordId(specificRow);
3643
+ if (reinforceId !== guardedExisting.dzId) {
3644
+ const removed = removePatternsByIds(storeRoot, new Set([guardedExisting.dzId]));
3645
+ if (removed.error !== undefined || removed.removed === 0) {
3646
+ commandFailed = true;
3647
+ writeErr(`dz teach --guard: class pair stored, but old pattern cleanup failed${removed.error === undefined ? '' : ` — ${removed.error}`}`);
3648
+ }
3649
+ }
3650
+ } else if (stored.class === 'failed') {
3651
+ const partialSpecific = stored.records.find((row) => row.lessonForm === 'specific');
3652
+ if (partialSpecific !== undefined && patternRecordId(partialSpecific) !== guardedExisting.dzId) {
3653
+ const rolledBack = removePatternsByIds(storeRoot, new Set([patternRecordId(partialSpecific)]));
3654
+ if (rolledBack.error !== undefined) writeErr(`dz teach --guard: partial enrichment rollback failed — ${rolledBack.error}`);
3655
+ }
3656
+ recordsToMirror = [];
3657
+ reinforceId = guardedExisting.dzId;
3658
+ }
3659
+ if (!commandFailed) {
3660
+ const trained = await reinforceRunner(storeRoot, reinforceId, Math.max(0, Math.min(1, reward)));
3661
+ reinforced = trained.flushed > 0;
3662
+ if (reinforced) {
3663
+ const clearedQ = clearAgentdbQuarantine(storeRoot, [reinforceId]);
3664
+ if (clearedQ.cleared > 0) write(' ↳ promoted out of quarantine (mirror updated)');
3665
+ }
3666
+ }
3667
+ }
3668
+ const count = guardedExisting === undefined ? stored.count : loadStorePatternsSync(storeRoot).length;
3326
3669
 
3327
- write(`Learned: "${pattern.slice(0, 60)}${pattern.length > 60 ? '...' : ''}"`);
3328
- write(` Domain: ${domain} Reward: ${reward} Backend: memory (@dzhechkov/memory)`);
3670
+ write(`${guardedExisting !== undefined && stored.class === 'stored' ? 'Enriched' : 'Learned'}: "${pattern.slice(0, 60)}${pattern.length > 60 ? '...' : ''}"`);
3671
+ write(` Domain: ${entry.domain} Reward: ${entry.reward} Backend: memory (@dzhechkov/memory)`);
3329
3672
  write(` Total patterns: ${count}`);
3330
3673
  // WHERE the write landed. MEASURED before this line existed: teach printed the pattern, the
3331
3674
  // domain, the reward and the backend — and not one word about the path, so a store written to
@@ -3335,6 +3678,20 @@ async function cmdTeach(options: Map<string, string>, flags: Set<string>, cwd: s
3335
3678
  // sees a path, cannot tell what chose it, and has no reason to question it. `default` adds
3336
3679
  // nothing, so the line stays byte-identical for everyone who set nothing.
3337
3680
  write(storeLine('written'));
3681
+ if (stored.class === 'stored') write(' Class form: stored separately and linked to the specific lesson');
3682
+ if (classWasRequested && stored.class === 'absent') write(' Class form: skipped; the specific lesson was saved');
3683
+ if (stored.class === 'rejected') writeErr(`dz teach: class form rejected — ${stored.reason ?? 'invalid class form'}; the specific lesson was saved`);
3684
+ if (stored.class === 'failed') writeErr(`dz teach: specific stored; class failed — ${stored.reason ?? 'unknown storage failure'}`);
3685
+ if (guardedExisting !== undefined && stored.class === 'stored') {
3686
+ write(commandFailed
3687
+ ? ' Guard: class enrichment stored; reinforcement skipped because old-pattern cleanup failed'
3688
+ : reinforced
3689
+ ? ` Guard: enriched existing pattern (cos=${guardedExisting.cosine.toFixed(2)}), then reinforced the linked specific form`
3690
+ : ' Guard: class enrichment stored; reinforcement did not flush');
3691
+ }
3692
+ if (!classWasRequested && interactive) {
3693
+ 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.');
3694
+ }
3338
3695
  // ADVICE, not a gate. Someone putting medical lessons in a shared store owns both
3339
3696
  // directories and this binary; refusing would be defending a user against themselves,
3340
3697
  // which this design does not attempt. Making the choice INFORMED is the part that is
@@ -3349,8 +3706,8 @@ async function cmdTeach(options: Map<string, string>, flags: Set<string>, cwd: s
3349
3706
  write(' ⚠ quarantined: excluded from auto-inject, damped in recall — promote by confirming it (dz teach --reinforce "<text>") or dz recall --promote <dzId> --apply');
3350
3707
  }
3351
3708
  // The lexical write above is durable — the vector mirror is strictly best-effort (I-3).
3352
- await emitMirrorQ(storeRoot, [entry], 'dz-teach', quarantineOn);
3353
- return 0;
3709
+ await emitMirrorQ(storeRoot, recordsToMirror, 'dz-teach', quarantineOn);
3710
+ return commandFailed ? 1 : 0;
3354
3711
  }
3355
3712
 
3356
3713
  async function cmdConsolidate(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): Promise<number> {
@@ -3702,7 +4059,14 @@ async function cmdRecallPromote(
3702
4059
  return 0;
3703
4060
  }
3704
4061
 
3705
- async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): Promise<number> {
4062
+ async function cmdRecall(
4063
+ options: Map<string, string>,
4064
+ flags: Set<string>,
4065
+ cwd: string,
4066
+ write: Write,
4067
+ writeErr: WriteErr,
4068
+ classMatcher?: RecallPatternsOptions['classMatcher'],
4069
+ ): Promise<number> {
3706
4070
  const projectRoot = resolve(cwd, options.get('project') ?? '.');
3707
4071
  const asJson = flags.has('json');
3708
4072
  const all = flags.has('all');
@@ -3721,7 +4085,17 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
3721
4085
  // review could not make correct: a tag set by the writer is decidable, prose is not.
3722
4086
  const allPatterns = loadStorePatternsSync(projectRoot);
3723
4087
  const holdout = applyExportHoldout(allPatterns, heldOutAfterOptIn(options.get('include-domain')));
3724
- const patterns = holdout.exported;
4088
+ const classByPair = new Map(
4089
+ holdout.exported
4090
+ .filter((row) => row.lessonForm === 'class' && row.lessonPairId !== undefined)
4091
+ .map((row) => [row.lessonPairId as string, row.pattern]),
4092
+ );
4093
+ const patterns = holdout.exported.map((row) => {
4094
+ const classForm = row.lessonForm === 'specific' && row.lessonPairId !== undefined
4095
+ ? classByPair.get(row.lessonPairId)
4096
+ : undefined;
4097
+ return classForm === undefined ? row : { ...row, classForm };
4098
+ });
3725
4099
  const holdoutNote = renderHoldoutNote(holdout);
3726
4100
  // The opt-in is honoured without argument — and named out loud. A flag that silently
3727
4101
  // includes medical lessons in a portable export is a flag whose consequence the user
@@ -3755,7 +4129,15 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
3755
4129
  const perDomain = Object.fromEntries(
3756
4130
  Object.entries(rawStats.perDomain).filter(([d]) => !holdout.domains.includes(canonicalDomainKey(d))),
3757
4131
  );
3758
- const stats = { ...rawStats, topUses: topHoldout.exported, perDomain };
4132
+ const generalizedPairs = new Set(patterns.flatMap((p) => p.lessonPairId === undefined ? [] : [p.lessonPairId]));
4133
+ const unpairedLessons = patterns.filter((p) => p.lessonPairId === undefined).length;
4134
+ const stats = {
4135
+ ...rawStats,
4136
+ topUses: topHoldout.exported,
4137
+ perDomain,
4138
+ generalized: generalizedPairs.size,
4139
+ logicalLessons: generalizedPairs.size + unpairedLessons,
4140
+ };
3759
4141
  const backendStats = resolveLearningBackend(projectRoot).getStats();
3760
4142
  if (asJson) {
3761
4143
  write(JSON.stringify({ patterns, stats, learning: backendStats, withheld: holdout.withheld.length, withheldDomains: holdout.domains }));
@@ -3767,6 +4149,7 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
3767
4149
  write(` backend: ${backendStats.backend}${backendStats.advisory !== undefined ? ` (${backendStats.advisory})` : ''}`);
3768
4150
  write(` domains: ${Object.entries(stats.perDomain).map(([k, v]) => `${k}=${v}`).join(', ') || 'none'}`);
3769
4151
  write(` exact-dup groups: ${stats.exactDupGroups}`);
4152
+ write(` generalized: ${stats.generalized} of ${stats.logicalLessons} lessons`);
3770
4153
  write(` re-teach trend: ${stats.teachEvents} teach event(s), ${stats.reinforceEvents} reinforce event(s)`);
3771
4154
  write(' top uses:');
3772
4155
  for (const row of stats.topUses) write(` ${row.uses}× [${row.reward.toFixed(2)}] (${row.domain}) ${row.pattern.slice(0, 80)}`);
@@ -3887,6 +4270,10 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
3887
4270
  ? ('lexical' as const)
3888
4271
  : ('hybrid' as const);
3889
4272
  const wantedDomain = options.get('domain');
4273
+ const classRecallOptions = {
4274
+ onClassDegraded: writeErr,
4275
+ ...(classMatcher === undefined ? {} : { classMatcher }),
4276
+ };
3890
4277
  // OVER-FETCH before boosting (Codex QE #5): the boost used to run on hits ALREADY
3891
4278
  // truncated to `limit`, so an exact-domain lesson sitting at rank limit+1 could
3892
4279
  // never receive its promised lift — the feature was weakest in exactly the case it
@@ -3909,8 +4296,8 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
3909
4296
  // second occurrence. Foreign stdout is routed to stderr for the duration of the engine call — our
3910
4297
  // own output is written after it returns.
3911
4298
  const result = asJson
3912
- ? await withForeignStdoutOnStderr(() => recallHybrid(projectRoot, query, { limit: fetchLimit, mode, deferExposures: true, ...(wantedDomain !== undefined ? { domain: wantedDomain } : {}) }))
3913
- : await recallHybrid(projectRoot, query, { limit: fetchLimit, mode, deferExposures: true, ...(wantedDomain !== undefined ? { domain: wantedDomain } : {}) });
4299
+ ? await withForeignStdoutOnStderr(() => recallHybrid(projectRoot, query, { limit: fetchLimit, mode, deferExposures: true, ...classRecallOptions, ...(wantedDomain !== undefined ? { domain: wantedDomain } : {}) }))
4300
+ : await recallHybrid(projectRoot, query, { limit: fetchLimit, mode, deferExposures: true, ...classRecallOptions, ...(wantedDomain !== undefined ? { domain: wantedDomain } : {}) });
3914
4301
 
3915
4302
  if (mode === 'semantic' && result.vectorEngine === 'none') {
3916
4303
  // --semantic is an explicit ask — degrading it silently would be dishonest (FR-3).
@@ -3946,13 +4333,26 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
3946
4333
  let globalHits: typeof result.hits = [];
3947
4334
  if (readGlobal) {
3948
4335
  const g = asJson
3949
- ? await withForeignStdoutOnStderr(() => recallHybrid(globalRoot, query, { limit: fetchLimit, mode, deferExposures: true, ...(wantedDomain !== undefined ? { domain: wantedDomain } : {}) }))
3950
- : await recallHybrid(globalRoot, query, { limit: fetchLimit, mode, deferExposures: true, ...(wantedDomain !== undefined ? { domain: wantedDomain } : {}) });
4336
+ ? await withForeignStdoutOnStderr(() => recallHybrid(globalRoot, query, { limit: fetchLimit, mode, deferExposures: true, ...classRecallOptions, ...(wantedDomain !== undefined ? { domain: wantedDomain } : {}) }))
4337
+ : await recallHybrid(globalRoot, query, { limit: fetchLimit, mode, deferExposures: true, ...classRecallOptions, ...(wantedDomain !== undefined ? { domain: wantedDomain } : {}) });
3951
4338
  globalHits = g.hits;
3952
4339
  }
4340
+ const projectHits = boost ? boost.hits : result.hits;
4341
+ // TWO different keys on purpose, and they must not be unified. The cross-store MERGE key is the
4342
+ // lesson TEXT: the same lesson taught into the project store and the global store is one lesson
4343
+ // shown once, labelled `both`, and those two records legitimately differ in timestamp, reward and
4344
+ // domain. The matchedForm LOOKUP key may be narrower. Folding the merge key into
4345
+ // patternIdentityOf (which includes ts) made two `dz teach` calls of identical text stop
4346
+ // collapsing — it reddened the pre-existing P3 case in recall-reads-both-stores.test.ts.
4347
+ const mergeKey = (hit: (typeof projectHits)[number]): string => hit.pattern.pattern;
4348
+ const globalByIdentity = new Map(globalHits.map((hit) => [mergeKey(hit), hit]));
3953
4349
  const merged = readGlobal
3954
- ? mergeStoreHits(boost ? boost.hits : result.hits, globalHits, (h) => h.pattern.pattern)
3955
- : (boost ? boost.hits : result.hits);
4350
+ ? mergeStoreHits(projectHits, globalHits, mergeKey).map((hit) => {
4351
+ if (hit.origin !== 'both') return hit;
4352
+ const matchedForm = mergeLessonMatchedForms(hit.matchedForm, globalByIdentity.get(mergeKey(hit))?.matchedForm);
4353
+ return matchedForm === undefined ? hit : { ...hit, matchedForm };
4354
+ })
4355
+ : projectHits;
3956
4356
  const hits = merged.slice(0, limit);
3957
4357
  // Computed ONCE, honoured by EVERY return path. It used to live only on the text tail, so the two
3958
4358
  // paths that return earlier — `--json` and the zero-hits branch — still reported success. That
@@ -4000,6 +4400,7 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
4000
4400
  // version emitted the number while its own comment promised null (found by independent review).
4001
4401
  write(JSON.stringify(hits.map((h) => ({
4002
4402
  ...h.pattern,
4403
+ ...(h.matchedForm === undefined ? {} : { matchedForm: h.matchedForm }),
4003
4404
  relevance: boost === null && 'score' in h && typeof h.score === 'number' ? h.score : null,
4004
4405
  // The TRUE cosine, as a second companion key — the design named it and the first ship missed
4005
4406
  // it, so a scripted consumer STILL could not threshold (found while recalibrating the floors:
@@ -4064,6 +4465,10 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
4064
4465
  // guessing which second one answered.
4065
4466
  write(` store (read): ${join(globalRoot, '.dz')} [cross-project]`);
4066
4467
  }
4468
+ const classMatches = hits.filter((h) => h.matchedForm === 'class' || h.matchedForm === 'both').length;
4469
+ if (hits.some((h) => h.pattern.classForm !== undefined)) {
4470
+ write(` class-form matches: ${classMatches} of ${hits.length}`);
4471
+ }
4067
4472
  let sawQuarantined = false;
4068
4473
  for (const h of hits) {
4069
4474
  const backendTag = vectorOn ? ` ⟨${h.backend}⟩` : '';
@@ -4088,9 +4493,21 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
4088
4493
  // 160, not 80: at 80 characters the evidence a reader needs to judge relevance sits in the
4089
4494
  // hidden remainder, and the cosine then appears to describe the visible fragment rather than
4090
4495
  // the whole lesson. `--full` prints it all, still on one line.
4091
- const width = flags.has('full') ? Number.POSITIVE_INFINITY : 160;
4092
- const oneLined = oneLine(h.pattern.pattern);
4093
- const shown = width === Number.POSITIVE_INFINITY ? oneLined : oneLined.slice(0, width);
4496
+ const specificText = oneLine(h.pattern.pattern);
4497
+ const classText = h.pattern.classForm === undefined ? undefined : oneLine(h.pattern.classForm);
4498
+ let shown = specificText;
4499
+ if (classText !== undefined) {
4500
+ const suffix = ` [match: ${h.matchedForm ?? 'specific'}]`;
4501
+ if (flags.has('full')) {
4502
+ shown = `specific: ${specificText} · class: ${classText}${suffix}`;
4503
+ } else {
4504
+ const contentWidth = Math.max(2, 160 - 'specific: '.length - ' · class: '.length - suffix.length);
4505
+ const specificWidth = Math.floor(contentWidth / 2);
4506
+ shown = `specific: ${specificText.slice(0, specificWidth)} · class: ${classText.slice(0, contentWidth - specificWidth)}${suffix}`;
4507
+ }
4508
+ } else if (!flags.has('full')) {
4509
+ shown = shown.slice(0, 160);
4510
+ }
4094
4511
  // WHICH store this hit came from. A merged list that does not say re-creates the fragmentation
4095
4512
  // blindness the store-location line just removed, one level down: the reader would see more
4096
4513
  // results and have no way to tell whether the global store is even connected.
@@ -5094,7 +5511,13 @@ async function cmdSetup(options: Map<string, string>, flags: Set<string>, cwd: s
5094
5511
  ? selectArg.split(',').map((s) => s.trim()).filter((s) => s.length > 0)
5095
5512
  : getPreset(preset)?.skills;
5096
5513
  const install = select !== undefined && select.length > 0
5097
- ? await installSkills({ target, projectRoot, cwd, explicitSkillsDir: options.get('skills-dir'), select, force: flags.has('force'), enrich: flags.has('enrich') })
5514
+ ? await installSkills({
5515
+ target, projectRoot, cwd, explicitSkillsDir: options.get('skills-dir'), select,
5516
+ force: flags.has('force'), enrich: flags.has('enrich'), noHooks: flags.has('no-hooks'),
5517
+ noIntegrations: flags.has('no-integrations'),
5518
+ noVerify: flags.has('no-verify'),
5519
+ ...(options.get('allow-integrations') !== undefined ? { allowIntegrations: options.get('allow-integrations')! } : {}),
5520
+ })
5098
5521
  : undefined;
5099
5522
  if (install) {
5100
5523
  write(`║ ${String(install.results.length).padStart(2)} skill(s), ${String(install.written).padStart(3)} file(s) written${' '.repeat(15)}║`);
@@ -5104,6 +5527,7 @@ async function cmdSetup(options: Map<string, string>, flags: Set<string>, cwd: s
5104
5527
 
5105
5528
  // Step 5 (ADR-001 §8): DELIVER the codex hooks and verify them live. Non-aborting — the rest of
5106
5529
  // setup has already run and the summary still prints; only the exit code carries the failure.
5530
+ let setupIntegrationOutcomes: readonly IntegrationOutcome[] = install?.integrations ?? [];
5107
5531
  let codexHooksOk = true;
5108
5532
  if (target === 'codex' && !flags.has('no-hooks')) {
5109
5533
  write(`║ 5. Delivering codex hooks (live verify)... ║`);
@@ -5111,6 +5535,11 @@ async function cmdSetup(options: Map<string, string>, flags: Set<string>, cwd: s
5111
5535
  codexHooksOk = delivery.ok;
5112
5536
  for (const line of delivery.stdout) write(line);
5113
5537
  for (const line of delivery.stderr) writeErr(line);
5538
+ const hookIndex = setupIntegrationOutcomes.findIndex((row) => row.component === 'hooks' && row.status !== 'not-requested');
5539
+ if (hookIndex !== -1) {
5540
+ const hook = normalizeCodexHookOutcome(setupIntegrationOutcomes[hookIndex]!, delivery, flags.has('no-verify'));
5541
+ setupIntegrationOutcomes = setupIntegrationOutcomes.map((row, index) => index === hookIndex ? hook : row);
5542
+ }
5114
5543
  }
5115
5544
 
5116
5545
  write(`╠══════════════════════════════════════════════════════╣`);
@@ -5130,6 +5559,9 @@ async function cmdSetup(options: Map<string, string>, flags: Set<string>, cwd: s
5130
5559
  if (install) {
5131
5560
  write(`dz setup: ${install.results.length} skill(s), ${install.written} file(s) written, ${install.skipped} skipped`
5132
5561
  + (install.dirsSearched > 1 ? ` (searched ${install.dirsSearched} skill dirs)` : ''));
5562
+ for (const outcome of setupIntegrationOutcomes) {
5563
+ write(`dz setup integration ${outcome.component}: ${outcome.status.toUpperCase()}${outcome.reasonCode ? ` (${outcome.reasonCode})` : ''}`);
5564
+ }
5133
5565
  writeMissingSkillsHint(write, install.missing, selectArg !== undefined ? undefined : preset);
5134
5566
  }
5135
5567
  // A hook that was written but never witnessed firing is NOT a completed setup (ADR-002 §5): the
@@ -5144,7 +5576,8 @@ async function cmdSetup(options: Map<string, string>, flags: Set<string>, cwd: s
5144
5576
  if (erroredSteps.length > 0) {
5145
5577
  write(`\n✗ setup reported ${erroredSteps.length} failed step(s): ${erroredSteps.join(', ')} — exit 1`);
5146
5578
  }
5147
- return codexHooksOk && erroredSteps.length === 0 ? 0 : 1;
5579
+ const integrationsOk = !setupIntegrationOutcomes.some((row) => row.status === 'refused');
5580
+ return codexHooksOk && erroredSteps.length === 0 && integrationsOk ? 0 : 1;
5148
5581
  }
5149
5582
 
5150
5583
  function cmdPretrain(options: Map<string, string>, cwd: string, write: Write): number {
@@ -5802,6 +6235,9 @@ function cmdPublish(options: Map<string, string>, flags: Set<string>, cwd: strin
5802
6235
  if (guardResult.verdict === 'block') write(`dz publish: ⚠ guard BLOCK overridden via --no-guard: ${noGuard} (logged)`);
5803
6236
  else if (guardResult.verdict === 'warn') for (const v of guardResult.violations) write(`dz publish: ⚠ guard warn — ${v.rule}: ${v.detail}`);
5804
6237
  else write('dz publish: ✓ guard pre-flight passed');
6238
+ for (const observation of guardResult.observations ?? []) {
6239
+ write(`dz publish: ℹ guard observation — ${observation.rule} ${observation.scope}: ${observation.detail} [${observation.status}]`);
6240
+ }
5805
6241
  for (const n of guardResult.notes ?? []) write(`dz publish: ℹ guard note — ${n}`); // FN-7: on the record, never blocking
5806
6242
  }
5807
6243
 
@@ -7501,6 +7937,31 @@ export interface CodexHooksSummary {
7501
7937
  readonly stderr: readonly string[];
7502
7938
  }
7503
7939
 
7940
+ /** Map the retained Codex hook writer's one live verdict into the common integration contract. */
7941
+ export function normalizeCodexHookOutcome(
7942
+ base: IntegrationOutcome,
7943
+ delivery: CodexHooksSummary & { readonly report: CodexHooksSyncReport },
7944
+ noVerify = false,
7945
+ ): IntegrationOutcome {
7946
+ if (delivery.report.ready) {
7947
+ return {
7948
+ target: 'codex', component: 'hooks', status: 'emitted',
7949
+ registrations: [{ id: 'dz-codex-hooks', scope: 'user', registered: delivery.report.installed, approval: delivery.report.trust === 'trusted' ? 'approved' : 'unknown', ready: true }],
7950
+ carrier: { scope: 'user', path: '$CODEX_HOME/hooks.json' },
7951
+ ...(delivery.report.codexVersion !== null ? { runtimeVersion: delivery.report.codexVersion } : {}),
7952
+ evidenceVersion: 'codex-hooks-live-v1',
7953
+ };
7954
+ }
7955
+ return {
7956
+ ...base,
7957
+ target: 'codex', component: 'hooks', status: 'refused',
7958
+ registrations: [{ id: 'dz-codex-hooks', scope: 'user', registered: delivery.report.installed, approval: delivery.report.trust === 'trusted' ? 'approved' : 'unknown', ready: false }],
7959
+ reasonCode: 'CURRENT_LIVE_CHECK_FAILED',
7960
+ remediation: noVerify ? '--no-verify cannot establish ready; rerun with live verification' : 'approve the managed hooks and rerun the live verification',
7961
+ applied: delivery.report.written || delivery.report.installed,
7962
+ };
7963
+ }
7964
+
7504
7965
  /**
7505
7966
  * What the user is told about a sync report — the ONE place the success word can be printed.
7506
7967
  *
@@ -7829,10 +8290,480 @@ function gatherReadmeCounts(root: string): { label: string; a: number; b: number
7829
8290
  return pairs;
7830
8291
  }
7831
8292
 
8293
+ const MAX_VOLUME_FILES_PER_SCOPE = 512;
8294
+ const MAX_VOLUME_BYTES_PER_SCOPE = 32 * 1024 * 1024;
8295
+ const CYRILLIC_CHARACTER = /\p{Script=Cyrillic}/u;
8296
+
8297
+ function volumePathInside(root: string, candidate: string): boolean {
8298
+ return candidate === root || candidate.startsWith(root + sep);
8299
+ }
8300
+
8301
+ function cyrillicUtf8Bytes(buffer: Buffer): number {
8302
+ let bytes = 0;
8303
+ for (const character of buffer.toString('utf8')) {
8304
+ if (CYRILLIC_CHARACTER.test(character)) bytes += Buffer.byteLength(character, 'utf8');
8305
+ }
8306
+ return bytes;
8307
+ }
8308
+
8309
+ function gatherTemplateVolumeTarget(
8310
+ packageDir: string,
8311
+ target: string,
8312
+ ): TemplateVolumeTargetFact | undefined {
8313
+ const templateRoot = join(packageDir, 'templates', '.claude');
8314
+ if (!existsSync(templateRoot)) return undefined;
8315
+ const files: TemplateVolumeFileFact[] = [];
8316
+ const realFiles = new Set<string>();
8317
+ let totalBytes = 0;
8318
+ let failure: { reason: string; detail: string } | undefined;
8319
+ let packageReal: string;
8320
+ let templateReal: string;
8321
+ try {
8322
+ packageReal = realpathSync(packageDir);
8323
+ templateReal = realpathSync(templateRoot);
8324
+ if (!volumePathInside(packageReal, templateReal)) {
8325
+ failure = { reason: 'template-root-escape', detail: templateRoot };
8326
+ }
8327
+ } catch (error) {
8328
+ return {
8329
+ target,
8330
+ files,
8331
+ collection: { complete: false, reason: 'template-root-unreadable', detail: error instanceof Error ? error.message : String(error) },
8332
+ };
8333
+ }
8334
+
8335
+ const fail = (reason: string, detail: string): void => {
8336
+ failure ??= { reason, detail: detail.replace(/\s+/g, ' ').slice(0, 240) };
8337
+ };
8338
+ const addFile = (absolute: string, kind: 'rules' | 'commands' | 'skills'): void => {
8339
+ if (failure !== undefined) return;
8340
+ if (files.length >= MAX_VOLUME_FILES_PER_SCOPE) {
8341
+ fail('template-file-cap-exceeded', `${MAX_VOLUME_FILES_PER_SCOPE} files`);
8342
+ return;
8343
+ }
8344
+ try {
8345
+ const stat = lstatSync(absolute);
8346
+ if (!stat.isFile()) { fail('template-input-not-regular', absolute); return; }
8347
+ const real = realpathSync(absolute);
8348
+ if (!volumePathInside(templateReal, real)) { fail('template-input-escape', absolute); return; }
8349
+ if (realFiles.has(real)) { fail('template-input-duplicate', absolute); return; }
8350
+ if (stat.size <= 0 || stat.size > MAX_VOLUME_BYTES_PER_SCOPE - totalBytes) {
8351
+ fail(stat.size <= 0 ? 'template-input-empty' : 'template-byte-cap-exceeded', absolute);
8352
+ return;
8353
+ }
8354
+ const content = readFileSync(absolute);
8355
+ if (content.byteLength <= 0 || content.byteLength > MAX_VOLUME_BYTES_PER_SCOPE - totalBytes) {
8356
+ fail(content.byteLength <= 0 ? 'template-input-empty' : 'template-byte-cap-exceeded', absolute);
8357
+ return;
8358
+ }
8359
+ realFiles.add(real);
8360
+ totalBytes += content.byteLength;
8361
+ files.push({
8362
+ path: relative(packageDir, absolute).split(sep).join('/'),
8363
+ kind,
8364
+ bytes: content.byteLength,
8365
+ cyrillicUtf8Bytes: cyrillicUtf8Bytes(content),
8366
+ });
8367
+ } catch (error) {
8368
+ fail('template-input-unreadable', `${absolute}: ${error instanceof Error ? error.message : String(error)}`);
8369
+ }
8370
+ };
8371
+ const scanFlat = (name: 'rules' | 'commands'): void => {
8372
+ const dir = join(templateRoot, name);
8373
+ if (!existsSync(dir) || failure !== undefined) return;
8374
+ try {
8375
+ for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
8376
+ if (!entry.name.endsWith('.md')) continue;
8377
+ addFile(join(dir, entry.name), name);
8378
+ }
8379
+ } catch (error) {
8380
+ fail('template-directory-unreadable', `${dir}: ${error instanceof Error ? error.message : String(error)}`);
8381
+ }
8382
+ };
8383
+ const scanSkills = (dir: string): void => {
8384
+ if (!existsSync(dir) || failure !== undefined) return;
8385
+ let entries: Dirent[];
8386
+ try {
8387
+ entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
8388
+ } catch (error) {
8389
+ fail('template-directory-unreadable', `${dir}: ${error instanceof Error ? error.message : String(error)}`);
8390
+ return;
8391
+ }
8392
+ for (const entry of entries) {
8393
+ if (failure !== undefined) return;
8394
+ const absolute = join(dir, entry.name);
8395
+ if (entry.isSymbolicLink()) { fail('template-input-not-regular', absolute); return; }
8396
+ if (entry.isDirectory()) scanSkills(absolute);
8397
+ else if (entry.name === 'SKILL.md') addFile(absolute, 'skills');
8398
+ }
8399
+ };
8400
+
8401
+ scanFlat('rules');
8402
+ scanFlat('commands');
8403
+ scanSkills(join(templateRoot, 'skills'));
8404
+ files.sort((a, b) => a.path.localeCompare(b.path));
8405
+ return {
8406
+ target,
8407
+ files,
8408
+ collection: failure === undefined
8409
+ ? { complete: true }
8410
+ : { complete: false, reason: failure.reason, detail: failure.detail },
8411
+ };
8412
+ }
8413
+
8414
+ function volumeGitText(root: string, args: readonly string[], allowedStatuses: readonly number[] = [0]): string {
8415
+ const result = spawnSync('git', [...args], {
8416
+ cwd: root,
8417
+ encoding: 'utf8',
8418
+ maxBuffer: 32 * 1024 * 1024,
8419
+ });
8420
+ if (result.status === null || !allowedStatuses.includes(result.status)) {
8421
+ throw result.error ?? new Error(`git ${args[0] ?? ''} exited ${String(result.status)}`);
8422
+ }
8423
+ return result.stdout ?? '';
8424
+ }
8425
+
8426
+ function parseGuardStatusPaths(root: string): { readonly code: string; readonly path: string }[] {
8427
+ const text = volumeGitText(root, ['status', '--porcelain', '-uall']);
8428
+ return text.split('\n').flatMap((line) => {
8429
+ if (line.length < 4) return [];
8430
+ const code = line.slice(0, 2);
8431
+ const raw = line.slice(3).trim();
8432
+ const path = (raw.includes(' -> ') ? raw.split(' -> ')[1] : raw)?.trim();
8433
+ return path ? [{ code, path }] : [];
8434
+ });
8435
+ }
8436
+
8437
+ function parseFeatureTier(text: string): FeatureTier | undefined {
8438
+ const match = text.match(/^##\s+Tier:\s*(S|M|L|XL)\s*$/m);
8439
+ return match?.[1] as FeatureTier | undefined;
8440
+ }
8441
+
8442
+ function parseFeatureActiveSteps(text: string): (number | string)[] | undefined {
8443
+ const section = text.match(/^## Active steps[^\n]*\n([\s\S]*?)(?=^## |(?![\s\S]))/mi)?.[1];
8444
+ if (section === undefined) return undefined;
8445
+ const steps = [...section.matchAll(/\b(?:10|[0-9](?:\.5)?)\b/g)].map((match) => match[0]!);
8446
+ return [...new Set(steps)];
8447
+ }
8448
+
8449
+ function featureLifecycle(root: string, slug: string, assessment: string | undefined): FeatureVolumeFact['lifecycle'] {
8450
+ if (assessment !== undefined && /^Lifecycle:\s*complete\s*$/mi.test(assessment)) return { phase: 'complete' };
8451
+ try {
8452
+ const state = JSON.parse(readFileSync(join(root, '.dz', 'feature-adr', 'learning-state', `${slug}.json`), 'utf8')) as { step?: unknown };
8453
+ const match = typeof state.step === 'string' ? state.step.match(/\bStep\s+(\d+(?:\.5)?)/i) : null;
8454
+ if (!match?.[1]) return undefined;
8455
+ const current = Number(match[1]);
8456
+ if (!Number.isFinite(current)) return undefined;
8457
+ return { phase: 'in-progress', completedThroughStep: current <= 0 ? 0 : Math.ceil(current) - 1 };
8458
+ } catch {
8459
+ return undefined;
8460
+ }
8461
+ }
8462
+
8463
+ interface GatheredFeatureVolume {
8464
+ readonly fact: FeatureVolumeFact;
8465
+ readonly manifestText?: string;
8466
+ }
8467
+
8468
+ function gatherFeatureVolumeFact(root: string, slug: string): GatheredFeatureVolume {
8469
+ const featureDir = join(root, 'features', slug);
8470
+ const artifacts: FeatureArtifactFact[] = [];
8471
+ const contents = new Map<string, Buffer>();
8472
+ let totalBytes = 0;
8473
+ let failure: { reason: string; detail: string } | undefined;
8474
+ let rootReal: string;
8475
+ let featureReal: string;
8476
+ try {
8477
+ rootReal = realpathSync(root);
8478
+ featureReal = realpathSync(featureDir);
8479
+ if (!volumePathInside(rootReal, featureReal)) failure = { reason: 'feature-root-escape', detail: featureDir };
8480
+ } catch (error) {
8481
+ return { fact: {
8482
+ slug,
8483
+ artifacts,
8484
+ collection: { complete: false, reason: 'feature-root-unreadable', detail: error instanceof Error ? error.message : String(error) },
8485
+ } };
8486
+ }
8487
+ const fail = (reason: string, detail: string): void => {
8488
+ failure ??= { reason, detail: detail.replace(/\s+/g, ' ').slice(0, 240) };
8489
+ };
8490
+ const addArtifact = (absolute: string): void => {
8491
+ if (failure !== undefined) return;
8492
+ if (artifacts.length >= MAX_VOLUME_FILES_PER_SCOPE) {
8493
+ fail('feature-file-cap-exceeded', `${MAX_VOLUME_FILES_PER_SCOPE} files`);
8494
+ return;
8495
+ }
8496
+ try {
8497
+ const stat = lstatSync(absolute);
8498
+ if (!stat.isFile()) { fail('feature-input-not-regular', absolute); return; }
8499
+ const real = realpathSync(absolute);
8500
+ if (!volumePathInside(featureReal, real)) { fail('feature-input-escape', absolute); return; }
8501
+ if (stat.size <= 0 || stat.size > MAX_VOLUME_BYTES_PER_SCOPE - totalBytes) {
8502
+ fail(stat.size <= 0 ? 'feature-input-empty' : 'feature-byte-cap-exceeded', absolute);
8503
+ return;
8504
+ }
8505
+ const content = readFileSync(absolute);
8506
+ if (content.byteLength <= 0 || content.byteLength > MAX_VOLUME_BYTES_PER_SCOPE - totalBytes) {
8507
+ fail(content.byteLength <= 0 ? 'feature-input-empty' : 'feature-byte-cap-exceeded', absolute);
8508
+ return;
8509
+ }
8510
+ const path = relative(featureDir, absolute).split(sep).join('/');
8511
+ totalBytes += content.byteLength;
8512
+ contents.set(path, content);
8513
+ artifacts.push({ path, bytes: content.byteLength });
8514
+ } catch (error) {
8515
+ fail('feature-input-unreadable', `${absolute}: ${error instanceof Error ? error.message : String(error)}`);
8516
+ }
8517
+ };
8518
+ const scanNested = (dir: string): void => {
8519
+ if (!existsSync(dir) || failure !== undefined) return;
8520
+ let entries: Dirent[];
8521
+ try {
8522
+ entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
8523
+ } catch (error) {
8524
+ fail('feature-directory-unreadable', `${dir}: ${error instanceof Error ? error.message : String(error)}`);
8525
+ return;
8526
+ }
8527
+ for (const entry of entries) {
8528
+ if (failure !== undefined) return;
8529
+ const absolute = join(dir, entry.name);
8530
+ if (entry.isSymbolicLink()) { fail('feature-input-not-regular', absolute); return; }
8531
+ if (entry.isDirectory()) scanNested(absolute);
8532
+ else addArtifact(absolute);
8533
+ }
8534
+ };
8535
+ try {
8536
+ const entries = readdirSync(featureDir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
8537
+ for (const entry of entries) {
8538
+ if (failure !== undefined) break;
8539
+ const absolute = join(featureDir, entry.name);
8540
+ if (entry.name === '03_adr' || entry.name === '07_code_changes') {
8541
+ if (entry.isSymbolicLink() || !entry.isDirectory()) fail('feature-input-not-regular', absolute);
8542
+ else scanNested(absolute);
8543
+ } else if (entry.name === 'README.md' || /^0[0-9](?:[_.-][^/]*)?\.md$/.test(entry.name)) {
8544
+ addArtifact(absolute);
8545
+ }
8546
+ }
8547
+ } catch (error) {
8548
+ fail('feature-directory-unreadable', `${featureDir}: ${error instanceof Error ? error.message : String(error)}`);
8549
+ }
8550
+ artifacts.sort((a, b) => a.path.localeCompare(b.path));
8551
+ const assessment = contents.get('00_complexity_assessment.md')?.toString('utf8');
8552
+ const tier = assessment !== undefined ? parseFeatureTier(assessment) : undefined;
8553
+ const activeSteps = assessment !== undefined ? parseFeatureActiveSteps(assessment) : undefined;
8554
+ const lifecycle = featureLifecycle(root, slug, assessment);
8555
+ const fact: FeatureVolumeFact = {
8556
+ slug,
8557
+ ...(tier !== undefined ? { tier } : {}),
8558
+ ...(activeSteps !== undefined ? { activeSteps } : {}),
8559
+ namedConsumers: [],
8560
+ ...(lifecycle !== undefined ? { lifecycle } : {}),
8561
+ artifacts,
8562
+ collection: failure === undefined
8563
+ ? { complete: true }
8564
+ : { complete: false, reason: failure.reason, detail: failure.detail },
8565
+ };
8566
+ const manifestText = contents.get('07_code_changes/change_manifest.md')?.toString('utf8');
8567
+ return { fact, ...(manifestText !== undefined ? { manifestText } : {}) };
8568
+ }
8569
+
8570
+ function manifestPaths(text: string, slug: string): string[] {
8571
+ const paths = new Set<string>();
8572
+ for (const line of text.split('\n')) {
8573
+ const match = line.match(/^\s*-\s+`?([^`]+?)`?(?:\s+[—-]\s+.*)?$/);
8574
+ if (!match?.[1]) continue;
8575
+ const path = match[1].trim().replace(/\\/g, '/').replace(/^\.\//, '');
8576
+ if (path === '' || path.startsWith('/') || path.split('/').some((part) => part === '..')) continue;
8577
+ if (path.startsWith(`features/${slug}/`)) continue;
8578
+ paths.add(path);
8579
+ }
8580
+ return [...paths].sort();
8581
+ }
8582
+
8583
+ function attributableDiff(
8584
+ root: string,
8585
+ slug: string,
8586
+ feature: GatheredFeatureVolume,
8587
+ status: readonly { readonly code: string; readonly path: string }[],
8588
+ changedSlugs: readonly string[],
8589
+ ): NonNullable<FeatureVolumeFact['diff']> {
8590
+ if (changedSlugs.length !== 1 || changedSlugs[0] !== slug) {
8591
+ return { attributable: false, reason: changedSlugs.length > 1 ? 'ambiguous-feature-attribution' : 'feature-not-attributable' };
8592
+ }
8593
+ if (feature.manifestText === undefined) return { attributable: false, reason: 'change-manifest-unavailable' };
8594
+ const listed = manifestPaths(feature.manifestText, slug);
8595
+ const changedByPath = new Map(status.map((item) => [item.path, item.code]));
8596
+ const changed = listed.filter((path) => changedByPath.has(path));
8597
+ if (changed.length === 0) return { attributable: true, bytes: 0, base: 'HEAD', head: 'working-tree', method: 'git-unified-diff-bytes/v1', excludedFeaturePath: `features/${slug}/**` };
8598
+ const tracked: string[] = [];
8599
+ const untracked: string[] = [];
8600
+ for (const path of changed) {
8601
+ const absolute = resolve(root, path);
8602
+ if (!volumePathInside(root, absolute)) return { attributable: false, reason: 'manifest-path-escape' };
8603
+ if (changedByPath.get(path) === '??') {
8604
+ try {
8605
+ const stat = lstatSync(absolute);
8606
+ const real = realpathSync(absolute);
8607
+ if (!stat.isFile() || !volumePathInside(root, real) || stat.size > MAX_VOLUME_BYTES_PER_SCOPE) {
8608
+ return { attributable: false, reason: 'untracked-diff-input-refused' };
8609
+ }
8610
+ } catch { return { attributable: false, reason: 'untracked-diff-input-unreadable' }; }
8611
+ untracked.push(path);
8612
+ } else {
8613
+ tracked.push(path);
8614
+ }
8615
+ }
8616
+ let bytes = 0;
8617
+ try {
8618
+ if (tracked.length > 0) {
8619
+ const diff = volumeGitText(root, ['diff', '--binary', '--no-ext-diff', 'HEAD', '--', ...tracked]);
8620
+ bytes += Buffer.byteLength(diff, 'utf8');
8621
+ }
8622
+ for (const path of untracked) {
8623
+ const diff = volumeGitText(root, ['diff', '--no-index', '--binary', '--', '/dev/null', resolve(root, path)], [0, 1]);
8624
+ bytes += Buffer.byteLength(diff, 'utf8');
8625
+ if (bytes > MAX_VOLUME_BYTES_PER_SCOPE) return { attributable: false, reason: 'diff-byte-cap-exceeded' };
8626
+ }
8627
+ } catch {
8628
+ return { attributable: false, reason: 'git-diff-failed' };
8629
+ }
8630
+ return {
8631
+ attributable: true,
8632
+ bytes,
8633
+ base: 'HEAD',
8634
+ head: 'working-tree',
8635
+ method: 'git-unified-diff-bytes/v1',
8636
+ excludedFeaturePath: `features/${slug}/**`,
8637
+ };
8638
+ }
8639
+
8640
+ function gatherVolumeShadowFacts(
8641
+ root: string,
8642
+ packages: readonly { readonly dir: string; readonly name: string; readonly privateFlag: boolean }[],
8643
+ ): VolumeShadowInput {
8644
+ const templates = packages
8645
+ .filter((item) => !item.privateFlag)
8646
+ .sort((a, b) => a.name.localeCompare(b.name) || a.dir.localeCompare(b.dir))
8647
+ .flatMap((item) => {
8648
+ const fact = gatherTemplateVolumeTarget(join(root, item.dir), item.name);
8649
+ return fact === undefined ? [] : [fact];
8650
+ });
8651
+ let status: { code: string; path: string }[];
8652
+ try { status = parseGuardStatusPaths(root); }
8653
+ catch { return templates.length > 0 ? { templates } : {}; }
8654
+ const slugs = [...new Set(status.flatMap((item) => {
8655
+ const match = item.path.match(/^features\/([^/]+)\//);
8656
+ return match?.[1] ? [match[1]] : [];
8657
+ }))].sort();
8658
+ const gathered = slugs.slice(0, 32).map((slug) => gatherFeatureVolumeFact(root, slug));
8659
+ const measuredFeatures = gathered.map((feature) => ({
8660
+ ...feature.fact,
8661
+ diff: attributableDiff(root, feature.fact.slug, feature, status, slugs),
8662
+ }));
8663
+ const cappedFeatures: FeatureVolumeFact[] = slugs.slice(32).map((slug) => ({
8664
+ slug,
8665
+ artifacts: [],
8666
+ collection: {
8667
+ complete: false,
8668
+ reason: 'feature-scope-cap-exceeded',
8669
+ detail: 'only the first 32 lexicographically sorted changed feature scopes were traversed',
8670
+ },
8671
+ }));
8672
+ const features = [...measuredFeatures, ...cappedFeatures];
8673
+ return {
8674
+ ...(templates.length > 0 ? { templates } : {}),
8675
+ ...(features.length > 0 ? { features } : {}),
8676
+ };
8677
+ }
8678
+
7832
8679
  /** Gather the facts one op needs. All I/O is best-effort — a missing signal skips its rule, never crashes. */
7833
8680
  function gatherGuardFacts(op: string, root: string, text: string | undefined, storeCap: number): Record<string, unknown> {
7834
8681
  const facts: Record<string, unknown> = { op };
7835
8682
  if (op === 'publish') {
8683
+ // Advisory I/O: unreadable telemetry or fed state is absence of evidence, never a fabricated
8684
+ // stale finding and never a publish blocker.
8685
+ try {
8686
+ const routing = readRoutingTelemetry(root);
8687
+ facts['routingFreshness'] = { unfedRunIds: unfedRuns(routing.harvest.samples, routing.alreadyFed) };
8688
+ } catch { /* fail-open: routing-store-stale is SOFT and needs gathered evidence */ }
8689
+ // marketplace-parity: regenerate the two published showcase manifests from the LIVE registry,
8690
+ // but redirect every write to a unique temp root. Version is operator-owned, so pinning the
8691
+ // published value into generatePlugin makes the comparison composition-only by construction.
8692
+ try {
8693
+ const showcaseDir = join(root, '.claude-plugin');
8694
+ if (!existsSync(showcaseDir)) {
8695
+ facts['marketplaceParity'] = { applicable: false };
8696
+ } else {
8697
+ const pluginPath = join(showcaseDir, 'plugin.json');
8698
+ const marketplacePath = join(showcaseDir, 'marketplace.json');
8699
+ const hasPlugin = existsSync(pluginPath);
8700
+ const hasMarketplace = existsSync(marketplacePath);
8701
+ const manifestFailures: { file: 'plugin.json' | 'marketplace.json'; error: string }[] = [];
8702
+ const readManifest = (path: string, file: 'plugin.json' | 'marketplace.json'): unknown => {
8703
+ try {
8704
+ return JSON.parse(readFileSync(path, 'utf8')) as unknown;
8705
+ } catch (error) {
8706
+ const message = error instanceof Error ? error.message : String(error);
8707
+ manifestFailures.push({ file, error: message.replace(/\s+/g, ' ').slice(0, 240) });
8708
+ return undefined;
8709
+ }
8710
+ };
8711
+ const versionOf = (manifest: unknown): string | undefined => {
8712
+ if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) return undefined;
8713
+ const version = (manifest as Record<string, unknown>)['version'];
8714
+ return typeof version === 'string' && version !== '' ? version : undefined;
8715
+ };
8716
+ const publishedPlugin = hasPlugin ? readManifest(pluginPath, 'plugin.json') : undefined;
8717
+ const publishedMarketplace = hasMarketplace ? readManifest(marketplacePath, 'marketplace.json') : undefined;
8718
+ const marketplaceRecord = publishedMarketplace && typeof publishedMarketplace === 'object' && !Array.isArray(publishedMarketplace)
8719
+ ? publishedMarketplace as Record<string, unknown>
8720
+ : undefined;
8721
+ const marketplacePlugins = marketplaceRecord !== undefined && Array.isArray(marketplaceRecord['plugins'])
8722
+ ? marketplaceRecord['plugins']
8723
+ : [];
8724
+ const publishedVersion = versionOf(publishedPlugin)
8725
+ ?? versionOf(publishedMarketplace)
8726
+ ?? versionOf(marketplacePlugins[0]);
8727
+ if (manifestFailures.length > 0) {
8728
+ facts['marketplaceParity'] = {
8729
+ applicable: true,
8730
+ manifestFailures,
8731
+ ...(publishedVersion !== undefined ? { publishedVersion } : {}),
8732
+ };
8733
+ } else if (hasPlugin !== hasMarketplace) {
8734
+ facts['marketplaceParity'] = {
8735
+ applicable: true,
8736
+ onlyOnePresent: true,
8737
+ ...(publishedVersion !== undefined ? { publishedVersion } : {}),
8738
+ };
8739
+ } else if (!hasPlugin) {
8740
+ facts['marketplaceParity'] = { applicable: false };
8741
+ } else {
8742
+ let scratch: string | undefined;
8743
+ try {
8744
+ const registry = buildRegistry(root);
8745
+ scratch = mkdtempSync(join(tmpdir(), 'dz-guard-marketplace-'));
8746
+ const generated = generatePlugin(scratch, registry, { version: publishedVersion });
8747
+ const freshPlugin = JSON.parse(readFileSync(generated.pluginJsonPath, 'utf8')) as unknown;
8748
+ const freshMarketplace = JSON.parse(readFileSync(generated.marketplaceJsonPath, 'utf8')) as unknown;
8749
+ facts['marketplaceParity'] = {
8750
+ applicable: true,
8751
+ diverged: !isDeepStrictEqual(publishedPlugin, freshPlugin)
8752
+ || !isDeepStrictEqual(publishedMarketplace, freshMarketplace),
8753
+ ...(publishedVersion !== undefined ? { publishedVersion } : {}),
8754
+ };
8755
+ } catch {
8756
+ facts['marketplaceParity'] = {
8757
+ applicable: true,
8758
+ regenerateFailed: true,
8759
+ ...(publishedVersion !== undefined ? { publishedVersion } : {}),
8760
+ };
8761
+ } finally {
8762
+ if (scratch !== undefined) rmSync(scratch, { recursive: true, force: true });
8763
+ }
8764
+ }
8765
+ }
8766
+ } catch { /* fail-open only on unexpected showcase-discovery failure; manifest read/parse errors are structured violations above */ }
7836
8767
  // agents-md-policy-sync: fixed registry, no tree walk. The pure detector
7837
8768
  // recomputes every expected hash from current source text; this gatherer
7838
8769
  // only supplies bytes. Any unexpected gather failure omits the fact, and
@@ -7868,7 +8799,7 @@ function gatherGuardFacts(op: string, root: string, text: string | undefined, st
7868
8799
  const manifests: Manifest[] = [];
7869
8800
  const located: { dir: string; m: Manifest }[] = [];
7870
8801
  try {
7871
- const out = execSync('git ls-files "packages/@dzhechkov/*/package.json"', { cwd: root, encoding: 'utf-8' });
8802
+ const out = volumeGitText(root, ['ls-files', 'packages/@dzhechkov/*/package.json']);
7872
8803
  for (const rel of out.split('\n').map((s) => s.trim()).filter(Boolean)) {
7873
8804
  try {
7874
8805
  const m = JSON.parse(readFileSync(join(root, rel), 'utf8')) as Manifest;
@@ -7892,6 +8823,11 @@ function gatherGuardFacts(op: string, root: string, text: string | undefined, st
7892
8823
  packages.push({ name: m.name ?? '(unnamed)', deps });
7893
8824
  }
7894
8825
  facts['packages'] = packages;
8826
+ facts['volume'] = gatherVolumeShadowFacts(root, located.map(({ dir, m }) => ({
8827
+ dir,
8828
+ name: m.name ?? dir,
8829
+ privateFlag: m.private === true,
8830
+ })));
7895
8831
 
7896
8832
  // licence-hold (ADR-001, hermes-claude-adaptation): for each pack DECLARING a hold via a
7897
8833
  // `licenseHold` field, hand the raw evidence to the pure checker. Best-effort: an unreadable
@@ -8116,6 +9052,11 @@ function runGuardEvaluation(root: string, op: string, text: string | undefined,
8116
9052
  return result;
8117
9053
  }
8118
9054
 
9055
+ function renderGuardObservation(observation: GuardObservation): string {
9056
+ const tag = observation.status === 'unknown' ? 'note' : 'observe';
9057
+ return ` [${tag}] ${observation.rule} ${observation.scope}: ${observation.detail} [${observation.status}]`;
9058
+ }
9059
+
8119
9060
  /**
8120
9061
  * The tail facts of an append-only log, read from its END — O(1) in the file size, which is what
8121
9062
  * lets the chain be extended on every append without a full-file scan (FR-2). Anything unreadable
@@ -8456,7 +9397,8 @@ function cmdGuardPromote(options: Map<string, string>, flags: Set<string>, root:
8456
9397
  ...next,
8457
9398
  entries: Object.fromEntries(Object.entries(next.entries).map(([k, v]) => (applied.includes(k) ? [k, { ...v, appliedTs: nowTs }] : [k, v]))),
8458
9399
  };
8459
- try { writeJsonAtomic(join(root, PROMOTION_STATE_FILE), withApplied); } catch { /* best-effort */ }
9400
+ const withEvidence = recordPromotionRunEvidence(withApplied, report, nowTs);
9401
+ try { writeJsonAtomic(join(root, PROMOTION_STATE_FILE), withEvidence); } catch { /* best-effort */ }
8460
9402
  }
8461
9403
 
8462
9404
  // A refused conflict means the requested apply did NOT fully happen — exit non-zero rather than
@@ -8548,6 +9490,7 @@ function cmdGuard(options: Map<string, string>, flags: Set<string>, cwd: string,
8548
9490
 
8549
9491
  const glyph = result.verdict === 'block' ? '✗' : result.verdict === 'warn' ? '⚠' : '✓';
8550
9492
  write(`dz guard (${op}): ${glyph} ${result.verdict.toUpperCase()} [checked: ${result.checked.join(', ') || 'no rules for this op'}]`);
9493
+ for (const observation of result.observations ?? []) write(renderGuardObservation(observation));
8551
9494
  for (const v of result.violations) write(` [${v.severity === 'hard' ? 'BLOCK' : 'warn'}] ${v.rule}: ${v.detail}`);
8552
9495
  for (const n of result.notes ?? []) write(` [note] ${n}`); // information, never a verdict input (FN-7)
8553
9496
  if (result.verdict === 'block' && forced) write(` → forced through: ${force} (logged to .dz/guard-audit.jsonl)`);
@@ -12972,6 +13915,160 @@ function readOptionalText(path: string): string {
12972
13915
  }
12973
13916
  }
12974
13917
 
13918
+ function localEvidenceReadReason(resource: 'promotion-journal' | 'guard-audit', error: unknown): string {
13919
+ const code = error && typeof error === 'object' && 'code' in error ? String((error as { code?: unknown }).code) : '';
13920
+ return code === 'ENOENT' ? `${resource}-missing` : `${resource}-unreadable`;
13921
+ }
13922
+
13923
+ interface PromotionEvidenceRead {
13924
+ readonly source: FunnelEvidenceSource<PromotionRunEvidence>;
13925
+ readonly acceptances: readonly PromotionAcceptanceEvidence[];
13926
+ readonly truncatedPeriods: readonly string[];
13927
+ readonly acceptanceHistoryComplete: boolean;
13928
+ }
13929
+
13930
+ function unavailablePromotionEvidence(reason: string): PromotionEvidenceRead {
13931
+ return { source: { status: 'not-measured', reason }, acceptances: [], truncatedPeriods: [], acceptanceHistoryComplete: false };
13932
+ }
13933
+
13934
+ function readPromotionRunEvidence(root: string): PromotionEvidenceRead {
13935
+ let raw: unknown;
13936
+ try {
13937
+ raw = JSON.parse(readFileSync(join(root, PROMOTION_STATE_FILE), 'utf-8')) as unknown;
13938
+ } catch (error) {
13939
+ const reason = error instanceof SyntaxError
13940
+ ? 'promotion-journal-malformed'
13941
+ : localEvidenceReadReason('promotion-journal', error);
13942
+ return unavailablePromotionEvidence(reason);
13943
+ }
13944
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
13945
+ return unavailablePromotionEvidence('promotion-journal-malformed');
13946
+ }
13947
+ const record = raw as Record<string, unknown>;
13948
+ if (record['version'] !== 1 || !Object.hasOwn(record, 'runs')) {
13949
+ return unavailablePromotionEvidence(
13950
+ record['version'] === 1 ? 'promotion-history-not-recorded' : 'promotion-journal-malformed',
13951
+ );
13952
+ }
13953
+ if (!Array.isArray(record['runs'])) {
13954
+ return unavailablePromotionEvidence('promotion-history-malformed');
13955
+ }
13956
+ const state = normalizePromotionState(raw);
13957
+ if (state.runs === undefined || state.runs.length !== record['runs'].length) {
13958
+ return unavailablePromotionEvidence('promotion-history-malformed');
13959
+ }
13960
+ if (
13961
+ (Object.hasOwn(record, 'acceptances') && (
13962
+ !Array.isArray(record['acceptances']) || state.acceptances?.length !== record['acceptances'].length
13963
+ )) ||
13964
+ (Object.hasOwn(record, 'truncatedRunPeriods') && (
13965
+ !Array.isArray(record['truncatedRunPeriods']) || state.truncatedRunPeriods?.length !== record['truncatedRunPeriods'].length
13966
+ )) ||
13967
+ (Object.hasOwn(record, 'acceptanceHistoryComplete') && typeof record['acceptanceHistoryComplete'] !== 'boolean')
13968
+ ) {
13969
+ return unavailablePromotionEvidence('promotion-history-malformed');
13970
+ }
13971
+ const derivedAcceptances = state.runs.flatMap((run) => run.candidates
13972
+ .filter((candidate) => candidate.verdict === 'promote' && candidate.ruleContentAnchor !== null)
13973
+ .map((candidate) => ({ ruleContentAnchor: candidate.ruleContentAnchor!, acceptedTs: run.ts })));
13974
+ const claimedAcceptanceComplete = state.acceptanceHistoryComplete ??
13975
+ ((state.truncatedRunPeriods?.length ?? 0) === 0 && state.runs.every((run) => run.complete === true));
13976
+ if (claimedAcceptanceComplete && state.acceptances !== undefined && derivedAcceptances.some((derived) =>
13977
+ !state.acceptances!.some((stored) =>
13978
+ stored.ruleContentAnchor === derived.ruleContentAnchor &&
13979
+ Date.parse(stored.acceptedTs) <= Date.parse(derived.acceptedTs),
13980
+ ),
13981
+ )) {
13982
+ return unavailablePromotionEvidence('promotion-history-malformed');
13983
+ }
13984
+ const acceptancesByAnchor = new Map<string, PromotionAcceptanceEvidence>();
13985
+ for (const acceptance of [...(state.acceptances ?? []), ...derivedAcceptances]) {
13986
+ const prior = acceptancesByAnchor.get(acceptance.ruleContentAnchor);
13987
+ if (prior === undefined || Date.parse(acceptance.acceptedTs) < Date.parse(prior.acceptedTs)) {
13988
+ acceptancesByAnchor.set(acceptance.ruleContentAnchor, acceptance);
13989
+ }
13990
+ }
13991
+ return {
13992
+ source: { status: 'measured', rows: state.runs },
13993
+ acceptances: [...acceptancesByAnchor.values()],
13994
+ truncatedPeriods: state.truncatedRunPeriods ?? [],
13995
+ acceptanceHistoryComplete: claimedAcceptanceComplete,
13996
+ };
13997
+ }
13998
+
13999
+ interface GuardAuditEvidenceRead {
14000
+ readonly source: FunnelEvidenceSource<GuardEvent>;
14001
+ readonly rows: readonly GuardEvent[];
14002
+ readonly text: string | null;
14003
+ }
14004
+
14005
+ function readGuardAuditEvidence(root: string): GuardAuditEvidenceRead {
14006
+ let text: string;
14007
+ try {
14008
+ text = readFileSync(join(root, '.dz', 'guard-audit.jsonl'), 'utf-8');
14009
+ } catch (error) {
14010
+ return {
14011
+ source: { status: 'not-measured', reason: localEvidenceReadReason('guard-audit', error) },
14012
+ rows: [],
14013
+ text: null,
14014
+ };
14015
+ }
14016
+ const rows: GuardEvent[] = [];
14017
+ let malformed = false;
14018
+ for (const line of text.split('\n')) {
14019
+ if (line.trim() === '') continue;
14020
+ try {
14021
+ const raw = JSON.parse(line) as Record<string, unknown>;
14022
+ if (
14023
+ !isOffsetIsoTimestamp(raw['ts']) ||
14024
+ !['publish', 'teach', 'consolidate', 'reindex'].includes(String(raw['op'])) ||
14025
+ !['pass', 'warn', 'block'].includes(String(raw['verdict']))
14026
+ ) {
14027
+ malformed = true;
14028
+ continue;
14029
+ }
14030
+ const violations: { rule: string; contentAnchor?: string }[] = [];
14031
+ if (!Array.isArray(raw['violations'])) {
14032
+ malformed = true;
14033
+ continue;
14034
+ }
14035
+ for (const item of raw['violations']) {
14036
+ const rule = item && typeof item === 'object' ? (item as { rule?: unknown }).rule : undefined;
14037
+ if (typeof rule !== 'string' || rule === '' || rule.length > 200) {
14038
+ malformed = true;
14039
+ continue;
14040
+ }
14041
+ const anchor = (item as { contentAnchor?: unknown }).contentAnchor;
14042
+ if (anchor !== undefined && !isLessonRuleContentAnchor(anchor)) {
14043
+ malformed = true;
14044
+ continue;
14045
+ }
14046
+ violations.push({ rule, ...(typeof anchor === 'string' ? { contentAnchor: anchor } : {}) });
14047
+ }
14048
+ const verdict = raw['verdict'] as 'pass' | 'warn' | 'block';
14049
+ if ((verdict === 'pass') !== (violations.length === 0)) malformed = true;
14050
+ rows.push({
14051
+ ts: raw['ts'],
14052
+ op: raw['op'] as 'publish' | 'teach' | 'consolidate' | 'reindex',
14053
+ verdict,
14054
+ rules: violations.map((item) => item.rule),
14055
+ violations,
14056
+ });
14057
+ } catch {
14058
+ malformed = true;
14059
+ }
14060
+ }
14061
+ return {
14062
+ source: malformed
14063
+ ? { status: 'not-measured', reason: 'guard-audit-malformed' }
14064
+ : !verifyEventChainText(text).ok
14065
+ ? { status: 'not-measured', reason: 'guard-audit-chain-corrupt' }
14066
+ : { status: 'measured', rows },
14067
+ rows,
14068
+ text,
14069
+ };
14070
+ }
14071
+
12975
14072
  function deadwoodAllowlistText(): string {
12976
14073
  const require = createRequire(import.meta.url);
12977
14074
  const corePackage = require.resolve('@dzhechkov/harness-core/package.json');
@@ -13057,7 +14154,7 @@ function cmdCompounding(options: Map<string, string>, flags: Set<string>, cwd: s
13057
14154
  const json = flags.has('json');
13058
14155
  if (flags.has('help')) {
13059
14156
  write('dz compounding [--project <dir>] [--json] — honest learning-loop payoff report');
13060
- write(' pool payoff (write-only ratio) · guard repeat-violation trajectory · cold-vs-warm replay readiness · instrumentation health');
14157
+ write(' pool payoff · guard trajectory · replay readiness · instrumentation · monthly eligible→attempted→accepted→executions funnel');
13061
14158
  return 0;
13062
14159
  }
13063
14160
  for (const flag of flags) {
@@ -13089,40 +14186,24 @@ function cmdCompounding(options: Map<string, string>, flags: Set<string>, cwd: s
13089
14186
  // apply-leg usage events (read records only; aggregate rows carry no query by construction)
13090
14187
  const usage = readRecallUsageEvents(root);
13091
14188
 
13092
- // guard audit events
13093
- const guard: { ts: string; verdict: string; rules: string[] }[] = [];
13094
- try {
13095
- const text = readFileSync(join(root, '.dz', 'guard-audit.jsonl'), 'utf-8');
13096
- for (const line of text.split('\n')) {
13097
- if (line.trim() === '') continue;
13098
- try {
13099
- const o = JSON.parse(line) as { ts?: unknown; verdict?: unknown; violations?: { rule?: unknown }[] };
13100
- if (typeof o.ts === 'string' && typeof o.verdict === 'string') {
13101
- guard.push({
13102
- ts: o.ts,
13103
- verdict: o.verdict,
13104
- rules: Array.isArray(o.violations) ? o.violations.map((v) => (typeof v?.rule === 'string' ? v.rule : '')).filter(Boolean) : [],
13105
- });
13106
- }
13107
- } catch {
13108
- /* skip */
13109
- }
13110
- }
13111
- } catch {
13112
- /* no audit yet */
13113
- }
14189
+ const promotionEvidence = readPromotionRunEvidence(root);
14190
+ const guardEvidence = readGuardAuditEvidence(root);
14191
+ const guard = [...guardEvidence.rows];
13114
14192
 
13115
14193
  // The evidence logs themselves, verbatim: the report verifies their hash chains (feature
13116
14194
  // event-chain). Handing over the TEXT rather than a pre-computed verdict keeps one definition of
13117
14195
  // "the chain is intact" — a second copy here is how a gate and its report start disagreeing.
13118
14196
  const evidenceLogs: { log: string; text: string }[] = [];
13119
- for (const rel of ['.dz/recall-usage.jsonl', '.dz/guard-audit.jsonl']) {
14197
+ for (const rel of ['.dz/recall-usage.jsonl']) {
13120
14198
  try {
13121
14199
  evidenceLogs.push({ log: rel, text: readFileSync(join(root, ...rel.split('/')), 'utf-8') });
13122
14200
  } catch {
13123
14201
  /* absent log — reported by its own gate above, not invented here */
13124
14202
  }
13125
14203
  }
14204
+ if (guardEvidence.text !== null) {
14205
+ evidenceLogs.push({ log: '.dz/guard-audit.jsonl', text: guardEvidence.text });
14206
+ }
13126
14207
 
13127
14208
  const now = new Date();
13128
14209
  const report = assembleCompoundingReport({
@@ -13132,6 +14213,13 @@ function cmdCompounding(options: Map<string, string>, flags: Set<string>, cwd: s
13132
14213
  nowTs: now.toISOString(),
13133
14214
  evidenceLogs,
13134
14215
  cmdUsageDepthDays: cmdUsageDepthDays(root, now),
14216
+ lessonToRule: {
14217
+ promotionRuns: promotionEvidence.source,
14218
+ guardAudits: guardEvidence.source,
14219
+ promotionAcceptances: promotionEvidence.acceptances,
14220
+ truncatedPromotionPeriods: promotionEvidence.truncatedPeriods,
14221
+ acceptanceHistoryComplete: promotionEvidence.acceptanceHistoryComplete,
14222
+ },
13135
14223
  });
13136
14224
  // lesson-bandit-rerank §11: the payoff axis joins THIS report rather than growing a private
13137
14225
  // dashboard — the `rewardEvents : exposureEvents` row asks exactly the question this command
@@ -14272,6 +15360,34 @@ async function cmdBacklog(options: Map<string, string>, flags: Set<string>, cwd:
14272
15360
  return sub === undefined ? 0 : 1;
14273
15361
  }
14274
15362
 
15363
+ /** Read the recommender's existing telemetry planes plus its idempotency receipt. One adapter is
15364
+ * shared by `recommend` and the publish advisory so their freshness definitions cannot drift. */
15365
+ function readRoutingTelemetry(repoRoot: string): { harvest: ReturnType<typeof harvestStageOutcomes>; alreadyFed: string[] } {
15366
+ const records: unknown[] = readHarnessRecords(repoRoot);
15367
+ for (const base of [join(repoRoot, 'features'), join(repoRoot, '.dz', 'loop-trace')]) {
15368
+ if (!existsSync(base)) continue;
15369
+ let names: string[] = [];
15370
+ try { names = readdirSync(base); } catch { continue; }
15371
+ for (const name of names) {
15372
+ const sidecar = join(base, name, 'run-meta.json');
15373
+ if (!existsSync(sidecar)) continue;
15374
+ try {
15375
+ const meta = JSON.parse(readFileSync(sidecar, 'utf-8')) as { runMeta?: { resolved?: boolean; records?: unknown[] } };
15376
+ if (meta.runMeta?.resolved === true && Array.isArray(meta.runMeta.records)) records.push(...meta.runMeta.records);
15377
+ } catch { /* an unreadable sidecar contributes no asserted record */ }
15378
+ }
15379
+ }
15380
+ let alreadyFed: string[] = [];
15381
+ const fedPath = join(repoRoot, '.dz', 'routing-fed.json');
15382
+ if (existsSync(fedPath)) {
15383
+ const parsed = JSON.parse(readFileSync(join(repoRoot, '.dz', 'routing-fed.json'), 'utf-8')) as unknown;
15384
+ if (!Array.isArray(parsed)) throw new Error('.dz/routing-fed.json must be a JSON array of runIds');
15385
+ if (parsed.some((v) => typeof v !== 'string')) throw new Error('.dz/routing-fed.json contains a non-string runId');
15386
+ alreadyFed = parsed as string[];
15387
+ }
15388
+ return { harvest: harvestStageOutcomes(records), alreadyFed };
15389
+ }
15390
+
14275
15391
  /**
14276
15392
  * `dz routing` — inspect the learned cost-optimal routing outcome store (feature learned-cost-routing). Shows
14277
15393
  * what `args.models[stage]='auto-cost'` currently believes per (stage, complexity-tier, model): gated
@@ -14286,28 +15402,20 @@ function cmdRouting(options: Map<string, string>, flags: Set<string>, cwd: strin
14286
15402
  // ── recommend — harvest real telemetry, print per-stage picks WITH THE BASIS, optionally feed the
14287
15403
  // store (a9c3dd5c fn 3, ADR-001). Sources: live harness records + imported run-meta.json sidecars.
14288
15404
  if (options.get('_positional_0') === 'recommend') {
14289
- const records: unknown[] = readHarnessRecords(repoRoot);
14290
- // imported runs (trace-bundle ADR-001 D4): run-meta sidecars carry runMeta.records of the SAME shape
14291
- for (const base of [join(repoRoot, 'features'), join(repoRoot, '.dz', 'loop-trace')]) {
14292
- if (!existsSync(base)) continue;
14293
- let names: string[] = [];
14294
- try { names = readdirSync(base); } catch { continue; }
14295
- for (const name of names) {
14296
- const sidecar = join(base, name, 'run-meta.json');
14297
- if (!existsSync(sidecar)) continue;
14298
- try {
14299
- const meta = JSON.parse(readFileSync(sidecar, 'utf-8')) as { runMeta?: { resolved?: boolean; records?: unknown[] } };
14300
- if (meta.runMeta?.resolved === true && Array.isArray(meta.runMeta.records)) records.push(...meta.runMeta.records);
14301
- } catch { /* an unreadable sidecar contributes nothing — counted below as noResult */ }
14302
- }
15405
+ let routing: ReturnType<typeof readRoutingTelemetry>;
15406
+ try { routing = readRoutingTelemetry(repoRoot); }
15407
+ catch (e) {
15408
+ write(`dz routing recommend: cannot read routing freshness state — ${(e as Error).message}`);
15409
+ return 1;
14303
15410
  }
14304
- const harvest = harvestStageOutcomes(records);
14305
- const rec = recommendModels(harvest, { ...(options.get('tier') !== undefined ? { tier: options.get('tier')! } : {}) });
15411
+ const harvest = routing.harvest;
15412
+ let rec = recommendModels(harvest, {
15413
+ ...(options.get('tier') !== undefined ? { tier: options.get('tier')! } : {}),
15414
+ alreadyFed: routing.alreadyFed,
15415
+ });
14306
15416
  if (flags.has('apply')) {
14307
15417
  const fedPath = join(repoRoot, '.dz', 'routing-fed.json');
14308
- let alreadyFed: string[] = [];
14309
- try { alreadyFed = JSON.parse(readFileSync(fedPath, 'utf-8')) as string[]; } catch { /* first apply */ }
14310
- const plan = planFeed(harvest.samples, alreadyFed);
15418
+ const plan = planFeed(harvest.samples, routing.alreadyFed);
14311
15419
  for (const sample of plan.toFeed) finalizeOutcome(repoRoot, sample.stage, sample.tier, sample.model, sample.success);
14312
15420
  try {
14313
15421
  mkdirSync(join(repoRoot, '.dz'), { recursive: true });
@@ -14316,6 +15424,10 @@ function cmdRouting(options: Map<string, string>, flags: Set<string>, cwd: strin
14316
15424
  write(`dz routing recommend: fed ${plan.toFeed.length} sample(s) but could NOT persist the fed-set — a re-apply WILL double-count: ${(e as Error).message}`);
14317
15425
  return 1;
14318
15426
  }
15427
+ rec = recommendModels(harvest, {
15428
+ ...(options.get('tier') !== undefined ? { tier: options.get('tier')! } : {}),
15429
+ alreadyFed: plan.fedAfter,
15430
+ });
14319
15431
  write(`dz routing recommend --apply: fed ${plan.toFeed.length} sample(s) into .dz/routing-outcomes.json${plan.skippedRuns.length > 0 ? `; skipped ${plan.skippedRuns.length} already-fed run(s) (idempotent by runId)` : ''}`);
14320
15432
  }
14321
15433
  if (flags.has('json')) {
@@ -14327,6 +15439,9 @@ function cmdRouting(options: Map<string, string>, flags: Set<string>, cwd: strin
14327
15439
  write(` ${s.stage.padEnd(14)} ${s.spec.padEnd(20)} ${s.insufficientData ? '[insufficient data — cold-start pick]' : '[quality bar met]'} ${s.pick.evidence}`);
14328
15440
  }
14329
15441
  write(` basis: ${rec.basis.rule}`);
15442
+ if (rec.basis.freshness === 'current') write(' store: current (0 unharvested runs)');
15443
+ else if (rec.basis.freshness === 'stale') write(` store: STALE — ${rec.basis.unfed.count} run(s) harvested but never fed; auto-cost is routing from an older snapshot — run \`dz routing recommend --apply\``);
15444
+ else write(' store: UNFED — auto-cost has no learned rows; picks below are cold-start');
14330
15445
  write(` basis: skipped records — no result ${rec.basis.skipped.noResult}, no modelsUsed ${rec.basis.skipped.noModels} (predates model routing — history, not an error), no grade ${rec.basis.skipped.noGrade}, unknown model ${rec.basis.skipped.unknownModel}`);
14331
15446
  write(` basis: ${rec.basis.crossFamilyNote}`);
14332
15447
  return 0;
@@ -14997,11 +16112,20 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
14997
16112
  case 'bundle':
14998
16113
  return cmdBundle(options, flags, cwd, write);
14999
16114
  case 'teach':
15000
- return await cmdTeach(options, flags, cwd, write);
16115
+ return await cmdTeach(
16116
+ options,
16117
+ flags,
16118
+ cwd,
16119
+ write,
16120
+ writeErr,
16121
+ io.interactive ?? process.stdout.isTTY === true,
16122
+ io.teachGuardRunner ?? teachGuard,
16123
+ io.teachReinforceRunner ?? runTeachGuardReinforcement,
16124
+ );
15001
16125
  case 'consolidate':
15002
16126
  return await cmdConsolidate(options, flags, cwd, write);
15003
16127
  case 'recall':
15004
- return await cmdRecall(options, flags, cwd, write);
16128
+ return await cmdRecall(options, flags, cwd, write, writeErr, io.classMatcher);
15005
16129
  case 'vector':
15006
16130
  return await cmdVector(options, flags, cwd, write);
15007
16131
  case 'brain':
@@ -15054,6 +16178,8 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
15054
16178
  return cmdDriftCheck(options, flags, cwd, write);
15055
16179
  case 'hooks-sync':
15056
16180
  return cmdHooksSync(options, flags, cwd, write, writeErr);
16181
+ case 'integrations-verify':
16182
+ return cmdIntegrationsVerify(options, flags, cwd, write, writeErr);
15057
16183
  case 'agents-sync':
15058
16184
  return cmdAgentsSync(options, flags, cwd, write, writeErr);
15059
16185
  case 'sync-canonical':