@dzhechkov/harness-cli 0.8.6 → 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
@@ -29,6 +29,8 @@ import {
29
29
  TARGET_NAMES_SORTED,
30
30
  runDoctor,
31
31
  runInit,
32
+ discoverSkillIds,
33
+ runIntegrationsVerify,
32
34
  resolvePackageSkillRoots,
33
35
  PACKAGE_SKILL_LAYOUTS,
34
36
  benchmarkSkill,
@@ -81,6 +83,7 @@ import {
81
83
  NamedLockTimeoutError,
82
84
  NamedLockCompromisedError,
83
85
  type CodexHooksSyncReport,
86
+ type IntegrationManifestSource,
84
87
  type ParityCell,
85
88
  type ParityFeature,
86
89
  type ParityReportCell,
@@ -116,6 +119,8 @@ import {
116
119
  renderTimelineHtml,
117
120
  importEcc,
118
121
  recordPattern,
122
+ recordLessonForms,
123
+ normalizeLessonForms,
119
124
  resolveLearningBackend,
120
125
  storeStats,
121
126
  consolidateSessions,
@@ -141,6 +146,17 @@ import {
141
146
  importRvfCheckpoint,
142
147
  statuslineData,
143
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,
144
160
  computeUsage,
145
161
  deriveCostLedger,
146
162
  planLedgerBackfill,
@@ -167,6 +183,8 @@ import {
167
183
  queryBookKnowledge,
168
184
  loadStorePatternsSync,
169
185
  patternRecordId,
186
+ patternIdentityOf,
187
+ mergeLessonMatchedForms,
170
188
  loadStoreRecords,
171
189
  recordToPattern,
172
190
  bundleSkills,
@@ -204,12 +222,22 @@ import {
204
222
  DEFAULT_RULES,
205
223
  parsePnpmLockImporters,
206
224
  scannableStubPath,
225
+ type FeatureArtifactFact,
226
+ type FeatureTier,
227
+ type FeatureVolumeFact,
228
+ type GuardObservation,
229
+ type TemplateVolumeFileFact,
230
+ type TemplateVolumeTargetFact,
231
+ type VolumeShadowInput,
207
232
  // guard-promotion (feature guard-promotion, scout idea #1)
208
233
  assembleCandidates,
209
234
  renderPromotionReport,
210
235
  renderPromotionAdr,
211
236
  normalizePromotionState,
212
237
  nextPromotionState,
238
+ recordPromotionRunEvidence,
239
+ isLessonRuleContentAnchor,
240
+ isOffsetIsoTimestamp,
213
241
  globMatch,
214
242
  promotionAdrRelPath,
215
243
  DEFAULT_WINDOW_DAYS,
@@ -219,6 +247,10 @@ import {
219
247
  type ChangeSet,
220
248
  type ExistingRuleView,
221
249
  type PromotionReport,
250
+ type PromotionAcceptanceEvidence,
251
+ type PromotionRunEvidence,
252
+ type FunnelEvidenceSource,
253
+ type GuardEvent,
222
254
  decideProvenance,
223
255
  isInsideTree,
224
256
  signManifest,
@@ -522,7 +554,7 @@ import type { SetupSpec } from '@dzhechkov/harness-core';
522
554
  import type { LogTail } from '@dzhechkov/harness-core';
523
555
  import type { DeadwoodInventoryItem } from '@dzhechkov/harness-core';
524
556
  import type { ContractDiagnostic, ContractEvidenceReader } from '@dzhechkov/harness-core';
525
- import type { ProvenanceMode, PackVerdict, ClaudeUsageModel, PatternRecord, TargetName, BookKU, HarmonizeReport, UsageCalibrationPlan, ClaimFinding, RecallUsagePatternRow, GateExecution, GateStep, SlopFinding, SlopLintConfig, SlopRegistry } from '@dzhechkov/harness-core';
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';
526
558
  import { getPreset, PRESET_NAMES } from '@dzhechkov/harness-presets';
527
559
  import { scanGitHub, analyzeRepo, generateReport, deepAnalyze, scanAllSources, ScoutMemory } from '@dzhechkov/scout';
528
560
 
@@ -534,7 +566,7 @@ export const DZ_COMMANDS: readonly string[] = [
534
566
  'usage', 'claim-check', 'lint', 'sign', 'sbom', 'guard', 'verify-pack', 'setup',
535
567
  'pretrain', 'compose', 'diff', 'recommend', 'upgrade', 'auto-canonicalize',
536
568
  'publish', 'release', 'parity', 'registry', 'benchmark', 'mcp-scan',
537
- 'sync-upstream', 'drift-check', 'hooks-sync', 'agents-sync', 'sync-canonical',
569
+ 'sync-upstream', 'drift-check', 'hooks-sync', 'integrations-verify', 'agents-sync', 'sync-canonical',
538
570
  'plugin', 'downloads', 'stats', 'architecture', 'project-skills', 'mr-rakes',
539
571
  'retro', 'feature-adr-setup', 'challenge', 'discrimination-check',
540
572
  'mutation-gate', 'delivery-check', 'skills-verify', 'compounding', 'deadwood',
@@ -547,7 +579,7 @@ export const DZ_COMMANDS: readonly string[] = [
547
579
  const USAGE = `dz - DZ cross-platform harness CLI
548
580
 
549
581
  Usage:
550
- dz init --target <name> [--skills-dir <dir>] [--project <dir>] [--preset <name>] [--select id,id,...] [--force] [--enrich] [--no-hooks] [--no-verify] (--target codex ALSO installs the user-global dz veto+recall hooks and LIVE-verifies them (ADR-001 §8); --no-hooks = skills only; --no-verify skips the live probe and can never report ready)
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)
551
583
  dz verify [--skills-dir <dir>] [--target <name>]
552
584
  dz sync [--canonical <dir>] [--project <dir>] [--dry-run] [--force]
553
585
  dz update (alias of sync)
@@ -576,7 +608,7 @@ Usage:
576
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)
577
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)
578
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)
579
- dz compounding [--project <dir>] [--json] (honest learning-loop payoff report: pool write-only ratio, guard repeat-violation trajectory, cold-vs-warm replay readiness, instrumentation health a gate without enough data says INSUFFICIENT_DATA, never a fake verdict)
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)
580
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)
581
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)
582
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)
@@ -617,7 +649,7 @@ Usage:
617
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)
618
650
  dz backlog harmonize [--apply] [--threshold <0-1>] [--project <dir>] [--json] (batch semantic dedup of the backlog ideas; --dry-run default, --apply snapshots first)
619
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)
620
- dz teach "<pattern>" [--reward <0-1>] [--domain <name>] [--type rule|success-pattern|lesson-learned] [--project <dir>] [--no-mirror] (--project pins the learned store to <dir>/.dz, not the cwd — pin to a canonical brain)
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)
621
653
  dz teach --from-json <file> [--project <dir>] [--no-mirror] (bulk-import a 'dz recall --all --json' export — share a learned store across machines)
622
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)
623
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)
@@ -656,6 +688,7 @@ Usage:
656
688
  dz drift-check [--json] [--project <dir>] (CI gate: exit 1 if any shared skill drifted between its monorepo copies)
657
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)
658
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)
659
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)
660
693
  dz plugin [--version <ver>]
661
694
  dz downloads
@@ -706,6 +739,14 @@ export interface CliIo {
706
739
  * command that needs it (`brain ground`), and never when stdin is a TTY (nothing piped).
707
740
  */
708
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 }>;
709
750
  /**
710
751
  * Test seam for `dz release`: overrides subprocess execution for gate steps and the
711
752
  * gh/git side channels (production leaves it unset → real `execSync`, stdio piped).
@@ -823,6 +864,8 @@ interface InstallSkillsResult {
823
864
  * the source `SKILL.md`.
824
865
  */
825
866
  readonly applyFailures: SkillApplyFailure[];
867
+ readonly integrations: readonly IntegrationOutcome[];
868
+ readonly integrationDigest?: string;
826
869
  }
827
870
 
828
871
  /**
@@ -838,6 +881,10 @@ async function installSkills(opts: {
838
881
  select?: readonly string[] | undefined;
839
882
  force: boolean;
840
883
  enrich: boolean;
884
+ noHooks?: boolean;
885
+ noIntegrations?: boolean;
886
+ noVerify?: boolean;
887
+ allowIntegrations?: string;
841
888
  }): Promise<InstallSkillsResult> {
842
889
  const { target, projectRoot, cwd, explicitSkillsDir, select, force, enrich } = opts;
843
890
 
@@ -850,8 +897,8 @@ async function installSkills(opts: {
850
897
  // route them through one aggregation.
851
898
  if (target === 'agents-md' || target === 'gemini') {
852
899
  const report = target === 'gemini'
853
- ? runInitGeminiMd({ skillsDirs, projectRoot, ...(select !== undefined ? { select } : {}) })
854
- : 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 } : {}) });
855
902
  const results = report.skills.map((s) => ({
856
903
  id: s.id,
857
904
  written: s.written.length,
@@ -860,13 +907,21 @@ async function installSkills(opts: {
860
907
  let written = 0;
861
908
  let skipped = 0;
862
909
  for (const s of results) { written += s.written; skipped += s.skipped; }
863
- return { results, dirsSearched: skillsDirs.length, written, skipped, missing: [...report.missing], failures: [...report.failures], applyFailures: [...report.applyFailures] };
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 } : {}) };
864
911
  }
865
912
 
866
913
  const results: { id: string; written: number; skipped: number }[] = [];
867
914
  const failures: SkillLoadFailure[] = [];
868
915
  const applyFailures: SkillApplyFailure[] = [];
869
- 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()) {
870
925
  const r = await runInit({
871
926
  target,
872
927
  skillsDir,
@@ -874,12 +929,24 @@ async function installSkills(opts: {
874
929
  force,
875
930
  enrich,
876
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 } : {}),
877
941
  });
878
942
  for (const skill of r.skills) {
879
943
  results.push({ id: skill.id, written: skill.written.length, skipped: skill.skipped.length });
880
944
  }
881
945
  failures.push(...r.failures);
882
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;
883
950
  }
884
951
 
885
952
  let written = 0;
@@ -887,7 +954,7 @@ async function installSkills(opts: {
887
954
  for (const s of results) { written += s.written; skipped += s.skipped; }
888
955
  const installed = new Set(results.map((s) => s.id));
889
956
  const missing = select !== undefined ? [...select].filter((id) => !installed.has(id)) : [];
890
- 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 } : {}) };
891
958
  }
892
959
 
893
960
  /** Warn about preset/select ids that weren't found in any installed pack. */
@@ -949,26 +1016,46 @@ async function cmdInit(options: Map<string, string>, flags: Set<string>, cwd: st
949
1016
  select,
950
1017
  force: flags.has('force'),
951
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')! } : {}),
952
1023
  });
953
1024
 
954
- write(`dz init --target ${target}: ${r.results.length} skill(s), ${r.written} file(s) written, ${r.skipped} skipped`);
955
- if (r.dirsSearched > 1) {
956
- write(` (searched ${r.dirsSearched} skill directories)`);
957
- }
958
- writeMissingSkillsHint(write, r.missing, presetName);
959
-
960
- // Codex-targeted init DELIVERS the hooks and verifies them (ADR-001 §8). Skills alone are not the
961
- // target's harness: the veto + recall legs are what `--target codex` promises.
962
- // `--no-hooks` is the documented escape for "skills only" (the same flag `dz setup` already
963
- // carries): hook delivery writes USER-GLOBAL config, so a command that only wants skills compiled
964
- // must be able to say so — and every test that is about skills says it.
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;
965
1029
  let codexHooksOk = true;
966
1030
  if (target === 'codex' && !flags.has('no-hooks')) {
967
1031
  const delivery = deliverCodexHooks({ project: projectRoot, verify: !flags.has('no-verify') }, undefined, 'dz init');
968
1032
  codexHooksOk = delivery.ok;
969
1033
  for (const line of delivery.stdout) write(line);
970
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
+ }
971
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
+ }
1056
+ }
1057
+ writeMissingSkillsHint(write, r.missing, presetName);
1058
+
972
1059
  // Skip-and-collect must not become skip-and-SILENCE: a skill that failed to load is
973
1060
  // named on stderr and the command exits 1 (it exited 1 before too — by throwing).
974
1061
  if (r.failures.length > 0 || r.applyFailures.length > 0) {
@@ -987,7 +1074,35 @@ async function cmdInit(options: Map<string, string>, flags: Set<string>, cwd: st
987
1074
  for (const line of formatSkillApplyFailures(r.applyFailures)) writeErr(line);
988
1075
  return 1;
989
1076
  }
990
- 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;
991
1106
  }
992
1107
 
993
1108
  async function cmdVerify(options: Map<string, string>, cwd: string, write: Write, writeErr: WriteErr): Promise<number> {
@@ -2423,6 +2538,146 @@ function cmdStatuslineFaRecord(options: Map<string, string>, cwd: string, write:
2423
2538
  return 0;
2424
2539
  }
2425
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
+
2426
2681
  /**
2427
2682
  * `dz statusline` — render dz's OWN self-learning counts as one compact, emoji-tagged line for
2428
2683
  * Claude Code's status bar (modeled on agentic-qe's "🎓 12 patterns"). Claude Code pipes a JSON
@@ -2447,9 +2702,22 @@ function cmdStatusline(
2447
2702
  try {
2448
2703
  const projectRoot = statuslineProjectRoot(readStdin(), options, cwd);
2449
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
+ }
2450
2718
 
2451
2719
  if (flags.has('json')) {
2452
- write(JSON.stringify(data));
2720
+ write(JSON.stringify({ ...data, ...(eta !== undefined ? { eta } : {}) }));
2453
2721
  return 0;
2454
2722
  }
2455
2723
 
@@ -2459,13 +2727,12 @@ function cmdStatusline(
2459
2727
  if (data.consolidatedAgeH !== undefined) line += ` · ⟳ ${data.consolidatedAgeH}h`;
2460
2728
 
2461
2729
  // Live /feature-adr run in flight → PREPEND the pipeline learning segment to the base dz line.
2462
- const fa = data.featureAdr;
2463
2730
  if (fa !== undefined) {
2464
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.
2465
2732
  if (fa.kind === 'loop') {
2466
2733
  line = `🔁 loop ${fa.step} · ${line}`;
2467
2734
  } else {
2468
- 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}`;
2469
2736
  }
2470
2737
  }
2471
2738
 
@@ -3092,7 +3359,22 @@ function learningStoreLine(
3092
3359
  ) + (reason ? ' [' + reason + ']' : '');
3093
3360
  }
3094
3361
 
3095
- 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> {
3096
3378
  // WHICH store this lesson belongs to, and WHO decided (teach-chooses-its-store).
3097
3379
  // `--to` → `DZ_LEARN` → `.dz/config.json` learning.teachTo → project. The owner asked for a
3098
3380
  // per-session choice; for a CLI every invocation is a fresh process, so the only honest session
@@ -3176,26 +3458,39 @@ async function cmdTeach(options: Map<string, string>, flags: Set<string>, cwd: s
3176
3458
  write('dz teach --from-json: expected a JSON array (produced by `dz recall --all --json`)');
3177
3459
  return 1;
3178
3460
  }
3179
- 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));
3180
3465
  let imported = 0;
3181
3466
  let skipped = 0;
3182
3467
  const importedRecs: PatternRecord[] = [];
3183
3468
  for (const item of parsed) {
3184
3469
  const p = item as Partial<PatternRecord>;
3185
- if (!p || typeof p.pattern !== 'string' || p.pattern.trim() === '' || existing.has(p.pattern)) {
3470
+ if (!p || typeof p !== 'object') {
3186
3471
  skipped += 1;
3187
3472
  continue;
3188
3473
  }
3189
- const rec: PatternRecord = {
3190
- 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 : '',
3191
3480
  type: (typeof p.type === 'string' ? p.type : 'lesson-learned') as PatternRecord['type'],
3192
3481
  reward: typeof p.reward === 'number' ? Math.max(0, Math.min(1, p.reward)) : 0.8,
3193
3482
  domain: typeof p.domain === 'string' ? p.domain : 'general',
3194
3483
  ts: typeof p.ts === 'string' ? p.ts : new Date().toISOString(),
3195
3484
  source: 'dz-teach-import',
3196
- };
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;
3197
3492
  await recordPattern(storeRoot, rec);
3198
- existing.add(p.pattern);
3493
+ existing.add(importedKey(rec));
3199
3494
  importedRecs.push(rec);
3200
3495
  imported += 1;
3201
3496
  }
@@ -3279,23 +3574,30 @@ async function cmdTeach(options: Map<string, string>, flags: Set<string>, cwd: s
3279
3574
 
3280
3575
  const reward = parseFloat(options.get('reward') ?? '0.8');
3281
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;
3282
3580
 
3283
3581
  if (flags.has('guard')) {
3284
- 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)) });
3285
3583
  if (verdict.action === 'reinforce') {
3286
- const backend = resolveLearningBackend(storeRoot);
3287
- backend.addSample({ dzId: verdict.dzId, kind: 'reinforce', reward: Math.max(0, Math.min(1, reward)), ts: new Date().toISOString() });
3288
- const trained = await backend.train();
3289
- // HIGH-fix: only claim success when the reinforce actually FLUSHED. With backend 'off'
3290
- // (NoopLearningBackend) or a flush failure, flushed === 0 — falling through to the plain
3291
- // teach below so the lesson is NEVER silently discarded (the exact silent-drop the ADR forbids).
3292
- if (trained.flushed > 0) {
3293
- write(`↳ reinforced existing pattern ${verdict.dzId} (cos=${verdict.cosine.toFixed(2)}) — not re-added`);
3294
- const clearedQ = clearAgentdbQuarantine(storeRoot, [verdict.dzId]);
3295
- if (clearedQ.cleared > 0) write(' ↳ promoted out of quarantine (mirror updated)');
3296
- return 0;
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`);
3297
3600
  }
3298
- write(`dz teach --guard: reinforce of ${verdict.dzId} did not flush (backend off or write failure) — teaching the lesson normally instead`);
3299
3601
  }
3300
3602
  }
3301
3603
 
@@ -3310,24 +3612,63 @@ async function cmdTeach(options: Map<string, string>, flags: Set<string>, cwd: s
3310
3612
  // Typed through the shared schema (harness-core owns PatternRecord) so the
3311
3613
  // write side and the read side (recommend's loadPatterns) can never drift —
3312
3614
  // a field rename here is a compile error, not a silently re-muted loop (audit #2).
3313
- const entry: PatternRecord = {
3314
- pattern,
3315
- type: type as PatternRecord['type'],
3316
- reward: Math.max(0, Math.min(1, reward)),
3317
- domain,
3318
- ts: new Date().toISOString(),
3319
- source: 'dz-teach',
3320
- };
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
+ };
3321
3623
 
3322
3624
  // Tier-2 (ADR-005): persist through the unified @dzhechkov/memory store. recordPattern
3323
3625
  // folds any legacy .dz/patterns.jsonl into the backend (idempotent) and returns the
3324
3626
  // total count. The lossy `npx agentdb add` dual-write was removed in Tier-1 (audit #6).
3325
3627
  // lesson-quarantine (opt-in): a fresh lesson is a HYPOTHESIS until it earns promotion.
3326
3628
  const quarantineOn = readMemoryLearningConfig(storeRoot).quarantine;
3327
- 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;
3328
3669
 
3329
- write(`Learned: "${pattern.slice(0, 60)}${pattern.length > 60 ? '...' : ''}"`);
3330
- 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)`);
3331
3672
  write(` Total patterns: ${count}`);
3332
3673
  // WHERE the write landed. MEASURED before this line existed: teach printed the pattern, the
3333
3674
  // domain, the reward and the backend — and not one word about the path, so a store written to
@@ -3337,6 +3678,20 @@ async function cmdTeach(options: Map<string, string>, flags: Set<string>, cwd: s
3337
3678
  // sees a path, cannot tell what chose it, and has no reason to question it. `default` adds
3338
3679
  // nothing, so the line stays byte-identical for everyone who set nothing.
3339
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
+ }
3340
3695
  // ADVICE, not a gate. Someone putting medical lessons in a shared store owns both
3341
3696
  // directories and this binary; refusing would be defending a user against themselves,
3342
3697
  // which this design does not attempt. Making the choice INFORMED is the part that is
@@ -3351,8 +3706,8 @@ async function cmdTeach(options: Map<string, string>, flags: Set<string>, cwd: s
3351
3706
  write(' ⚠ quarantined: excluded from auto-inject, damped in recall — promote by confirming it (dz teach --reinforce "<text>") or dz recall --promote <dzId> --apply');
3352
3707
  }
3353
3708
  // The lexical write above is durable — the vector mirror is strictly best-effort (I-3).
3354
- await emitMirrorQ(storeRoot, [entry], 'dz-teach', quarantineOn);
3355
- return 0;
3709
+ await emitMirrorQ(storeRoot, recordsToMirror, 'dz-teach', quarantineOn);
3710
+ return commandFailed ? 1 : 0;
3356
3711
  }
3357
3712
 
3358
3713
  async function cmdConsolidate(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): Promise<number> {
@@ -3704,7 +4059,14 @@ async function cmdRecallPromote(
3704
4059
  return 0;
3705
4060
  }
3706
4061
 
3707
- 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> {
3708
4070
  const projectRoot = resolve(cwd, options.get('project') ?? '.');
3709
4071
  const asJson = flags.has('json');
3710
4072
  const all = flags.has('all');
@@ -3723,7 +4085,17 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
3723
4085
  // review could not make correct: a tag set by the writer is decidable, prose is not.
3724
4086
  const allPatterns = loadStorePatternsSync(projectRoot);
3725
4087
  const holdout = applyExportHoldout(allPatterns, heldOutAfterOptIn(options.get('include-domain')));
3726
- 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
+ });
3727
4099
  const holdoutNote = renderHoldoutNote(holdout);
3728
4100
  // The opt-in is honoured without argument — and named out loud. A flag that silently
3729
4101
  // includes medical lessons in a portable export is a flag whose consequence the user
@@ -3757,7 +4129,15 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
3757
4129
  const perDomain = Object.fromEntries(
3758
4130
  Object.entries(rawStats.perDomain).filter(([d]) => !holdout.domains.includes(canonicalDomainKey(d))),
3759
4131
  );
3760
- 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
+ };
3761
4141
  const backendStats = resolveLearningBackend(projectRoot).getStats();
3762
4142
  if (asJson) {
3763
4143
  write(JSON.stringify({ patterns, stats, learning: backendStats, withheld: holdout.withheld.length, withheldDomains: holdout.domains }));
@@ -3769,6 +4149,7 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
3769
4149
  write(` backend: ${backendStats.backend}${backendStats.advisory !== undefined ? ` (${backendStats.advisory})` : ''}`);
3770
4150
  write(` domains: ${Object.entries(stats.perDomain).map(([k, v]) => `${k}=${v}`).join(', ') || 'none'}`);
3771
4151
  write(` exact-dup groups: ${stats.exactDupGroups}`);
4152
+ write(` generalized: ${stats.generalized} of ${stats.logicalLessons} lessons`);
3772
4153
  write(` re-teach trend: ${stats.teachEvents} teach event(s), ${stats.reinforceEvents} reinforce event(s)`);
3773
4154
  write(' top uses:');
3774
4155
  for (const row of stats.topUses) write(` ${row.uses}× [${row.reward.toFixed(2)}] (${row.domain}) ${row.pattern.slice(0, 80)}`);
@@ -3889,6 +4270,10 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
3889
4270
  ? ('lexical' as const)
3890
4271
  : ('hybrid' as const);
3891
4272
  const wantedDomain = options.get('domain');
4273
+ const classRecallOptions = {
4274
+ onClassDegraded: writeErr,
4275
+ ...(classMatcher === undefined ? {} : { classMatcher }),
4276
+ };
3892
4277
  // OVER-FETCH before boosting (Codex QE #5): the boost used to run on hits ALREADY
3893
4278
  // truncated to `limit`, so an exact-domain lesson sitting at rank limit+1 could
3894
4279
  // never receive its promised lift — the feature was weakest in exactly the case it
@@ -3911,8 +4296,8 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
3911
4296
  // second occurrence. Foreign stdout is routed to stderr for the duration of the engine call — our
3912
4297
  // own output is written after it returns.
3913
4298
  const result = asJson
3914
- ? await withForeignStdoutOnStderr(() => recallHybrid(projectRoot, query, { limit: fetchLimit, mode, deferExposures: true, ...(wantedDomain !== undefined ? { domain: wantedDomain } : {}) }))
3915
- : await recallHybrid(projectRoot, query, { limit: fetchLimit, mode, deferExposures: true, ...(wantedDomain !== undefined ? { domain: wantedDomain } : {}) });
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 } : {}) });
3916
4301
 
3917
4302
  if (mode === 'semantic' && result.vectorEngine === 'none') {
3918
4303
  // --semantic is an explicit ask — degrading it silently would be dishonest (FR-3).
@@ -3948,13 +4333,26 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
3948
4333
  let globalHits: typeof result.hits = [];
3949
4334
  if (readGlobal) {
3950
4335
  const g = asJson
3951
- ? await withForeignStdoutOnStderr(() => recallHybrid(globalRoot, query, { limit: fetchLimit, mode, deferExposures: true, ...(wantedDomain !== undefined ? { domain: wantedDomain } : {}) }))
3952
- : await recallHybrid(globalRoot, query, { limit: fetchLimit, mode, deferExposures: true, ...(wantedDomain !== undefined ? { domain: wantedDomain } : {}) });
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 } : {}) });
3953
4338
  globalHits = g.hits;
3954
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]));
3955
4349
  const merged = readGlobal
3956
- ? mergeStoreHits(boost ? boost.hits : result.hits, globalHits, (h) => h.pattern.pattern)
3957
- : (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;
3958
4356
  const hits = merged.slice(0, limit);
3959
4357
  // Computed ONCE, honoured by EVERY return path. It used to live only on the text tail, so the two
3960
4358
  // paths that return earlier — `--json` and the zero-hits branch — still reported success. That
@@ -4002,6 +4400,7 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
4002
4400
  // version emitted the number while its own comment promised null (found by independent review).
4003
4401
  write(JSON.stringify(hits.map((h) => ({
4004
4402
  ...h.pattern,
4403
+ ...(h.matchedForm === undefined ? {} : { matchedForm: h.matchedForm }),
4005
4404
  relevance: boost === null && 'score' in h && typeof h.score === 'number' ? h.score : null,
4006
4405
  // The TRUE cosine, as a second companion key — the design named it and the first ship missed
4007
4406
  // it, so a scripted consumer STILL could not threshold (found while recalibrating the floors:
@@ -4066,6 +4465,10 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
4066
4465
  // guessing which second one answered.
4067
4466
  write(` store (read): ${join(globalRoot, '.dz')} [cross-project]`);
4068
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
+ }
4069
4472
  let sawQuarantined = false;
4070
4473
  for (const h of hits) {
4071
4474
  const backendTag = vectorOn ? ` ⟨${h.backend}⟩` : '';
@@ -4090,9 +4493,21 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
4090
4493
  // 160, not 80: at 80 characters the evidence a reader needs to judge relevance sits in the
4091
4494
  // hidden remainder, and the cosine then appears to describe the visible fragment rather than
4092
4495
  // the whole lesson. `--full` prints it all, still on one line.
4093
- const width = flags.has('full') ? Number.POSITIVE_INFINITY : 160;
4094
- const oneLined = oneLine(h.pattern.pattern);
4095
- const shown = width === Number.POSITIVE_INFINITY ? oneLined : oneLined.slice(0, width);
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
+ }
4096
4511
  // WHICH store this hit came from. A merged list that does not say re-creates the fragmentation
4097
4512
  // blindness the store-location line just removed, one level down: the reader would see more
4098
4513
  // results and have no way to tell whether the global store is even connected.
@@ -5096,7 +5511,13 @@ async function cmdSetup(options: Map<string, string>, flags: Set<string>, cwd: s
5096
5511
  ? selectArg.split(',').map((s) => s.trim()).filter((s) => s.length > 0)
5097
5512
  : getPreset(preset)?.skills;
5098
5513
  const install = select !== undefined && select.length > 0
5099
- ? await installSkills({ target, projectRoot, cwd, explicitSkillsDir: options.get('skills-dir'), select, force: flags.has('force'), enrich: flags.has('enrich') })
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
+ })
5100
5521
  : undefined;
5101
5522
  if (install) {
5102
5523
  write(`║ ${String(install.results.length).padStart(2)} skill(s), ${String(install.written).padStart(3)} file(s) written${' '.repeat(15)}║`);
@@ -5106,6 +5527,7 @@ async function cmdSetup(options: Map<string, string>, flags: Set<string>, cwd: s
5106
5527
 
5107
5528
  // Step 5 (ADR-001 §8): DELIVER the codex hooks and verify them live. Non-aborting — the rest of
5108
5529
  // setup has already run and the summary still prints; only the exit code carries the failure.
5530
+ let setupIntegrationOutcomes: readonly IntegrationOutcome[] = install?.integrations ?? [];
5109
5531
  let codexHooksOk = true;
5110
5532
  if (target === 'codex' && !flags.has('no-hooks')) {
5111
5533
  write(`║ 5. Delivering codex hooks (live verify)... ║`);
@@ -5113,6 +5535,11 @@ async function cmdSetup(options: Map<string, string>, flags: Set<string>, cwd: s
5113
5535
  codexHooksOk = delivery.ok;
5114
5536
  for (const line of delivery.stdout) write(line);
5115
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
+ }
5116
5543
  }
5117
5544
 
5118
5545
  write(`╠══════════════════════════════════════════════════════╣`);
@@ -5132,6 +5559,9 @@ async function cmdSetup(options: Map<string, string>, flags: Set<string>, cwd: s
5132
5559
  if (install) {
5133
5560
  write(`dz setup: ${install.results.length} skill(s), ${install.written} file(s) written, ${install.skipped} skipped`
5134
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
+ }
5135
5565
  writeMissingSkillsHint(write, install.missing, selectArg !== undefined ? undefined : preset);
5136
5566
  }
5137
5567
  // A hook that was written but never witnessed firing is NOT a completed setup (ADR-002 §5): the
@@ -5146,7 +5576,8 @@ async function cmdSetup(options: Map<string, string>, flags: Set<string>, cwd: s
5146
5576
  if (erroredSteps.length > 0) {
5147
5577
  write(`\n✗ setup reported ${erroredSteps.length} failed step(s): ${erroredSteps.join(', ')} — exit 1`);
5148
5578
  }
5149
- 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;
5150
5581
  }
5151
5582
 
5152
5583
  function cmdPretrain(options: Map<string, string>, cwd: string, write: Write): number {
@@ -5804,6 +6235,9 @@ function cmdPublish(options: Map<string, string>, flags: Set<string>, cwd: strin
5804
6235
  if (guardResult.verdict === 'block') write(`dz publish: ⚠ guard BLOCK overridden via --no-guard: ${noGuard} (logged)`);
5805
6236
  else if (guardResult.verdict === 'warn') for (const v of guardResult.violations) write(`dz publish: ⚠ guard warn — ${v.rule}: ${v.detail}`);
5806
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
+ }
5807
6241
  for (const n of guardResult.notes ?? []) write(`dz publish: ℹ guard note — ${n}`); // FN-7: on the record, never blocking
5808
6242
  }
5809
6243
 
@@ -7503,6 +7937,31 @@ export interface CodexHooksSummary {
7503
7937
  readonly stderr: readonly string[];
7504
7938
  }
7505
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
+
7506
7965
  /**
7507
7966
  * What the user is told about a sync report — the ONE place the success word can be printed.
7508
7967
  *
@@ -7831,6 +8290,392 @@ function gatherReadmeCounts(root: string): { label: string; a: number; b: number
7831
8290
  return pairs;
7832
8291
  }
7833
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
+
7834
8679
  /** Gather the facts one op needs. All I/O is best-effort — a missing signal skips its rule, never crashes. */
7835
8680
  function gatherGuardFacts(op: string, root: string, text: string | undefined, storeCap: number): Record<string, unknown> {
7836
8681
  const facts: Record<string, unknown> = { op };
@@ -7954,7 +8799,7 @@ function gatherGuardFacts(op: string, root: string, text: string | undefined, st
7954
8799
  const manifests: Manifest[] = [];
7955
8800
  const located: { dir: string; m: Manifest }[] = [];
7956
8801
  try {
7957
- 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']);
7958
8803
  for (const rel of out.split('\n').map((s) => s.trim()).filter(Boolean)) {
7959
8804
  try {
7960
8805
  const m = JSON.parse(readFileSync(join(root, rel), 'utf8')) as Manifest;
@@ -7978,6 +8823,11 @@ function gatherGuardFacts(op: string, root: string, text: string | undefined, st
7978
8823
  packages.push({ name: m.name ?? '(unnamed)', deps });
7979
8824
  }
7980
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
+ })));
7981
8831
 
7982
8832
  // licence-hold (ADR-001, hermes-claude-adaptation): for each pack DECLARING a hold via a
7983
8833
  // `licenseHold` field, hand the raw evidence to the pure checker. Best-effort: an unreadable
@@ -8202,6 +9052,11 @@ function runGuardEvaluation(root: string, op: string, text: string | undefined,
8202
9052
  return result;
8203
9053
  }
8204
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
+
8205
9060
  /**
8206
9061
  * The tail facts of an append-only log, read from its END — O(1) in the file size, which is what
8207
9062
  * lets the chain be extended on every append without a full-file scan (FR-2). Anything unreadable
@@ -8542,7 +9397,8 @@ function cmdGuardPromote(options: Map<string, string>, flags: Set<string>, root:
8542
9397
  ...next,
8543
9398
  entries: Object.fromEntries(Object.entries(next.entries).map(([k, v]) => (applied.includes(k) ? [k, { ...v, appliedTs: nowTs }] : [k, v]))),
8544
9399
  };
8545
- 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 */ }
8546
9402
  }
8547
9403
 
8548
9404
  // A refused conflict means the requested apply did NOT fully happen — exit non-zero rather than
@@ -8634,6 +9490,7 @@ function cmdGuard(options: Map<string, string>, flags: Set<string>, cwd: string,
8634
9490
 
8635
9491
  const glyph = result.verdict === 'block' ? '✗' : result.verdict === 'warn' ? '⚠' : '✓';
8636
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));
8637
9494
  for (const v of result.violations) write(` [${v.severity === 'hard' ? 'BLOCK' : 'warn'}] ${v.rule}: ${v.detail}`);
8638
9495
  for (const n of result.notes ?? []) write(` [note] ${n}`); // information, never a verdict input (FN-7)
8639
9496
  if (result.verdict === 'block' && forced) write(` → forced through: ${force} (logged to .dz/guard-audit.jsonl)`);
@@ -13058,6 +13915,160 @@ function readOptionalText(path: string): string {
13058
13915
  }
13059
13916
  }
13060
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
+
13061
14072
  function deadwoodAllowlistText(): string {
13062
14073
  const require = createRequire(import.meta.url);
13063
14074
  const corePackage = require.resolve('@dzhechkov/harness-core/package.json');
@@ -13143,7 +14154,7 @@ function cmdCompounding(options: Map<string, string>, flags: Set<string>, cwd: s
13143
14154
  const json = flags.has('json');
13144
14155
  if (flags.has('help')) {
13145
14156
  write('dz compounding [--project <dir>] [--json] — honest learning-loop payoff report');
13146
- write(' pool payoff (write-only ratio) · guard repeat-violation trajectory · cold-vs-warm replay readiness · instrumentation health');
14157
+ write(' pool payoff · guard trajectory · replay readiness · instrumentation · monthly eligible→attempted→accepted→executions funnel');
13147
14158
  return 0;
13148
14159
  }
13149
14160
  for (const flag of flags) {
@@ -13175,40 +14186,24 @@ function cmdCompounding(options: Map<string, string>, flags: Set<string>, cwd: s
13175
14186
  // apply-leg usage events (read records only; aggregate rows carry no query by construction)
13176
14187
  const usage = readRecallUsageEvents(root);
13177
14188
 
13178
- // guard audit events
13179
- const guard: { ts: string; verdict: string; rules: string[] }[] = [];
13180
- try {
13181
- const text = readFileSync(join(root, '.dz', 'guard-audit.jsonl'), 'utf-8');
13182
- for (const line of text.split('\n')) {
13183
- if (line.trim() === '') continue;
13184
- try {
13185
- const o = JSON.parse(line) as { ts?: unknown; verdict?: unknown; violations?: { rule?: unknown }[] };
13186
- if (typeof o.ts === 'string' && typeof o.verdict === 'string') {
13187
- guard.push({
13188
- ts: o.ts,
13189
- verdict: o.verdict,
13190
- rules: Array.isArray(o.violations) ? o.violations.map((v) => (typeof v?.rule === 'string' ? v.rule : '')).filter(Boolean) : [],
13191
- });
13192
- }
13193
- } catch {
13194
- /* skip */
13195
- }
13196
- }
13197
- } catch {
13198
- /* no audit yet */
13199
- }
14189
+ const promotionEvidence = readPromotionRunEvidence(root);
14190
+ const guardEvidence = readGuardAuditEvidence(root);
14191
+ const guard = [...guardEvidence.rows];
13200
14192
 
13201
14193
  // The evidence logs themselves, verbatim: the report verifies their hash chains (feature
13202
14194
  // event-chain). Handing over the TEXT rather than a pre-computed verdict keeps one definition of
13203
14195
  // "the chain is intact" — a second copy here is how a gate and its report start disagreeing.
13204
14196
  const evidenceLogs: { log: string; text: string }[] = [];
13205
- for (const rel of ['.dz/recall-usage.jsonl', '.dz/guard-audit.jsonl']) {
14197
+ for (const rel of ['.dz/recall-usage.jsonl']) {
13206
14198
  try {
13207
14199
  evidenceLogs.push({ log: rel, text: readFileSync(join(root, ...rel.split('/')), 'utf-8') });
13208
14200
  } catch {
13209
14201
  /* absent log — reported by its own gate above, not invented here */
13210
14202
  }
13211
14203
  }
14204
+ if (guardEvidence.text !== null) {
14205
+ evidenceLogs.push({ log: '.dz/guard-audit.jsonl', text: guardEvidence.text });
14206
+ }
13212
14207
 
13213
14208
  const now = new Date();
13214
14209
  const report = assembleCompoundingReport({
@@ -13218,6 +14213,13 @@ function cmdCompounding(options: Map<string, string>, flags: Set<string>, cwd: s
13218
14213
  nowTs: now.toISOString(),
13219
14214
  evidenceLogs,
13220
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
+ },
13221
14223
  });
13222
14224
  // lesson-bandit-rerank §11: the payoff axis joins THIS report rather than growing a private
13223
14225
  // dashboard — the `rewardEvents : exposureEvents` row asks exactly the question this command
@@ -15110,11 +16112,20 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
15110
16112
  case 'bundle':
15111
16113
  return cmdBundle(options, flags, cwd, write);
15112
16114
  case 'teach':
15113
- 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
+ );
15114
16125
  case 'consolidate':
15115
16126
  return await cmdConsolidate(options, flags, cwd, write);
15116
16127
  case 'recall':
15117
- return await cmdRecall(options, flags, cwd, write);
16128
+ return await cmdRecall(options, flags, cwd, write, writeErr, io.classMatcher);
15118
16129
  case 'vector':
15119
16130
  return await cmdVector(options, flags, cwd, write);
15120
16131
  case 'brain':
@@ -15167,6 +16178,8 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
15167
16178
  return cmdDriftCheck(options, flags, cwd, write);
15168
16179
  case 'hooks-sync':
15169
16180
  return cmdHooksSync(options, flags, cwd, write, writeErr);
16181
+ case 'integrations-verify':
16182
+ return cmdIntegrationsVerify(options, flags, cwd, write, writeErr);
15170
16183
  case 'agents-sync':
15171
16184
  return cmdAgentsSync(options, flags, cwd, write, writeErr);
15172
16185
  case 'sync-canonical':