@dzhechkov/harness-cli 0.7.8 → 0.8.1

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
@@ -302,6 +302,9 @@ import {
302
302
  renderContentProbe,
303
303
  findNonRegistrableSkillDirs,
304
304
  assembleCompoundingReport,
305
+ banditStats,
306
+ narrowBanditReport,
307
+ renderBanditHealth,
305
308
  // Cold-vs-warm EPOCH RUNNER (feature epoch-replay) — orchestrates + scores, never calls a model.
306
309
  replayableInstances,
307
310
  buildWorkOrder,
@@ -430,6 +433,8 @@ import {
430
433
  parseMutationRegistry,
431
434
  applyMutationToText,
432
435
  countFailingTests,
436
+ detectSuiteCompletionReceipt,
437
+ detectSuiteReceiptMismatch,
433
438
  classifyBaseline,
434
439
  classifyRunFailure,
435
440
  classifyMutationOutcome,
@@ -460,7 +465,33 @@ import {
460
465
  buildCadenceReport,
461
466
  tgVisibleSha256,
462
467
  CADENCE_WINDOW_DAYS,
468
+ readQeRounds,
469
+ QE_ROUNDS_DEFAULT_CEILING,
470
+ describeStoreLocation,
471
+ storeLocationLine,
472
+ resolveTeachTarget,
473
+ teachReasonPhrase,
474
+ readTeachToConfig,
475
+ TeachTargetError,
476
+ mergeStoreHits,
477
+ sameStore,
478
+ globalStoreRoot,
479
+ storeCountLabel,
480
+ // operator-profile (ADR-001): per-user 0600 store + marked block in ~/.claude/CLAUDE.md
481
+ renderProfileBlock,
482
+ readProfile,
483
+ writeProfile,
484
+ syncProfileBlock,
485
+ checkProfileDrift,
486
+ parseRegister,
487
+ registerOwnerWord,
488
+ profileAgeDays,
489
+ parseDomainList,
490
+ domainListText,
491
+ parseYesNo,
492
+ REGISTERS,
463
493
  } from '@dzhechkov/harness-core';
494
+ import type { OperatorProfile, Domain as ProfileDomain, Register as ProfileRegister } from '@dzhechkov/harness-core';
464
495
  import type { RecordKind, RecordDecision } from '@dzhechkov/harness-core';
465
496
  import type { CadenceWindow } from '@dzhechkov/harness-core';
466
497
  import type { MutationEntryResult, MutationObservation, MutationRegistryEntry } from '@dzhechkov/harness-core';
@@ -515,12 +546,19 @@ Usage:
515
546
  dz score --slug <feature> [--project <dir>] [--json] (process scorecard for ONE feature-adr run, from its artifacts: ADR confirmation, discrimination, cross-model QE grade, live verification, README-first, learning loop, amendments — descriptive-only, a low score exits 0)
516
547
  dz recap [--day|--week|--month] [--at <ISO date>] [--project <dir>] [--json] (what was done over a window, from records only: deliveries with the grade an independent review STATED — a report naming two grades is reported ambiguous, never guessed — registry publishes, gate verdicts, knowledge reuse. --quarter/--half-year/--year are RECOGNISED and REFUSED with the real span in days: there is one complete quarter and the longest record is 174 days. Every section carries its own data-start date, and "the source was not read" never prints as zero. Contaminated measures — commit count, lines, tokens, learning-event volume, inventory counts, lesson count — are not computed, and the report says so. exit 0 reported / 2 refused)
517
548
  dz cadence [--window day|week|month|quarter|halfyear|year] [--json] (the WHAT-SHIPPED aggregator: graded-shipment cadence by ISO week + npm-publish cadence (recap cache) + guard repeat decay on the FIXED rule set + recall reuse; a window deeper than 2× the record is REFUSED with the depth named (ADR: a cadence from one point is scale forgery); exit 0 report / 2 refused-window / 1 usage)
549
+ dz qe-rounds (--slug <feature> | --feature-dir <abs>) [--ceiling <n>] [--project <dir>] [--json] (how many Step-8 review rounds has this feature ALREADY had? Reads what dz qe-bridge already wrote — signoff-<runId>.json and failed-*.json under features/<slug>/.fa-state/qe-bridge — and writes nothing itself, so it can answer for runs already past. A round is a runId, not a file; an attempt with no verdict is counted SEPARATELY and never merged; an unreadable record is NAMED and the count is declared a LOWER BOUND. ONE directory, never a union across checkouts. exit 0 under the ceiling / 1 at-or-over — owner decides, the command does not judge whether the rounds were warranted / 2 NOT ESTABLISHED, which is never "zero rounds")
518
550
  dz tg-post --draft <file.html> [--manifest <sources.json>] [--channel <@name|id>] [--send --yes] [--night] [--preview] [--json] (the sender for an APPROVED channel post, per the accepted genai-tweets-channel ADRs: HTML mode only — never MarkdownV2; link preview OFF by default (x.com previews in Telegram are broken); the 00:00-06:00 MSK quiet window refuses without an explicit --night. DEFAULT IS A DRY-RUN: it validates the draft (tag balance, allowed tags, bare &/<, the 4096 visible-character limit with the overshoot counted) and runs the provenance gate over --manifest IN-PROCESS — a draft with no manifest is refused as unchecked, and anything but ALLOWED refuses. A real send needs --send --yes, stating ADR-004's manual-publishing decision out loud each time. The token comes from TELEGRAM_BOT_TOKEN or telegram.tokenFile in .dz/config.json and is never printed. exit 0 sent or clean dry-run / 1 refused or Telegram error / 2 usage)
519
551
  dz name-check [--command <n>] [--module <basename>] [--export <a,b>] [--project <dir>] [--json] (is this name free, BEFORE a line of code? Scans workspace SOURCE — never dist, because a stale build answers 'free' confidently. Checks a dz command name against the dispatcher AND the help block, a module basename against every package's src/, and exported identifiers against every declaration in the workspace. exit 0 all free / 1 at least one taken, naming where / 2 nothing asked or the scan did not run — an empty sweep is never a clean bill. Honest limit, printed on the passing path: it reads declarations, so a re-export under a different name stays the build's job)
520
552
  dz provenance-check --manifest <sources.json> [--project <dir>] [--json] (nothing goes out citing a source that may not leave this machine. Checks PROVENANCE, not words: every claim names its source, and only a KNOWN kind that resolves safely is cleared. Repo paths go through 'git -C <root> check-ignore' over the RESOLVED path — a symlink into an ignored directory is REFUSED (git classifies the string and never dereferences, MEASURED), and the verdict does not change with your working directory. Store records must be named in the git-TRACKED provenance-public.json, so declaring one public is a reviewable commit rather than a field inside an ignored store. An undeclared kind is refused, never inferred from the path's shape. exit 0 allowed / 1 blocked / 3 NOT ESTABLISHED — an empty manifest, an unreadable one, or an oracle that did not run is never a pass. It proves what was CITED: it cannot see a paraphrase with no citation, nor confidential text pasted by hand into an allowed file)
553
+ dz project-skills [--project <dir>] [--json] [--stages-json] (polymorphic feature-adr: resolve architecture/project-skills.json — fixed roles product-vision/critic/brand/impl-bar plus an open extra[] — into per-stage guidance. READ-ONLY. --project names the root explicitly, so it works from any cwd; without it the manifest is read from the current repo. No manifest ⇒ a byte-identical generic run)
554
+ dz discrimination-check --slug <slug> [--base <ref>] [--json] (does the ADR's named test actually DISCRIMINATE? Re-runs it on a worktree at the pre-feature commit, where it MUST go red. A test that passes with the feature removed proves nothing; dz amendment-check proves the test exists, this proves it bites)
555
+ dz guard [check|promote|init] [--json] [--force] (HARD/SOFT repo rules — readme-first, lockfile-in-sync, claim tagging — run automatically as a pre-flight inside dz publish. HARD blocks, SOFT warns)
556
+ dz architecture [--check --slug <s> --desc <text>] [--project <dir>] [--revise] (the live product map + vision: --check is the soft Step-0 сверка of a new feature against them, reporting {signal,confidence} rather than blocking)
557
+ dz sbom [--pack <name>] [--out <file>] (CycloneDX software bill of materials for the workspace, or for one pack with --pack)
521
558
  dz amendment-check --slug <slug> | --feature-dir <dir> | --all [--json] (the deterministic Step-8 amendment gate: every AM-N row must resolve to a test found INSIDE the file the row names; the PLAN is authoritative when it carries rows, and an ideation amendment the plan drops is a failure. exit 0 pass/skip, 1 fail, 3 NOT-ESTABLISHED — a section that parsed ZERO rows is never a pass. --all is a CENSUS and always exits 0. Does NOT prove non-vacuity — that is dz discrimination-check)
522
559
  dz feature-adr-record --kind ledger|training-pair --stage <s> [--slug <s>] [--row|--pair <json>] [--mark <n>] [--once] [--json] (the witnessed writer for the run-cost ledger and training pairs: the payload arrives as an ARGUMENT, never as shell; a malformed or wrong-kind payload is REFUSED before any write; the timestamp is stamped before serialising; the append is verified by re-reading the tail. exit 0 written|duplicate|skipped, 2 refused, 3 not-verified — a record failure is never blocking)
523
560
  dz feature-adr-checkpoint (--slug <feature> | --feature-dir <abs>) --stage <s> --input-hash <h> --result <json> [--artifact a,b] [--json] (record a pipeline stage ONLY after measuring its artifacts on disk; refuses a null result, an absent artifact, or a stage that declares none — the subagent runs a COMMAND instead of hand-writing durable state)
561
+ dz profile [init|show|set|sync] [--json] (WHO the assistant is talking to — per-user store at ~/.dz/profile.json (0600, NEVER in a project), delivered as a marked block in ~/.claude/CLAUDE.md so it loads in EVERY project, dz installed or not. init = five questions (language, register, deep/weak domains as comma lists — "networking (CCIE; NSX)" keeps the parenthetical as the note, Enter skips — teaches y/n with one re-ask, never a silent default); show ALWAYS prints the store path + age + drift verdict + the rendered block; set register|language|teaches <v> or set deep|weak add|rm <tag> [note] — register accepts the owner's own words (профи / профи лайт / просто), an unknown value is REFUSED naming the accepted set; sync re-writes the block (runs automatically after init/set; foreign content byte-for-byte, timestamped backup before every modifying write). The register changes FORM, never FACTS, and governs dialogue only — never ADRs/commits/QE reports; both rules are baked into the rendered block at every level. exit 0 done / 1 no profile or failed / 2 refused input)
524
562
  dz reqe [--slug <feature> [--done --report <f>]] [--json] (the re-QE debt ledger: a usage-switched run whose Step-8 QE ran on the coder's OWN family records a debt; list debts, print the cross-family review brief, settle FAIL-CLOSED against a graded report — the settlement lands in 08_qe_report.md)
525
563
  dz qe-bridge --family claude --slug <feature> [--coder-family codex|claude] [--model <id>] [--files a,b] [--out <f>] [--timeout <s>] [--allow-same-family] [--json] (the REVERSE QE bridge: run an INDEPENDENT Claude reviewer over a feature's Step-8 artifacts from ANY host — a Codex session included, plain shell, no Claude agent plane needed — and land a PARSED signoff. The reviewer runs ISOLATED: an EMPTY temp cwd plus --safe-mode --strict-mcp-config --tools '' --no-session-persistence, so no CLAUDE.md/skills/plugins/hooks/MCP load, and the verdict is read from the --output-format json RESULT ENVELOPE — text a session customization printed onto the same stdout can never become a signoff. Probes the model before trusting it; sends SCOPED extracts with a loud 200k-char ceiling (never silent truncation); the grade must AGREE across three LAST-anchored channels (terminal marker line, fenced qe-bridge-signoff JSON, the report's own GRADE line) AND the marker must be the FINAL content — empty, gradeless, self-contradicting or miscounted output is one of 18 NAMED failures with an audit record under features/<slug>/.fa-state/qe-bridge/ (runId, resolved executable + binOverride, prompt sha256, channel offsets, requestedOut, reportWritten, retained raw stdout; 0600 files in a 0700 dir), never a clean review. A --coder-family that contradicts the recorded reqe debt is refused. Writes features/<slug>/08b_reqe_report.md, which dz reqe --done settles unchanged. DISCLOSURE: the extracts you scope are sent to the Claude runtime; the bridge cannot classify secrets. DZ_QE_BRIDGE_CLAUDE_BIN is a TEST SEAM, not a flag. exit 0 signoff parsed (ANY grade — it reports, it does not gate) / 1 named failure / 2 usage)
526
564
  dz mutation-gate [--package <dir>] [--registry <file>] [--test-cmd "<cmd>"] [--only <id[,id]>] [--timeout <ms>] [--rebaseline per-entry|final] [--keep-scratch] [--json] (prove each NAMED protection has a test that DISCRIMINATES: copy the package to a scratch dir, verify the baseline suite is green, apply each registry mutation, run the suite, REQUIRE red, restore. The red must be BEHAVIOURAL: a mutation that no longer parses is MUTATION_UNPARSEABLE; a red run whose OWN output reports a test FILE failing to load (node --test file-level not-ok with exitCode, vitest Failed Suites) is MUTATION_LOAD_FATAL — the signal comes from the same run as the failing count, never from a separate isolated import; red output whose shape matches no known runner is INCONCLUSIVE (a runner-coverage gap, loud, never PROVEN); a count far above the entry's bound is OVER_FAILING; a restored tree that does not reproduce green makes the entry INCONCLUSIVE (flaky). Mutation writes are realpath-contained to the scratch copy: a symlink escape or a node_modules/ target is refused (exit 2), the real tree is never written. A mutation that does not apply, a green suite, or an inconclusive run is a FAILURE — never a skip. exit 0 all proven / 1 gate failed / 2 setup error)
@@ -2633,6 +2671,76 @@ function cmdUsageByStage(
2633
2671
  * non-zero exit. `--project <dir>` scopes ONLY the `.dz/config.json` read/write; measurement is
2634
2672
  * account-wide (all projects).
2635
2673
  */
2674
+ /**
2675
+ * dz qe-rounds — how many Step-8 review rounds has one feature already had?
2676
+ *
2677
+ * The stopping rule ("Max iterations: 3") lived ONLY as a sentence in a prose module, so every
2678
+ * restart of the agent forgot it. MEASURED 2026-08-27: one real slug reached 38 graded rounds.
2679
+ * This makes the rule answerable by a command instead of by memory — and the exit code ASKS the
2680
+ * owner rather than concluding, because whether 38 rounds were warranted is not a thing a counter
2681
+ * can know.
2682
+ */
2683
+ function cmdQeRounds(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
2684
+ const root = resolve(cwd, options.get('project') ?? '.');
2685
+ const slug = (options.get('slug') ?? '').trim();
2686
+ const dirOpt = (options.get('feature-dir') ?? '').trim();
2687
+ if (!slug && !dirOpt) {
2688
+ write('dz qe-rounds: need --slug <feature> or --feature-dir <abs path>');
2689
+ return 2;
2690
+ }
2691
+ // One directory, never a union. MEASURED: the slug `package-story-page-hardening` exists in two
2692
+ // checkouts holding 38 and 7 records; a tool that searched for the slug would report 45 for a run
2693
+ // that had 38, and the output would look identical to a correct one.
2694
+ const featureDir = dirOpt ? resolve(cwd, dirOpt) : join(root, 'features', slug);
2695
+
2696
+ const ceilingRaw = options.get('ceiling');
2697
+ let ceiling = QE_ROUNDS_DEFAULT_CEILING;
2698
+ if (ceilingRaw !== undefined) {
2699
+ const n = Number(ceilingRaw);
2700
+ if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1) {
2701
+ write('dz qe-rounds: --ceiling must be a positive integer, got ' + JSON.stringify(ceilingRaw));
2702
+ return 2;
2703
+ }
2704
+ ceiling = n;
2705
+ }
2706
+
2707
+ const r = readQeRounds(featureDir, { ceiling });
2708
+ if (flags.has('json')) {
2709
+ write(JSON.stringify(r));
2710
+ return r.status === 'not-established' ? 2 : r.status === 'at-or-over-ceiling' ? 1 : 0;
2711
+ }
2712
+
2713
+ if (r.status === 'not-established') {
2714
+ write('dz qe-rounds: NOT ESTABLISHED — ' + (r.notEstablishedReason ?? 'no readable records'));
2715
+ write(' This is not "zero rounds". Nothing was measured, so nothing follows about continuing.');
2716
+ if (r.unreadable.length) {
2717
+ write(' unreadable record(s):');
2718
+ for (const u of r.unreadable) write(' - ' + u.file + ' — ' + u.why);
2719
+ }
2720
+ return 2;
2721
+ }
2722
+
2723
+ write('dz qe-rounds: ' + r.rounds + ' graded round(s), ceiling ' + r.ceiling
2724
+ + (r.failedAttempts.length ? ', plus ' + r.failedAttempts.length + ' attempt(s) with no verdict' : ''));
2725
+ write(' dir: ' + r.dir);
2726
+ if (r.firstAt && r.lastAt) write(' window: ' + r.firstAt + ' → ' + r.lastAt);
2727
+ if (r.grades.length) write(' grades: ' + r.grades.join(' '));
2728
+ if (r.unreadable.length) {
2729
+ // Named, never silently skipped: a dropped record makes the count quietly too low, and in a
2730
+ // counter whose job is to STOP a loop that fails open.
2731
+ write(' ' + r.unreadable.length + ' unreadable record(s) — the count below is a LOWER BOUND:');
2732
+ for (const u of r.unreadable) write(' - ' + u.file + ' — ' + u.why);
2733
+ }
2734
+ if (r.status === 'at-or-over-ceiling') {
2735
+ write(' STOP: the ceiling is reached. The rule in 08-qe.md says the remaining gaps go to the');
2736
+ write(' owner for a decision. This command does not judge whether the rounds were warranted —');
2737
+ write(' it only makes sure the question gets asked.');
2738
+ return 1;
2739
+ }
2740
+ write(' under the ceiling — another round is within the documented budget');
2741
+ return 0;
2742
+ }
2743
+
2636
2744
  function cmdCadence(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
2637
2745
  const root = resolve(cwd, options.get('project') ?? '.');
2638
2746
  const windowRaw = (options.get('window') ?? 'week').trim() as CadenceWindow;
@@ -2797,8 +2905,66 @@ function cmdUsage(
2797
2905
  }
2798
2906
  }
2799
2907
 
2800
- async function cmdTeach(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): Promise<number> {
2908
+ /**
2909
+ * Which store a learning WRITE belongs to, resolved once and shared.
2910
+ *
2911
+ * `dz retro` reads a recurrence ledger and then calls `cmdTeach` to append to it. Before this
2912
+ * helper existed, a session mode split the two: the ledger was read from the project while the
2913
+ * write went to the home store, so the recurrence count never advanced and the drill threshold
2914
+ * could never be reached (cross-family QE, 2026-08-27). A read and its write must resolve the
2915
+ * same way or the counter they share is a fiction.
2916
+ */
2917
+ function resolveLearningStore(
2918
+ options: Map<string, string>, cwd: string,
2919
+ ): { projectRoot: string; storeRoot: string; target: ReturnType<typeof resolveTeachTarget> } {
2801
2920
  const projectRoot = resolve(cwd, options.get('project') ?? '.');
2921
+ const target = resolveTeachTarget({
2922
+ flag: options.get('to'),
2923
+ env: process.env.DZ_LEARN,
2924
+ config: readTeachToConfig(projectRoot),
2925
+ });
2926
+ return {
2927
+ projectRoot,
2928
+ storeRoot: target.store === 'global' ? globalStoreRoot() : projectRoot,
2929
+ target,
2930
+ };
2931
+ }
2932
+
2933
+ /** The store line for a learning write, carrying the path AND what chose it. */
2934
+ function learningStoreLine(
2935
+ storeRoot: string, projectOption: string | undefined,
2936
+ target: ReturnType<typeof resolveTeachTarget>, verb: 'written' | 'read',
2937
+ ): string {
2938
+ const reason = teachReasonPhrase(target.reason);
2939
+ return storeLocationLine(
2940
+ describeStoreLocation(storeRoot, projectOption, target.store === 'global' ? 'global' : undefined),
2941
+ verb,
2942
+ ) + (reason ? ' [' + reason + ']' : '');
2943
+ }
2944
+
2945
+ async function cmdTeach(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): Promise<number> {
2946
+ // WHICH store this lesson belongs to, and WHO decided (teach-chooses-its-store).
2947
+ // `--to` → `DZ_LEARN` → `.dz/config.json` learning.teachTo → project. The owner asked for a
2948
+ // per-session choice; for a CLI every invocation is a fresh process, so the only honest session
2949
+ // is the shell, and the only honest session state is an environment variable.
2950
+ // An unknown value is REFUSED here rather than defaulted: `DZ_LEARN=globl` silently writing to
2951
+ // the project store would be precisely the invisible mislabel this mode exists to prevent.
2952
+ let resolved;
2953
+ try {
2954
+ resolved = resolveLearningStore(options, cwd);
2955
+ } catch (e) {
2956
+ if (e instanceof TeachTargetError) { write('dz teach: ' + e.message); return 1; }
2957
+ throw e;
2958
+ }
2959
+ // The default keeps writing exactly where it wrote before this feature existed — MEASURED: this
2960
+ // repo's own store holds 361 records written under that behaviour, and every other user's store
2961
+ // is the same. Only an explicit choice moves it.
2962
+ const { storeRoot, target: teachTarget } = resolved;
2963
+ // The verb is per OUTCOME, not per command: a harmonize dry-run and a failed --reinforce READ
2964
+ // the store and change nothing, so saying "written" there is a false claim about what happened
2965
+ // (cross-family QE round 2, 2026-08-27).
2966
+ const storeLine = (verb: 'written' | 'read'): string =>
2967
+ learningStoreLine(storeRoot, options.get('project'), teachTarget, verb);
2802
2968
 
2803
2969
  // Vector tier (dz-rvf-vector-bridge FR-1): best-effort mirror AFTER the lexical write is
2804
2970
  // durable (I-3). Auto-gated on the agentdb memory backend / an explicit vector-engine config
@@ -2825,7 +2991,15 @@ async function cmdTeach(options: Map<string, string>, flags: Set<string>, cwd: s
2825
2991
  // learned store. ONE implementation (harmonizeVectorStore), two entry points (QR-6). Routed
2826
2992
  // BEFORE the --from-json / single-teach paths. Dry-run by default; --apply after a backup.
2827
2993
  if (flags.has('harmonize')) {
2828
- return runHarmonize(projectRoot, options, flags, write);
2994
+ // `--harmonize --apply` MUTATES this store. A forgotten session mode pointing it at ~/.dz
2995
+ // and saying nothing was the exact hazard this feature exists to close (cross-family QE).
2996
+ // Suppressed under --json: this line ahead of the report made stdout unparseable, which is a
2997
+ // worse defect than the invisibility it was closing (measured live, cross-family QE round 2).
2998
+ if (!flags.has('json')) write(storeLine(flags.has('apply') ? 'written' : 'read'));
2999
+ return runHarmonize(storeRoot, options, flags, write, {
3000
+ store: join(storeRoot, '.dz'),
3001
+ storeChosenBy: teachTarget.reason,
3002
+ });
2829
3003
  }
2830
3004
 
2831
3005
  // Bulk import: `dz teach --from-json <file>` ingests a `dz recall --all --json`
@@ -2852,7 +3026,7 @@ async function cmdTeach(options: Map<string, string>, flags: Set<string>, cwd: s
2852
3026
  write('dz teach --from-json: expected a JSON array (produced by `dz recall --all --json`)');
2853
3027
  return 1;
2854
3028
  }
2855
- const existing = new Set(loadStorePatternsSync(projectRoot).map((p) => p.pattern));
3029
+ const existing = new Set(loadStorePatternsSync(storeRoot).map((p) => p.pattern));
2856
3030
  let imported = 0;
2857
3031
  let skipped = 0;
2858
3032
  const importedRecs: PatternRecord[] = [];
@@ -2870,7 +3044,7 @@ async function cmdTeach(options: Map<string, string>, flags: Set<string>, cwd: s
2870
3044
  ts: typeof p.ts === 'string' ? p.ts : new Date().toISOString(),
2871
3045
  source: 'dz-teach-import',
2872
3046
  };
2873
- await recordPattern(projectRoot, rec);
3047
+ await recordPattern(storeRoot, rec);
2874
3048
  existing.add(p.pattern);
2875
3049
  importedRecs.push(rec);
2876
3050
  imported += 1;
@@ -2880,7 +3054,7 @@ async function cmdTeach(options: Map<string, string>, flags: Set<string>, cwd: s
2880
3054
  // Carrying a brain to a new machine goes through this path, and the mirror gate is the SAME one
2881
3055
  // teach uses — so without a config the whole import lands unindexed while `vector status` still
2882
3056
  // prints `pending: 0`. Say it here, where the user can act on it (FR-6).
2883
- if (imported > 0 && !flags.has('no-mirror') && !vectorMirrorEnabled(projectRoot)) {
3057
+ if (imported > 0 && !flags.has('no-mirror') && !vectorMirrorEnabled(storeRoot)) {
2884
3058
  write(` ⚠ the vector mirror writer is OFF — these ${imported} pattern(s) are LEXICAL ONLY`);
2885
3059
  write(` enable it in .dz/config.json (memory.backend=agentdb), then run: dz vector reindex`);
2886
3060
  }
@@ -2888,8 +3062,8 @@ async function cmdTeach(options: Map<string, string>, flags: Set<string>, cwd: s
2888
3062
  // a shared store as silently as a hand-typed teach — and it returned before the
2889
3063
  // advice single-teach prints. The same advice, at the same point in the flow: after
2890
3064
  // the write, naming the choice, blocking nothing.
2891
- let resolvedImportRoot = projectRoot;
2892
- try { resolvedImportRoot = realpathSync(projectRoot); } catch { /* unresolvable is not the brain */ }
3065
+ let resolvedImportRoot = storeRoot;
3066
+ try { resolvedImportRoot = realpathSync(storeRoot); } catch { /* unresolvable is not the brain */ }
2893
3067
  const importedMedical = importedRecs.filter(
2894
3068
  (r) => DEFAULT_HELD_OUT_DOMAINS.map(canonicalDomainKey).includes(canonicalDomainKey(r.domain)),
2895
3069
  );
@@ -2900,11 +3074,12 @@ async function cmdTeach(options: Map<string, string>, flags: Set<string>, cwd: s
2900
3074
  write(advice);
2901
3075
  }
2902
3076
  }
2903
- write(` Backend: memory (@dzhechkov/memory) Total now: ${loadStorePatternsSync(projectRoot).length}`);
3077
+ write(` Backend: memory (@dzhechkov/memory) Total now: ${loadStorePatternsSync(storeRoot).length}`);
3078
+ write(storeLine('written'));
2904
3079
  // ONE batched mirror call through the same seam as single-teach (QR-6 — no bespoke path).
2905
- await emitMirror(projectRoot, importedRecs, 'dz-teach-import');
3080
+ await emitMirror(storeRoot, importedRecs, 'dz-teach-import');
2906
3081
  if (imported > 0) {
2907
- const report = await harmonizeVectorStore(projectRoot, {});
3082
+ const report = await harmonizeVectorStore(storeRoot, {});
2908
3083
  write(` ℹ ${imported} imported — ${report.clusters.length} near-duplicate cluster(s): review with dz vector harmonize (dry-run); merge with dz vector harmonize --apply after backup`);
2909
3084
  }
2910
3085
  return 0;
@@ -2912,7 +3087,7 @@ async function cmdTeach(options: Map<string, string>, flags: Set<string>, cwd: s
2912
3087
 
2913
3088
  const reinforce = options.get('reinforce');
2914
3089
  if (reinforce !== undefined && reinforce.trim() !== '') {
2915
- const backend = resolveLearningBackend(projectRoot);
3090
+ const backend = resolveLearningBackend(storeRoot);
2916
3091
  const sampleReward = options.has('reward') ? parseFloat(options.get('reward') ?? '0.8') : undefined;
2917
3092
  backend.addSample({
2918
3093
  dzId: reinforce,
@@ -2924,8 +3099,9 @@ async function cmdTeach(options: Map<string, string>, flags: Set<string>, cwd: s
2924
3099
  if (trained.flushed > 0) {
2925
3100
  write(`↳ reinforced ${reinforce}`);
2926
3101
  // lesson-quarantine: reinforcement IS promotion — keep the hook daemon's mirror in step.
2927
- const clearedQ = clearAgentdbQuarantine(projectRoot, [reinforce]);
3102
+ const clearedQ = clearAgentdbQuarantine(storeRoot, [reinforce]);
2928
3103
  if (clearedQ.cleared > 0) write(` ↳ promoted out of quarantine (mirror updated)`);
3104
+ write(storeLine('written'));
2929
3105
  return 0;
2930
3106
  }
2931
3107
  // HIGH-fix: a no-match must NOT auto-teach the raw argument — callers pass dzIds or truncated
@@ -2934,6 +3110,9 @@ async function cmdTeach(options: Map<string, string>, flags: Set<string>, cwd: s
2934
3110
  write(`dz teach --reinforce: no existing pattern matched ${JSON.stringify(reinforce)} — nothing reinforced`);
2935
3111
  write(' If this is a genuinely NEW lesson, teach it explicitly: dz teach "<full lesson text>" --reward <0-1> --domain <area>');
2936
3112
  write(' To find the exact pattern to reinforce: dz recall "<terms>" (match by its full text)');
3113
+ // WHICH store was searched — otherwise "no existing pattern matched" reads as "this lesson is
3114
+ // new" when it may simply be sitting in the other store. `read`, because nothing was written.
3115
+ write(storeLine('read'));
2937
3116
  return 1;
2938
3117
  }
2939
3118
 
@@ -2943,6 +3122,8 @@ async function cmdTeach(options: Map<string, string>, flags: Set<string>, cwd: s
2943
3122
  write(' Example: dz teach "Used DataLoader to fix N+1 query" --reward 0.9 --domain performance');
2944
3123
  write(' Import: dz teach --from-json patterns.json (bulk-import a `dz recall --all --json` export)');
2945
3124
  write(' Harmonize: dz teach --harmonize [--apply] (semantic dedup — alias of dz vector harmonize; dry-run default)');
3125
+ write(' Store: dz teach "<lesson>" --to project|global (project = this repo; global = ~/.dz, shared across every project)');
3126
+ write(' session default: export DZ_LEARN=global · project default: .dz/config.json → learning.teachTo');
2946
3127
  return 1;
2947
3128
  }
2948
3129
 
@@ -2950,9 +3131,9 @@ async function cmdTeach(options: Map<string, string>, flags: Set<string>, cwd: s
2950
3131
  const domain = options.get('domain') ?? 'general';
2951
3132
 
2952
3133
  if (flags.has('guard')) {
2953
- const verdict = await teachGuard(projectRoot, pattern, { reward: Math.max(0, Math.min(1, reward)) });
3134
+ const verdict = await teachGuard(storeRoot, pattern, { reward: Math.max(0, Math.min(1, reward)) });
2954
3135
  if (verdict.action === 'reinforce') {
2955
- const backend = resolveLearningBackend(projectRoot);
3136
+ const backend = resolveLearningBackend(storeRoot);
2956
3137
  backend.addSample({ dzId: verdict.dzId, kind: 'reinforce', reward: Math.max(0, Math.min(1, reward)), ts: new Date().toISOString() });
2957
3138
  const trained = await backend.train();
2958
3139
  // HIGH-fix: only claim success when the reinforce actually FLUSHED. With backend 'off'
@@ -2960,7 +3141,7 @@ async function cmdTeach(options: Map<string, string>, flags: Set<string>, cwd: s
2960
3141
  // teach below so the lesson is NEVER silently discarded (the exact silent-drop the ADR forbids).
2961
3142
  if (trained.flushed > 0) {
2962
3143
  write(`↳ reinforced existing pattern ${verdict.dzId} (cos=${verdict.cosine.toFixed(2)}) — not re-added`);
2963
- const clearedQ = clearAgentdbQuarantine(projectRoot, [verdict.dzId]);
3144
+ const clearedQ = clearAgentdbQuarantine(storeRoot, [verdict.dzId]);
2964
3145
  if (clearedQ.cleared > 0) write(' ↳ promoted out of quarantine (mirror updated)');
2965
3146
  return 0;
2966
3147
  }
@@ -2992,27 +3173,35 @@ async function cmdTeach(options: Map<string, string>, flags: Set<string>, cwd: s
2992
3173
  // folds any legacy .dz/patterns.jsonl into the backend (idempotent) and returns the
2993
3174
  // total count. The lossy `npx agentdb add` dual-write was removed in Tier-1 (audit #6).
2994
3175
  // lesson-quarantine (opt-in): a fresh lesson is a HYPOTHESIS until it earns promotion.
2995
- const quarantineOn = readMemoryLearningConfig(projectRoot).quarantine;
2996
- const count = await recordPattern(projectRoot, entry, quarantineOn ? { quarantine: true } : {});
3176
+ const quarantineOn = readMemoryLearningConfig(storeRoot).quarantine;
3177
+ const count = await recordPattern(storeRoot, entry, quarantineOn ? { quarantine: true } : {});
2997
3178
 
2998
3179
  write(`Learned: "${pattern.slice(0, 60)}${pattern.length > 60 ? '...' : ''}"`);
2999
3180
  write(` Domain: ${domain} Reward: ${reward} Backend: memory (@dzhechkov/memory)`);
3000
3181
  write(` Total patterns: ${count}`);
3182
+ // WHERE the write landed. MEASURED before this line existed: teach printed the pattern, the
3183
+ // domain, the reward and the backend — and not one word about the path, so a store written to
3184
+ // the wrong directory was indistinguishable from one written to the right one. A user running
3185
+ // this in eight project directories gets eight isolated stores and believes they accumulate.
3186
+ // ...and WHY that store. Without this a user who exported DZ_LEARN three hours ago and forgot
3187
+ // sees a path, cannot tell what chose it, and has no reason to question it. `default` adds
3188
+ // nothing, so the line stays byte-identical for everyone who set nothing.
3189
+ write(storeLine('written'));
3001
3190
  // ADVICE, not a gate. Someone putting medical lessons in a shared store owns both
3002
3191
  // directories and this binary; refusing would be defending a user against themselves,
3003
3192
  // which this design does not attempt. Making the choice INFORMED is the part that is
3004
3193
  // ours to do — the write has already happened when this prints.
3005
3194
  // RESOLVE before deciding. A symlink named `.health-brain` pointing at a shared project
3006
3195
  // silenced this advice in exactly the case it exists for.
3007
- let resolvedRoot = projectRoot;
3008
- try { resolvedRoot = realpathSync(projectRoot); } catch { /* a path we cannot resolve is not the brain */ }
3196
+ let resolvedRoot = storeRoot;
3197
+ try { resolvedRoot = realpathSync(storeRoot); } catch { /* a path we cannot resolve is not the brain */ }
3009
3198
  const sharedAdvice = renderSharedStoreAdvice(domain, resolvedRoot);
3010
3199
  if (sharedAdvice !== '') write(sharedAdvice);
3011
3200
  if (quarantineOn) {
3012
3201
  write(' ⚠ quarantined: excluded from auto-inject, damped in recall — promote by confirming it (dz teach --reinforce "<text>") or dz recall --promote <dzId> --apply');
3013
3202
  }
3014
3203
  // The lexical write above is durable — the vector mirror is strictly best-effort (I-3).
3015
- await emitMirrorQ(projectRoot, [entry], 'dz-teach', quarantineOn);
3204
+ await emitMirrorQ(storeRoot, [entry], 'dz-teach', quarantineOn);
3016
3205
  return 0;
3017
3206
  }
3018
3207
 
@@ -3572,8 +3761,8 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
3572
3761
  // second occurrence. Foreign stdout is routed to stderr for the duration of the engine call — our
3573
3762
  // own output is written after it returns.
3574
3763
  const result = asJson
3575
- ? await withForeignStdoutOnStderr(() => recallHybrid(projectRoot, query, { limit: fetchLimit, mode }))
3576
- : await recallHybrid(projectRoot, query, { limit: fetchLimit, mode });
3764
+ ? await withForeignStdoutOnStderr(() => recallHybrid(projectRoot, query, { limit: fetchLimit, mode, deferExposures: true, ...(wantedDomain !== undefined ? { domain: wantedDomain } : {}) }))
3765
+ : await recallHybrid(projectRoot, query, { limit: fetchLimit, mode, deferExposures: true, ...(wantedDomain !== undefined ? { domain: wantedDomain } : {}) });
3577
3766
 
3578
3767
  if (mode === 'semantic' && result.vectorEngine === 'none') {
3579
3768
  // --semantic is an explicit ask — degrading it silently would be dishonest (FR-3).
@@ -3589,7 +3778,34 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
3589
3778
  // tagged with that domain WITHOUT dropping foreign ones — a boost, not a filter, so a
3590
3779
  // shared store keeps the cross-domain transfers that make it worth more than two stores.
3591
3780
  const boost = wantedDomain !== undefined ? applyDomainBoost(result.hits, wantedDomain) : null;
3592
- const hits = (boost ? boost.hits : result.hits).slice(0, limit);
3781
+
3782
+ // ── The cross-project store ────────────────────────────────────────────────────────────────
3783
+ //
3784
+ // Our own shipped precedent (learning_bridge.py:23): "the compounding objection was answered by
3785
+ // making RECALL read both stores rather than by merging them" — and :880, "two stores are
3786
+ // different stores". Writing needs a choice; reading almost never does, so there is no mode here.
3787
+ //
3788
+ // The global store is a project store rooted at the home directory: same code, same format.
3789
+ // It is read ONLY when it exists AND is a different store — a user recalling FROM their home
3790
+ // directory would otherwise read one file twice and see every hit doubled.
3791
+ //
3792
+ // ABSENT global store ⇒ this whole block is skipped and the output is byte-identical to before.
3793
+ // That is the load-bearing property: every existing user must be unaffected by a feature they
3794
+ // did not ask for.
3795
+ const globalRoot = globalStoreRoot();
3796
+ const readGlobal = !sameStore(projectRoot, globalRoot)
3797
+ && existsSync(join(globalRoot, '.dz', 'memory'));
3798
+ let globalHits: typeof result.hits = [];
3799
+ if (readGlobal) {
3800
+ const g = asJson
3801
+ ? await withForeignStdoutOnStderr(() => recallHybrid(globalRoot, query, { limit: fetchLimit, mode, deferExposures: true, ...(wantedDomain !== undefined ? { domain: wantedDomain } : {}) }))
3802
+ : await recallHybrid(globalRoot, query, { limit: fetchLimit, mode, deferExposures: true, ...(wantedDomain !== undefined ? { domain: wantedDomain } : {}) });
3803
+ globalHits = g.hits;
3804
+ }
3805
+ const merged = readGlobal
3806
+ ? mergeStoreHits(boost ? boost.hits : result.hits, globalHits, (h) => h.pattern.pattern)
3807
+ : (boost ? boost.hits : result.hits);
3808
+ const hits = merged.slice(0, limit);
3593
3809
  // Computed ONCE, honoured by EVERY return path. It used to live only on the text tail, so the two
3594
3810
  // paths that return earlier — `--json` and the zero-hits branch — still reported success. That
3595
3811
  // made the contract change invisible to exactly the caller the ADR justifies it by: a script
@@ -3605,6 +3821,23 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
3605
3821
  // "foreign-domain lessons kept" about the pre-cut list while the printed list was
3606
3822
  // missing one. Count it and say so; the reader can act on it (raise --limit).
3607
3823
  const displaced = boost !== null ? countDisplacedByCut(result.hits, boost.hits, limit) : 0;
3824
+ // lesson-bandit-rerank FR-8/AC-11: the bandit block must describe the POST-cut list the reader
3825
+ // actually sees. `dz recall` over-fetches under --domain and truncates AGAIN here, so reporting
3826
+ // the core's own (already post-merge-cut) count would still describe a list one cut too early.
3827
+ // ABSENT while disarmed — its presence is what tells a reader the feature ran.
3828
+ const shownDzIds = hits.map((h) => patternRecordId(h.pattern));
3829
+ const banditBlock = result.bandit === undefined
3830
+ ? undefined
3831
+ : narrowBanditReport(result.bandit, shownDzIds);
3832
+ // Exposures were DEFERRED (`deferExposures: true`) precisely so this cut happens first: `dz recall`
3833
+ // over-fetches under --domain and truncates again here, and counting an over-fetched hit as "seen"
3834
+ // both inflates the health metrics and mislabels a candidate the reader never laid eyes on.
3835
+ result.commitExposures?.(shownDzIds);
3836
+ const renderBanditNote = (b: NonNullable<typeof banditBlock>): string =>
3837
+ ` ℹ bandit payoff: ${b.moved} of ${b.armsConsidered} shown hit(s) moved (ctx ${b.contextKey}` +
3838
+ `, ${b.unknownArms} with no measured payoff yet, ${b.quarantinedExcluded} quarantined excluded` +
3839
+ `${b.exploration ? `, exploration ON — ${b.explored} trial impression(s)` : ''}` +
3840
+ `${b.reason !== null ? `, state ${b.reason}` : ''})`;
3608
3841
  if (asJson) {
3609
3842
  // Portable contract UNCHANGED (I-7/AC-6): a plain PatternRecord[] — round-trips through
3610
3843
  // `dz teach --from-json` regardless of which backend ranked each hit.
@@ -3635,6 +3868,10 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
3635
3868
  const cutNoteJson = renderDomainCutNote(displaced, limit);
3636
3869
  if (cutNoteJson !== '') process.stderr.write(`${cutNoteJson}\n`);
3637
3870
  }
3871
+ // STDERR, not stdout: `--json`'s stdout contract is a plain PatternRecord[] that round-trips
3872
+ // through `dz teach --from-json`, and wrapping it in an object to make room for one report
3873
+ // would break every existing consumer. The block travels beside the other honesty notes.
3874
+ if (banditBlock !== undefined) process.stderr.write(`${JSON.stringify({ bandit: banditBlock })}\n`);
3638
3875
  return semanticUnserved ? 1 : 0;
3639
3876
  }
3640
3877
 
@@ -3668,7 +3905,17 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
3668
3905
  ? `${lexLabel} only (the semantic tier returned ${result.semanticCandidates} stale id(s) — run: dz consolidate)`
3669
3906
  : `${lexLabel} only (semantic tier empty — run: dz vector reindex)`
3670
3907
  : `${lexLabel} ranking (lexical)`;
3671
- write(`dz recall "${shownQuery}" — ${hits.length} hit(s), ${ranking}`);
3908
+ // The store COUNT appears only when a second store actually contributed. Saying "1 store" where
3909
+ // nothing was said before would break the byte-identity property for every existing user; with a
3910
+ // single store the store-location line below already names WHICH one.
3911
+ const storesRead = readGlobal ? `, ${storeCountLabel(2)}` : '';
3912
+ write(`dz recall "${shownQuery}" — ${hits.length} hit(s), ${ranking}${storesRead}`);
3913
+ write(storeLocationLine(describeStoreLocation(projectRoot, options.get('project')), 'read'));
3914
+ if (readGlobal) {
3915
+ // Naming only the project store while the header says "2 stores" would leave the reader
3916
+ // guessing which second one answered.
3917
+ write(` store (read): ${join(globalRoot, '.dz')} [cross-project]`);
3918
+ }
3672
3919
  let sawQuarantined = false;
3673
3920
  for (const h of hits) {
3674
3921
  const backendTag = vectorOn ? ` ⟨${h.backend}⟩` : '';
@@ -3696,7 +3943,14 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
3696
3943
  const width = flags.has('full') ? Number.POSITIVE_INFINITY : 160;
3697
3944
  const oneLined = oneLine(h.pattern.pattern);
3698
3945
  const shown = width === Number.POSITIVE_INFINITY ? oneLined : oneLined.slice(0, width);
3699
- write(` [${h.pattern.reward.toFixed(2)}] (${oneLine(h.pattern.domain)})${backendAndSim}${qTag} ${shown}`);
3946
+ // WHICH store this hit came from. A merged list that does not say re-creates the fragmentation
3947
+ // blindness the store-location line just removed, one level down: the reader would see more
3948
+ // results and have no way to tell whether the global store is even connected.
3949
+ // Empty when only one store was read, so a single-store run stays byte-identical.
3950
+ const originTag = readGlobal && 'origin' in h
3951
+ ? ` {${(h as { origin: string }).origin}}`
3952
+ : '';
3953
+ write(` [${h.pattern.reward.toFixed(2)}] (${oneLine(h.pattern.domain)})${backendAndSim}${qTag}${originTag} ${shown}`);
3700
3954
  }
3701
3955
  // The apply leg records itself. `dz recall` wrote NOTHING to the usage log — MEASURED 2026-08-24,
3702
3956
  // 1106 rows before the call and 1106 after — so "how many lessons were recalled" was underivable,
@@ -3733,6 +3987,12 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
3733
3987
  // The loop stays VISIBLE (ADR D2): a quarantined hit is shown, marked, and explained.
3734
3988
  write(' ⚠q = quarantined (unproven hypothesis, rank damped) — confirm with dz teach --reinforce, or dz recall --promote <dzId> --apply');
3735
3989
  }
3990
+ if (banditBlock !== undefined) {
3991
+ // Say what the payoff term did — INCLUDING when it did nothing. `moved: 0` over many queries
3992
+ // means the feature is armed and inert, which is precisely the outcome nobody would notice
3993
+ // without this line.
3994
+ write(renderBanditNote(banditBlock));
3995
+ }
3736
3996
  if (boost !== null && shownDomain !== undefined) {
3737
3997
  // Say what the boost did — INCLUDING when it did nothing. A silent reorder would
3738
3998
  // let the reader believe the ranking was domain-aware when it had no match to work with.
@@ -3807,7 +4067,17 @@ function renderHarmonize(report: HarmonizeReport, write: Write): void {
3807
4067
  * and `dz teach --harmonize` — ONE implementation, no bespoke second path (QR-6). Guards (AC-6):
3808
4068
  * `--apply` + `--dry-run` together is rejected; `--threshold` must be in `(0, 1]`; no flag ⇒ dry-run.
3809
4069
  */
3810
- async function runHarmonize(projectRoot: string, options: Map<string, string>, flags: Set<string>, write: Write): Promise<number> {
4070
+ async function runHarmonize(
4071
+ projectRoot: string, options: Map<string, string>, flags: Set<string>, write: Write,
4072
+ /**
4073
+ * Where this harmonize is pointed and what chose it. Under `--json` the human store line is
4074
+ * suppressed to keep stdout ONE document, so the destination has to travel INSIDE that document
4075
+ * or `--harmonize --apply --to global` mutates ~/.dz while revealing nothing (cross-family QE
4076
+ * round 3, 2026-08-27 — the round-2 fix closed a parse break and reopened the silent-store
4077
+ * hazard the whole feature exists to close).
4078
+ */
4079
+ storeAnnotation?: { store: string; storeChosenBy: string },
4080
+ ): Promise<number> {
3811
4081
  const apply = flags.has('apply');
3812
4082
  if (apply && flags.has('dry-run')) {
3813
4083
  write('dz vector harmonize: --apply and --dry-run are mutually exclusive');
@@ -3823,7 +4093,7 @@ async function runHarmonize(projectRoot: string, options: Map<string, string>, f
3823
4093
  }
3824
4094
  const report = await harmonizeVectorStore(projectRoot, { apply, ...(threshold !== undefined ? { threshold } : {}) });
3825
4095
  if (flags.has('json')) {
3826
- write(JSON.stringify(report));
4096
+ write(JSON.stringify(storeAnnotation !== undefined ? { ...report, ...storeAnnotation } : report));
3827
4097
  return report.error !== undefined ? 1 : 0;
3828
4098
  }
3829
4099
  renderHarmonize(report, write);
@@ -4702,7 +4972,7 @@ async function cmdSetup(options: Map<string, string>, flags: Set<string>, cwd: s
4702
4972
  // nothing writes to (audit code#3).
4703
4973
  const wiring = setupResult.steps.find((s) => s.name === 'agentdb wiring');
4704
4974
  const backendLabel = memoryOpt === 'agentdb'
4705
- ? (wiring?.status === 'done' ? 'agentdb (.dz/agentdb.db, shared hook+MCP store)' : `agentdb INCOMPLETE — see setup steps`)
4975
+ ? (wiring?.status === 'done' ? 'agentdb (.dz/agentdb.db + .dz/agentdb-mcp.db, separate stores)' : `agentdb INCOMPLETE — see setup steps`)
4706
4976
  : 'sessions.jsonl + patterns.jsonl';
4707
4977
  write(`║ Learning: ${backendLabel.padEnd(41)}║`);
4708
4978
  write(`║ Hooks: ${flags.has('no-hooks') ? 'disabled' : 'session-start + session-end'}${' '.repeat(flags.has('no-hooks') ? 30 : 15)}║`);
@@ -4716,7 +4986,17 @@ async function cmdSetup(options: Map<string, string>, flags: Set<string>, cwd: s
4716
4986
  }
4717
4987
  // A hook that was written but never witnessed firing is NOT a completed setup (ADR-002 §5): the
4718
4988
  // step is reported failed, the process was not aborted.
4719
- return codexHooksOk ? 0 : 1;
4989
+ //
4990
+ // …and neither is a setup that PRINTED an error step. The first version returned on codexHooksOk
4991
+ // alone, so `dz setup` could report `agentdb wiring: error` — the very invariant this feature
4992
+ // defends — and still exit 0, which is what a CI job reads (cross-family QE, Codex gpt-5.6-sol).
4993
+ // An exit code that disagrees with the report on screen is the same class of lie as a green run
4994
+ // on a stale build.
4995
+ const erroredSteps = setupResult.steps.filter((s) => s.status === 'error').map((s) => s.name);
4996
+ if (erroredSteps.length > 0) {
4997
+ write(`\n✗ setup reported ${erroredSteps.length} failed step(s): ${erroredSteps.join(', ')} — exit 1`);
4998
+ }
4999
+ return codexHooksOk && erroredSteps.length === 0 ? 0 : 1;
4720
5000
  }
4721
5001
 
4722
5002
  function cmdPretrain(options: Map<string, string>, cwd: string, write: Write): number {
@@ -5290,11 +5570,10 @@ function cmdVerifyPack(options: Map<string, string>, flags: Set<string>, cwd: st
5290
5570
  try { signed = JSON.parse(readFileSync(manifestPath, 'utf8')); }
5291
5571
  catch { write(`dz verify-pack: ${MANIFEST_NAME} is not valid JSON`); return 1; }
5292
5572
 
5293
- // Same rule as the signer: a working-tree file `files[]` excludes was never "added to the pack".
5294
- // Unscoped when npm cannot answer an extracted tarball verifies exactly as before.
5295
- let shippedForVerify: string[] | undefined;
5296
- try { shippedForVerify = npmPackedPaths(packDir); } catch { shippedForVerify = undefined; }
5297
- const res = verifyManifest(packDir, signed as never, readFileSync(pubPath, 'utf8'), shippedForVerify);
5573
+ // The verifier sweeps the directory it was handed. A caller-supplied shipment list could omit an
5574
+ // unsigned file and turn the bidirectional check off, so verification has no narrowing option.
5575
+ // Release/publish verifies an extracted tarball separately; `verify-pack` verifies this exact tree.
5576
+ const res = verifyManifest(packDir, signed as never, readFileSync(pubPath, 'utf8'));
5298
5577
  if (res.ok) { write(`dz verify-pack: OK — ${packDir} matches its signed manifest`); return 0; }
5299
5578
  write(`dz verify-pack: FAILED — ${packDir}`);
5300
5579
  for (const f of res.failures) write(` ${f.path}: ${f.reason}`);
@@ -8041,11 +8320,16 @@ async function cmdDownloads(cwd: string, write: Write): Promise<number> {
8041
8320
  * The pure engine lives in harness-core; this is the thin I/O shell (scan disk + git, render).
8042
8321
  */
8043
8322
  function cmdArchitecture(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
8044
- // Resolve repo root: prefer git toplevel, fall back to cwd (matches cmdRoam / cmdStats duality).
8045
- let repoRoot = cwd;
8323
+ // Resolve repo root: an explicit --project wins, then git toplevel, then cwd (matches cmdRoam /
8324
+ // cmdStats duality). The --project leg is the same fix as cmdProjectSkills got for field report
8325
+ // doc-25b, applied to its neighbour before it costs a second report: this command also reads an
8326
+ // `architecture/` tree, and on a feature-adr run against a target checkout the Step-0 product-vision
8327
+ // сверка silently no-ops for exactly that reason.
8328
+ const explicitRoot = (options.get('project') ?? '').trim();
8329
+ let repoRoot = explicitRoot !== '' ? resolve(cwd, explicitRoot) : cwd;
8046
8330
  try {
8047
- repoRoot = execSync('git rev-parse --show-toplevel', { cwd, encoding: 'utf-8' }).trim() || cwd;
8048
- } catch { /* not a git repo — use cwd */ }
8331
+ repoRoot = execSync('git rev-parse --show-toplevel', { cwd: repoRoot, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || repoRoot;
8332
+ } catch { /* not a git repo — use the root as given */ }
8049
8333
 
8050
8334
  const manifest = loadSubsystemManifest(repoRoot);
8051
8335
  if (!manifest) {
@@ -8091,11 +8375,21 @@ function cmdArchitecture(options: Map<string, string>, flags: Set<string>, cwd:
8091
8375
  * --stages-json { hasManifest, design, code, qe, report } — ready to thread into prompts
8092
8376
  * No manifest ⇒ empty guidance strings ⇒ byte-identical run (FR-7).
8093
8377
  */
8094
- function cmdProjectSkills(flags: Set<string>, cwd: string, write: Write): number {
8095
- let repoRoot = cwd;
8378
+ function cmdProjectSkills(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
8379
+ // `--project <dir>` names the root EXPLICITLY. Until 2026-08-25 this command took only `cwd`, so
8380
+ // the manifest was reachable exclusively from the workspace you happened to stand in — and because
8381
+ // the known-flag list is deliberately FLAT, `--project` passed validation on a command that never
8382
+ // read it: exit 0, no warning, no manifest. MEASURED that day: a feature-adr run against a target
8383
+ // repo probed with `cd REPO`, found nothing, and fell open to a generic run — the honest
8384
+ // `polymorphism:null` was recorded and not one project lens reached any stage.
8385
+ const explicitRoot = (options.get('project') ?? '').trim();
8386
+ let repoRoot = explicitRoot !== '' ? resolve(cwd, explicitRoot) : cwd;
8096
8387
  try {
8097
- repoRoot = execSync('git rev-parse --show-toplevel', { cwd, encoding: 'utf-8' }).trim() || cwd;
8098
- } catch { /* not a git repo use cwd */ }
8388
+ // stderr is SWALLOWED: a non-repo directory is an ordinary case here, and letting git print
8389
+ // "fatal: not a git repository" onto the operator's terminal made a working command look broken.
8390
+ // The sibling cmdFeatureAdrSetup already redirects; this one did not.
8391
+ repoRoot = execSync('git rev-parse --show-toplevel', { cwd: repoRoot, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || repoRoot;
8392
+ } catch { /* not a git repo — use the root as given */ }
8099
8393
 
8100
8394
  const plan = planProjectSkills(repoRoot);
8101
8395
  const hasManifest = plan.injections.length > 0 || plan.skipped.length > 0;
@@ -8184,6 +8478,7 @@ async function cmdMrRakes(options: Map<string, string>, flags: Set<string>, cwd:
8184
8478
  ['type', 'lesson-learned'],
8185
8479
  ]);
8186
8480
  if (options.has('project')) opts.set('project', options.get('project')!);
8481
+ if (options.has('to')) opts.set('to', options.get('to')!); // same reason as in cmdRetro
8187
8482
  await cmdTeach(opts, new Set<string>(['guard']), cwd, write);
8188
8483
  }
8189
8484
  }
@@ -8218,10 +8513,23 @@ async function cmdRetro(options: Map<string, string>, flags: Set<string>, cwd: s
8218
8513
  const hits = detectProcessRakes(events);
8219
8514
 
8220
8515
  // Ledger: count prior 'retro'-domain records per signature in the dz-teach store.
8221
- const projectRoot = resolve(cwd, options.get('project') ?? '.');
8516
+ // It MUST be read from the store the co-learning write below will append to. A session mode
8517
+ // (`DZ_LEARN=global`) once split the two — the count was read from the project while the record
8518
+ // landed in the home store, so the recurrence never advanced and the drill threshold could never
8519
+ // be reached (cross-family QE, 2026-08-27). A read and its write resolve the same way, or the
8520
+ // counter they share is a fiction.
8521
+ let ledgerRoot: string;
8522
+ try {
8523
+ ledgerRoot = resolveLearningStore(options, cwd).storeRoot;
8524
+ } catch (e) {
8525
+ // Refuse HERE rather than after the whole retro has been rendered — the same refusal the
8526
+ // teach below would raise, but before any work is spent on it.
8527
+ if (e instanceof TeachTargetError) { write('dz retro: ' + e.message); return 1; }
8528
+ throw e;
8529
+ }
8222
8530
  const ledger = new Map<string, number>();
8223
8531
  try {
8224
- const records = loadStorePatternsSync(projectRoot).filter((r) => r.domain === RETRO_DOMAIN);
8532
+ const records = loadStorePatternsSync(ledgerRoot).filter((r) => r.domain === RETRO_DOMAIN);
8225
8533
  for (const sig of PROCESS_SIGNATURES) {
8226
8534
  const lesson = retroLessonText(sig.id);
8227
8535
  ledger.set(sig.id, records.filter((r) => r.pattern === lesson).length);
@@ -8246,6 +8554,11 @@ async function cmdRetro(options: Map<string, string>, flags: Set<string>, cwd: s
8246
8554
  ['type', 'lesson-learned'],
8247
8555
  ]);
8248
8556
  if (options.has('project')) opts.set('project', options.get('project')!);
8557
+ // `--to` MUST travel with it. Without this the ledger above resolves with the flag and the
8558
+ // write below resolves without it, so `dz retro --to global` counted a global ledger while
8559
+ // recording into the project — the very split Decision 5 exists to close, reopened one level
8560
+ // down (cross-family QE round 2, 2026-08-27).
8561
+ if (options.has('to')) opts.set('to', options.get('to')!);
8249
8562
  await cmdTeach(opts, new Set<string>(['guard', 'no-mirror']), cwd, write);
8250
8563
  }
8251
8564
  }
@@ -8836,25 +9149,43 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
8836
9149
  // F-2 — rule-3 containment root: the scratch copy AS THE FILESYSTEM sees it. Every mutation
8837
9150
  // write below is asserted to RESOLVE inside this root before it happens.
8838
9151
  const realScratchRoot = realpathSync(copyDir);
8839
-
8840
- const runSuite = (): { exitCode: number | null; output: string } => {
8841
- try {
8842
- const out = execSync(testCmd, { cwd: copyDir, stdio: 'pipe', encoding: 'utf-8', timeout, maxBuffer: 64 * 1024 * 1024, env: { ...process.env, FORCE_COLOR: '0' } });
8843
- return { exitCode: 0, output: out };
8844
- } catch (e) {
8845
- const err = e as { status?: number | null; stdout?: string; stderr?: string };
8846
- return {
8847
- exitCode: typeof err.status === 'number' ? err.status : null,
8848
- output: `${String(err.stdout ?? '')}\n${String(err.stderr ?? '')}`,
8849
- };
9152
+ const requireCompletionReceipt = parsed.registry.requireCompletionReceipt === true;
9153
+
9154
+ const runSuite = (): { exitCode: number | null; output: string; failureReason?: string } => {
9155
+ const run = spawnSync(testCmd, {
9156
+ cwd: copyDir,
9157
+ shell: true,
9158
+ encoding: 'utf-8',
9159
+ timeout,
9160
+ maxBuffer: 64 * 1024 * 1024,
9161
+ env: { ...process.env, FORCE_COLOR: '0' },
9162
+ });
9163
+ const errorCode = run.error && 'code' in run.error && typeof run.error.code === 'string'
9164
+ ? run.error.code
9165
+ : undefined;
9166
+ const signal = typeof run.signal === 'string' ? run.signal : undefined;
9167
+ let failureReason: string | undefined;
9168
+ if (typeof run.status !== 'number') {
9169
+ if (errorCode === 'ETIMEDOUT') failureReason = `timeout after ${timeout}ms${signal === undefined ? '' : `; signal=${signal}`}`;
9170
+ else if (errorCode === 'ENOBUFS') failureReason = 'maxBuffer exceeded (ENOBUFS; 67108864-byte output ceiling)';
9171
+ else if (signal !== undefined) failureReason = `child killed by signal ${signal}`;
9172
+ else if (errorCode !== undefined) failureReason = `spawn failure code ${errorCode}`;
9173
+ else failureReason = 'spawn failure with no error code or signal';
8850
9174
  }
9175
+ return {
9176
+ exitCode: typeof run.status === 'number' ? run.status : null,
9177
+ // Receipt markers may be on stderr. Preserve both streams even on exit 0; stdout-only
9178
+ // collection would silently lose a green-run marker.
9179
+ output: `${String(run.stdout ?? '')}\n${String(run.stderr ?? '')}`,
9180
+ ...(failureReason !== undefined ? { failureReason } : {}),
9181
+ };
8851
9182
  };
8852
9183
 
8853
9184
  // Baseline BEFORE any mutation: a red copy proves nothing, and reading it as a mutation
8854
9185
  // result would be this gate shipping the defect class it exists to catch.
8855
9186
  if (!json) write(`mutation-gate: baseline suite in scratch copy of ${pkgDir} …`);
8856
9187
  const base = runSuite();
8857
- baseline = classifyBaseline(base.exitCode);
9188
+ baseline = classifyBaseline(base.exitCode, base.failureReason);
8858
9189
  if (!baseline.ok) {
8859
9190
  if (json) { write(JSON.stringify({ packageDir: pkgDir, registryPath, testCommand: testCmd, baseline, results: [], exitCode: 2 }, null, 2)); return 2; }
8860
9191
  write(renderMutationReport([], baseline, pkgDir));
@@ -8893,7 +9224,7 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
8893
9224
  return fail(`entry '${entry.id}': ${entry.file} resolves to ${realTarget ?? '<unresolvable>'} — OUTSIDE the scratch copy (${realScratchRoot}). A path component is a symlink escaping the scratch tree, so writing the mutation would mutate the REAL working tree (SPEC rule 3). Refused; nothing was written.`);
8894
9225
  }
8895
9226
  if (!json) write(`mutation-gate: ${entry.id} — mutating ${entry.file}, running suite …`);
8896
- let run: { exitCode: number | null; output: string } | null = null;
9227
+ let run: { exitCode: number | null; output: string; failureReason?: string } | null = null;
8897
9228
  let parseError: string | undefined;
8898
9229
  try {
8899
9230
  writeFileSync(filePath, applied.text);
@@ -8919,6 +9250,12 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
8919
9250
  // 'file-load' ⇒ MUTATION_LOAD_FATAL (structural); 'unrecognised' ⇒ INCONCLUSIVE (a
8920
9251
  // runner-coverage gap of this tool, loud, never PROVEN); 'assertions' ⇒ behavioural, the
8921
9252
  // count-based verdicts apply.
9253
+ const completionReceipt = run === null ? undefined : detectSuiteCompletionReceipt(run.output);
9254
+ let receiptMismatch = run === null ? undefined : detectSuiteReceiptMismatch(run.output);
9255
+ if (run !== null && requireCompletionReceipt
9256
+ && completionReceipt === undefined && receiptMismatch === undefined) {
9257
+ receiptMismatch = 'required mutation-suite-receipt-ok completion receipt missing';
9258
+ }
8922
9259
  let fileLoadFailure: string | undefined;
8923
9260
  let outputUnrecognised: string | undefined;
8924
9261
  if (run !== null && run.exitCode !== null && run.exitCode !== 0) {
@@ -8931,13 +9268,16 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
8931
9268
  }
8932
9269
  // Route-b guard (per-entry mode): a red mutated run is attributable only if the RESTORED
8933
9270
  // tree reproduces green — otherwise a flaky neighbour may be what went red. Skipped when the
8934
- // classification already failed the entry structurally (file-load / unrecognised): those
8935
- // verdicts outrank the rebaseline check, so the extra suite run would buy nothing.
9271
+ // classification already failed the entry (file-load / unrecognised / receipt mismatch):
9272
+ // those verdicts outrank the rebaseline check, so the extra suite run would buy nothing.
8936
9273
  let rebaselineExitCode: number | null | undefined;
9274
+ let rebaselineFailureReason: string | undefined;
8937
9275
  if (rebaselineMode === 'per-entry' && run !== null && run.exitCode !== null && run.exitCode !== 0
8938
- && fileLoadFailure === undefined && outputUnrecognised === undefined) {
9276
+ && fileLoadFailure === undefined && outputUnrecognised === undefined && receiptMismatch === undefined) {
8939
9277
  if (!json) write(`mutation-gate: ${entry.id} — re-baselining the restored tree …`);
8940
- rebaselineExitCode = runSuite().exitCode;
9278
+ const rebaselineRun = runSuite();
9279
+ rebaselineExitCode = rebaselineRun.exitCode;
9280
+ rebaselineFailureReason = rebaselineRun.failureReason;
8941
9281
  }
8942
9282
  const obs: MutationObservation = {
8943
9283
  entry,
@@ -8947,7 +9287,10 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
8947
9287
  ...(parseError !== undefined ? { parseError } : {}),
8948
9288
  ...(fileLoadFailure !== undefined ? { fileLoadFailure } : {}),
8949
9289
  ...(outputUnrecognised !== undefined ? { outputUnrecognised } : {}),
9290
+ ...(receiptMismatch !== undefined ? { receiptMismatch } : {}),
9291
+ ...(run?.failureReason !== undefined ? { runFailureReason: run.failureReason } : {}),
8950
9292
  ...(rebaselineExitCode !== undefined ? { rebaselineExitCode } : {}),
9293
+ ...(rebaselineFailureReason !== undefined ? { rebaselineFailureReason } : {}),
8951
9294
  };
8952
9295
  observations.push(obs);
8953
9296
  results.push(classifyMutationOutcome(obs));
@@ -8957,15 +9300,20 @@ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd:
8957
9300
  // verdict of this run is downgraded (any of them may have been the flake, and there is no
8958
9301
  // per-entry evidence to say which) — re-classifying with the final exit turns them
8959
9302
  // INCONCLUSIVE while leaving NOT_APPLIED / UNDEFENDED / MUTATION_UNPARSEABLE /
8960
- // MUTATION_LOAD_FATAL untouched.
9303
+ // MUTATION_LOAD_FATAL / RECEIPT_MISMATCH untouched.
8961
9304
  if (rebaselineMode === 'final') {
8962
9305
  if (!json) write('mutation-gate: final re-baseline of the restored tree …');
8963
- const finalExit = runSuite().exitCode;
9306
+ const finalRun = runSuite();
9307
+ const finalExit = finalRun.exitCode;
8964
9308
  if (finalExit !== 0) {
8965
- const what = finalExit === null ? 'no exit code' : `exit ${finalExit}`;
9309
+ const what = finalExit === null ? `no exit code: ${finalRun.failureReason ?? 'unknown timeout / spawn failure'}` : `exit ${finalExit}`;
8966
9310
  warnings.push(`final re-baseline NOT green (${what}) — the suite is flaky; red-based verdicts downgraded to INCONCLUSIVE`);
8967
9311
  if (!json) write(`mutation-gate: final re-baseline NOT green (${what}) — red-based verdicts downgraded to INCONCLUSIVE`);
8968
- const reclassified = observations.map((obs) => classifyMutationOutcome({ ...obs, rebaselineExitCode: finalExit }));
9312
+ const reclassified = observations.map((obs) => classifyMutationOutcome({
9313
+ ...obs,
9314
+ rebaselineExitCode: finalExit,
9315
+ ...(finalRun.failureReason !== undefined ? { rebaselineFailureReason: finalRun.failureReason } : {}),
9316
+ }));
8969
9317
  results.length = 0;
8970
9318
  results.push(...reclassified);
8971
9319
  }
@@ -12036,8 +12384,13 @@ function cmdCompounding(options: Map<string, string>, flags: Set<string>, cwd: s
12036
12384
  }
12037
12385
 
12038
12386
  const report = assembleCompoundingReport({ lessons, usage, guard, nowTs: new Date().toISOString(), evidenceLogs });
12039
- if (json) write(JSON.stringify({ ...report, exitCode: 0 }, null, 2));
12040
- else write(renderCompoundingReport(report));
12387
+ // lesson-bandit-rerank §11: the payoff axis joins THIS report rather than growing a private
12388
+ // dashboard — the `rewardEvents : exposureEvents` row asks exactly the question this command
12389
+ // already asks of the reinforcement loop (is the apply leg alive, or is it a write-only log?).
12390
+ // Read-only, and INSUFFICIENT_DATA on an absent state file — never a fake verdict.
12391
+ const bandit = banditStats(root);
12392
+ if (json) write(JSON.stringify({ ...report, bandit, exitCode: 0 }, null, 2));
12393
+ else write(`${renderCompoundingReport(report)}\n\n${renderBanditHealth(bandit)}`);
12041
12394
  return 0;
12042
12395
  }
12043
12396
 
@@ -13518,6 +13871,285 @@ async function cmdImportEcc(options: Map<string, string>, flags: Set<string>, cw
13518
13871
  return report.skippedError > 0 && report.imported === 0 ? 1 : 0;
13519
13872
  }
13520
13873
 
13874
+ // ── dz profile (feature operator-profile, ADR-001) ─────────────────────────────────────────────
13875
+ //
13876
+ // WHO the assistant is talking to: a per-user store at ~/.dz/profile.json (0600, NEVER under a
13877
+ // project root) delivered as a marked block in ~/.claude/CLAUDE.md — loaded in every project on
13878
+ // the machine, dz installed or not. Four subcommands: init (five questions), show (path + age +
13879
+ // rendered block — the path ALWAYS prints, per the teach-target announce-the-store precedent),
13880
+ // set (register / language / teaches / deep|weak add|rm), sync (write the block; runs
13881
+ // automatically after init and set). An unknown register is REFUSED naming the accepted set —
13882
+ // never silently defaulted.
13883
+
13884
+ /** The refusal line for a bad register — one place, so init/set refuse identically. */
13885
+ function profileRegisterRefusal(raw: string): string {
13886
+ return `dz profile: unknown register ${JSON.stringify(raw)} — accepted: ${REGISTERS.join(' | ')} (или своими словами: профи | профи лайт | просто)`;
13887
+ }
13888
+
13889
+ function profileRegisterEcho(register: ProfileRegister): string {
13890
+ return `${register} (${registerOwnerWord(register)})`;
13891
+ }
13892
+
13893
+ /** Shared epilogue of init/set/sync: push the block into ~/.claude/CLAUDE.md and say what happened. */
13894
+ function profileSyncAndReport(profile: OperatorProfile, write: Write, writeErr: WriteErr): number {
13895
+ const res = syncProfileBlock(profile);
13896
+ if (res.problem !== null) {
13897
+ writeErr(`dz profile: sync failed: ${res.problem}`);
13898
+ return 1;
13899
+ }
13900
+ if (res.changed) {
13901
+ const backupNote = res.backup === null ? '' : ' (backup: ' + res.backup + ')';
13902
+ write(`synced block into ${res.target}` + backupNote);
13903
+ } else {
13904
+ write(`block in ${res.target} already up to date`);
13905
+ }
13906
+ return 0;
13907
+ }
13908
+
13909
+ async function cmdProfile(options: Map<string, string>, flags: Set<string>, write: Write, writeErr: WriteErr): Promise<number> {
13910
+ const sub = options.get('_positional_0') ?? 'show';
13911
+ const json = flags.has('json');
13912
+ // --json contract (cross-family finding, 2026-08-28): stdout carries exactly ONE JSON document
13913
+ // per invocation. Before this, `set --json` printed a human field echo BEFORE the JSON and the
13914
+ // sync status line AFTER it, `sync --json` ignored json entirely and `init --json` emitted its
13915
+ // prompts to stdout — none of the three parsed. Human lines now go through `say` (dropped in
13916
+ // json mode) and every refusal in json mode IS the one document.
13917
+ const say = (line: string): void => { if (!json) write(line); };
13918
+ const fail = (code: number, error: string): number => {
13919
+ if (json) write(JSON.stringify({ ok: false, error }));
13920
+ else writeErr(error);
13921
+ return code;
13922
+ };
13923
+
13924
+ if (sub === 'show') {
13925
+ const { profile, path, problem } = readProfile();
13926
+ if (profile === null) {
13927
+ // The path prints on EVERY exit — announcing the store is the point (ADR Decision 4).
13928
+ if (json) { write(JSON.stringify({ path, profile: null, problem })); return 1; }
13929
+ write(`store: ${path}`);
13930
+ writeErr(`dz profile: ${problem === 'missing' ? 'no profile yet — run `dz profile init`' : problem}`);
13931
+ return 1;
13932
+ }
13933
+ const age = profileAgeDays(profile);
13934
+ const drift = checkProfileDrift();
13935
+ const block = renderProfileBlock(profile);
13936
+ if (json) {
13937
+ write(JSON.stringify({ path, ageDays: age, drift, profile, block }));
13938
+ return 0;
13939
+ }
13940
+ write(`store: ${path}`);
13941
+ const ageNote = age === null ? '' : ' (' + age + (age === 1 ? ' day' : ' days') + ' ago)';
13942
+ write(`updated: ${profile.updatedAt}` + ageNote);
13943
+ write(`register: ${profileRegisterEcho(profile.register)} · language: ${profile.language} · teaches: ${profile.teaches ? 'yes' : 'no'}`);
13944
+ write(`block: ${drift.verdict}` + (drift.verdict === 'in-sync' ? '' : ' — ' + drift.detail));
13945
+ write('');
13946
+ write(block);
13947
+ return 0;
13948
+ }
13949
+
13950
+ if (sub === 'init') {
13951
+ // Five questions, node:readline, under two minutes. An existing profile pre-fills every
13952
+ // default (domains included) — init is safe to re-run; Enter keeps what is stored.
13953
+ const existing = readProfile().profile;
13954
+ const { createInterface } = await import('node:readline');
13955
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
13956
+ // Buffered ask, not rl.question(): with PIPED stdin the answer lines can all arrive while no
13957
+ // question is pending, and readline DROPS a 'line' nobody listens for — the next question()
13958
+ // then never settles and the process dies with an unsettled top-level await (MEASURED on the
13959
+ // first smoke of this command, exit 13). Buffering every line makes piped `printf 'a\nb\nc' |
13960
+ // dz profile init` and an interactive TTY behave identically. null = stdin closed early.
13961
+ const buffered: string[] = [];
13962
+ let pendingAsk: ((line: string | null) => void) | null = null;
13963
+ let stdinClosed = false;
13964
+ rl.on('line', (line) => {
13965
+ if (pendingAsk !== null) { const resolve = pendingAsk; pendingAsk = null; resolve(line); }
13966
+ else buffered.push(line);
13967
+ });
13968
+ rl.on('close', () => {
13969
+ stdinClosed = true;
13970
+ if (pendingAsk !== null) { const resolve = pendingAsk; pendingAsk = null; resolve(null); }
13971
+ });
13972
+ const ask = (prompt: string): Promise<string | null> => {
13973
+ // In --json mode the prompts go to STDERR — stdout must stay exactly one JSON document.
13974
+ (json ? process.stderr : process.stdout).write(prompt);
13975
+ if (buffered.length > 0) return Promise.resolve(buffered.shift() as string);
13976
+ if (stdinClosed) return Promise.resolve(null);
13977
+ return new Promise((resolve) => { pendingAsk = resolve; });
13978
+ };
13979
+ const eofRefusal = (): number =>
13980
+ fail(1, 'dz profile: stdin closed before the five questions were answered — nothing written');
13981
+ // Domain question: an EXISTING list renders as the default an Enter keeps; on a fresh
13982
+ // profile Enter honestly means "none" — onboarding never forces an answer.
13983
+ const askDomains = async (prompt: string, current: readonly ProfileDomain[]): Promise<readonly ProfileDomain[] | null> => {
13984
+ const hint = current.length > 0 ? ' [' + domainListText(current) + ']' : ' (Enter — пропустить)';
13985
+ const answer = await ask(prompt + hint + ': ');
13986
+ if (answer === null) return null;
13987
+ if (answer.trim() === '') return current;
13988
+ return parseDomainList(answer);
13989
+ };
13990
+ // y/n: an unrecognised answer RE-ASKS once; a second unrecognised answer takes the
13991
+ // documented default and SAYS so. Measured defect this closes: a domains line fed to the
13992
+ // old y/n question silently became `teaches: no`.
13993
+ const askYesNo = async (prompt: string, dflt: boolean): Promise<boolean | null> => {
13994
+ for (let attempt = 0; attempt < 2; attempt++) {
13995
+ const answer = await ask(prompt + ' y/n [' + (dflt ? 'y' : 'n') + ']: ');
13996
+ if (answer === null) return null;
13997
+ if (answer.trim() === '') return dflt;
13998
+ const parsed = parseYesNo(answer);
13999
+ if (parsed !== null) return parsed;
14000
+ if (attempt === 0) writeErr('dz profile: did not understand ' + JSON.stringify(answer.trim()) + ' — answer y or n (да/нет)');
14001
+ }
14002
+ (json ? writeErr : write)('unrecognised again — taking the default: ' + (dflt ? 'yes' : 'no'));
14003
+ return dflt;
14004
+ };
14005
+ try {
14006
+ const langDefault = existing?.language ?? 'ru';
14007
+ const langAnswer = await ask(`1/5 Dialogue language (ru, en, …) [${langDefault}]: `);
14008
+ if (langAnswer === null) return eofRefusal();
14009
+ const language = langAnswer.trim() === '' ? langDefault : langAnswer.trim();
14010
+
14011
+ const regDefault: ProfileRegister = existing?.register ?? 'pro-lite';
14012
+ let register: ProfileRegister | null = null;
14013
+ while (register === null) {
14014
+ const regAnswer = await ask(`2/5 Default register — pro / pro-lite / plain (профи / профи лайт / просто) [${profileRegisterEcho(regDefault)}]: `);
14015
+ if (regAnswer === null) return eofRefusal();
14016
+ const regRaw = regAnswer.trim();
14017
+ if (regRaw === '') { register = regDefault; break; }
14018
+ register = parseRegister(regRaw);
14019
+ if (register === null) writeErr(profileRegisterRefusal(regRaw));
14020
+ }
14021
+
14022
+ // ADR Decision 2: a single register dial cannot express the owner — the domain lists ARE
14023
+ // the profile, and the weak list is the load-bearing one. So onboarding asks for BOTH,
14024
+ // phrased so no self-assessment is needed (the design-report wording, tested with the owner).
14025
+ const deepDomains = await askDomains(
14026
+ '3/5 Назовите 2–4 области, где вам НЕ нужно пояснять термины (сертификации, чем занимались 5+ лет) — через запятую',
14027
+ existing?.deepDomains ?? [],
14028
+ );
14029
+ if (deepDomains === null) return eofRefusal();
14030
+ const weakDomains = await askDomains(
14031
+ '4/5 Где наоборот — терминам нужна одна поясняющая фраза? — через запятую',
14032
+ existing?.weakDomains ?? [],
14033
+ );
14034
+ if (weakDomains === null) return eofRefusal();
14035
+
14036
+ const teaches = await askYesNo('5/5 Do you teach — must explanations be re-tellable?', existing?.teaches ?? true);
14037
+ if (teaches === null) return eofRefusal();
14038
+
14039
+ const profile: OperatorProfile = {
14040
+ version: 1,
14041
+ updatedAt: new Date().toISOString(),
14042
+ language,
14043
+ register,
14044
+ deepDomains,
14045
+ weakDomains,
14046
+ teaches,
14047
+ };
14048
+ const initWrite = writeProfile(profile);
14049
+ if (initWrite.problem !== undefined) return fail(2, `dz profile: ${initWrite.problem}`);
14050
+ const { path } = initWrite;
14051
+ say(`wrote ${path} (0600) — register ${profileRegisterEcho(register)}, language ${language}, teaches ${teaches ? 'yes' : 'no'}`);
14052
+ say('deep: ' + (deepDomains.length > 0 ? domainListText(deepDomains) : '(none)') + ' · weak: ' + (weakDomains.length > 0 ? domainListText(weakDomains) : '(none)'));
14053
+ if (deepDomains.length === 0 && weakDomains.length === 0) {
14054
+ say('no domains yet — the profile is a single dial until you add them: `dz profile set deep add <tag> [note]` · `dz profile set weak add <tag>`');
14055
+ }
14056
+ if (json) {
14057
+ const sync = syncProfileBlock(profile);
14058
+ write(JSON.stringify({ ok: sync.problem === null, path, profile, sync }));
14059
+ return sync.problem === null ? 0 : 1;
14060
+ }
14061
+ return profileSyncAndReport(profile, write, writeErr);
14062
+ } finally {
14063
+ rl.close();
14064
+ }
14065
+ }
14066
+
14067
+ if (sub === 'set') {
14068
+ const read = readProfile();
14069
+ if (read.profile === null) {
14070
+ return fail(1, 'dz profile: ' + (read.problem === 'missing' ? 'no profile at ' + read.path + ' — run `dz profile init` first' : read.path + ': ' + read.problem));
14071
+ }
14072
+ const key = options.get('_positional_1') ?? '';
14073
+ const val = options.get('_positional_2') ?? '';
14074
+ let next: OperatorProfile;
14075
+ // The field echo is DEFERRED until writeProfile succeeds (round-5 P2-3): `dz profile set
14076
+ // language '<marker>'` used to print `language: <marker>` BEFORE the write check exited 2 —
14077
+ // a success-looking confirmation of a mutation that was never applied. In --json mode nothing
14078
+ // changes: echoes go through `say`, which json drops.
14079
+ let echo: string;
14080
+ if (key === 'register') {
14081
+ const register = parseRegister(val);
14082
+ if (register === null) { return fail(2, profileRegisterRefusal(val)); }
14083
+ next = { ...read.profile, register };
14084
+ echo = `register: ${profileRegisterEcho(register)}`;
14085
+ } else if (key === 'language') {
14086
+ if (val.trim() === '') { return fail(2, 'dz profile: usage: dz profile set language <code>'); }
14087
+ next = { ...read.profile, language: val.trim() };
14088
+ echo = `language: ${next.language}`;
14089
+ } else if (key === 'teaches') {
14090
+ const t = val.trim().toLowerCase();
14091
+ if (!['on', 'off', 'true', 'false', 'yes', 'no', 'y', 'n', 'да', 'нет'].includes(t)) {
14092
+ return fail(2, 'dz profile: usage: dz profile set teaches on|off');
14093
+ }
14094
+ next = { ...read.profile, teaches: ['on', 'true', 'yes', 'y', 'да'].includes(t) };
14095
+ echo = `teaches: ${next.teaches ? 'yes' : 'no'}`;
14096
+ } else if (key === 'deep' || key === 'weak') {
14097
+ const op = val;
14098
+ const tag = (options.get('_positional_3') ?? '').trim();
14099
+ const note = options.get('_positional_4');
14100
+ const listKey = key === 'deep' ? 'deepDomains' : 'weakDomains';
14101
+ const list = read.profile[listKey];
14102
+ if (op === 'add') {
14103
+ if (tag === '') { return fail(2, `dz profile: usage: dz profile set ${key} add <tag> [note]`); }
14104
+ const rest = list.filter((d) => d.tag !== tag);
14105
+ const domain: ProfileDomain = note === undefined || note.trim() === '' ? { tag } : { tag, note: note.trim() };
14106
+ next = { ...read.profile, [listKey]: [...rest, domain] };
14107
+ echo = key + ': + ' + (domain.note ? tag + ' (' + domain.note + ')' : tag);
14108
+ } else if (op === 'rm') {
14109
+ if (tag === '') { return fail(2, `dz profile: usage: dz profile set ${key} rm <tag>`); }
14110
+ if (!list.some((d) => d.tag === tag)) {
14111
+ return fail(1, `dz profile: no ${key} domain ${JSON.stringify(tag)} — have: ${list.map((d) => d.tag).join(', ') || '(none)'}`);
14112
+ }
14113
+ next = { ...read.profile, [listKey]: list.filter((d) => d.tag !== tag) };
14114
+ echo = `${key}: - ${tag}`;
14115
+ } else {
14116
+ return fail(2, `dz profile: usage: dz profile set ${key} add|rm <tag> [note]`);
14117
+ }
14118
+ } else {
14119
+ return fail(2, 'dz profile: usage: dz profile set register|language|teaches|deep|weak …');
14120
+ }
14121
+ const stamped: OperatorProfile = { ...next, updatedAt: new Date().toISOString() };
14122
+ const setWrite = writeProfile(stamped);
14123
+ // A refused write must never fall through to sync: syncing an unwritten profile would put the
14124
+ // poisoned value into CLAUDE.md while the store still holds the old one (round-4 fix). And it
14125
+ // must never have echoed either — the echo below only runs on an APPLIED mutation.
14126
+ if (setWrite.problem !== undefined) return fail(2, `dz profile: ${setWrite.problem}`);
14127
+ say(echo);
14128
+ const { path } = setWrite;
14129
+ if (json) {
14130
+ const sync = syncProfileBlock(stamped);
14131
+ write(JSON.stringify({ ok: sync.problem === null, path, profile: stamped, sync }));
14132
+ return sync.problem === null ? 0 : 1;
14133
+ }
14134
+ return profileSyncAndReport(stamped, write, writeErr);
14135
+ }
14136
+
14137
+ if (sub === 'sync') {
14138
+ const read = readProfile();
14139
+ if (read.profile === null) {
14140
+ return fail(1, 'dz profile: ' + (read.problem === 'missing' ? 'no profile at ' + read.path + ' — run `dz profile init` first' : read.path + ': ' + read.problem));
14141
+ }
14142
+ if (json) {
14143
+ const res = syncProfileBlock(read.profile);
14144
+ write(JSON.stringify({ ok: res.problem === null, target: res.target, changed: res.changed, backup: res.backup, problem: res.problem }));
14145
+ return res.problem === null ? 0 : 1;
14146
+ }
14147
+ return profileSyncAndReport(read.profile, write, writeErr);
14148
+ }
14149
+
14150
+ return fail(2, `dz profile: unknown subcommand ${JSON.stringify(sub)} — accepted: init | show | set | sync`);
14151
+ }
14152
+
13521
14153
  export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
13522
14154
  const cwd = io.cwd ?? process.cwd();
13523
14155
  const write: Write = io.write ?? ((line) => { console.log(line); });
@@ -13674,7 +14306,7 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
13674
14306
  case 'architecture':
13675
14307
  return cmdArchitecture(options, flags, cwd, write);
13676
14308
  case 'project-skills':
13677
- return cmdProjectSkills(flags, cwd, write);
14309
+ return cmdProjectSkills(options, flags, cwd, write);
13678
14310
  case 'mr-rakes':
13679
14311
  return await cmdMrRakes(options, flags, cwd, write);
13680
14312
  case 'retro':
@@ -13701,6 +14333,8 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
13701
14333
  return cmdRecap(options, flags, cwd, write);
13702
14334
  case 'cadence':
13703
14335
  return cmdCadence(options, flags, cwd, write);
14336
+ case 'qe-rounds':
14337
+ return cmdQeRounds(options, flags, cwd, write);
13704
14338
  case 'tg-post':
13705
14339
  return cmdTgPost(options, flags, cwd, write);
13706
14340
  case 'name-check':
@@ -13713,6 +14347,8 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
13713
14347
  return cmdAmendmentCheck(options, flags, cwd, write);
13714
14348
  case 'feature-adr-checkpoint':
13715
14349
  return cmdFeatureAdrCheckpoint(options, flags, cwd, write);
14350
+ case 'profile':
14351
+ return await cmdProfile(options, flags, write, writeErr);
13716
14352
  case 'reqe':
13717
14353
  return cmdReqe(options, flags, cwd, write);
13718
14354
  case 'qe-bridge':