@dzhechkov/harness-cli 0.8.21 → 0.8.23

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
@@ -19,6 +19,37 @@ import { createRequire } from 'node:module';
19
19
  import { isDeepStrictEqual } from 'node:util';
20
20
  import { JOURNAL_KINDS, formatLine, parseLine, selectWindow, appendWitnessed, type JournalKind, type JournalIo } from '@dzhechkov/harness-core';
21
21
  import { appendRunEvent, readRunRegistry, liveParents, liveness, probePid, settleDeadRuns, planRegistryArchive, planWorktreeCleanup, renderCleanupPlan, worktreeRemovalsToApply, type WorktreeFact, type RunEvent } from '@dzhechkov/harness-core';
22
+ import {
23
+ openRound,
24
+ closeRound,
25
+ listRounds,
26
+ parseCodexTokens,
27
+ classifyRoundExecOutcome,
28
+ buildRoundExecRow,
29
+ type RoundLedgerRow,
30
+ type RoundExecLedgerRow,
31
+ type RoundState,
32
+ } from '@dzhechkov/harness-core';
33
+
34
+ // round-state-lock fix-round AM-1: augment (not fork) harness-core's `RoundState` with an opaque
35
+ // per-open identity token. Module augmentation keeps this CLI-only (harness-core/src/round.ts is
36
+ // out of this fix's scope — its own pure decisions never need to know the token exists) while still
37
+ // letting every `RoundState`-typed value in this file carry `stateId` with full type-checking. The
38
+ // field is OPTIONAL: a state written before this fix (or by a test's raw `writeFileSync`) parses
39
+ // fine without it, and identity checks below treat a missing `stateId` as `undefined === undefined`
40
+ // (matches only itself), never as a wildcard.
41
+ declare module '@dzhechkov/harness-core' {
42
+ interface RoundState {
43
+ /** 16 random hex chars, minted once by `open`. The identity comparison `exec`/`close` use
44
+ * instead of pid: `process.ppid` coincides for two `dz` launched from the same shell, and every
45
+ * run-owned state carries pid 0 (teach:0ea46034 — pid is not identity). */
46
+ readonly stateId?: string;
47
+ /** Lead edit after Codex re-review: identity of ONE exec claim (two execs of the same round
48
+ * instance are different claims) and when it was taken — the stale-exec warning counts from it. */
49
+ readonly execClaimId?: string;
50
+ readonly execClaimedAt?: string;
51
+ }
52
+ }
22
53
 
23
54
  import {
24
55
  createSkill,
@@ -98,10 +129,15 @@ import {
98
129
  hasPolicyFence,
99
130
  TARGET_NAMES,
100
131
  buildParityMatrix,
132
+ computeParity,
133
+ PARITY_FEATURES,
101
134
  downgradeForStaleEvidence,
102
135
  findStaleTranscriptEvidence,
103
136
  TARGET_CAPABILITIES,
104
137
  TARGET_SHORT_LABELS,
138
+ applyLegStatus,
139
+ applyLegReasonMessage,
140
+ resolveAgentdbPath,
105
141
  WORKFLOW_TEMPLATES_RETIRED_MESSAGE,
106
142
  parsePlan,
107
143
  isParseErrors,
@@ -174,7 +210,7 @@ import {
174
210
  type StoreCountSnapshot,
175
211
  type RunSegment,
176
212
  type StageSample,
177
- computeUsage,
213
+ computeSpendReport,
178
214
  deriveCostLedger,
179
215
  planLedgerBackfill,
180
216
  listCostLedgerRuns,
@@ -186,10 +222,7 @@ import {
186
222
  verifyCostLedgerReport,
187
223
  writeCostLedgerJsonl,
188
224
  COST_LEDGER_SCOPE,
189
- deriveUsageCalibration,
190
- normalizeClaudeUsageModelKey,
191
- readUsageLimits,
192
- parseWeeklyResetAnchor,
225
+ spendReport,
193
226
  claimCheck,
194
227
  summarize,
195
228
  BUNDLED_SLOP_REGISTRY_URL,
@@ -210,6 +243,10 @@ import {
210
243
  recordToPattern,
211
244
  bundleSkills,
212
245
  brainHome,
246
+ brainAgentdbPath,
247
+ listPreReindexSnapshots,
248
+ rotatePreReindexSnapshots,
249
+ scanSnapshotDir,
213
250
  listBrain,
214
251
  bookKbPath,
215
252
  promoteProjectToBrain,
@@ -418,6 +455,7 @@ import {
418
455
  countRecallEventsForRun,
419
456
  unknownFlagNotice,
420
457
  mirrorWriterExplanation,
458
+ mirrorWriterReason,
421
459
  appendRecallUsage,
422
460
  closenessLine,
423
461
  anyAboveFloor,
@@ -510,6 +548,8 @@ import {
510
548
  renderReqeList,
511
549
  REQE_SCOPE,
512
550
  // Mutation gate (feature ha-mutation-gate) — break each named protection, run the suite, require red.
551
+ REGISTRY_SELFCHECK_TESTS,
552
+ buildMutationTestCommand,
513
553
  parseMutationRegistry,
514
554
  applyMutationToText,
515
555
  attributeBaselineRedness,
@@ -593,7 +633,7 @@ import type { SetupSpec } from '@dzhechkov/harness-core';
593
633
  import type { LogTail } from '@dzhechkov/harness-core';
594
634
  import type { DeadwoodInventoryItem } from '@dzhechkov/harness-core';
595
635
  import type { ContractDiagnostic, ContractEvidenceReader } from '@dzhechkov/harness-core';
596
- import type { ProvenanceMode, PackVerdict, ClaudeUsageModel, PatternRecord, RecallPatternsOptions, TeachGuardResult, TargetName, IntegrationOutcome, BookKU, HarmonizeReport, UsageCalibrationPlan, ClaimFinding, RecallUsagePatternRow, GateExecution, GateStep, SlopFinding, SlopLintConfig, SlopRegistry } from '@dzhechkov/harness-core';
636
+ import type { ProvenanceMode, PackVerdict, PatternRecord, RecallPatternsOptions, TeachGuardResult, TargetName, IntegrationOutcome, BookKU, HarmonizeReport, ClaimFinding, RecallUsagePatternRow, GateExecution, GateStep, SlopFinding, SlopLintConfig, SlopRegistry } from '@dzhechkov/harness-core';
597
637
  import { getPreset, PRESET_NAMES } from '@dzhechkov/harness-presets';
598
638
  import { scanGitHub, analyzeRepo, generateReport, deepAnalyze, scanAllSources, ScoutMemory } from '@dzhechkov/scout';
599
639
 
@@ -637,7 +677,7 @@ export const DZ_COMMANDS: readonly string[] = [
637
677
  'epoch-replay', 'score', 'recap', 'cadence', 'qe-rounds', 'restart-advisor', 'tg-post',
638
678
  'name-check', 'brief-check', 'provenance-check', 'journal', 'feature-adr-record', 'runs', 'runs-record', 'runs-clean', 'amendment-check', 'contract-check',
639
679
  'feature-adr-checkpoint', 'profile', 'reqe', 'qe-bridge', 'backlog', 'routing',
640
- 'bto-optimize', 'dashboard', 'roam', 'import-ecc', 'chain',
680
+ 'bto-optimize', 'dashboard', 'roam', 'import-ecc', 'chain', 'round',
641
681
  ];
642
682
 
643
683
  const USAGE = `dz - DZ cross-platform harness CLI
@@ -669,7 +709,7 @@ Usage:
669
709
  dz upgrade [--target <name>] [--pubkey <path>] [--require-signing] (a TAMPERED pack aborts the upgrade)
670
710
  dz sign --pack <dir> --key <path-outside-repo> (Ed25519 manifest + CycloneDX SBOM for a pack)
671
711
  dz verify-pack --pack <dir> [--pubkey <path>] (signature check; fail-closed; key from the repo, never the pack)
672
- dz publish [--filter <name>] [--bump-only] [--claim-check <off|warn|error>] [--require-signing] [--provenance|--no-provenance] (dry-run by default; pass --yes/--confirm/--no-dry-run to go live; claim-check gate default warn — surfaces README claim findings, never blocks; error fails an offending package)
712
+ dz publish [--filter <name>] [--bump-only] [--claim-check <off|warn|error>] [--mirror-cmd <cmd>|--no-mirror] [--require-signing] [--provenance|--no-provenance] (dry-run by default; pass --yes/--confirm/--no-dry-run to go live; claim-check gate default warn — surfaces README claim findings, never blocks; error fails an offending package)
673
713
  dz release [--filter <name>] [--tag] [--publish] [--json] [--dry-run] [--no-issue] (VERIFIED release: 4 HARD gates in FRONT of dz publish — full package test suites, audit >=high, node --check of every dist/bin file, bin smoke-boot via "node <bin> --help" — any red gate STOPS the release (exit 1) + best-effort gh issue; all green ⇒ re-sign reminder, then prints the ready dz publish command (or chains with --publish); never duplicates publish's own gates)
674
714
  dz parity [--target <name>] [--json] (the honest feature×target map, COMPUTED from the capability model — which harness feature is full / manual / absent on each of the ${TARGET_NAMES.length} targets, and via which form)
675
715
  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)
@@ -699,7 +739,8 @@ Usage:
699
739
  dz amendment-check --slug <slug> | --feature-dir <dir> | --all [--json] (the deterministic Step-8 amendment gate: every AM-N / AM-CP-N row must resolve to a test found INSIDE the file the row names (the challenge-panel prefix is part of the id: AM-CP-1 is never AM-1); the PLAN is authoritative when it carries rows, and an ideation amendment the plan drops is a failure. exit 0 pass/skip, 1 fail, 3 NOT-ESTABLISHED — a section that parsed ZERO rows is never a pass, UNLESS the plan explicitly declares \"None\"/\"нет\", which is an answer and reports skip. --all is a CENSUS and always exits 0. Does NOT prove non-vacuity — that is dz discrimination-check)
700
740
  dz contract-check --slug <s> [--json] (read-only retrospective feature contract gate: extracts canonical AC-N + ADR Confirmation items, requires one artifact-anchored met|unmet|not-testable verdict per CC-N, and rejects A/B with unmet. exit 0 pass / 1 readable contract or verdict violation / 2 invalid invocation or unreadable/not-established artifacts)
701
741
  dz journal add --kind decision|verdict|run|error|block "<text>" [--ref <trace>] [--at <ISO>] [--quote <file>] [--commit-quote]; dz journal show [--day|--week] [--at <date>] [--kind <kind>] [--json] (UTC day files, witnessed append; quotes stay local unless explicitly staged)
702
- 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)
742
+ dz feature-adr-record --kind ledger|training-pair --stage <s> [--slug <s>] [--row|--pair <json>] [--run-id <id>] [--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; for a ledger row, 'ts' is ALWAYS the actual write instant (ledger-stage-minutes FR-1) — a payload-supplied 'ts' is never trusted for the delta below, and is preserved as 'payloadTs' rather than discarded; --run-id fills the payload's runId ONLY when it is a gap — absent, null, '', or non-string, the same 'missing when absent or blank' rule runnerId uses — and stamps runIdSource:'cli-flag' when it does; for an auto:true ledger row that carries a runId — from the payload, from --run-id, or resolved at write time — the append also carries minutesSincePrev/minutesSource:'ledger-ts-delta' measured against the LAST row of the same run found by a best-effort reverse scan that reports 'unavailable' (never a guess) on a missing prior row OR a corrupt/non-object ledger line anywhere between it and the file's end (ledger-corrupt-line); minutes itself stays untouched. New fields (ts, minutesSincePrev, minutesSource) are always appended after every existing key, never reordering one. 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)
743
+ dz round open --slug <s> --round <n|auto> --topic <text> [--project <brain>] [--run <id>] [--owner-pid <n>|--owner-run <runId>] [--force] [--json]; dz round exec --slug <s> --round <n> --brief <file> [--log <file>] [--model gpt-5.6-sol] [--effort high] [--timeout-min 30] [--json]; dz round close --slug <s> --round <n> --outcome shipped|refuted|blocked|abandoned [--reason <text>] [--lesson teach:<id>...]|[--no-new-knowledge <reason>] [--tokens N] [--agents N] [--coder <spec>] [--reviewer <spec>] [--note <text>] [--no-cost] [--json]; dz round status [--older-than <minutes>] [--json] (focused rounds outside feature-adr: open tracks the parent process by default, an explicit pid, or a registered run; live/stalled run owners stay live and missing registry evidence stays unknown; open --force refuses a live or unknown owner and archives a known-dead owner's state; recall precedes work, then the witnessed ledger is trusted only after reading it back)
703
744
  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)
704
745
  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)
705
746
  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)
@@ -732,7 +773,8 @@ Usage:
732
773
  dz brain query "<q>" [--source <slug>] [--limit <N>] [--any] [--rerank] [--json] (cross-source recall; --any = OR match; --rerank reorders top-K)
733
774
  dz brain add [--source <slug>] [--project <dir>] [--from-slice <f>|--from-pack <p>|--from-kus <f> --slug <s>] [--kind <k>] [--license <spdx>] [--json] (grow the brain: promote this project, or import a slice/pack/KU-array)
734
775
  dz brain update <slug> [--project <dir>] [--json] (non-destructive refresh: re-mirror a re-ingested source into the brain)
735
- dz brain reindex [--json] (snapshot, re-embed book-KU brain vectors, stamp current model)
776
+ dz brain reindex [--json] (snapshot, re-embed book-KU brain vectors, stamp current model; also rotates old pre-reindex snapshots)
777
+ dz brain snapshots [--keep <N>] [--prune] [--json] [--project <dir>] (list — or, with --prune, rotate — pre-reindex snapshot families of the home brain, or of <dir>/.dz/agentdb.db; default keep 3)
736
778
  dz brain primer <slug> [--json] (print a source's capability card — KU-type histogram + top decision moments)
737
779
  dz brain export --source <slug> --out <file> (export ONE source as a portable, lexical-only books.sqlite slice)
738
780
  dz brain ground [<prompt>] [--k <N>] [--source <slug>] [--text] [--budget <N>] [--full] (UserPromptSubmit hook; --budget inlines top-K KUs within ~N tokens; --full = ~8000)
@@ -741,7 +783,7 @@ Usage:
741
783
  dz statusline [--json] [--install] [--project <dir>] (live self-learning panel for Claude Code's status bar; reads the CC JSON payload from STDIN)
742
784
  dz store-guard [--status|--reset] [--yes] [--project <dir>] (show the monotonic external high-water mark; --reset is the only lowering path and requires confirmation or --yes)
743
785
  dz statusline --fa-record --slug <s> --step "<label>" [--kind <feature-adr|loop>] [--tier <S|M|L|XL>] [--run-id <id>] [--recalled <n>] [--stored <n>] [--mode <m>] (feature-adr: record live per-run learning state + phase → 📐 SECOND-LINE phase panel; the monotone guard absorbs a backwards plain "Step <n>" only within the same non-empty run id, while an absent/empty id retains legacy fresh-slot behavior — prefix the label with ⛔ or ⏸ to record a legitimate regression)
744
- dz usage [--json] [--project <dir>] | dz usage --calibrate --session <pct> --weekly <pct> [--model fable=<pct>] [--project <dir>] (ESTIMATE Claude usage from fixed reset windows; optional per-model weekly binding; exit 0 ALWAYS; pct=null when limits unconfigured)
786
+ dz usage [--json] [--project <dir>] (7-day UTC spend from local Claude Code + subagent transcripts; provider-limit routing disabled by design)
745
787
  dz usage --by-stage [--run <runId> | --slug <slug>] [--epsilon <0..1>] [--write <file.jsonl>] [--json] (per-stage cost ledger for ONE feature-adr run + the reconciliation invariant: accounted + unaccounted = run total; verdict BALANCED | DEFECT | INSUFFICIENT_DATA; local transcript ESTIMATES — catches ATTRIBUTION errors, not pricing errors)
746
788
  dz chain [--project <dir>] [--json] (verify EVERY hash-chained journal in ONE command: coverage is DERIVED from the CHAINED_JOURNALS registry, never typed, so a journal cannot be given a chain and checked by nobody. An ABSENT journal is NAMED absent, never omitted — omission and cleanliness are indistinguishable in a report. Statuses: ok | healed (defects the current unbroken run has outlived — verdicts over present records are sound) | unchained (present, no chained record yet — legal) | absent | broken | unreadable. Exit 1 on broken/unreadable: a verifier that reports damage and exits 0 is one no automation can act on)
747
789
  dz claim-check [paths...] [--json] [--fail-on high|medium|none] [--project <dir>] (enforce the Integrity Rule: flag untagged/overstated accuracy claims; default scan = root README.md + every discovered package's README.md + features/*/08_qe_report.md + docs/**/*.md (historical feature artifacts are NOT scanned — pass paths explicitly); exit 1 only at/above --fail-on, default high)
@@ -801,7 +843,12 @@ export interface MutationGateRunnerObservation {
801
843
 
802
844
  export type MutationGateRunner = (
803
845
  command: string,
804
- options: { readonly cwd: string; readonly timeoutMs: number },
846
+ options: {
847
+ readonly cwd: string;
848
+ readonly timeoutMs: number;
849
+ readonly phase: 'baseline' | 'mutation' | 'rebaseline' | 'final-rebaseline';
850
+ readonly entryId?: string;
851
+ },
805
852
  ) => MutationGateRunnerObservation;
806
853
 
807
854
  /** Output sink + working directory — injectable so the CLI is testable. */
@@ -830,10 +877,45 @@ export interface CliIo {
830
877
  readonly interactive?: boolean;
831
878
  /** Fault seam proving that class-form recall degrades to specific recall with a stderr receipt. */
832
879
  readonly classMatcher?: RecallPatternsOptions['classMatcher'];
880
+ /** Focused-round seams: production still uses the real store, writer, ledger tail and pid probe. */
881
+ readonly roundNow?: () => number;
882
+ readonly roundPid?: number;
883
+ readonly roundRecall?: (projectRoot: string, topic: string, options: {
884
+ readonly limit: number;
885
+ readonly runId?: string;
886
+ }) => Promise<readonly {
887
+ readonly id: string;
888
+ readonly reward: number;
889
+ readonly domain: string;
890
+ readonly text: string;
891
+ }[]>;
892
+ readonly roundLessonExists?: (projectRoot: string, id: string) => boolean;
893
+ readonly roundLedgerWriter?: (projectRoot: string, row: RoundLedgerRow | RoundExecLedgerRow) => unknown;
894
+ readonly roundLedgerReader?: (projectRoot: string) => string;
895
+ readonly roundPidProbe?: (pid: number) => boolean | null;
896
+ readonly roundRunRegistryReader?: (projectRoot: string) => string;
897
+ readonly roundKillGraceMs?: number;
898
+ /** round-state-lock NFR-2: overrides `withNamedLockSync`'s acquisition deadline for `dz round`
899
+ * mutations so a test can force `lock busy` deterministically. Omitted in production. */
900
+ readonly roundLockTimeoutMs?: number;
901
+ readonly roundSpawn?: (request: {
902
+ readonly command: 'codex';
903
+ readonly args: readonly string[];
904
+ readonly cwd: string;
905
+ readonly logPath: string;
906
+ readonly timeoutMs: number;
907
+ readonly killGraceMs?: number;
908
+ }) => Promise<{
909
+ readonly exitCode: number | null;
910
+ readonly timedOut: boolean;
911
+ readonly signal: NodeJS.Signals | null;
912
+ readonly errorCode?: string;
913
+ readonly error?: string;
914
+ }>;
833
915
  /** Guard decision seam; production always uses the real vector-backed teach guard. */
834
916
  readonly teachGuardRunner?: (projectRoot: string, text: string, opts: { readonly reward?: number }) => Promise<TeachGuardResult>;
835
917
  /** Reinforcement flush seam paired with `teachGuardRunner`; production uses the configured backend. */
836
- readonly teachReinforceRunner?: (projectRoot: string, dzId: string, reward: number) => Promise<{ readonly flushed: number }>;
918
+ readonly teachReinforceRunner?: (projectRoot: string, dzId: string, reward?: number) => Promise<{ readonly flushed: number; readonly dzId?: string }>;
837
919
  /**
838
920
  * Test seam for `dz release`: overrides subprocess execution for gate steps and the
839
921
  * gh/git side channels (production leaves it unset → real `execSync`, stdio piped).
@@ -841,6 +923,8 @@ export interface CliIo {
841
923
  * without spawning anything.
842
924
  */
843
925
  readonly releaseRunner?: ReleaseExecRunner;
926
+ /** Post-publish mirror command seam; production uses synchronous shell execution. */
927
+ readonly publishMirrorRunner?: PublishMirrorRunner;
844
928
  /**
845
929
  * Test seam for `dz install`: overrides the `npm install` subprocess (production leaves
846
930
  * it unset → real `execSync`, stdio piped). A stub runner that pre-stages a fixture
@@ -860,6 +944,21 @@ export type ReleaseExecRunner = (
860
944
  opts: { readonly cwd: string; readonly timeoutMs: number },
861
945
  ) => { exitCode: number; stdout: string; stderr: string; timedOut?: boolean };
862
946
 
947
+ export type PublishMirrorRunner = (
948
+ command: string,
949
+ options: { readonly cwd: string; readonly env: NodeJS.ProcessEnv },
950
+ ) => string;
951
+
952
+ type PublishMirrorState = {
953
+ readonly status: 'confirmed' | 'unconfirmed' | 'skipped' | 'not-configured';
954
+ readonly command: string;
955
+ readonly commit?: string;
956
+ readonly receipt?: { readonly manifestUrl: string; readonly confirmedAt: string; readonly waitedMs: number };
957
+ readonly error?: string;
958
+ readonly reason?: string;
959
+ readonly warning?: string;
960
+ };
961
+
863
962
  interface ParsedArgs {
864
963
  readonly command: string;
865
964
  readonly options: Map<string, string>;
@@ -2977,10 +3076,13 @@ function cmdStatusline(
2977
3076
  : `🎓 dz: ${data.patterns} (${breakdown.active} active${breakdown.quarantined > 0
2978
3077
  ? ` · ${breakdown.quarantined} quarantined${breakdown.attention ? ' ⚠' : ''}`
2979
3078
  : ''})${breakdown.tierDelta !== undefined ? ` ⚠ tiers Δ${breakdown.tierDelta}` : ''}`;
2980
- // Показатель зеркала печатается и здесь: `dz statusline` та же панель, и показатель,
2981
- // живущий только во вспомогательном скрипте, для этой поверхности просто не существовал.
2982
- if (data.patternMirror?.state === 'unavailable') line += ' (mirror unreadable ⚠)';
2983
- else if (data.patternMirror?.state === 'different') line += ` (mirror ${data.patternMirror.vector})`;
3079
+ // Зеркало самостоятельный источник панели. Отсутствие печатается явно; нечитаемый файл
3080
+ // сохраняет прежнее отдельное состояние, чтобы отказ инструмента не выглядел как настройка off.
3081
+ line += data.patternMirror?.state === 'unavailable'
3082
+ ? ' · mirror: unreadable'
3083
+ : data.mirror.available
3084
+ ? ` · mirror: ${data.mirror.lessons} lessons (pending ${data.mirror.pending})`
3085
+ : ' · mirror: absent';
2984
3086
  if (data.storeHealth?.verdict === 'collapsed') {
2985
3087
  line += ` ⛔ COLLAPSE: was ${data.storeHealth.previousMax ?? '?'} · dz store-guard --reset`;
2986
3088
  } else if (data.storeHealth?.verdict === 'cold-start-over-existing') {
@@ -2988,6 +3090,8 @@ function cmdStatusline(
2988
3090
  } else if (data.storeHealth?.verdict === 'unreadable') {
2989
3091
  line += ` ⛔ STORE UNREADABLE${data.storeHealth.unreadableFiles !== undefined && data.storeHealth.unreadableFiles.length > 0
2990
3092
  ? `: ${data.storeHealth.unreadableFiles.join(', ')}` : ''}`;
3093
+ } else if (data.storeHealth?.verdict === 'busy') {
3094
+ line += ' ⏳ STORE BUSY';
2991
3095
  } else if (data.storeHealth?.verdict === 'source-changed') {
2992
3096
  line += ' ⚠ store source changed';
2993
3097
  }
@@ -3022,172 +3126,6 @@ function cmdStatusline(
3022
3126
  }
3023
3127
  }
3024
3128
 
3025
- function isJsonRecord(value: unknown): value is Record<string, unknown> {
3026
- return typeof value === 'object' && value !== null && !Array.isArray(value);
3027
- }
3028
-
3029
- function usageConfigPath(projectRoot: string): string {
3030
- return join(projectRoot, '.dz', 'config.json');
3031
- }
3032
-
3033
- function readProjectConfigForUsage(projectRoot: string): { config: Record<string, unknown>; warning?: string } {
3034
- const path = usageConfigPath(projectRoot);
3035
- try {
3036
- if (!existsSync(path)) return { config: {} };
3037
- const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown;
3038
- if (isJsonRecord(parsed)) return { config: parsed };
3039
- return { config: {}, warning: 'existing config is not a JSON object; writing a minimal config' };
3040
- } catch {
3041
- return { config: {}, warning: 'existing config could not be parsed; writing a minimal config' };
3042
- }
3043
- }
3044
-
3045
- function applyUsageCalibrationToConfig(config: Record<string, unknown>, plan: UsageCalibrationPlan): Record<string, unknown> {
3046
- const next: Record<string, unknown> = { ...config };
3047
- const memory = isJsonRecord(next['memory']) ? { ...next['memory'] } : {};
3048
- const usage = isJsonRecord(memory['usage']) ? { ...memory['usage'] } : {};
3049
-
3050
- for (const change of plan.changes) {
3051
- if (change.key === 'session') {
3052
- usage['sessionTokenLimit'] = change.after;
3053
- } else if (change.key === 'weekly') {
3054
- usage['weeklyTokenLimit'] = change.after;
3055
- } else {
3056
- const model = normalizeClaudeUsageModelKey(change.key);
3057
- if (model) {
3058
- const existingByModel = isJsonRecord(usage['weeklyTokenLimitByModel']) ? { ...usage['weeklyTokenLimitByModel'] } : {};
3059
- existingByModel[model] = change.after;
3060
- usage['weeklyTokenLimitByModel'] = existingByModel;
3061
- }
3062
- }
3063
- }
3064
-
3065
- if (plan.changes.length > 0) {
3066
- usage['calibratedAt'] = plan.after.calibratedAt;
3067
- usage['source'] = plan.after.source;
3068
- // A fresh calibration re-arms routing for THIS account and clears the legacy free-text switch:
3069
- // the calibration is the very act the disable-note demanded.
3070
- usage['calibrationAccount'] = plan.after.calibrationAccount ?? null;
3071
- }
3072
-
3073
- memory['usage'] = usage;
3074
- next['memory'] = memory;
3075
- return next;
3076
- }
3077
-
3078
- function parseUsageModelArgs(modelArgs: readonly string[]): { modelPct: Record<string, unknown>; skipped: string[] } {
3079
- const modelPct: Record<string, unknown> = {};
3080
- const skipped: string[] = [];
3081
- for (const raw of modelArgs) {
3082
- const eq = raw.indexOf('=');
3083
- if (eq <= 0 || eq === raw.length - 1) {
3084
- skipped.push(`model ${raw}: skipped malformed model=pct argument`);
3085
- continue;
3086
- }
3087
- const modelName = raw.slice(0, eq).trim();
3088
- const model = normalizeClaudeUsageModelKey(modelName);
3089
- if (!model) {
3090
- skipped.push(`model ${modelName}: skipped unknown model`);
3091
- continue;
3092
- }
3093
- modelPct[model] = raw.slice(eq + 1).trim();
3094
- }
3095
- return { modelPct, skipped };
3096
- }
3097
-
3098
- function writeUsageCalibrationSummary(opts: {
3099
- projectRoot: string;
3100
- plan: UsageCalibrationPlan;
3101
- preSkipped: readonly string[];
3102
- configWarning?: string | undefined;
3103
- wrote: boolean;
3104
- write: Write;
3105
- }): void {
3106
- opts.write('usage calibrate: estimated local transcript counts; claude.ai/settings/usage is authoritative');
3107
- opts.write(`usage calibrate: project ${opts.projectRoot}`);
3108
- if (opts.configWarning) opts.write(`usage calibrate: ${opts.configWarning}`);
3109
- for (const change of opts.plan.changes) {
3110
- opts.write(
3111
- `usage calibrate: ${change.key} tokens=${change.tokens} pct=${change.pct}% limit ${change.before ?? 'null'} -> ${change.after}`,
3112
- );
3113
- }
3114
- const skipped = [...opts.preSkipped, ...opts.plan.skipped];
3115
- for (const item of skipped) opts.write(`usage calibrate: skipped ${item}`);
3116
- if (opts.wrote) {
3117
- opts.write('usage calibrate: wrote .dz/config.json with source claude.ai/settings/usage');
3118
- } else {
3119
- opts.write('usage calibrate: no config changes written');
3120
- }
3121
- }
3122
-
3123
- function cmdUsageCalibrate(
3124
- options: Map<string, string>,
3125
- optionLists: Map<string, string[]>,
3126
- cwd: string,
3127
- write: Write,
3128
- ): number {
3129
- const projectRoot = resolve(cwd, options.get('project') ?? '.');
3130
- const suppliedModels = optionLists.get('model') ?? [];
3131
- const parsedModels = parseUsageModelArgs(suppliedModels);
3132
- const modelPct = parsedModels.modelPct;
3133
- const hasModelPct = Object.keys(modelPct).length > 0;
3134
- const input = {
3135
- ...(options.has('session') ? { sessionPct: options.get('session') } : {}),
3136
- ...(options.has('weekly') ? { weeklyPct: options.get('weekly') } : {}),
3137
- ...(hasModelPct ? { modelPct } : {}),
3138
- calibratedAt: new Date().toISOString(),
3139
- source: 'claude.ai/settings/usage' as const,
3140
- };
3141
- const missingInputs: string[] = [];
3142
- if (!options.has('session') && !options.has('weekly') && !hasModelPct) {
3143
- missingInputs.push('no calibration percentages supplied');
3144
- }
3145
-
3146
- try {
3147
- const current = computeUsage(projectRoot);
3148
- const before = readUsageLimits(projectRoot);
3149
- const plan = deriveUsageCalibration(current, before, input);
3150
- if (plan.changes.length === 0) {
3151
- writeUsageCalibrationSummary({
3152
- projectRoot,
3153
- plan,
3154
- preSkipped: [...parsedModels.skipped, ...missingInputs],
3155
- wrote: false,
3156
- write,
3157
- });
3158
- return 0;
3159
- }
3160
-
3161
- const existing = readProjectConfigForUsage(projectRoot);
3162
- const nextConfig = applyUsageCalibrationToConfig(existing.config, plan);
3163
- try {
3164
- mkdirSync(join(projectRoot, '.dz'), { recursive: true });
3165
- writeFileSync(usageConfigPath(projectRoot), JSON.stringify(nextConfig, null, 2) + '\n');
3166
- writeUsageCalibrationSummary({
3167
- projectRoot,
3168
- plan,
3169
- preSkipped: [...parsedModels.skipped, ...missingInputs],
3170
- configWarning: existing.warning,
3171
- wrote: true,
3172
- write,
3173
- });
3174
- } catch {
3175
- writeUsageCalibrationSummary({
3176
- projectRoot,
3177
- plan,
3178
- preSkipped: [...parsedModels.skipped, ...missingInputs, 'write failed'],
3179
- configWarning: existing.warning,
3180
- wrote: false,
3181
- write,
3182
- });
3183
- }
3184
- return 0;
3185
- } catch {
3186
- write('usage calibrate: skipped internal error; no config changes written');
3187
- return 0;
3188
- }
3189
- }
3190
-
3191
3129
  /**
3192
3130
  * `dz usage --by-stage` — the per-stage cost ledger for one feature-adr run (feature `cost-ledger`).
3193
3131
  *
@@ -3246,17 +3184,6 @@ function cmdUsageByStage(
3246
3184
  return 0;
3247
3185
  }
3248
3186
 
3249
- /**
3250
- * `dz usage` — print an ESTIMATE of Claude session + weekly usage from fixed reset windows,
3251
- * aggregated READONLY from the local transcript store (see {@link computeUsage}). `--json` emits
3252
- * the single-line contract the feature-adr usage-probe agent parses; `--calibrate` is the only
3253
- * write path and records human-transcribed claude.ai percentages in `.dz/config.json`.
3254
- *
3255
- * **Exit code is 0 ALWAYS** — including on internal error the whole body is guarded and prints the
3256
- * all-null JSON, so a probe can NEVER distinguish "usage unknown" from "command failed" via a
3257
- * non-zero exit. `--project <dir>` scopes ONLY the `.dz/config.json` read/write; measurement is
3258
- * account-wide (all projects).
3259
- */
3260
3187
  /**
3261
3188
  * dz qe-rounds — how many Step-8 review rounds has one feature already had?
3262
3189
  *
@@ -3435,6 +3362,52 @@ function cmdRestartAdvisor(options: Map<string, string>, flags: Set<string>, cwd
3435
3362
  }));
3436
3363
  }
3437
3364
 
3365
+ function packageCommitCount(root: string, sinceIso: string): number | null {
3366
+ try {
3367
+ // Assemble git's flag so the CLI flag-inventory scanner does not mistake a child-process option
3368
+ // for a user-facing dz option. The argv delivered to git is still exactly `--count`.
3369
+ const raw = execFileSync('git', ['rev-list', '--' + 'count', `--since=${sinceIso}`, 'HEAD', '--', 'packages/'], {
3370
+ cwd: root,
3371
+ encoding: 'utf8',
3372
+ stdio: ['ignore', 'pipe', 'pipe'],
3373
+ }).trim();
3374
+ return /^\d+$/.test(raw) ? Number(raw) : null;
3375
+ } catch {
3376
+ return null;
3377
+ }
3378
+ }
3379
+
3380
+ function roundTraceSince(root: string): string | null {
3381
+ let firstDate: string | null = null;
3382
+ let lastRoundDate: string | null = null;
3383
+ try {
3384
+ const rows = readFileSync(join(root, '.dz', 'feature-adr', 'run-cost-ledger.jsonl'), 'utf8').split('\n');
3385
+ for (const line of rows) {
3386
+ if (line.trim() === '') continue;
3387
+ let row: Record<string, unknown>;
3388
+ try {
3389
+ const parsed = JSON.parse(line) as unknown;
3390
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) continue;
3391
+ row = parsed as Record<string, unknown>;
3392
+ } catch { continue; }
3393
+ const date = typeof row['date'] === 'string' && Number.isFinite(Date.parse(row['date'])) ? row['date'] : null;
3394
+ if (date === null) continue;
3395
+ if (firstDate === null) firstDate = date;
3396
+ if (row['stage'] === 'round' || row['stage'] === 'round-exec') lastRoundDate = date;
3397
+ }
3398
+ } catch { return null; }
3399
+ return lastRoundDate ?? firstDate;
3400
+ }
3401
+
3402
+ function roundsTracingEnabled(root: string): boolean {
3403
+ try {
3404
+ const parsed = JSON.parse(readFileSync(join(root, '.dz', 'config.json'), 'utf8')) as { rounds?: { traced?: unknown } };
3405
+ return parsed?.rounds?.traced !== false;
3406
+ } catch {
3407
+ return true;
3408
+ }
3409
+ }
3410
+
3438
3411
  function cmdCadence(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
3439
3412
  const root = resolve(cwd, options.get('project') ?? '.');
3440
3413
  const windowRaw = (options.get('window') ?? 'week').trim() as CadenceWindow;
@@ -3442,7 +3415,9 @@ function cmdCadence(options: Map<string, string>, flags: Set<string>, cwd: strin
3442
3415
  write('dz cadence: --window must be one of ' + Object.keys(CADENCE_WINDOW_DAYS).join('|'));
3443
3416
  return 1;
3444
3417
  }
3445
- const r = buildCadenceReport(root, windowRaw);
3418
+ const now = Date.now();
3419
+ const windowStartIso = new Date(now - CADENCE_WINDOW_DAYS[windowRaw] * 86_400_000).toISOString();
3420
+ const r = buildCadenceReport(root, windowRaw, now, packageCommitCount(root, windowStartIso));
3446
3421
  if (flags.has('json')) { write(JSON.stringify(r)); return r.decision.ok ? 0 : 2; }
3447
3422
  write('dz cadence — window ' + r.window + ', record depth ' + r.depthDays + ' day(s)');
3448
3423
  if (!r.decision.ok) {
@@ -3456,145 +3431,105 @@ function cmdCadence(options: Map<string, string>, flags: Set<string>, cwd: strin
3456
3431
  write(' ' + w.padEnd(12) + String(r.shipments.graded[w] ?? 0).padStart(15) + String(r.npmPublishes.weekly[w] ?? 0).padStart(15) + String(r.recalls.weekly[w] ?? 0).padStart(9));
3457
3432
  }
3458
3433
  write(' graded ' + r.shipments.gradedTotal + ' (' + Object.entries(r.shipments.byGrade).sort().map(([g, n]) => g + '×' + n).join(', ') + ') · UNGRADED ' + r.shipments.ungraded + ' (named, not hidden)');
3434
+ const roundCount = r.rounds.byStage.round;
3435
+ const roundPart = roundCount === 0
3436
+ ? 'rounds 0 (ни одной строки круга в окне)'
3437
+ : `rounds ${roundCount} (shipped ${r.rounds.byOutcome.shipped} · refuted ${r.rounds.byOutcome.refuted} · blocked ${r.rounds.byOutcome.blocked} · abandoned ${r.rounds.byOutcome.abandoned})`;
3438
+ write(` ${roundPart} · exec ${r.rounds.byStage['round-exec']} (done ${r.rounds.byOutcome.done} · timeout ${r.rounds.byOutcome.timeout} · session-limit ${r.rounds.byOutcome['session-limit']} · model-refused ${r.rounds.byOutcome['model-refused']} · failed ${r.rounds.byOutcome.failed} · empty ${r.rounds.byOutcome.empty}) · commits(packages/) ${r.rounds.commitsInWindow ?? 'not measured'}`);
3439
+ for (const round of r.rounds.unfinished) {
3440
+ write(` ✗ ${round.slug}#${round.round} ${round.outcome} — ${round.reason ?? 'причина не названа'}`);
3441
+ }
3459
3442
  if (r.guard.decay.length > 0) {
3460
3443
  write(' guard repeat decay (FIXED set — rules with pre-window history only):');
3461
3444
  for (const d of r.guard.decay.slice(0, 8)) write(' ' + d.rule.padEnd(28) + 'before×' + d.before + ' → in-window×' + d.inWindow);
3462
3445
  }
3463
3446
  if (r.guard.excludedNewborn.length > 0) write(' excluded newborn rule(s) (no pre-window history — a zero here would be youth, not virtue): ' + r.guard.excludedNewborn.join(', '));
3464
- for (const dgr of [r.npmPublishes.degraded, r.guard.degraded, r.recalls.degraded]) if (dgr) write(' DEGRADED: ' + dgr);
3447
+ for (const dgr of [r.npmPublishes.degraded, r.guard.degraded, r.recalls.degraded, r.rounds.degraded]) if (dgr) write(' DEGRADED: ' + dgr);
3465
3448
  return 0;
3466
3449
  }
3467
3450
 
3468
3451
  function cmdUsage(
3469
3452
  options: Map<string, string>,
3470
- optionLists: Map<string, string[]>,
3453
+ _optionLists: Map<string, string[]>,
3471
3454
  flags: Set<string>,
3472
3455
  cwd: string,
3473
3456
  write: Write,
3474
3457
  ): number {
3475
3458
  const projectRoot = resolve(cwd, options.get('project') ?? '.');
3476
- const nullContract = () =>
3477
- JSON.stringify({
3478
- sessionPct: null,
3479
- weeklyPct: null,
3480
- sessionTokens: 0,
3481
- weeklyTokens: 0,
3482
- resetsAt: { session: null, weekly: null },
3483
- limits: { session: null, weekly: null },
3484
- estimated: true,
3485
- });
3459
+ const reqeDue = (): number => {
3460
+ try {
3461
+ return scanReqeDebts(projectRoot).debts.length;
3462
+ } catch {
3463
+ return 0;
3464
+ }
3465
+ };
3466
+ const jsonContract = (spend: ReturnType<typeof computeSpendReport>): string => JSON.stringify({
3467
+ sessionPct: null,
3468
+ weeklyPct: null,
3469
+ routing: 'disabled-by-design',
3470
+ spend,
3471
+ reqeDue: reqeDue(),
3472
+ });
3473
+ const number = (value: number): string =>
3474
+ (Number.isInteger(value) ? String(value) : String(Math.round(value * 100) / 100));
3486
3475
  try {
3487
- if (flags.has('calibrate')) return cmdUsageCalibrate(options, optionLists, cwd, write);
3476
+ if (flags.has('calibrate')) {
3477
+ // Keep the retired mode's value flags known so its one-line removal receipt is not polluted
3478
+ // by generic unknown-flag notices before dispatch.
3479
+ void ['--session', '--weekly'];
3480
+ write('dz usage --calibrate removed 2026-09-12: provider limits are not measurable (no API, per-account weekly resets, ad-hoc resets); dz usage reports spend only');
3481
+ return 2;
3482
+ }
3488
3483
  if (flags.has('by-stage')) return cmdUsageByStage(options, flags, write);
3489
3484
 
3490
- const u = computeUsage(projectRoot);
3491
- const lim = readUsageLimits(projectRoot);
3492
- const modelLimits = lim.weeklyTokenLimitByModel;
3493
- const hasModelLimits = modelLimits !== undefined && Object.keys(modelLimits).length > 0;
3485
+ const spend = computeSpendReport();
3494
3486
  if (flags.has('json')) {
3495
- const limitsPayload: {
3496
- session: number | null;
3497
- weekly: number | null;
3498
- weeklyByModel?: Partial<Record<ClaudeUsageModel, number>>;
3499
- } = { session: lim.sessionTokenLimit ?? null, weekly: lim.weeklyTokenLimit ?? null };
3500
- if (hasModelLimits) limitsPayload.weeklyByModel = { ...modelLimits };
3501
- const payload: {
3502
- sessionPct: number | null;
3503
- weeklyPct: number | null;
3504
- sessionTokens: number;
3505
- weeklyTokens: number;
3506
- resetsAt: { session: string | null; weekly: string | null };
3507
- limits: typeof limitsPayload;
3508
- weeklyByModel?: typeof u.weeklyByModel;
3509
- estimated: true;
3510
- reqeDue?: number;
3511
- notEstablished?: readonly string[];
3512
- estimatesNotForRouting?: { sessionPct: number | null; weeklyPct: number | null };
3513
- } = {
3514
- sessionPct: u.sessionPct,
3515
- weeklyPct: u.weeklyPct,
3516
- sessionTokens: u.sessionTokens,
3517
- weeklyTokens: u.weeklyTokens,
3518
- resetsAt: { session: u.sessionResetsAt, weekly: u.weeklyResetsAt },
3519
- limits: limitsPayload,
3520
- estimated: true,
3521
- };
3522
- // ADR-001 usage-honesty: a consumer that reads null pcts deserves the WHY (closed reason
3523
- // set), and a human deserves the raw estimates when POLICY (not measurement) nulled them.
3524
- if (u.notEstablished.length > 0) payload.notEstablished = u.notEstablished;
3525
- if (u.estimatesNotForRouting !== undefined) payload.estimatesNotForRouting = u.estimatesNotForRouting;
3526
- if (hasModelLimits && u.weeklyByModel !== undefined) payload.weeklyByModel = u.weeklyByModel;
3527
- // re-QE debt surfacing (backlog 6b40e667 — QE #9: the json contract must carry the debt too,
3528
- // a probe is exactly the consumer that needs it). The field appears ONLY when a debt exists,
3529
- // so the zero-debt contract stays byte-identical to the pinned legacy shape. Best-effort.
3530
- try {
3531
- const reqeCount = scanReqeDebts(resolve(cwd, options.get('project') ?? '.')).debts.length;
3532
- if (reqeCount > 0) payload.reqeDue = reqeCount;
3533
- } catch { /* advisory only */ }
3534
- write(
3535
- JSON.stringify(payload),
3536
- );
3487
+ write(jsonContract(spend));
3537
3488
  return 0;
3538
3489
  }
3539
- if (u.sessionPct === null && u.weeklyPct === null && u.notEstablished.length > 0) {
3540
- // Limits may be fully configured and the pcts STILL null — that is the honesty, not a config
3541
- // gap. Say why, and show the human the raw estimates when only policy nulled them.
3542
- write('usage: not established — ' + u.notEstablished.join(', '));
3543
- if (u.estimatesNotForRouting !== undefined) {
3544
- const e = u.estimatesNotForRouting;
3545
- write(' estimates (NOT for routing): session ~' + (e.sessionPct ?? '?') + '% · week ~' + (e.weeklyPct ?? '?') + '% — recalibrate on THIS account: dz usage --calibrate --session <pct> --weekly <pct>');
3546
- } else {
3547
- write(' the scan established nothing (' + u.sessionTokens + ' session / ' + u.weeklyTokens + ' weekly tokens counted) — a percentage would be a guess, and routing must not eat guesses');
3490
+ write('usage spend last 7 UTC days');
3491
+ write('date weighted input output cache-read cache-write events');
3492
+ for (const day of spend.days) {
3493
+ write(`${day.date} ${number(day.weightedTokens)} ${number(day.input)} ${number(day.output)} ${number(day.cacheRead)} ${number(day.cacheWrite)} ${day.events}`);
3494
+ }
3495
+ const total = spend.total7d;
3496
+ write(`7-day total ${number(total.weightedTokens)} ${number(total.input)} ${number(total.output)} ${number(total.cacheRead)} ${number(total.cacheWrite)} ${total.events}`);
3497
+ // "unknown" = `event.model ?? 'unknown'` in `spendReport` — an event with NO model field AT
3498
+ // ALL, or one whose model string matched none of the four recognized substrings (in practice
3499
+ // almost always `<synthetic>`). Fix-round-1 (Codex review, MEDIUM #3): a prior wording here and
3500
+ // in the README said "not an event without a model", which is the OPPOSITE of what the code
3501
+ // does — corrected to name both causes.
3502
+ write('by model — weighted share (0..1) (7-day window; "unknown" = event with no model, or an unrecognized model string e.g. "<synthetic>")');
3503
+ const models = Object.entries(spend.byModel);
3504
+ if (models.length === 0) write(' (no events)');
3505
+ for (const [model, row] of models) {
3506
+ write(` ${model} ${number(row.weightedTokens)} ${number(row.sharePct / 100)}`);
3507
+ }
3508
+ const today = spend.daysByModel.at(-1);
3509
+ if (today !== undefined) {
3510
+ // Fix-round-1 (Codex review, MEDIUM #1): this block used to print weighted tokens only, so
3511
+ // AC-5's "today block shows Sonnet's share of today" had nothing to read it off of. The share
3512
+ // denominator is TODAY's own total (`spend.days.at(-1)`, the same last entry as `today` by
3513
+ // construction — both arrays are built from the same `days` in `spendReport`), not the 7-day
3514
+ // total — a day's share of a week would silently understate every model.
3515
+ write(`today (${today.date}) by model — weighted share-of-day (0..1)`);
3516
+ const todayModels = Object.entries(today.models);
3517
+ const todayTotal = spend.days.at(-1)?.weightedTokens ?? 0;
3518
+ if (todayModels.length === 0) write(' (no events)');
3519
+ for (const [model, weightedTokens] of todayModels) {
3520
+ const shareOfDay = todayTotal > 0 ? weightedTokens / todayTotal : 0;
3521
+ write(` ${model} ${number(weightedTokens)} ${number(shareOfDay)}`);
3548
3522
  }
3549
- try {
3550
- const reqe = scanReqeDebts(resolve(cwd, options.get('project') ?? '.'));
3551
- if (reqe.debts.length > 0) write('re-QE due: ' + reqe.debts.length + ' usage-switched run(s) kept same-family QE — run `dz reqe` for the cross-family pass');
3552
- } catch { /* advisory only */ }
3553
- return 0;
3554
- }
3555
- if (u.sessionPct === null && u.weeklyPct === null) {
3556
- write(
3557
- 'usage: unconfigured — set memory.usage.sessionTokenLimit / weeklyTokenLimit in .dz/config.json (percentages are ESTIMATES calibrated from observed exhaustion)',
3558
- );
3559
- try {
3560
- const reqe = scanReqeDebts(resolve(cwd, options.get('project') ?? '.'));
3561
- if (reqe.debts.length > 0) write('re-QE due: ' + reqe.debts.length + ' usage-switched run(s) kept same-family QE — run `dz reqe` for the cross-family pass');
3562
- } catch { /* advisory only */ }
3563
- return 0;
3564
3523
  }
3565
- // Compact human line — a short HH:MM / weekday hint on the resets, best-effort.
3566
- const clock = (iso: string | null): string => {
3567
- if (!iso) return '?';
3568
- try {
3569
- return new Date(iso).toISOString().slice(11, 16);
3570
- } catch {
3571
- return '?';
3572
- }
3573
- };
3574
- const s = u.sessionPct === null ? 'n/a' : '~' + u.sessionPct + '%';
3575
- const binding = hasModelLimits && u.weeklyBindingModel !== undefined ? ' ' + u.weeklyBindingModel + '-bound' : '';
3576
- const w = u.weeklyPct === null ? 'n/a' : '~' + u.weeklyPct + '%' + binding;
3577
- // The weekly reset is WEEKLY: print the anchor verbatim (weekday + offset), not a bare clock
3578
- // time — 'resets 08:59' reads as daily and hides the weekday (idea c8513be9: the bare form
3579
- // misread a Monday reading as '41 minutes after the boundary' when the boundary was Wednesday's).
3580
- const weeklyAnchorLabel = typeof lim.weeklyResetAnchor === 'string' && lim.weeklyResetAnchor !== ''
3581
- ? lim.weeklyResetAnchor
3582
- : clock(u.weeklyResetsAt);
3583
- write('usage: session ' + s + ' (resets ' + clock(u.sessionResetsAt) + ') · week ' + w + ' (resets ' + weeklyAnchorLabel + ') · estimated');
3584
- if (typeof lim.weeklyResetAnchor === 'string' && parseWeeklyResetAnchor(lim.weeklyResetAnchor)?.offsetMinutes === undefined) {
3585
- write(' ⚠ weeklyResetAnchor has NO utc offset — the boundary follows the SERVER timezone, not your account\'s true reset instant (measured: the same moment lands a week apart under UTC vs +03:00). Pin it: "' + lim.weeklyResetAnchor + ' +03:00" (your offset) in .dz/config.json');
3586
- }
3587
- // re-QE debt surfacing (backlog 6b40e667): the moment someone checks usage is the moment a
3588
- // usage-switched self-review debt should be visible. Best-effort — never breaks the contract.
3589
- try {
3590
- const reqe = scanReqeDebts(resolve(cwd, options.get('project') ?? '.'));
3591
- if (reqe.debts.length > 0) write('re-QE due: ' + reqe.debts.length + ' usage-switched run(s) kept same-family QE — run `dz reqe` for the cross-family pass');
3592
- } catch { /* advisory only */ }
3524
+ write('source: local Claude Code + subagent transcripts, cost-weighted');
3593
3525
  return 0;
3594
3526
  } catch {
3595
- // never let a probe see a non-zero exit — print the all-null contract and exit 0.
3596
- if (flags.has('json')) write(nullContract());
3597
- else write('usage: unconfigured — set memory.usage.sessionTokenLimit / weeklyTokenLimit in .dz/config.json');
3527
+ const empty = spendReport([], { nowMs: Date.now(), days: 7 });
3528
+ if (flags.has('json')) write(jsonContract(empty));
3529
+ else {
3530
+ write('usage spend — last 7 UTC days');
3531
+ write('source: local Claude Code + subagent transcripts, cost-weighted');
3532
+ }
3598
3533
  return 0;
3599
3534
  }
3600
3535
  }
@@ -3636,13 +3571,16 @@ function learningStoreLine(
3636
3571
  ) + (reason ? ' [' + reason + ']' : '');
3637
3572
  }
3638
3573
 
3639
- function inspectLearningStore(projectRoot: string): {
3574
+ function inspectLearningStore(
3575
+ projectRoot: string,
3576
+ countOptions?: { readonly busyTimeoutMs?: number; readonly attempts?: number },
3577
+ ): {
3640
3578
  mark: StoreMark | undefined;
3641
3579
  health: StoreHealth;
3642
3580
  rows: ReturnType<typeof countLearningStoreRowsReadonly>;
3643
3581
  } {
3644
3582
  const mark = readStoreMark(projectRoot);
3645
- const rows = countLearningStoreRowsReadonly(projectRoot);
3583
+ const rows = countLearningStoreRowsReadonly(projectRoot, countOptions);
3646
3584
  return { mark, rows, health: checkStoreHealth({ projectRoot, ...rows, mark }) };
3647
3585
  }
3648
3586
 
@@ -3739,7 +3677,8 @@ function refreshLearningStoreMark(
3739
3677
  const rows = countLearningStoreRowsReadonly(projectRoot);
3740
3678
  const counts = observedRows(rows);
3741
3679
  if (counts === undefined) {
3742
- writeErr(`⚠ dz store guard: ${options.reader ? 'reader observation' : 'store operation'} completed but the external mark was not updated — a store tier is unreadable`);
3680
+ const busy = rows.lexicalRows === 'busy' || rows.vectorRows === 'busy';
3681
+ writeErr(`⚠ dz store guard: ${options.reader ? 'reader observation' : 'store operation'} completed but the external mark was not updated — ${busy ? 'store busy; health not measured this run' : 'a store tier is unreadable'}`);
3743
3682
  return;
3744
3683
  }
3745
3684
  writeStoreMark(projectRoot, {
@@ -3773,7 +3712,7 @@ function allowLearningStoreWrite(
3773
3712
  ): boolean {
3774
3713
  let inspection: ReturnType<typeof inspectLearningStore>;
3775
3714
  try {
3776
- inspection = inspectLearningStore(projectRoot);
3715
+ inspection = inspectLearningStore(projectRoot, { busyTimeoutMs: 250, attempts: 3 });
3777
3716
  } catch (error) {
3778
3717
  const health: StoreHealth = {
3779
3718
  verdict: 'unreadable',
@@ -3789,6 +3728,10 @@ function allowLearningStoreWrite(
3789
3728
  // own row had landed.
3790
3729
  return true;
3791
3730
  }
3731
+ if (inspection.health.verdict === 'busy') {
3732
+ writeErr(`dz store guard: NOT MEASURED — ${inspection.health.reason}`);
3733
+ return true;
3734
+ }
3792
3735
  if (inspection.health.verdict === 'source-changed') {
3793
3736
  const counts = observedRows(inspection.rows);
3794
3737
  if (counts === undefined) return false;
@@ -3828,6 +3771,7 @@ function warnLearningStoreRead(projectRoot: string, writeErr: WriteErr, command:
3828
3771
  for (const line of lexicalSourceLines(rows)) writeErr(line);
3829
3772
  return;
3830
3773
  }
3774
+ if (health.verdict === 'busy') return;
3831
3775
  const counts = observedRows(rows);
3832
3776
  if (counts !== undefined && counts.lexicalRows + counts.vectorRows > 0
3833
3777
  && (mark === undefined || mark.lexicalLast !== counts.lexicalRows || mark.vectorLast !== counts.vectorRows
@@ -3915,24 +3859,30 @@ async function cmdStoreGuard(
3915
3859
  async function runTeachGuardReinforcement(
3916
3860
  projectRoot: string,
3917
3861
  dzId: string,
3918
- reward: number,
3862
+ reward?: number,
3919
3863
  preserveQuarantine = false,
3920
- ): Promise<{ readonly flushed: number }> {
3864
+ ): Promise<{ readonly flushed: number; readonly dzId?: string }> {
3865
+ const matchedDzId = loadStoreRecords(projectRoot)
3866
+ .find((record) => record.id === dzId || record.text === dzId)?.id;
3921
3867
  const backend = resolveLearningBackend(projectRoot);
3922
3868
  backend.addSample({
3923
3869
  dzId,
3924
3870
  kind: preserveQuarantine ? 'recall-hit' : 'reinforce',
3925
- reward,
3871
+ ...(reward !== undefined ? { reward } : {}),
3926
3872
  ts: new Date().toISOString(),
3927
3873
  });
3928
- return backend.train();
3874
+ const trained = await backend.train();
3875
+ return {
3876
+ ...trained,
3877
+ ...(trained.flushed > 0 && matchedDzId !== undefined ? { dzId: matchedDzId } : {}),
3878
+ };
3929
3879
  }
3930
3880
 
3931
3881
  async function cmdTeach(
3932
3882
  options: Map<string, string>, flags: Set<string>, cwd: string, write: Write,
3933
3883
  writeErr: WriteErr = (line) => { console.error(line); }, interactive = false,
3934
3884
  guardRunner: (projectRoot: string, text: string, opts: { readonly reward?: number }) => Promise<TeachGuardResult> = teachGuard,
3935
- reinforceRunner: (projectRoot: string, dzId: string, reward: number, preserveQuarantine?: boolean) => Promise<{ readonly flushed: number }> = runTeachGuardReinforcement,
3885
+ reinforceRunner: (projectRoot: string, dzId: string, reward?: number, preserveQuarantine?: boolean) => Promise<{ readonly flushed: number; readonly dzId?: string }> = runTeachGuardReinforcement,
3936
3886
  ): Promise<number> {
3937
3887
  // WHICH store this lesson belongs to, and WHO decided (teach-chooses-its-store).
3938
3888
  // `--to` → `DZ_LEARN` → `.dz/config.json` learning.teachTo → project. The owner asked for a
@@ -3968,20 +3918,58 @@ async function cmdTeach(
3968
3918
  // (D3) — an unconfigured project runs ZERO vector code and its output stays byte-identical
3969
3919
  // to the pre-feature baseline (AC-1). Failures are queued + logged by the service itself and
3970
3920
  // NOT printed on the default path (teach must stay quiet/scriptable); only success emits.
3921
+ // AM-4 (dz-harness-hub issue #10 defect 4, feature setup-installs-apply-leg): a mirror attempt
3922
+ // that produced ZERO rows, resolved NO working engine (`receipt.engine === undefined` — deps
3923
+ // missing/unusable, the ABI-115 failure AM-2 fixes being the measured cause), AND left the
3924
+ // agentdb store file still absent is not "nothing to report" — it is the vector tier having
3925
+ // never come into being, and a lesson taught in that window has nowhere to mirror into until
3926
+ // `dz consolidate`/a later teach (once the store exists) runs. BOTH signals are required so this
3927
+ // never misfires for an rvf-configured project (whose store is not `.dz/agentdb.db` at all) or
3928
+ // for the ordinary "already mirrored, nothing new" case (which resolves an engine successfully).
3929
+ const emitVectorTierAbsentIfNeeded = (root: string, receipt: { readonly engine?: string | undefined }): void => {
3930
+ if (receipt.engine === undefined && !existsSync(resolveAgentdbPath(root))) {
3931
+ write(' ↳ vector tier absent — run dz consolidate');
3932
+ }
3933
+ };
3934
+ // AM-9/AM-10 (issue #10 defect 6, feature setup-installs-apply-leg): `vectorMirrorEnabled(root)`
3935
+ // alone used to decide "say nothing" for every disabled reason alike, including a config that
3936
+ // CLAIMS agentdb via a top-level `backend` key (`{"backend":"agentdb"}` instead of
3937
+ // `{"memory":{"backend":"agentdb"}}`) — a real, readable intent this silently dropped on the
3938
+ // floor. Named for `config-unreadable` / `legacy-shape` — both are a config that TRIED to say
3939
+ // something and got it wrong. THREE reasons stay silent: `engine-off` (deliberate), `no-config`
3940
+ // (the pre-existing AC-1 contract — a NAMED test in `cli.test.ts`/`teach-chooses-its-store.test.ts`
3941
+ // — printing there broke both, MEASURED), and `not-enabled` (AM-10, narrower than the amendment's
3942
+ // literal instruction — MEASURED: `not-enabled` is ALSO the state of the ORDINARY, first-class
3943
+ // jsonl backend `dz setup` produces by default, and printing there added a line to the single most
3944
+ // common `dz teach` invocation shape, reproducer: `mkdir .dz && echo '{"memory":{"backend":
3945
+ // "jsonl"}}' > .dz/config.json && dz teach "x"` → new line `↳ vector tier OFF: …` on the DEFAULT,
3946
+ // fully-supported jsonl path. `not-enabled` cannot distinguish "chose jsonl on purpose" from "typo'd
3947
+ // a backend name", so it is grouped with the other legitimate-quiet states rather than with the
3948
+ // two states that are unambiguously a mistake.
3949
+ const emitMirrorOffIfNeeded = (root: string): boolean => {
3950
+ const reason = mirrorWriterReason(root);
3951
+ if (reason.state !== 'config-unreadable' && reason.state !== 'legacy-shape') return false;
3952
+ write(` ↳ vector tier OFF: ${mirrorWriterExplanation(reason.state)}`);
3953
+ return true;
3954
+ };
3971
3955
  const emitMirror = async (root: string, records: readonly PatternRecord[], source: string): Promise<void> => {
3972
- if (flags.has('no-mirror') || records.length === 0 || !vectorMirrorEnabled(root)) return;
3956
+ if (flags.has('no-mirror') || records.length === 0) return;
3957
+ if (!vectorMirrorEnabled(root)) { emitMirrorOffIfNeeded(root); return; }
3973
3958
  const receipt = await mirrorPatternsToVector(root, records, source);
3974
3959
  if (receipt.mirrored > 0) write(` ↳ mirrored to vector tier (${receipt.engine ?? 'vector'})`);
3960
+ else emitVectorTierAbsentIfNeeded(root, receipt);
3975
3961
  };
3976
3962
  // lesson-quarantine FR-8: the fresh-teach mirror carries the qStatus marker so the hook daemon
3977
3963
  // (which reads only the mirror's metadata) can exclude unproven lessons from auto-inject.
3978
3964
  const emitMirrorQ = async (root: string, records: readonly PatternRecord[], source: string, quarantined: boolean): Promise<void> => {
3979
- if (flags.has('no-mirror') || records.length === 0 || !vectorMirrorEnabled(root)) return;
3965
+ if (flags.has('no-mirror') || records.length === 0) return;
3966
+ if (!vectorMirrorEnabled(root)) { emitMirrorOffIfNeeded(root); return; }
3980
3967
  const entries = records
3981
3968
  .map((r) => patternVectorEntry(r, source, quarantined ? { quarantined: true } : {}))
3982
3969
  .filter((e): e is NonNullable<typeof e> => e !== undefined);
3983
3970
  const receipt = await mirrorEntriesToVector(root, entries);
3984
3971
  if (receipt.mirrored > 0) write(` ↳ mirrored to vector tier (${receipt.engine ?? 'vector'})${quarantined ? ' [quarantined]' : ''}`);
3972
+ else emitVectorTierAbsentIfNeeded(root, receipt);
3985
3973
  };
3986
3974
 
3987
3975
  // `dz teach --harmonize` — documented ALIAS of `dz vector harmonize`: SEMANTIC dedup of the
@@ -4099,20 +4087,23 @@ async function cmdTeach(
4099
4087
 
4100
4088
  const reinforce = options.get('reinforce');
4101
4089
  if (reinforce !== undefined && reinforce.trim() !== '') {
4102
- const backend = resolveLearningBackend(storeRoot);
4103
4090
  const sampleReward = options.has('reward') ? parseFloat(options.get('reward') ?? '0.8') : undefined;
4104
- backend.addSample({
4105
- dzId: reinforce,
4106
- kind: 'reinforce',
4107
- ts: new Date().toISOString(),
4108
- ...(sampleReward !== undefined ? { reward: sampleReward } : {}),
4109
- });
4110
- const trained = await backend.train();
4091
+ const trained = await reinforceRunner(storeRoot, reinforce, sampleReward);
4111
4092
  if (trained.flushed > 0) {
4112
- write(`↳ reinforced ${reinforce}`);
4093
+ const records = loadStoreRecords(storeRoot);
4094
+ const reinforcedDzId = trained.dzId
4095
+ ?? findExactLesson(records, reinforce)?.id
4096
+ ?? records.find((record) => record.id === reinforce)?.id;
4097
+ write(reinforcedDzId !== undefined && reinforcedDzId !== reinforce
4098
+ ? `↳ reinforced ${reinforcedDzId} (matched by text)`
4099
+ : `↳ reinforced ${reinforcedDzId ?? reinforce}`);
4113
4100
  // lesson-quarantine: reinforcement IS promotion — keep the hook daemon's mirror in step.
4114
- const clearedQ = clearAgentdbQuarantine(storeRoot, [reinforce]);
4115
- if (clearedQ.cleared > 0) write(`promoted out of quarantine (mirror updated)`);
4101
+ if (reinforcedDzId === undefined) {
4102
+ write('mirror quarantine NOT cleared: matched pattern has no dzId');
4103
+ } else {
4104
+ const clearedQ = clearAgentdbQuarantine(storeRoot, [reinforcedDzId]);
4105
+ if (clearedQ.cleared > 0) write(` ↳ promoted out of quarantine (mirror updated)`);
4106
+ }
4116
4107
  write(storeLine('written'));
4117
4108
  refreshLearningStoreMark(storeRoot, writeErr, 'dz teach --reinforce');
4118
4109
  return 0;
@@ -5464,6 +5455,7 @@ Usage:
5464
5455
  dz brain add --from-kus <file.json> --slug <s> [--kind repo|book|paper] [--license <spdx>] [--override] [--json]
5465
5456
  dz brain update <slug> [--project <dir>] [--json]
5466
5457
  dz brain reindex [--json]
5458
+ dz brain snapshots [--keep <N>] [--prune] [--json] [--project <dir>]
5467
5459
  dz brain primer <slug> [--json]
5468
5460
  dz brain export --source <slug> --out <file>
5469
5461
  dz brain ground [<prompt>] [--k <N>] [--source <slug>] [--text] [--budget <N>] [--full]
@@ -5902,9 +5894,98 @@ async function cmdBrain(
5902
5894
  }
5903
5895
  write(`dz brain reindex: re-embedded ${result.reembedded} KU vector(s) with ${result.model} (manifest v${result.version})`);
5904
5896
  if (result.backupPath !== undefined) write(` snapshot: ${result.backupPath}`);
5897
+ if (result.snapshots !== undefined) {
5898
+ const mb = (result.snapshots.removedBytes / (1024 * 1024)).toFixed(1);
5899
+ write(` ↳ snapshots: kept ${result.snapshots.kept.length}, removed ${result.snapshots.removed.length} (${mb} MB)`);
5900
+ if (result.snapshots.errors !== undefined && result.snapshots.errors.length > 0) {
5901
+ write(` ⚠ snapshot rotation error(s): ${result.snapshots.errors.join('; ')}`);
5902
+ }
5903
+ if (result.snapshots.scanErrors !== undefined && result.snapshots.scanErrors.length > 0) {
5904
+ write(` ⚠ snapshot scan error(s), nothing removed this call: ${result.snapshots.scanErrors.join('; ')}`);
5905
+ }
5906
+ if (result.snapshots.partialFamilies !== undefined && result.snapshots.partialFamilies.length > 0) {
5907
+ write(` ⚠ .bak preserved after a sibling failure in famil(y/ies): ${result.snapshots.partialFamilies.join(', ')}`);
5908
+ }
5909
+ }
5905
5910
  return 0;
5906
5911
  }
5907
5912
 
5913
+ // ── dz brain snapshots [--keep N] [--prune] [--json] ────────────────────────────────────────
5914
+ // Manual rotation of the brain's OWN pre-reindex snapshots — independent of `dz brain reindex`
5915
+ // (FR-7). The owner's hub forbids running a live reindex there today, and 13 snapshots / 50 MB
5916
+ // sit unrotated regardless; this command reaches the same family-aware rotation without one.
5917
+ // Without --prune it only LISTS families (dry, never deletes); --prune applies FR-1..FR-5.
5918
+ if (sub === 'snapshots') {
5919
+ // Lead edit after acceptance (2026-09-13): the owner's hub keeps its 13 families next to the
5920
+ // PROJECT store (.dz/agentdb.db, written by the vector-tier reindex), not the home brain —
5921
+ // `--project <dir>` addresses that store; without it the home brain is the target as before.
5922
+ const projectArg = options.get('project');
5923
+ const dbFile = projectArg !== undefined ? resolveAgentdbPath(resolve(cwd, projectArg)) : brainAgentdbPath(brainHome());
5924
+ const keepRaw = options.get('keep');
5925
+ let keep = 3;
5926
+ if (keepRaw !== undefined) {
5927
+ // AM-1 (fix-round, Codex review Grade D): `Number('')` is `0` and `Number(' 2')` is `2` —
5928
+ // both used to validate as an ordinary non-negative integer, silently accepting empty/
5929
+ // whitespace input. Only the literal digit-string shape is accepted; no trimming.
5930
+ if (!/^(0|[1-9]\d*)$/.test(keepRaw)) {
5931
+ write(`dz brain snapshots: --keep must be a non-negative integer (got '${keepRaw}')`);
5932
+ return 2;
5933
+ }
5934
+ keep = Number(keepRaw);
5935
+ // Lead edit after re-review (Codex C): a digit string can still overflow a safe integer.
5936
+ if (!Number.isSafeInteger(keep)) {
5937
+ write(`dz brain snapshots: --keep is out of range (got '${keepRaw}')`);
5938
+ return 2;
5939
+ }
5940
+ }
5941
+ if (!flags.has('prune')) {
5942
+ // Lead edit after re-review: the list is only trustworthy when the scan was complete —
5943
+ // an unreadable directory is reported with ⚠ and exit 1, never as "no families".
5944
+ const { families, scanErrors } = scanSnapshotDir(dbFile);
5945
+ if (asJson) {
5946
+ write(JSON.stringify({ keep, families: families.map((f) => ({ ms: f.ms, files: f.files.map((file) => file.name), bytes: f.bytes })), scanErrors }));
5947
+ return scanErrors.length > 0 ? 1 : 0;
5948
+ }
5949
+ if (scanErrors.length > 0) write(` ⚠ scan error(s) — the list below may be incomplete: ${scanErrors.join('; ')}`);
5950
+ if (families.length === 0) {
5951
+ write(`dz brain snapshots: no pre-reindex snapshot families next to ${dbFile}`);
5952
+ return scanErrors.length > 0 ? 1 : 0;
5953
+ }
5954
+ write(`dz brain snapshots — ${families.length} family(-ies) @ ${dbFile}`);
5955
+ for (const f of families) {
5956
+ const mb = (f.bytes / (1024 * 1024)).toFixed(1);
5957
+ write(` ${new Date(f.ms).toISOString()} ms=${f.ms} ${f.files.length} file(s) ${mb} MB`);
5958
+ }
5959
+ write(' (dry run — pass --prune to remove families older than --keep)');
5960
+ return scanErrors.length > 0 ? 1 : 0;
5961
+ }
5962
+ const report = rotatePreReindexSnapshots(dbFile, { keep });
5963
+ const scanFailed = report.scanErrors !== undefined && report.scanErrors.length > 0;
5964
+ // agentdb-snapshot-lock FR-4: a busy snapshot lock is reported exactly like a scan failure —
5965
+ // nothing removed, ⚠, exit 1 — never a silent "kept N, removed 0" that reads like an empty rotation.
5966
+ const lockBusy = report.errors !== undefined && report.errors.some((e) => e.startsWith('lock busy'));
5967
+ if (asJson) { write(JSON.stringify(report)); return scanFailed || lockBusy ? 1 : 0; }
5968
+ const mb = (report.removedBytes / (1024 * 1024)).toFixed(1);
5969
+ write(`dz brain snapshots: kept ${report.kept.length}, removed ${report.removed.length} (${mb} MB)`);
5970
+ if (report.removed.length > 0) write(` removed: ${report.removed.join(', ')}`);
5971
+ if (report.errors !== undefined && report.errors.length > 0) {
5972
+ write(` ⚠ ${report.errors.length} error(s): ${report.errors.join('; ')}`);
5973
+ }
5974
+ // AM-4: an incomplete scan means NOTHING was removed this call — say so, never silently.
5975
+ if (report.scanErrors !== undefined && report.scanErrors.length > 0) {
5976
+ write(` ⚠ scan error(s), nothing removed this call: ${report.scanErrors.join('; ')}`);
5977
+ }
5978
+ // AM-2: a family whose .bak survived only because a sibling failed to unlink.
5979
+ if (report.partialFamilies !== undefined && report.partialFamilies.length > 0) {
5980
+ write(` ⚠ .bak preserved after a sibling failure in famil(y/ies): ${report.partialFamilies.join(', ')}`);
5981
+ }
5982
+ // FR-3: a live reindex marker rescued a family, or an expired one was cleaned up — honest, never an error.
5983
+ if (report.notes !== undefined && report.notes.length > 0) {
5984
+ write(` note: ${report.notes.join('; ')}`);
5985
+ }
5986
+ return scanFailed || lockBusy ? 1 : 0;
5987
+ }
5988
+
5908
5989
  // ── dz brain ground [<prompt>] ───────────────────────────────────────────────────────────────
5909
5990
  // The UserPromptSubmit hook entrypoint. ALWAYS exits 0 — grounding is advisory and must never
5910
5991
  // fail a prompt. Emits nothing (silent) unless the brain has relevant citations for the prompt.
@@ -6120,6 +6201,18 @@ async function cmdSetup(options: Map<string, string>, flags: Set<string>, cwd: s
6120
6201
  // Step 3: Run setup (hooks + memory + config)
6121
6202
  write(`║ 3. Setting up learning environment... ║`);
6122
6203
  const memoryOpt = options.get('memory');
6204
+ // ADR-001 Decision 2 (feature setup-installs-apply-leg): bake THIS CLI's own installed
6205
+ // @dzhechkov/harness-core into the generated apply-leg hooks — the installation actually running
6206
+ // `dz setup` is the one a consumer's project can always reach, unlike a hard-coded npm prefix
6207
+ // (FR-3). Best-effort: an unresolvable core (should not happen — the CLI depends on it) falls
6208
+ // back to core's own self-resolution inside `runSetup`, never a crash.
6209
+ let coreDistDir: string | undefined;
6210
+ try {
6211
+ const corePkgJson = createRequire(import.meta.url).resolve('@dzhechkov/harness-core/package.json');
6212
+ coreDistDir = join(dirname(corePkgJson), 'dist');
6213
+ } catch {
6214
+ coreDistDir = undefined;
6215
+ }
6123
6216
  const setupResult = runSetup({
6124
6217
  projectRoot,
6125
6218
  target,
@@ -6129,6 +6222,7 @@ async function cmdSetup(options: Map<string, string>, flags: Set<string>, cwd: s
6129
6222
  noMemory: flags.has('no-memory'),
6130
6223
  force: flags.has('force'),
6131
6224
  installDriver: flags.has('install-driver'),
6225
+ coreDistDir,
6132
6226
  });
6133
6227
 
6134
6228
  for (const step of setupResult.steps) {
@@ -6857,7 +6951,37 @@ function cmdSbom(options: Map<string, string>, flags: Set<string>, cwd: string,
6857
6951
  return 0;
6858
6952
  }
6859
6953
 
6860
- function cmdPublish(options: Map<string, string>, flags: Set<string>, cwd: string, writeOutput: Write): number {
6954
+ function mirrorCommandFromConfig(cwd: string): { command?: string; warning?: string } {
6955
+ const configPath = join(cwd, '.dz', 'config.json');
6956
+ if (!existsSync(configPath)) return {};
6957
+ try {
6958
+ const config = JSON.parse(readFileSync(configPath, 'utf8')) as { publish?: { mirrorCommand?: unknown } };
6959
+ const command = typeof config.publish?.mirrorCommand === 'string' ? config.publish.mirrorCommand.trim() : '';
6960
+ return command === '' ? {} : { command };
6961
+ } catch (error) {
6962
+ const reason = error instanceof Error ? error.message : String(error);
6963
+ return { warning: `.dz/config.json unreadable — ${reason}` };
6964
+ }
6965
+ }
6966
+
6967
+ function mirrorShellToken(value: string): string {
6968
+ if (value === '') return "''";
6969
+ if (!/^[A-Za-z0-9@/.,+_-]+$/.test(value)) throw new Error('published package/version list is not shell-safe');
6970
+ return value;
6971
+ }
6972
+
6973
+ function mirrorFailureMessage(error: unknown): string {
6974
+ if (error instanceof Error && error.message.trim() !== '') return error.message.trim().split(/\r?\n/, 1)[0] ?? 'mirror command failed';
6975
+ return String(error);
6976
+ }
6977
+
6978
+ function cmdPublish(
6979
+ options: Map<string, string>,
6980
+ flags: Set<string>,
6981
+ cwd: string,
6982
+ writeOutput: Write,
6983
+ mirrorRunner?: PublishMirrorRunner,
6984
+ ): number {
6861
6985
  const json = flags.has('json');
6862
6986
  // Under --json stdout carries exactly one JSON document, so every human line — guard notes, refusals,
6863
6987
  // progress — goes to stderr instead of being dropped: a refusal that prints nothing is the silent
@@ -6865,9 +6989,9 @@ function cmdPublish(options: Map<string, string>, flags: Set<string>, cwd: strin
6865
6989
  const write: Write = json ? (line) => { process.stderr.write(`${line}\n`); } : writeOutput;
6866
6990
  // Reject unknown flags/options so a typo (e.g. `--dry-rum`) can NEVER be
6867
6991
  // silently swallowed and flip the command into live-publish mode.
6868
- const allowedFlags = new Set(['dry-run', 'no-dry-run', 'yes', 'confirm', 'bump-only', 'help', 'require-signing', 'provenance', 'no-provenance', 'json']);
6869
- const allowedOptions = new Set(['filter', 'claim-check', 'no-guard', 'sign-key']);
6870
- const allowedHelp = ' allowed: --dry-run (default), --yes/--confirm/--no-dry-run (go live), --bump-only, --filter <substr>, --claim-check <off|warn|error>, --no-guard "<reason>" (skip the guard pre-flight; logged)';
6992
+ const allowedFlags = new Set(['dry-run', 'no-dry-run', 'yes', 'confirm', 'bump-only', 'help', 'require-signing', 'provenance', 'no-provenance', 'json', 'no-mirror']);
6993
+ const allowedOptions = new Set(['filter', 'claim-check', 'no-guard', 'sign-key', 'mirror-cmd']);
6994
+ const allowedHelp = ' allowed: --dry-run (default), --yes/--confirm/--no-dry-run (go live), --bump-only, --filter <substr>, --claim-check <off|warn|error>, --mirror-cmd <cmd>, --no-mirror, --no-guard "<reason>" (skip the guard pre-flight; logged)';
6871
6995
  for (const flag of flags) {
6872
6996
  if (!allowedFlags.has(flag)) {
6873
6997
  write(`dz publish: unknown option --${flag}`);
@@ -7047,7 +7171,7 @@ function cmdPublish(options: Map<string, string>, flags: Set<string>, cwd: strin
7047
7171
  // longer exist. Default to the same path `dz sign --init` writes, so the ordinary operator needs no
7048
7172
  // new flag; `--sign-key` overrides it.
7049
7173
  const signKey = (options.get('sign-key') ?? join(homedir(), '.dz', 'keys', 'dz.key')).trim();
7050
- const report = publishPackages(cwd, {
7174
+ const publishReport = publishPackages(cwd, {
7051
7175
  provenance,
7052
7176
  dryRun,
7053
7177
  filter,
@@ -7127,9 +7251,82 @@ function cmdPublish(options: Map<string, string>, flags: Set<string>, cwd: strin
7127
7251
  },
7128
7252
  });
7129
7253
 
7254
+ const configMirror = mirrorCommandFromConfig(cwd);
7255
+ const configuredCommand = (options.get('mirror-cmd') ?? configMirror.command ?? '').trim();
7256
+ const publishedVersions = publishReport.packages
7257
+ .filter((pkg) => pkg.status === 'published')
7258
+ .map((pkg) => `${pkg.name}@${pkg.newVersion}`);
7259
+ const expected = publishedVersions.join(',');
7260
+ const fullMirrorCommand = configuredCommand === ''
7261
+ ? ''
7262
+ : `${configuredCommand} --expect ${mirrorShellToken(expected)} --json`;
7263
+ let mirror: PublishMirrorState;
7264
+
7265
+ // This exact conjunction is the Step-7/8 mutation anchor: an epilogue is eligible only after a
7266
+ // live sweep that actually landed at least one package. Other explicit skip states are handled
7267
+ // before command resolution so each reason remains distinguishable in text and JSON.
7268
+ const mirrorEligible = !dryRun && publishReport.published >= 1;
7269
+ if (bumpOnly) {
7270
+ mirror = { status: 'skipped', command: fullMirrorCommand, reason: 'bump-only' };
7271
+ } else if (!mirrorEligible) {
7272
+ mirror = {
7273
+ status: 'skipped',
7274
+ command: fullMirrorCommand,
7275
+ reason: dryRun ? 'dry-run' : 'published=0',
7276
+ };
7277
+ } else if (flags.has('no-mirror')) {
7278
+ mirror = { status: 'skipped', command: fullMirrorCommand, reason: '--no-mirror' };
7279
+ } else if (configMirror.warning !== undefined && options.get('mirror-cmd') === undefined) {
7280
+ mirror = { status: 'not-configured', command: '', warning: configMirror.warning };
7281
+ } else if (configuredCommand === '') {
7282
+ mirror = { status: 'not-configured', command: '' };
7283
+ } else {
7284
+ const runMirror: PublishMirrorRunner = mirrorRunner
7285
+ ?? ((command, runnerOptions) => execSync(command, {
7286
+ cwd: runnerOptions.cwd,
7287
+ env: runnerOptions.env,
7288
+ encoding: 'utf8',
7289
+ stdio: 'pipe',
7290
+ }));
7291
+ try {
7292
+ const stdout = runMirror(fullMirrorCommand, {
7293
+ cwd,
7294
+ env: { ...process.env, DZ_PUBLISHED: expected },
7295
+ });
7296
+ const parsed = JSON.parse(stdout) as {
7297
+ ok?: unknown;
7298
+ commit?: unknown;
7299
+ receipt?: { manifestUrl?: unknown; confirmedAt?: unknown; waitedMs?: unknown };
7300
+ error?: unknown;
7301
+ };
7302
+ if (parsed.ok !== true) throw new Error(typeof parsed.error === 'string' ? parsed.error : 'mirror command returned ok:false');
7303
+ if (typeof parsed.commit !== 'string' || parsed.commit === '') throw new Error('mirror command returned no commit');
7304
+ if (typeof parsed.receipt?.manifestUrl !== 'string'
7305
+ || typeof parsed.receipt.confirmedAt !== 'string'
7306
+ || typeof parsed.receipt.waitedMs !== 'number') {
7307
+ throw new Error('mirror command returned no live-manifest receipt');
7308
+ }
7309
+ mirror = {
7310
+ status: 'confirmed',
7311
+ command: fullMirrorCommand,
7312
+ commit: parsed.commit,
7313
+ receipt: {
7314
+ manifestUrl: parsed.receipt.manifestUrl,
7315
+ confirmedAt: parsed.receipt.confirmedAt,
7316
+ waitedMs: parsed.receipt.waitedMs,
7317
+ },
7318
+ };
7319
+ } catch (error) {
7320
+ mirror = { status: 'unconfirmed', command: fullMirrorCommand, error: mirrorFailureMessage(error) };
7321
+ }
7322
+ }
7323
+
7324
+ const report = { ...publishReport, mirror };
7325
+ const exitCode = report.errors > 0 ? 1 : mirror.status === 'unconfirmed' ? 3 : 0;
7326
+
7130
7327
  if (json) {
7131
7328
  writeOutput(JSON.stringify(report));
7132
- return report.errors > 0 ? 1 : 0;
7329
+ return exitCode;
7133
7330
  }
7134
7331
 
7135
7332
  write(`\ndz publish${dryRun ? ' --dry-run' : ''}${bumpOnly ? ' --bump-only' : ''}${claimCheckOpt !== 'warn' ? ` --claim-check ${claimCheckOpt}` : ''}`);
@@ -7181,7 +7378,19 @@ function cmdPublish(options: Map<string, string>, flags: Set<string>, cwd: strin
7181
7378
  }
7182
7379
  for (const warning of report.warnings ?? []) write(` ⚠ warning: ${warning}`);
7183
7380
  for (const path of report.releaseLineSynced ?? []) write(` ↳ release line synced: ${path}`);
7184
- return report.errors > 0 ? 1 : 0;
7381
+ if (mirror.status === 'confirmed') {
7382
+ write(` ✓ mirror: confirmed — ${mirror.commit} (${mirror.receipt?.manifestUrl})`);
7383
+ } else if (mirror.status === 'not-configured') {
7384
+ if (mirror.warning !== undefined) write(` ⚠ mirror: ${mirror.warning}`);
7385
+ else write(' ℹ mirror: not configured');
7386
+ } else if (mirror.status === 'skipped') {
7387
+ write(` ℹ mirror: skipped (${mirror.reason})`);
7388
+ } else {
7389
+ write(` ✗ mirror: unconfirmed — ${mirror.error}`);
7390
+ const rerun = mirror.command || 'configure publish.mirrorCommand, then run it';
7391
+ write(`dz publish: published, mirror NOT confirmed — ${mirror.error}; re-run: ${rerun}`);
7392
+ }
7393
+ return exitCode;
7185
7394
  }
7186
7395
 
7187
7396
  /* ------------------------------------------------------------------ */
@@ -7189,7 +7398,7 @@ function cmdPublish(options: Map<string, string>, flags: Set<string>, cwd: strin
7189
7398
  /* ADR-001): computed from the declarative model, never hand-written */
7190
7399
  /* ------------------------------------------------------------------ */
7191
7400
 
7192
- function cmdParity(options: Map<string, string>, flags: Set<string>, write: Write, writeErr: WriteErr): number {
7401
+ function cmdParity(options: Map<string, string>, flags: Set<string>, write: Write, writeErr: WriteErr, cwd: string): number {
7193
7402
  const json = flags.has('json');
7194
7403
  if (flags.has('help')) {
7195
7404
  write('dz parity [--target <name>] [--json] — the computed feature×target map (never hand-written)');
@@ -7217,7 +7426,31 @@ function cmdParity(options: Map<string, string>, flags: Set<string>, write: Writ
7217
7426
  }
7218
7427
  }
7219
7428
 
7220
- const matrix = buildParityMatrix();
7429
+ // ADR-001 Decision 3 (feature setup-installs-apply-leg): `learning-apply` on `claude-code` is
7430
+ // MEASURED, not declared — `hooks-prompt` is present for that ONE target only when
7431
+ // `applyLegStatus(root).installed`. `computeParity` itself is untouched (FR-5); only the
7432
+ // capability SET fed into it for this one cell differs from the static `TARGET_CAPABILITIES`.
7433
+ // `applyLegStatus` never throws (fix round 1, Q3 finding: an unreadable helper used to be able to
7434
+ // crash this command rather than degrade to a named remedy).
7435
+ const applyLegStatusVal = applyLegStatus(cwd);
7436
+ const applyLegInstalled = applyLegStatusVal.installed;
7437
+ const matrix = buildParityMatrix().map((row) => {
7438
+ if (row.feature.id !== 'learning-apply' || applyLegInstalled) return row;
7439
+ const claudeCodeCaps = TARGET_CAPABILITIES['claude-code'].filter((c) => c !== 'hooks-prompt');
7440
+ return { feature: row.feature, cells: { ...row.cells, 'claude-code': computeParity(row.feature, claudeCodeCaps) } };
7441
+ });
7442
+ // The "not installed" remedy — named ONLY for the one cell whose grant is a live measurement,
7443
+ // never a blanket note for every `manual` cell (most targets are manual by DESIGN, not absence).
7444
+ // `stale-version`/`unreadable` route through `applyLegReasonMessage` — the SAME text-producing
7445
+ // function `dz doctor` uses for those two reasons (fix round 1, HIGH finding 2 / Q3 finding 7), so
7446
+ // the two instruments cannot disagree about WHY a stale or broken install is not "full".
7447
+ const applyLegRemedy = (featureId: string, t: TargetName): string => {
7448
+ if (featureId !== 'learning-apply' || t !== 'claude-code' || applyLegInstalled) return '';
7449
+ if (applyLegStatusVal.reason === 'stale-version' || applyLegStatusVal.reason === 'unreadable') {
7450
+ return ` — ${applyLegReasonMessage(applyLegStatusVal)}`;
7451
+ }
7452
+ return ' — not installed — run dz setup --target claude-code --memory agentdb';
7453
+ };
7221
7454
  // EVIDENCE staleness, folded into the report (fix round 2, R2-3). Derived from the records
7222
7455
  // themselves — no `codex --version`, no subprocess, so `dz parity` stays a deterministic function
7223
7456
  // of the model. A cell whose deciding form rests on a transcript that is older than the newest
@@ -7259,8 +7492,12 @@ function cmdParity(options: Map<string, string>, flags: Set<string>, write: Writ
7259
7492
  if (json) {
7260
7493
  const shown = target !== undefined ? [target] : TARGET_NAMES;
7261
7494
  const rows = matrix.map((r) => {
7262
- const cells: Record<string, ParityReportCell> = {};
7263
- for (const t of shown) cells[t] = reportCell(r.feature, t, r.cells[t]);
7495
+ const cells: Record<string, ParityReportCell & { note?: string }> = {};
7496
+ for (const t of shown) {
7497
+ const cell = reportCell(r.feature, t, r.cells[t]);
7498
+ const remedy = applyLegRemedy(r.feature.id, t);
7499
+ cells[t] = remedy === '' ? cell : { ...cell, note: remedy.replace(/^ — /, '') };
7500
+ }
7264
7501
  return { id: r.feature.id, title: r.feature.title, cells };
7265
7502
  });
7266
7503
  // A filtered response stays internally consistent: capabilities are filtered too (Codex QE gap 9).
@@ -7287,7 +7524,7 @@ function cmdParity(options: Map<string, string>, flags: Set<string>, write: Writ
7287
7524
  : c.level === 'inconclusive'
7288
7525
  ? `via ${c.via ?? ''} — INCONCLUSIVE: stale evidence for ${(c.staleEvidence ?? []).join(', ')}`
7289
7526
  : `via ${c.via ?? ''}`;
7290
- write(` ${icon} ${r.feature.title.padEnd(58)} ${detail}`);
7527
+ write(` ${icon} ${r.feature.title.padEnd(58)} ${detail}${applyLegRemedy(r.feature.id, t)}`);
7291
7528
  }
7292
7529
  write('\n ✓ full (the complete experience) ◐ manual (works, you drive it by hand) ? evidence stale (re-probe) — not available');
7293
7530
  for (const line of staleNote(t)) write(line);
@@ -9524,6 +9761,36 @@ function gatherGuardFacts(op: string, root: string, text: string | undefined, st
9524
9761
  const facts: Record<string, unknown> = { op };
9525
9762
  const publishPackageRoots: string[] = [];
9526
9763
  if (op === 'publish') {
9764
+ try {
9765
+ const roundsDir = join(root, '.dz', 'rounds');
9766
+ const states = readdirSync(roundsDir)
9767
+ .filter((name) => name.endsWith('.json'))
9768
+ .map((name) => readRoundState(join(roundsDir, name)))
9769
+ .filter((state): state is RoundState => state !== null);
9770
+ facts['openRounds'] = listRounds(states, {
9771
+ now: Date.now(),
9772
+ olderThanMinutes: 120,
9773
+ isPidAlive: probePid,
9774
+ isRunAlive: (runId) => roundRunOwnerAlive(root, runId, Date.now()),
9775
+ }).map((row) => ({
9776
+ slug: row.state.slug,
9777
+ round: row.state.round,
9778
+ ageMinutes: row.ageMinutes,
9779
+ pidAlive: row.pidAlive,
9780
+ }));
9781
+ } catch { /* absent/unreadable round state is no fabricated violation */ }
9782
+ const since = roundTraceSince(root);
9783
+ const enabled = roundsTracingEnabled(root);
9784
+ if (!enabled) {
9785
+ facts['codeCommitsSinceLastRound'] = { commits: null, since, enabled: false };
9786
+ } else if (since !== null) {
9787
+ facts['codeCommitsSinceLastRound'] = { commits: packageCommitCount(root, since), since };
9788
+ } else if (existsSync(join(root, '.dz', 'feature-adr', 'run-cost-ledger.jsonl'))) {
9789
+ // The ledger EXISTS but carries no dated row: that is a measurable absence and gets a note.
9790
+ // No ledger file at all is a fresh project — the rule stays not-established silently, so a
9791
+ // note that every new repo would carry does not drown the ones that mean something.
9792
+ facts['codeCommitsSinceLastRound'] = { commits: null, since: null };
9793
+ }
9527
9794
  // Advisory I/O: unreadable telemetry or fed state is absence of evidence, never a fabricated
9528
9795
  // stale finding and never a publish blocker.
9529
9796
  try {
@@ -11736,6 +12003,118 @@ function parseCheckMutatedFile(absFile: string, text: string): MutationParseChec
11736
12003
  }
11737
12004
  }
11738
12005
 
12006
+ const MUTATION_GATE_OUTPUT_TAIL_MAX_LINES = 20;
12007
+ const MUTATION_GATE_OUTPUT_TAIL_MAX_BYTES = 2 * 1024;
12008
+
12009
+ export function boundedMutationGateOutputTail(output: string): string | undefined {
12010
+ const normalized = output.replace(/\r\n?/g, '\n').replace(/\n+$/, '');
12011
+ if (normalized === '') return undefined;
12012
+
12013
+ let tail = normalized.split('\n').slice(-MUTATION_GATE_OUTPUT_TAIL_MAX_LINES).join('\n');
12014
+ const encoded = Buffer.from(tail, 'utf8');
12015
+ if (encoded.byteLength <= MUTATION_GATE_OUTPUT_TAIL_MAX_BYTES) return tail;
12016
+
12017
+ const codePoints = Array.from(tail);
12018
+ let start = codePoints.length;
12019
+ let byteLength = 0;
12020
+ while (start > 0) {
12021
+ const nextByteLength = Buffer.byteLength(codePoints[start - 1]!, 'utf8');
12022
+ if (byteLength + nextByteLength > MUTATION_GATE_OUTPUT_TAIL_MAX_BYTES) break;
12023
+ byteLength += nextByteLength;
12024
+ start -= 1;
12025
+ }
12026
+ return codePoints.slice(start).join('');
12027
+ }
12028
+
12029
+ // ── Full-output capture for a RED baseline/rebaseline line (gate-stability, 2026-09-12) ────────
12030
+ // The bounded tail above is a diagnostic teaser (3-20 lines); under a multi-entry gate run the
12031
+ // tail was measured to hand back an unrelated neighbour's stderr, leaving OVER_FAILING/
12032
+ // INCONCLUSIVE undiagnosable. Only the baseline and rebaseline lines write here — the per-entry
12033
+ // mutation run is EXPECTED to redden and already carries a bounded tail; this is for the lines
12034
+ // whose redness means "the copy itself is broken", where the full transcript is the only way to
12035
+ // tell what actually happened.
12036
+
12037
+ const MUTATION_GATE_OUTPUT_FILE_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
12038
+
12039
+ function mutationGateOutputDir(): string {
12040
+ return process.env.DZ_MUTGATE_OUTPUT_DIR ?? join(tmpdir(), 'dz-mutgate-output');
12041
+ }
12042
+
12043
+ /** own filename prefix (fix-round-1 HIGH finding) — see isMutationGateOutputFile. */
12044
+ const MUTATION_GATE_OUTPUT_FILE_PREFIX = 'dz-mutgate-';
12045
+ /** exact shape of `new Date().toISOString().replace(/:/g, '-')`, e.g. `2026-09-12T20-00-00.000Z`. */
12046
+ const MUTATION_GATE_OUTPUT_TS_PATTERN = String.raw`\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.\d{3}Z`;
12047
+ const MUTATION_GATE_OUTPUT_FILE_RE = new RegExp(
12048
+ `^${MUTATION_GATE_OUTPUT_FILE_PREFIX}.+-(baseline|rebaseline|final-rebaseline)-${MUTATION_GATE_OUTPUT_TS_PATTERN}\\.log$`,
12049
+ );
12050
+
12051
+ /**
12052
+ * true only for a filename THIS executor could have written — rotation never touches a foreign
12053
+ * file. Fix-round-1 HIGH finding (Codex review, gate-stability): the prior
12054
+ * `^.+-(baseline|rebaseline|final-rebaseline)-.+\.log$` had no own prefix and accepted ANY
12055
+ * trailing text as the "timestamp", so a pre-existing unrelated file dropped into a shared
12056
+ * `DZ_MUTGATE_OUTPUT_DIR` (e.g. `service-baseline-backup.log`) matched and could be rotated away.
12057
+ * Now BOTH the `dz-mutgate-` prefix AND the exact ISO-timestamp shape we ourselves write are
12058
+ * required — a foreign file can accidentally share the prefix but essentially never our precise
12059
+ * timestamp format, and a file we did NOT write never carries both.
12060
+ */
12061
+ function isMutationGateOutputFile(name: string): boolean {
12062
+ return MUTATION_GATE_OUTPUT_FILE_RE.test(name);
12063
+ }
12064
+
12065
+ function rotateMutationGateOutputDir(dir: string): void {
12066
+ let names: string[];
12067
+ try { names = readdirSync(dir); } catch { return; }
12068
+ const cutoff = Date.now() - MUTATION_GATE_OUTPUT_FILE_RETENTION_MS;
12069
+ for (const name of names) {
12070
+ if (!isMutationGateOutputFile(name)) continue; // "чужие файлы не трогаются" — own prefix only
12071
+ const full = join(dir, name);
12072
+ try {
12073
+ if (statSync(full).mtimeMs < cutoff) rmSync(full, { force: true });
12074
+ } catch { /* best effort — a listing race is not this executor's problem */ }
12075
+ }
12076
+ }
12077
+
12078
+ /** Discriminated outcome of a save attempt — a red run either saved (path) or did not (error);
12079
+ * never both. See writeMutationGateOutputOnRed. */
12080
+ type MutationGateOutputWrite = { readonly path: string } | { readonly error: string };
12081
+
12082
+ /**
12083
+ * Saves the FULL stdout+stderr of a RED baseline/rebaseline run and returns `{ path }`, or
12084
+ * `{ error }` on any I/O failure (EACCES/ENOSPC/EROFS/ENOTDIR and the like — never blocks the gate
12085
+ * on a logging problem: fix-round-1 MEDIUM finding, the prior silent `catch { return undefined; }`
12086
+ * made a failed save indistinguishable from "nothing to save"), or `undefined` when exitCode is 0
12087
+ * (nothing written on green — NFR-1 byte-identity).
12088
+ */
12089
+ function writeMutationGateOutputOnRed(
12090
+ entryId: string | undefined,
12091
+ phase: 'baseline' | 'rebaseline' | 'final-rebaseline',
12092
+ exitCode: number | null,
12093
+ output: string,
12094
+ ): MutationGateOutputWrite | undefined {
12095
+ if (exitCode === 0) return undefined;
12096
+ try {
12097
+ const dir = mutationGateOutputDir();
12098
+ mkdirSync(dir, { recursive: true });
12099
+ rotateMutationGateOutputDir(dir);
12100
+ const ts = new Date().toISOString().replace(/:/g, '-');
12101
+ const full = join(dir, `${MUTATION_GATE_OUTPUT_FILE_PREFIX}${entryId ?? 'baseline'}-${phase}-${ts}.log`);
12102
+ writeFileSync(full, output);
12103
+ return { path: full };
12104
+ } catch (e) {
12105
+ return { error: String((e as Error)?.message ?? e) };
12106
+ }
12107
+ }
12108
+
12109
+ /** Unpacks a `writeMutationGateOutputOnRed` result into the `{outputPath, outputError}` shape the
12110
+ * pure engine (classifyBaseline / MutationObservation) consumes. */
12111
+ function splitMutationGateOutputWrite(
12112
+ result: MutationGateOutputWrite | undefined,
12113
+ ): { outputPath?: string; outputError?: string } {
12114
+ if (result === undefined) return {};
12115
+ return 'path' in result ? { outputPath: result.path } : { outputError: result.error };
12116
+ }
12117
+
11739
12118
  function cmdMutationGate(
11740
12119
  options: Map<string, string>,
11741
12120
  flags: Set<string>,
@@ -11800,6 +12179,12 @@ function cmdMutationGate(
11800
12179
  const testCmdRaw = options.get('test-cmd') ?? parsed.registry.testCommand ?? 'npm test';
11801
12180
  if (/[\0\n\r]/.test(testCmdRaw)) return fail('--test-cmd may not contain NUL or newline characters');
11802
12181
  const testCmd = testCmdRaw;
12182
+ const excludedSelfChecks = REGISTRY_SELFCHECK_TESTS.filter((testFile) =>
12183
+ entries.some((entry) => buildMutationTestCommand(testCmd, entry).excluded.includes(testFile)),
12184
+ );
12185
+ if (!json) {
12186
+ write(`mutation-gate: self-check excluded from mutant runs: ${excludedSelfChecks.join(', ') || '(none)'}`);
12187
+ }
11803
12188
 
11804
12189
  const timeoutOpt = Number(options.get('timeout') ?? '300000');
11805
12190
  const timeout = Number.isFinite(timeoutOpt) && timeoutOpt > 0 ? timeoutOpt : 300000;
@@ -11878,11 +12263,20 @@ function cmdMutationGate(
11878
12263
  const requireCompletionReceipt = parsed.registry.requireCompletionReceipt === true;
11879
12264
 
11880
12265
  type SuiteRun = MutationGateRunnerObservation & { readonly internalAttemptLog?: string };
11881
- const invokeSuite = (): MutationGateRunnerObservation => {
12266
+ const invokeSuite = (
12267
+ suiteCommand: string,
12268
+ phase: 'baseline' | 'mutation' | 'rebaseline' | 'final-rebaseline',
12269
+ entryId?: string,
12270
+ ): MutationGateRunnerObservation => {
11882
12271
  if (injectedRunner !== undefined) {
11883
- return injectedRunner(testCmd, { cwd: copyDir, timeoutMs: timeout });
12272
+ return injectedRunner(suiteCommand, {
12273
+ cwd: copyDir,
12274
+ timeoutMs: timeout,
12275
+ phase,
12276
+ ...(entryId !== undefined ? { entryId } : {}),
12277
+ });
11884
12278
  }
11885
- const run = spawnSync(testCmd, {
12279
+ const run = spawnSync(suiteCommand, {
11886
12280
  cwd: copyDir,
11887
12281
  shell: true,
11888
12282
  encoding: 'utf-8',
@@ -11918,8 +12312,9 @@ function cmdMutationGate(
11918
12312
  const runSuite = (
11919
12313
  phase: 'baseline' | 'mutation' | 'rebaseline' | 'final-rebaseline',
11920
12314
  entryId?: string,
12315
+ suiteCommand = testCmd,
11921
12316
  ): SuiteRun => {
11922
- const retried = runWithOneInternalRetry(invokeSuite);
12317
+ const retried = runWithOneInternalRetry(() => invokeSuite(suiteCommand, phase, entryId));
11923
12318
  const loggedAttempts = retried.attempts.map((attempt) => {
11924
12319
  if (attempt.outcome !== 'completed' || retried.value === null) return attempt;
11925
12320
  const outcome = retried.value.exitCode === null
@@ -11955,12 +12350,16 @@ function cmdMutationGate(
11955
12350
  // result would be this gate shipping the defect class it exists to catch.
11956
12351
  if (!json) write(`mutation-gate: baseline suite in scratch copy of ${pkgDir} …`);
11957
12352
  const base = runSuite('baseline');
12353
+ const { outputPath: baseOutputPath, outputError: baseOutputError } =
12354
+ splitMutationGateOutputWrite(writeMutationGateOutputOnRed(undefined, 'baseline', base.exitCode, base.output));
11958
12355
  baseline = classifyBaseline(
11959
12356
  base.exitCode,
11960
12357
  base.failureReason,
11961
12358
  base.exitCode !== null && base.exitCode !== 0
11962
12359
  ? attributeBaselineRedness(base.output, entries.map((entry) => entry.file))
11963
12360
  : undefined,
12361
+ baseOutputPath,
12362
+ baseOutputError,
11964
12363
  );
11965
12364
  if (!baseline.ok) {
11966
12365
  if (json) { write(JSON.stringify({ packageDir: pkgDir, registryPath, testCommand: testCmd, baseline, results, internalRetries, exitCode: 1 }, null, 2)); return 1; }
@@ -12022,7 +12421,7 @@ function cmdMutationGate(
12022
12421
  if (check.error !== undefined) {
12023
12422
  parseError = check.error; // no suite run: the verdict is MUTATION_UNPARSEABLE regardless
12024
12423
  } else if (parseInternalFailureReason === undefined) {
12025
- run = runSuite('mutation', entry.id);
12424
+ run = runSuite('mutation', entry.id, buildMutationTestCommand(testCmd, entry).testCommand);
12026
12425
  }
12027
12426
  } finally {
12028
12427
  writeFileSync(filePath, sourceText); // restore the COPY so the next entry starts pristine
@@ -12057,6 +12456,9 @@ function cmdMutationGate(
12057
12456
  let rebaselineExitCode: number | null | undefined;
12058
12457
  let rebaselineFailureReason: string | undefined;
12059
12458
  let rebaselineAttribution: ReturnType<typeof attributeBaselineRedness> | undefined;
12459
+ let rebaselineOutputTail: string | undefined;
12460
+ let rebaselineOutputPath: string | undefined;
12461
+ let rebaselineOutputError: string | undefined;
12060
12462
  let rebaselineInternalAttemptLog: string | undefined;
12061
12463
  if (rebaselineMode === 'per-entry' && run !== null && run.exitCode !== null && run.exitCode !== 0
12062
12464
  && fileLoadFailure === undefined && outputUnrecognised === undefined && receiptMismatch === undefined) {
@@ -12065,11 +12467,16 @@ function cmdMutationGate(
12065
12467
  rebaselineExitCode = rebaselineRun.exitCode;
12066
12468
  rebaselineFailureReason = rebaselineRun.failureReason;
12067
12469
  rebaselineInternalAttemptLog = rebaselineRun.internalAttemptLog;
12068
- if (rebaselineRun.exitCode !== null && rebaselineRun.exitCode !== 0) {
12069
- rebaselineAttribution = attributeBaselineRedness(
12070
- rebaselineRun.output,
12071
- entries.map((candidate) => candidate.file),
12072
- );
12470
+ if (rebaselineRun.exitCode !== 0) {
12471
+ rebaselineOutputTail = boundedMutationGateOutputTail(rebaselineRun.output);
12472
+ ({ outputPath: rebaselineOutputPath, outputError: rebaselineOutputError } =
12473
+ splitMutationGateOutputWrite(writeMutationGateOutputOnRed(entry.id, 'rebaseline', rebaselineRun.exitCode, rebaselineRun.output)));
12474
+ if (rebaselineRun.exitCode !== null) {
12475
+ rebaselineAttribution = attributeBaselineRedness(
12476
+ rebaselineRun.output,
12477
+ entries.map((candidate) => candidate.file),
12478
+ );
12479
+ }
12073
12480
  }
12074
12481
  }
12075
12482
  const entryRunFailureReason = run?.failureReason ?? parseInternalFailureReason;
@@ -12090,6 +12497,9 @@ function cmdMutationGate(
12090
12497
  ...(rebaselineExitCode !== undefined ? { rebaselineExitCode } : {}),
12091
12498
  ...(rebaselineFailureReason !== undefined ? { rebaselineFailureReason } : {}),
12092
12499
  ...(rebaselineAttribution !== undefined ? { rebaselineAttribution } : {}),
12500
+ ...(rebaselineOutputTail !== undefined ? { rebaselineOutputTail } : {}),
12501
+ ...(rebaselineOutputPath !== undefined ? { outputPath: rebaselineOutputPath } : {}),
12502
+ ...(rebaselineOutputError !== undefined ? { outputError: rebaselineOutputError } : {}),
12093
12503
  };
12094
12504
  observations.push(obs);
12095
12505
  results.push(classifyMutationOutcome(obs));
@@ -12105,6 +12515,9 @@ function cmdMutationGate(
12105
12515
  const finalRun = runSuite('final-rebaseline');
12106
12516
  const finalExit = finalRun.exitCode;
12107
12517
  if (finalExit !== 0) {
12518
+ const finalOutputTail = boundedMutationGateOutputTail(finalRun.output);
12519
+ const { outputPath: finalOutputPath, outputError: finalOutputError } =
12520
+ splitMutationGateOutputWrite(writeMutationGateOutputOnRed(undefined, 'final-rebaseline', finalExit, finalRun.output));
12108
12521
  const what = finalExit === null ? `no exit code: ${finalRun.failureReason ?? 'unknown timeout / spawn failure'}` : `exit ${finalExit}`;
12109
12522
  warnings.push(`final re-baseline NOT green (${what}) — the suite is flaky; red-based verdicts downgraded to INCONCLUSIVE`);
12110
12523
  if (!json) write(`mutation-gate: final re-baseline NOT green (${what}) — red-based verdicts downgraded to INCONCLUSIVE`);
@@ -12118,6 +12531,9 @@ function cmdMutationGate(
12118
12531
  ...(finalExit !== null && finalExit !== 0
12119
12532
  ? { rebaselineAttribution: attributeBaselineRedness(finalRun.output, entries.map((entry) => entry.file)) }
12120
12533
  : {}),
12534
+ ...(finalOutputTail !== undefined ? { rebaselineOutputTail: finalOutputTail } : {}),
12535
+ ...(finalOutputPath !== undefined ? { outputPath: finalOutputPath } : {}),
12536
+ ...(finalOutputError !== undefined ? { outputError: finalOutputError } : {}),
12121
12537
  }));
12122
12538
  results.length = 0;
12123
12539
  results.push(...reclassified);
@@ -14079,6 +14495,928 @@ function cmdRunsRecord(options: Map<string, string>, flags: Set<string>, cwd: st
14079
14495
  }
14080
14496
  }
14081
14497
 
14498
+ const ROUND_LEDGER_REL = join('.dz', 'feature-adr', 'run-cost-ledger.jsonl');
14499
+
14500
+ /**
14501
+ * round-state-root FR-1/FR-2: where `dz round` state (and its ledger, FR-4) lives — flag beats env
14502
+ * beats cwd. `--project` is untouched by this and stays recall-only (lesson 2ac30a70). Only an
14503
+ * EXPLICIT flag/env value is validated for absoluteness; the cwd fallback is `resolve(cwd)`, exactly
14504
+ * what every subcommand used before this feature (NFR-1: byte-identical when neither is set).
14505
+ */
14506
+ function resolveRoundStateRoot(
14507
+ options: Map<string, string>,
14508
+ env: NodeJS.ProcessEnv,
14509
+ cwd: string,
14510
+ ): { readonly ok: true; readonly root: string; readonly source: 'flag' | 'env' | 'cwd' }
14511
+ | { readonly ok: false; readonly reason: string } {
14512
+ const flagRaw = options.get('state-root');
14513
+ if (flagRaw !== undefined) {
14514
+ if (!isAbsolute(flagRaw)) return { ok: false, reason: `--state-root должен быть абсолютным путём: ${flagRaw}` };
14515
+ return { ok: true, root: flagRaw, source: 'flag' };
14516
+ }
14517
+ const envRaw = env['DZ_ROUND_STATE_ROOT'];
14518
+ if (envRaw !== undefined) {
14519
+ // A variable that is SET but blank is a misconfiguration, not an absence: falling back to cwd
14520
+ // here would be exactly the stray-write this flag exists to prevent (Codex review, 2026-09-13).
14521
+ if (envRaw.trim() === '') return { ok: false, reason: 'DZ_ROUND_STATE_ROOT задана, но пуста — укажите абсолютный путь или снимите переменную' };
14522
+ if (!isAbsolute(envRaw)) return { ok: false, reason: `DZ_ROUND_STATE_ROOT должен быть абсолютным путём: ${envRaw}` };
14523
+ return { ok: true, root: envRaw, source: 'env' };
14524
+ }
14525
+ return { ok: true, root: resolve(cwd), source: 'cwd' };
14526
+ }
14527
+
14528
+ function roundStatePath(root: string, slug: string, round: number): string {
14529
+ return join(root, '.dz', 'rounds', `${slug}-${round}.json`);
14530
+ }
14531
+
14532
+ /** round-state-lock T2: parses raw JSON text into a `RoundState`, shared by `readRoundState` (reads
14533
+ * from disk) and the AC-1 recheck-under-lock (compares a raw string captured before recall against
14534
+ * one read again inside the lock, so it needs to parse the SAME raw text twice without a third
14535
+ * disk read). */
14536
+ function parseRoundState(raw: string): RoundState | null {
14537
+ try {
14538
+ const row = JSON.parse(raw) as Partial<RoundState>;
14539
+ if (typeof row.slug !== 'string' || !Number.isInteger(row.round) || typeof row.topic !== 'string'
14540
+ || typeof row.startedAt !== 'string' || !Number.isInteger(row.pid) || !Array.isArray(row.recalled)
14541
+ || row.recalled.some((id) => typeof id !== 'string')) return null;
14542
+ if (row.execs !== undefined && (!Array.isArray(row.execs) || row.execs.some((entry) =>
14543
+ typeof entry.startedAt !== 'string' || typeof entry.endedAt !== 'string'
14544
+ || (entry.exitCode !== null && !Number.isInteger(entry.exitCode))
14545
+ || typeof entry.outcome !== 'string'
14546
+ || (entry.tokens !== null && !Number.isInteger(entry.tokens))))) return null;
14547
+ return row as RoundState;
14548
+ } catch {
14549
+ return null;
14550
+ }
14551
+ }
14552
+
14553
+ function readRoundState(path: string): RoundState | null {
14554
+ try {
14555
+ return parseRoundState(readFileSync(path, 'utf8'));
14556
+ } catch {
14557
+ return null;
14558
+ }
14559
+ }
14560
+
14561
+ /** round-state-lock: the raw bytes at `path`, or `null` when absent/unreadable. Used to detect
14562
+ * whether the state file changed between a check made BEFORE the (long, unlocked) recall and one
14563
+ * made again INSIDE the round-state lock — a byte-identical read means nothing raced us. */
14564
+ function readRawRoundState(path: string): string | null {
14565
+ try {
14566
+ return readFileSync(path, 'utf8');
14567
+ } catch {
14568
+ return null;
14569
+ }
14570
+ }
14571
+
14572
+ /** round-state-lock fix-round AM-1: 16 random hex chars, minted once per `open`. */
14573
+ function generateRoundStateId(): string {
14574
+ return randomBytes(8).toString('hex');
14575
+ }
14576
+
14577
+ /** Refusal shape shared by `exec`'s claim AND restore sections (AM-1): the state this section
14578
+ * expected to still be there — identified by `expectedStateId`, not by pid or by "did the file
14579
+ * change" — is either gone (`'gone'`) or has been replaced by something with a DIFFERENT identity
14580
+ * (`'replaced'`). Both cases leave the file untouched: writing over either would be exactly the
14581
+ * lost-update/resurrection bug this fix-round exists to close. */
14582
+ type RoundStateGone = { readonly refused: 'gone' };
14583
+ type RoundStateReplaced = { readonly refused: 'replaced'; readonly stateId: string | undefined; readonly execClaimId?: string | undefined };
14584
+
14585
+ /** Lead edit after Codex re-review: a LEGACY state (written before stateId existed) must not be
14586
+ * matched by `undefined === undefined` — under the lock, the first exec/close that meets it mints
14587
+ * an id, writes it back, and continues with that id as the identity of THIS operation. */
14588
+ function ensureStateId(path: string, fresh: RoundState): RoundState {
14589
+ if (fresh.stateId !== undefined) return fresh;
14590
+ const minted = { ...fresh, stateId: randomBytes(8).toString('hex') };
14591
+ writeJsonAtomic(path, minted);
14592
+ return minted;
14593
+ }
14594
+
14595
+ function readStateOrRefuse(
14596
+ path: string,
14597
+ expectedStateId: string | undefined,
14598
+ ): RoundState | RoundStateGone | RoundStateReplaced {
14599
+ const fresh = readRoundState(path);
14600
+ if (fresh === null) return { refused: 'gone' };
14601
+ if (expectedStateId === undefined && fresh.stateId === undefined) return ensureStateId(path, fresh);
14602
+ if (fresh.stateId !== expectedStateId) return { refused: 'replaced', stateId: fresh.stateId };
14603
+ return fresh;
14604
+ }
14605
+
14606
+ /** round-state-lock fix-round AM-2: the same "gone vs replaced" shape as `readStateOrRefuse`, but
14607
+ * `close`'s missing-file case is NOT a failure — a round the ledger row was already witnessed for,
14608
+ * whose state file is already gone, is exactly `close`'s own success postcondition reached by a
14609
+ * different path (e.g. a prior invocation's delete step landed after this one read the ledger tail).
14610
+ * Kept as a separate type (not reused from `readStateOrRefuse`) because the two `refused` tags carry
14611
+ * different exit codes and messages — collapsing them would make a future edit to one silently reuse
14612
+ * the other's wording. */
14613
+ type RoundStateAlreadyClosed = { readonly refused: 'closed-already' };
14614
+
14615
+ function readStateForCloseOrRefuse(
14616
+ path: string,
14617
+ expectedStateId: string | undefined,
14618
+ ): RoundState | RoundStateAlreadyClosed | RoundStateReplaced {
14619
+ const fresh = readRoundState(path);
14620
+ if (fresh === null) return { refused: 'closed-already' };
14621
+ if (expectedStateId === undefined && fresh.stateId === undefined) return ensureStateId(path, fresh);
14622
+ if (fresh.stateId !== expectedStateId) return { refused: 'replaced', stateId: fresh.stateId };
14623
+ return fresh;
14624
+ }
14625
+
14626
+ /** round-state-lock fix-round AM-4: the exact ledger-row marker `closeRound` (harness-core) will
14627
+ * compute for THIS close attempt, predicted from the same three inputs (slug, round, closedAt)
14628
+ * BEFORE calling it — so a retried `close` with the same injected `roundNow` (same `closedAt`) can
14629
+ * detect "the ledger already carries this attempt's row" and skip writing a duplicate. Mirrors
14630
+ * `closeRound`'s own marker formula in harness-core/src/round.ts exactly; a drift between the two
14631
+ * would only defeat the RETRY-dedup check (closeRound's own success postcondition, verified by
14632
+ * rereading the ledger tail, is unaffected either way). Deliberately NOT keyed on `stateId`: the
14633
+ * run-cost ledger row schema (`RoundLedgerRow`) has no such column, and adding one is out of this
14634
+ * fix's scope (round.ts stays untouched) — (slug, round, closedAt) is the identity already exposed
14635
+ * through the marker, and it is exactly as unique for a genuine retry (same close command, same
14636
+ * injected clock) as a `stateId` would be. */
14637
+ function predictedRoundCloseMarker(slug: string, round: number, closedAtIso: string): string {
14638
+ const closedMs = Date.parse(closedAtIso);
14639
+ const compactTs = new Date(closedMs).toISOString().replace(/[-:.]/g, '');
14640
+ return `round-${slug}-${round}-${compactTs}`;
14641
+ }
14642
+
14643
+ /** round-state-lock fix-round AM-5: `open`/`status` warn when a round has been sitting with
14644
+ * `ownerKind: 'exec'` for more than this many minutes — the shape of a restore-section that
14645
+ * exhausted its lock-busy retries (see `ROUND_RESTORE_LOCK_ATTEMPTS`) and left the round claimed by
14646
+ * an `exec` that already finished. There is no separate "since when has this been exec" timestamp on
14647
+ * `RoundState`, so this measures from `startedAt` (the round's own start) — a deliberate
14648
+ * approximation: an `exec` that ran briefly near round-open would read as "young" even if its
14649
+ * restore failed just now. Good enough to surface the stuck case at all; not a claim of precision. */
14650
+ const ROUND_EXEC_STALE_MINUTES = 10;
14651
+
14652
+ function roundExecStaleAgeMinutes(state: RoundState, now: number): number | null {
14653
+ if (state.ownerKind !== 'exec') return null;
14654
+ // Lead edit after Codex re-review: count from the exec claim, not from the round's own start —
14655
+ // a fresh exec inside an old round is not stuck. Legacy states without the field fall back.
14656
+ const claimedMs = Date.parse(state.execClaimedAt ?? state.startedAt);
14657
+ if (!Number.isFinite(claimedMs)) return null;
14658
+ const minutes = Math.floor((now - claimedMs) / 60_000);
14659
+ return minutes >= ROUND_EXEC_STALE_MINUTES ? minutes : null;
14660
+ }
14661
+
14662
+ function readRoundLedgerTail(root: string): string {
14663
+ try {
14664
+ const body = readFileSync(join(root, ROUND_LEDGER_REL), 'utf8');
14665
+ return body.slice(-64 * 1024);
14666
+ } catch {
14667
+ return '';
14668
+ }
14669
+ }
14670
+
14671
+ function readRoundLedger(root: string): string {
14672
+ try {
14673
+ return readFileSync(join(root, ROUND_LEDGER_REL), 'utf8');
14674
+ } catch {
14675
+ return '';
14676
+ }
14677
+ }
14678
+
14679
+ function roundRunOwnerAlive(
14680
+ root: string,
14681
+ runId: string,
14682
+ now: number,
14683
+ registryReader?: (projectRoot: string) => string,
14684
+ pidProbe: (pid: number) => boolean | null = probePid,
14685
+ ): boolean | null {
14686
+ const registry = readRunRegistry(root, registryReader === undefined
14687
+ ? runRegistryIO
14688
+ : { ...runRegistryIO, read: () => registryReader(root) });
14689
+ if (registry.status !== 'readable') return null;
14690
+ const decision = liveness(registry.runs.find((run) => run.runId === runId), now, pidProbe);
14691
+ return decision.state === 'live' || decision.state === 'stalled' ? true : decision.state === 'orphaned' ? false : null;
14692
+ }
14693
+
14694
+ function nextRoundNumber(ledger: string, slug: string): number {
14695
+ let count = 0;
14696
+ for (const line of ledger.split('\n')) {
14697
+ try {
14698
+ const row = JSON.parse(line) as { slug?: unknown; stage?: unknown };
14699
+ if (row.slug === slug && row.stage === 'round') count++;
14700
+ } catch { /* malformed and torn rows are not completed rounds */ }
14701
+ }
14702
+ return count + 1;
14703
+ }
14704
+
14705
+ type RoundSpawnReceipt = {
14706
+ readonly exitCode: number | null;
14707
+ readonly timedOut: boolean;
14708
+ readonly signal: NodeJS.Signals | null;
14709
+ readonly errorCode?: string;
14710
+ readonly error?: string;
14711
+ };
14712
+
14713
+ export async function spawnRoundCodex(request: {
14714
+ readonly command: string;
14715
+ readonly args: readonly string[];
14716
+ readonly cwd: string;
14717
+ readonly logPath: string;
14718
+ readonly timeoutMs: number;
14719
+ readonly killGraceMs?: number;
14720
+ }): Promise<RoundSpawnReceipt> {
14721
+ mkdirSync(dirname(request.logPath), { recursive: true });
14722
+ const logFd = openSync(request.logPath, 'w');
14723
+ return await new Promise<RoundSpawnReceipt>((resolveRun) => {
14724
+ let settled = false;
14725
+ let timedOut = false;
14726
+ let escalation: NodeJS.Timeout | undefined;
14727
+ let child: ChildProcess | undefined;
14728
+ const finish = (receipt: Omit<RoundSpawnReceipt, 'timedOut'>): void => {
14729
+ if (settled) return;
14730
+ settled = true;
14731
+ clearTimeout(deadline);
14732
+ if (escalation !== undefined) clearTimeout(escalation);
14733
+ try { closeSync(logFd); } catch { /* the subprocess receipt remains authoritative */ }
14734
+ resolveRun({ ...receipt, timedOut });
14735
+ };
14736
+ const deadline = setTimeout(() => {
14737
+ timedOut = true;
14738
+ try { child?.kill('SIGTERM'); } catch { /* SIGKILL below is the bounded fallback */ }
14739
+ escalation = setTimeout(() => {
14740
+ try { child?.kill('SIGKILL'); } catch { /* close/error decides the receipt */ }
14741
+ }, request.killGraceMs ?? 10_000);
14742
+ }, request.timeoutMs);
14743
+ try {
14744
+ child = spawn(request.command, [...request.args], {
14745
+ cwd: request.cwd,
14746
+ stdio: ['ignore', logFd, logFd],
14747
+ });
14748
+ } catch (error) {
14749
+ const err = error as NodeJS.ErrnoException;
14750
+ finish({ exitCode: null, signal: null, ...(err.code === undefined ? {} : { errorCode: err.code }), error: err.message });
14751
+ return;
14752
+ }
14753
+ child.on('error', (error: NodeJS.ErrnoException) => {
14754
+ finish({ exitCode: null, signal: null, ...(error.code === undefined ? {} : { errorCode: error.code }), error: error.message });
14755
+ });
14756
+ child.on('close', (code, signal) => finish({ exitCode: code, signal }));
14757
+ });
14758
+ }
14759
+
14760
+ function roundExecReceiptFound(tail: string, expected: RoundExecLedgerRow): boolean {
14761
+ for (const line of tail.split('\n')) {
14762
+ try {
14763
+ const row = JSON.parse(line) as Partial<RoundExecLedgerRow>;
14764
+ if (row.stage === 'round-exec' && row.slug === expected.slug && row.round === expected.round
14765
+ && row.startedAt === expected.startedAt && row.endedAt === expected.endedAt
14766
+ && row.outcome === expected.outcome && row.exitCode === expected.exitCode) return true;
14767
+ } catch { /* a torn or unrelated line is not this receipt */ }
14768
+ }
14769
+ return false;
14770
+ }
14771
+
14772
+ /** Refusal shape returned by {@link withRoundStateLock} in place of throwing, so every `dz round`
14773
+ * mutation observes the SAME lock-busy contract (FR-4): a `NamedLockTimeoutError` becomes `exit 1`,
14774
+ * a `lock busy: …` message, and a `{ refused: 'lock-busy' }` JSON field — never a bare stack trace,
14775
+ * and never a silent fall-through that would let a caller mistake absence-of-error for success. */
14776
+ type RoundLockBusy = { readonly refused: 'lock-busy'; readonly reason: string };
14777
+
14778
+ /**
14779
+ * round-state-lock T1 — the one named lock every `.dz/rounds/*.json` mutation goes through
14780
+ * (`<stateRoot>/.dz/locks/round-state.lock`, `withNamedLockSync` from `@dzhechkov/harness-core`).
14781
+ *
14782
+ * `fn` MUST be short and synchronous (the same caveat `withNamedLockSync` itself carries): it may
14783
+ * reread state and write it, never spawn a subprocess or await anything — the recall step and the
14784
+ * ledger write stay OUTSIDE the lock by design (teach:0ea46034), and the long-running `codex exec`
14785
+ * child in `round exec` runs between two separate short lock holds, not inside one.
14786
+ *
14787
+ * `io.roundLockTimeoutMs` (NFR-2) lets tests force a small deadline instead of the real default;
14788
+ * omitting it keeps production behaviour (and every existing test) byte-identical.
14789
+ */
14790
+ function withRoundStateLock<T>(stateRoot: string, fn: () => T, io: CliIo): T | RoundLockBusy {
14791
+ try {
14792
+ return withNamedLockSync(
14793
+ stateRoot,
14794
+ 'round-state',
14795
+ fn,
14796
+ io.roundLockTimeoutMs === undefined ? {} : { timeoutMs: io.roundLockTimeoutMs },
14797
+ );
14798
+ } catch (error) {
14799
+ if (error instanceof NamedLockTimeoutError) {
14800
+ return { refused: 'lock-busy', reason: error.message };
14801
+ }
14802
+ throw error;
14803
+ }
14804
+ }
14805
+
14806
+ /** round-state-lock fix-round AM-5: the restore-section retry budget — `exec`'s SECOND lock hold
14807
+ * (returning ownership after the codex child exits) tries up to this many times, with the SAME
14808
+ * per-attempt timeout, before it gives up and leaves the round `ownerKind: 'exec'` for a human to
14809
+ * notice (via the `open`/`status` staleness warning) rather than looping forever against a lock that
14810
+ * may never free up. */
14811
+ const ROUND_RESTORE_LOCK_ATTEMPTS = 4; // 1 attempt + 3 retries (AM-5; lead edit after re-review)
14812
+
14813
+ function withRoundStateLockRetried<T>(stateRoot: string, fn: () => T, io: CliIo, attempts: number): T | RoundLockBusy {
14814
+ let lastBusy: RoundLockBusy | null = null;
14815
+ for (let attempt = 0; attempt < attempts; attempt++) {
14816
+ const result = withRoundStateLock(stateRoot, fn, io);
14817
+ if (!(typeof result === 'object' && result !== null && 'refused' in result && result.refused === 'lock-busy')) {
14818
+ return result;
14819
+ }
14820
+ lastBusy = result;
14821
+ }
14822
+ return lastBusy!;
14823
+ }
14824
+
14825
+ async function cmdRound(
14826
+ options: Map<string, string>,
14827
+ optionLists: Map<string, string[]>,
14828
+ flags: Set<string>,
14829
+ cwd: string,
14830
+ write: Write,
14831
+ io: CliIo,
14832
+ ): Promise<number> {
14833
+ const sub = options.get('_positional_0') ?? '';
14834
+ const json = flags.has('json');
14835
+ const stateRootResolution = resolveRoundStateRoot(options, process.env, cwd);
14836
+ if (!stateRootResolution.ok) {
14837
+ write(json ? JSON.stringify({ message: stateRootResolution.reason }) : stateRootResolution.reason);
14838
+ return 2;
14839
+ }
14840
+ const stateRoot = stateRootResolution.root;
14841
+ const stateRootExplicit = stateRootResolution.source !== 'cwd';
14842
+ const projectRoot = resolve(cwd, options.get('project') ?? '.');
14843
+ const now = io.roundNow?.() ?? Date.now();
14844
+ const emit = (message: string, extra: Record<string, unknown> = {}): void => {
14845
+ write(json ? JSON.stringify({ message, ...extra }) : message);
14846
+ };
14847
+ const address = (roundOverride?: number): { slug: string; round: number } | null => {
14848
+ const slug = options.get('slug') ?? '';
14849
+ const round = roundOverride ?? Number(options.get('round'));
14850
+ if (!/^[a-z0-9][a-z0-9._-]*$/i.test(slug) || !Number.isInteger(round) || round < 1) return null;
14851
+ return { slug, round };
14852
+ };
14853
+
14854
+ if (sub === 'open') {
14855
+ const slug = options.get('slug') ?? '';
14856
+ const roundRaw = options.get('round');
14857
+ const autoRound = roundRaw === 'auto'
14858
+ ? nextRoundNumber(io.roundLedgerReader?.(stateRoot) ?? readRoundLedger(stateRoot), slug)
14859
+ : undefined;
14860
+ const at = address(autoRound);
14861
+ const topic = options.get('topic') ?? '';
14862
+ if (at === null || topic.trim() === '') {
14863
+ emit('нужны --slug --round --topic');
14864
+ return 2;
14865
+ }
14866
+ const ownerPidRaw = options.get('owner-pid');
14867
+ const ownerRunRaw = options.get('owner-run');
14868
+ if (ownerPidRaw !== undefined && ownerRunRaw !== undefined) {
14869
+ emit('--owner-pid и --owner-run взаимоисключающие');
14870
+ return 2;
14871
+ }
14872
+ if (ownerRunRaw !== undefined && ownerRunRaw.trim() === '') {
14873
+ emit('--owner-run пуст');
14874
+ return 2;
14875
+ }
14876
+ const ownerRun = ownerRunRaw?.trim();
14877
+ const ownerPid = ownerRunRaw !== undefined ? 0 : ownerPidRaw === undefined ? process.ppid : Number(ownerPidRaw);
14878
+ const ownerKind = ownerRunRaw !== undefined ? 'run' as const : ownerPidRaw === undefined ? 'parent' as const : 'explicit' as const;
14879
+ const path = roundStatePath(stateRoot, at.slug, at.round);
14880
+ // round-state-lock FR-3/AC-1: captured BEFORE the (long, unlocked) recall below, so the
14881
+ // recheck under the lock can tell "unchanged since this snapshot" from "a different process
14882
+ // opened it while we were recalling".
14883
+ const beforeRaw = readRawRoundState(path);
14884
+ const existing = beforeRaw === null
14885
+ ? null
14886
+ : parseRoundState(beforeRaw) ?? {
14887
+ slug: at.slug, round: at.round, topic: '', startedAt: new Date(now).toISOString(),
14888
+ pid: 1, ownerKind: 'explicit', recalled: [],
14889
+ };
14890
+ let existingOwnerAlive: boolean | null = null;
14891
+ if (existing !== null && flags.has('force') && existing.ownerKind !== 'run') {
14892
+ try { existingOwnerAlive = (io.roundPidProbe ?? probePid)(existing.pid); } catch { /* unavailable is unknown and refuses */ }
14893
+ }
14894
+ const isRunAlive = (runId: string): boolean | null => roundRunOwnerAlive(
14895
+ stateRoot, runId, now, io.roundRunRegistryReader, io.roundPidProbe ?? probePid,
14896
+ );
14897
+ const runId = options.get('run')?.trim();
14898
+ const recallOptions = { limit: 5, ...(runId === undefined || runId === '' ? {} : { runId }) };
14899
+ const preflight = openRound({
14900
+ ...at, topic, startedAt: new Date(now).toISOString(), ownerPid, ownerKind,
14901
+ ...(ownerRun === undefined || ownerRun === '' ? {} : { ownerRun }),
14902
+ ...(runId === undefined || runId === '' ? {} : { run: runId }), recalled: [], existing,
14903
+ force: flags.has('force'), existingOwnerAlive, isRunAlive,
14904
+ });
14905
+ if (!preflight.ok) {
14906
+ // AM-5: the round we are refusing to touch may itself be a stuck `exec` claim (its restore
14907
+ // section exhausted its lock-busy retries and left `ownerKind: 'exec'`) — name that out loud
14908
+ // rather than leaving the operator to guess why a pid that "shouldn't" be alive is blocking.
14909
+ const staleMinutes = existing === null ? null : roundExecStaleAgeMinutes(existing, now);
14910
+ const reason = staleMinutes === null
14911
+ ? preflight.reason
14912
+ : `${preflight.reason} (владелец завис в exec ${staleMinutes} мин)`;
14913
+ emit(reason, { round: at.round, ...(staleMinutes === null ? {} : { staleExecMinutes: staleMinutes }) });
14914
+ return preflight.exit;
14915
+ }
14916
+
14917
+ let lessons: readonly { id: string; reward: number; domain: string; text: string }[] = [];
14918
+ try {
14919
+ lessons = io.roundRecall !== undefined
14920
+ ? await io.roundRecall(projectRoot, topic, recallOptions)
14921
+ : (await recallHybrid(projectRoot, topic, recallOptions)).hits.slice(0, 5).map((hit) => ({
14922
+ id: patternRecordId(hit.pattern),
14923
+ reward: hit.pattern.reward,
14924
+ domain: hit.pattern.domain,
14925
+ text: hit.pattern.pattern,
14926
+ }));
14927
+ } catch {
14928
+ lessons = [];
14929
+ }
14930
+ const opened = openRound({
14931
+ ...at, topic, startedAt: new Date(now).toISOString(), ownerPid, ownerKind,
14932
+ ...(ownerRun === undefined || ownerRun === '' ? {} : { ownerRun }),
14933
+ ...(runId === undefined || runId === '' ? {} : { run: runId }),
14934
+ recalled: lessons.slice(0, 5).map((lesson) => lesson.id), existing: null,
14935
+ force: false, existingOwnerAlive: null, isRunAlive,
14936
+ });
14937
+ if (!opened.ok) { emit(opened.reason); return opened.exit; }
14938
+ const openedState: RoundState = { ...opened.state, execs: [], stateId: generateRoundStateId() };
14939
+ let archived: string | undefined;
14940
+ try {
14941
+ const locked = withRoundStateLock(stateRoot, () => {
14942
+ // AM-3/AM-6: recall ran unlocked and may have taken a while — reread NOW, under the lock,
14943
+ // and decide fresh from what is ACTUALLY there rather than from the pre-recall snapshot.
14944
+ //
14945
+ // AM-3 (was: refuse only when the bytes changed AND the foreign pid differed from ours):
14946
+ // `ppid` coincides for two `dz` launched from the same shell, and every run-owned state
14947
+ // carries pid 0 — so "same pid" proved nothing about identity. ANY change in raw bytes since
14948
+ // `beforeRaw` is now the refusal trigger; the foreign pid is reported for diagnostics only,
14949
+ // never consulted for the decision.
14950
+ //
14951
+ // AM-6 (was: an unconditional `readFileSync(path)` while archiving threw a bare ENOENT if
14952
+ // the target vanished mid-recall): a state that is simply GONE now is not a race to refuse —
14953
+ // it is exactly the "no existing round" case, --force or not. Re-decide fresh: no file under
14954
+ // the lock ⇒ ordinary open, no archive, regardless of what `beforeRaw`/`existing` said.
14955
+ const nowRaw = readRawRoundState(path);
14956
+ if (nowRaw === beforeRaw) {
14957
+ // Unchanged since the pre-recall snapshot: proceed exactly as `preflight` planned —
14958
+ // including the --force archive-a-dead-owner flow, which is safe here because nothing
14959
+ // touched `existing`'s bytes while we were recalling.
14960
+ if (preflight.archiveExisting && existing !== null) {
14961
+ const compactStartedAt = new Date(existing.startedAt).toISOString().replace(/[-:.]/g, '');
14962
+ archived = join(stateRoot, '.dz', 'rounds', 'archive', `${at.slug}-${at.round}-${compactStartedAt}.json`);
14963
+ mkdirSync(dirname(archived), { recursive: true });
14964
+ writeFileSync(archived, readFileSync(path), { flag: 'wx' });
14965
+ }
14966
+ writeJsonAtomic(path, openedState);
14967
+ return { ok: true as const };
14968
+ }
14969
+ if (nowRaw === null) {
14970
+ // AM-6: vanished under us — nothing left to conflict with or to archive.
14971
+ writeJsonAtomic(path, openedState);
14972
+ return { ok: true as const };
14973
+ }
14974
+ // Something is there now, and it is byte-different from what we planned around: refuse.
14975
+ // The pid below is diagnostic only (AM-3) — it never gates the decision.
14976
+ const foreign = parseRoundState(nowRaw);
14977
+ return { refused: 'already-open' as const, pid: foreign?.pid ?? -1 };
14978
+ }, io);
14979
+ if ('refused' in locked) {
14980
+ if (locked.refused === 'lock-busy') {
14981
+ emit(`lock busy: ${locked.reason}`, { refused: 'lock-busy' });
14982
+ return 1;
14983
+ }
14984
+ emit(`круг уже открыт (pid ${locked.pid}) — состояние не перезаписано`, { refused: 'already-open', pid: locked.pid });
14985
+ return 1;
14986
+ }
14987
+ } catch (error) {
14988
+ emit(`круг не открыт: ${error instanceof Error ? error.message : String(error)}`);
14989
+ return 1;
14990
+ }
14991
+ const owner = openedState.ownerKind === 'run'
14992
+ ? `владелец: run ${openedState.ownerRun} (run)`
14993
+ : `владелец: pid ${openedState.pid} (${openedState.ownerKind})`;
14994
+ if (json) {
14995
+ emit('круг открыт', { state: openedState, owner, stateRoot, lessons: lessons.slice(0, 5), ...(archived === undefined ? {} : { archived }) });
14996
+ } else {
14997
+ if (archived !== undefined) write(`архивировано: ${archived}`);
14998
+ write(`=== КРУГ ОТКРЫТ: ${at.slug} круг ${at.round}`);
14999
+ write(`state root: ${stateRoot}`);
15000
+ write(owner);
15001
+ write(`--- уроки для брифа (${lessons.slice(0, 5).length} поднято):`);
15002
+ for (const lesson of lessons.slice(0, 5)) {
15003
+ const oneLine = lesson.text.replace(/[\r\n\u2028\u2029\u0085\v\f]+/g, ' ⏎ ');
15004
+ write(` [${lesson.reward.toFixed(2)}] (${lesson.domain}) ${oneLine.slice(0, 160)}`);
15005
+ }
15006
+ }
15007
+ return 0;
15008
+ }
15009
+
15010
+ if (sub === 'exec') {
15011
+ const at = address();
15012
+ const briefArg = options.get('brief') ?? '';
15013
+ const timeoutRaw = options.get('timeout-min') ?? '30';
15014
+ const timeoutMinutes = Number(timeoutRaw);
15015
+ if (at === null || briefArg.trim() === '' || !Number.isInteger(timeoutMinutes) || timeoutMinutes <= 0) {
15016
+ emit('нужны --slug --round --brief; --timeout-min должен быть целым числом больше нуля');
15017
+ return 2;
15018
+ }
15019
+ const briefPath = resolve(cwd, briefArg);
15020
+ let briefText: string;
15021
+ try {
15022
+ briefText = readFileSync(briefPath, 'utf8');
15023
+ } catch {
15024
+ emit(`brief не читается: ${briefArg}`);
15025
+ return 2;
15026
+ }
15027
+ const path = roundStatePath(stateRoot, at.slug, at.round);
15028
+ let state = readRoundState(path);
15029
+ if (state === null) {
15030
+ emit(existsSync(path) ? 'состояние круга не читается' : 'круг не открыт');
15031
+ return 1;
15032
+ }
15033
+
15034
+ const model = options.get('model') ?? 'gpt-5.6-sol';
15035
+ const effort = options.get('effort') ?? 'high';
15036
+ const logArg = options.get('log') ?? join('.dz', 'rounds', `${at.slug}-${at.round}.exec.log`);
15037
+ const logPath = resolve(cwd, logArg);
15038
+ const startedMs = io.roundNow?.() ?? Date.now();
15039
+ const startedAt = new Date(startedMs).toISOString();
15040
+ const request = {
15041
+ command: 'codex' as const,
15042
+ args: [
15043
+ 'exec',
15044
+ '-c', `model=${model}`,
15045
+ '-c', `model_reasoning_effort=${effort}`,
15046
+ '--dangerously-bypass-approvals-and-sandbox',
15047
+ briefText,
15048
+ ],
15049
+ cwd: stateRoot,
15050
+ logPath,
15051
+ timeoutMs: timeoutMinutes * 60_000,
15052
+ killGraceMs: io.roundKillGraceMs ?? 10_000,
15053
+ };
15054
+ let execClaimId = '';
15055
+ try {
15056
+ // T3/FR-1, fix-round AM-1: reread state under the lock immediately before claiming ownership
15057
+ // — a short, synchronous critical section, released before the (possibly long) codex child
15058
+ // below runs. NO fallback to the pre-lock `state` snapshot (that was the resurrection bug:
15059
+ // `readRoundState(path) ?? state!` would recreate a round that had been closed in the
15060
+ // meantime). The claim proceeds ONLY when the state currently under the lock still carries the
15061
+ // exact `stateId` we read before acquiring it — pid/ppid can coincide across processes, but a
15062
+ // `stateId` never does.
15063
+ execClaimId = randomBytes(8).toString('hex');
15064
+ const claimed = withRoundStateLock(stateRoot, () => {
15065
+ const outcome = readStateOrRefuse(path, state!.stateId);
15066
+ if ('refused' in outcome) return outcome;
15067
+ if (outcome.ownerKind === 'exec' && outcome.execClaimId !== undefined) {
15068
+ return { refused: 'exec-in-progress' as const, execClaimId: outcome.execClaimId };
15069
+ }
15070
+ writeJsonAtomic(path, { ...outcome, pid: io.roundPid ?? process.pid, ownerKind: 'exec', execClaimId, execClaimedAt: new Date(io.roundNow?.() ?? Date.now()).toISOString() });
15071
+ return { ok: true as const, base: outcome };
15072
+ }, io);
15073
+ if ('refused' in claimed) {
15074
+ if (claimed.refused === 'lock-busy') {
15075
+ emit(`exec не запущен: владелец круга не обновлён: lock busy: ${claimed.reason}`, { refused: 'lock-busy' });
15076
+ return 1;
15077
+ }
15078
+ if (claimed.refused === 'gone') {
15079
+ emit('exec не запущен: круг закрыт во время exec, владелец не менялся', { refused: 'gone' });
15080
+ return 1;
15081
+ }
15082
+ if (claimed.refused === 'exec-in-progress') {
15083
+ emit(`exec не запущен: у круга уже идёт exec (claim ${claimed.execClaimId})`, { refused: 'exec-in-progress', execClaimId: claimed.execClaimId });
15084
+ return 1;
15085
+ }
15086
+ const replaced = claimed as RoundStateReplaced;
15087
+ emit(
15088
+ `exec не запущен: состояние круга заменено (stateId ${replaced.stateId ?? 'unknown'}), возврат владельца пропущен`,
15089
+ { refused: 'replaced', stateId: replaced.stateId },
15090
+ );
15091
+ return 1;
15092
+ }
15093
+ state = claimed.base;
15094
+ } catch (error) {
15095
+ emit(`exec не запущен: владелец круга не обновлён: ${error instanceof Error ? error.message : String(error)}`);
15096
+ return 1;
15097
+ }
15098
+ let receipt: RoundSpawnReceipt;
15099
+ try {
15100
+ try {
15101
+ receipt = await (io.roundSpawn ?? spawnRoundCodex)(request);
15102
+ } catch (error) {
15103
+ const err = error as NodeJS.ErrnoException;
15104
+ receipt = { exitCode: null, timedOut: false, signal: null, ...(err.code === undefined ? {} : { errorCode: err.code }), error: err.message };
15105
+ }
15106
+ } finally {
15107
+ try {
15108
+ // T3/FR-1, fix-round AM-1/AM-5: the return leg — a second short lock hold, symmetric with
15109
+ // the claim above, and gated by the SAME stateId check (the child may have run long enough
15110
+ // for someone else to close or replace this round while it was running). AM-5: a busy lock
15111
+ // here gets up to ROUND_RESTORE_LOCK_ATTEMPTS tries with the same timeout before giving up —
15112
+ // a codex child can legitimately run for a while, so ownership recovery deserves more than
15113
+ // one attempt before leaving the round stuck at `ownerKind: 'exec'`.
15114
+ const restored = withRoundStateLockRetried(stateRoot, () => {
15115
+ const outcome = readStateOrRefuse(path, state!.stateId);
15116
+ if ('refused' in outcome) return outcome;
15117
+ // Lead edit after Codex re-review: restore only OUR claim — another exec of the same round
15118
+ // instance has its own execClaimId and must not be wiped by our base state.
15119
+ if (outcome.execClaimId !== execClaimId) {
15120
+ return { refused: 'replaced' as const, stateId: outcome.stateId, execClaimId: outcome.execClaimId };
15121
+ }
15122
+ writeJsonAtomic(path, state);
15123
+ return { ok: true as const };
15124
+ }, io, ROUND_RESTORE_LOCK_ATTEMPTS);
15125
+ if ('refused' in restored) {
15126
+ if (restored.refused === 'lock-busy') {
15127
+ // AM-5: no new flag or command is added — this names the manual remedy in prose (a
15128
+ // literal `--flag`-shaped token here would be caught by known-flags-drift.test.ts as an
15129
+ // undocumented flag, which would be exactly the wrong signal for text naming no flag at
15130
+ // all). The durable fix is that `open`/`status` surface the resulting stuck
15131
+ // `ownerKind: 'exec'` on their own (roundExecStaleAgeMinutes), so it is never silently
15132
+ // left for someone to trip over.
15133
+ emit(
15134
+ 'владелец круга не восстановлен (ownerKind=exec остался): повторите dz round exec для этого круга, когда блокировка освободится',
15135
+ { refused: 'lock-busy', ownerKind: 'exec' },
15136
+ );
15137
+ return 1;
15138
+ }
15139
+ if (restored.refused === 'gone') {
15140
+ emit('круг закрыт во время exec, владелец не менялся', { refused: 'gone' });
15141
+ return 1;
15142
+ }
15143
+ emit(
15144
+ `состояние круга заменено (stateId ${restored.stateId ?? 'unknown'}), возврат владельца пропущен`,
15145
+ { refused: 'replaced', stateId: restored.stateId },
15146
+ );
15147
+ return 1;
15148
+ }
15149
+ } catch (error) {
15150
+ emit(`exec завершён, но владелец круга не восстановлен: ${error instanceof Error ? error.message : String(error)}`);
15151
+ return 1;
15152
+ }
15153
+ }
15154
+ const endedMs = io.roundNow?.() ?? Date.now();
15155
+ const endedAt = new Date(endedMs).toISOString();
15156
+ let logBuffer = Buffer.alloc(0);
15157
+ try { logBuffer = readFileSync(logPath); } catch { /* no output is an empty receipt */ }
15158
+ const logText = logBuffer.toString('utf8');
15159
+ const bytes = logBuffer.byteLength;
15160
+ const tokens = parseCodexTokens(logText);
15161
+ const outcome = classifyRoundExecOutcome({
15162
+ exitCode: receipt.exitCode,
15163
+ timedOut: receipt.timedOut,
15164
+ bytes,
15165
+ tail: logBuffer.subarray(Math.max(0, bytes - 4096)).toString('utf8'),
15166
+ });
15167
+ const row = buildRoundExecRow({
15168
+ ...at,
15169
+ model,
15170
+ effort,
15171
+ minutes: Math.max(0, Math.floor((endedMs - startedMs) / 60_000)),
15172
+ tokens,
15173
+ outcome,
15174
+ exitCode: receipt.exitCode,
15175
+ bytes,
15176
+ startedAt,
15177
+ endedAt,
15178
+ log: logArg,
15179
+ brief: briefArg,
15180
+ });
15181
+ if (io.roundLedgerWriter !== undefined) io.roundLedgerWriter(stateRoot, row);
15182
+ else cmdFeatureAdrRecord(new Map([
15183
+ ['kind', 'ledger'], ['stage', 'round-exec'], ['slug', state.slug], ['row', JSON.stringify(row)], ['project', stateRoot],
15184
+ ]), new Set(), stateRoot, () => undefined);
15185
+ const ledgerTail = io.roundLedgerReader?.(stateRoot) ?? readRoundLedgerTail(stateRoot);
15186
+ if (!roundExecReceiptFound(ledgerTail, row)) {
15187
+ emit('строка round-exec не найдена — результат НЕ подтверждён');
15188
+ return 1;
15189
+ }
15190
+ try {
15191
+ writeJsonAtomic(path, {
15192
+ ...state,
15193
+ execs: [...(state.execs ?? []), { startedAt, endedAt, exitCode: receipt.exitCode, outcome, tokens }],
15194
+ });
15195
+ } catch (error) {
15196
+ emit(`строка round-exec подтверждена, но состояние не обновлено: ${error instanceof Error ? error.message : String(error)}`);
15197
+ return 1;
15198
+ }
15199
+ if (receipt.errorCode === 'ENOENT') emit('codex не найден', { row });
15200
+ else emit(`round exec: ${row.minutes} min; exit ${row.exitCode ?? 'null'}; ${row.bytes} bytes; tokens ${row.tokens ?? 'не найдены'}; ${row.outcome}`, { row });
15201
+ return outcome === 'done' ? 0 : 1;
15202
+ }
15203
+
15204
+ if (sub === 'close') {
15205
+ const at = address();
15206
+ if (at === null || !options.has('outcome')) {
15207
+ emit('нужны --slug --round --outcome');
15208
+ return 2;
15209
+ }
15210
+ const path = roundStatePath(stateRoot, at.slug, at.round);
15211
+ const state = readRoundState(path);
15212
+ if (state === null) {
15213
+ emit(existsSync(path) ? 'состояние круга не читается — круг НЕ закрыт' : 'круг не открыт');
15214
+ return 1;
15215
+ }
15216
+ const lessons = optionLists.get('lesson') ?? [];
15217
+ const knownLessonIds = lessons.filter((id) => {
15218
+ try {
15219
+ return io.roundLessonExists !== undefined
15220
+ ? io.roundLessonExists(projectRoot, id)
15221
+ : loadStoreRecords(projectRoot).some((record) => record.id === id);
15222
+ } catch { return false; }
15223
+ });
15224
+ const numeric = (key: string): number | undefined => options.has(key) ? Number(options.get(key)) : undefined;
15225
+ const closedAtIso = new Date(now).toISOString();
15226
+ // AM-4: predict the marker `closeRound` will compute for THIS attempt (same slug/round/closedAt
15227
+ // it will use) and check whether the ledger already carries it BEFORE calling `closeRound` —
15228
+ // this is what makes a retried `close` idempotent: if a prior invocation's write already landed
15229
+ // (this run's own tail read, not trusted from the earlier failed attempt's own belief), skip the
15230
+ // write below instead of appending a duplicate row.
15231
+ const predictedMarker = predictedRoundCloseMarker(at.slug, at.round, closedAtIso);
15232
+ const tailBeforeWrite = io.roundLedgerReader?.(stateRoot) ?? readRoundLedgerTail(stateRoot);
15233
+ // Lead edit after Codex re-review: a retried close carries a NEW clock, so the marker alone never
15234
+ // matches — the row's stateId (identity of the state instance) is what makes the retry idempotent.
15235
+ const alreadyRecorded = tailBeforeWrite.includes(predictedMarker)
15236
+ || (state.stateId !== undefined && tailBeforeWrite.includes(`"stateId":"${state.stateId}"`));
15237
+ // Lead edit after Codex re-review: a retry whose row is already in the ledger (same stateId) must
15238
+ // not re-run closeRound's postcondition against a marker computed from the NEW clock — the earlier
15239
+ // row is the receipt; only the state-file removal remains.
15240
+ const closed = alreadyRecorded
15241
+ ? { ok: true as const, row: undefined, marker: `already-recorded:${state.stateId ?? predictedMarker}` }
15242
+ : closeRound({
15243
+ state,
15244
+ outcome: options.get('outcome') ?? '',
15245
+ ...(options.has('reason') ? { reason: options.get('reason') } : {}),
15246
+ lessons,
15247
+ knownLessonIds,
15248
+ ...(options.has('no-new-knowledge') ? { noNewKnowledge: options.get('no-new-knowledge') } : {}),
15249
+ ...(options.has('tokens') ? { tokens: numeric('tokens') } : {}),
15250
+ ...(options.has('agents') ? { agents: numeric('agents') } : {}),
15251
+ ...(options.has('coder') ? { coder: options.get('coder') } : {}),
15252
+ ...(options.has('reviewer') ? { reviewer: options.get('reviewer') } : {}),
15253
+ ...(options.has('note') ? { note: options.get('note') } : {}),
15254
+ ...(flags.has('no-cost') ? { noCost: true } : {}),
15255
+ closedAt: closedAtIso,
15256
+ ...(state.stateId !== undefined ? { stateId: state.stateId } : {}),
15257
+ }, {
15258
+ writeLedger: (row) => {
15259
+ // AM-4 idempotent retry: the row for this attempt was already witnessed in the tail read
15260
+ // above — do not append a second one. `closeRound`'s own postcondition (rereading the tail
15261
+ // and checking it contains the marker) still passes, because the marker is already there.
15262
+ if (alreadyRecorded) return undefined;
15263
+ if (io.roundLedgerWriter !== undefined) return io.roundLedgerWriter(stateRoot, row);
15264
+ return cmdFeatureAdrRecord(new Map([
15265
+ ['kind', 'ledger'], ['stage', 'round'], ['slug', state.slug], ['row', JSON.stringify(row)], ['project', stateRoot],
15266
+ ]), new Set(), stateRoot, () => undefined);
15267
+ },
15268
+ readLedgerTail: () => io.roundLedgerReader?.(stateRoot) ?? readRoundLedgerTail(stateRoot),
15269
+ });
15270
+ if (!closed.ok) { emit(closed.reason); return closed.exit; }
15271
+ try {
15272
+ // T4/FR-1/FR-2, fix-round AM-2: the ledger write above (via `closed`) stays OUTSIDE the lock
15273
+ // (teach:0ea46034); only the final reread-and-delete is a lock-guarded critical section, and it
15274
+ // now deletes ONLY the exact state instance the ledger row above was written for — identified
15275
+ // by `state.stateId`, read before the lock was ever taken.
15276
+ const deleted = withRoundStateLock(stateRoot, () => {
15277
+ const outcome = readStateForCloseOrRefuse(path, state.stateId);
15278
+ if ('refused' in outcome) return outcome;
15279
+ unlinkSync(path);
15280
+ return { ok: true as const };
15281
+ }, io);
15282
+ if ('refused' in deleted) {
15283
+ if (deleted.refused === 'lock-busy') {
15284
+ // AM-4: the ledger row is ALREADY written by the time this lock is even attempted (see
15285
+ // above) — so a busy lock here never leaves the outcome unrecorded, only the round's OWN
15286
+ // state file open. Say exactly that, and make the retry path explicit.
15287
+ emit(
15288
+ 'строка леджера записана, состояние круга осталось открытым — повторите close',
15289
+ { refused: 'lock-busy', ledgerWritten: true },
15290
+ );
15291
+ return 1;
15292
+ }
15293
+ if (deleted.refused === 'closed-already') {
15294
+ // AM-2: the state file is already gone — this close's own ledger row is written (above, or
15295
+ // by a previous invocation of this same idempotent attempt), so this is the same round
15296
+ // reaching its already-closed postcondition by a different path, not a failure.
15297
+ emit('круг уже закрыт (строка леджера записана)', { closed: true, alreadyClosed: true, marker: closed.marker });
15298
+ return 0;
15299
+ }
15300
+ // AM-2: something else's state sits at this path now (a different stateId) — never delete it.
15301
+ emit('состояние заменено, не удалено', { refused: 'replaced', stateId: deleted.stateId });
15302
+ return 1;
15303
+ }
15304
+ } catch (error) {
15305
+ emit(`строка подтверждена, но состояние не удалено — круг НЕ закрыт: ${error instanceof Error ? error.message : String(error)}`);
15306
+ return 1;
15307
+ }
15308
+ emit(`✓ строка круга в леджере подтверждена чтением (${closed.marker})`, { row: closed.row, marker: closed.marker });
15309
+ return 0;
15310
+ }
15311
+
15312
+ if (sub === 'status') {
15313
+ const rawThreshold = options.get('older-than') ?? '120';
15314
+ const olderThan = Number(rawThreshold);
15315
+ if (!Number.isInteger(olderThan) || olderThan < 0) {
15316
+ emit('--older-than должен быть целым числом минут не меньше нуля', { open: [] });
15317
+ return 0;
15318
+ }
15319
+ const dir = join(stateRoot, '.dz', 'rounds');
15320
+ const states: RoundState[] = [];
15321
+ try {
15322
+ for (const name of readdirSync(dir).filter((entry) => entry.endsWith('.json')).sort()) {
15323
+ const state = readRoundState(join(dir, name));
15324
+ if (state !== null) states.push(state);
15325
+ }
15326
+ } catch { /* no state directory is an honestly empty report */ }
15327
+ const rows = listRounds(states, {
15328
+ now,
15329
+ olderThanMinutes: olderThan,
15330
+ isPidAlive: io.roundPidProbe ?? probePid,
15331
+ isRunAlive: (runId) => roundRunOwnerAlive(
15332
+ stateRoot, runId, now, io.roundRunRegistryReader, io.roundPidProbe ?? probePid,
15333
+ ),
15334
+ });
15335
+ // AM-5: independent of the `--older-than` filter above (a stuck exec claim is worth flagging at
15336
+ // 10 minutes regardless of the round's own age threshold) — computed over ALL open states, and
15337
+ // additive: when none apply, neither branch below emits anything extra, so the two byte-pinned
15338
+ // zero-rounds lines (NFR-1, see the comment below) stay untouched.
15339
+ const staleExec = states
15340
+ .map((state) => {
15341
+ const minutes = roundExecStaleAgeMinutes(state, now);
15342
+ return minutes === null ? null : { slug: state.slug, round: state.round, minutes };
15343
+ })
15344
+ .filter((warning): warning is { slug: string; round: number; minutes: number } => warning !== null);
15345
+ if (json) {
15346
+ emit(rows.length > 0 ? `⚠ ${rows.length} open round(s) older than ${olderThan} min` : 'нет старых открытых кругов', {
15347
+ stateRoot, olderThan, open: rows, ...(staleExec.length === 0 ? {} : { staleExec }),
15348
+ });
15349
+ } else {
15350
+ // FR-3 prints `state root: <dir>` on open unconditionally; here it is printed only when the
15351
+ // root was EXPLICITLY chosen (--state-root / DZ_ROUND_STATE_ROOT). Printing it unconditionally
15352
+ // would change the two default-cwd zero-rounds lines pinned exactly by
15353
+ // round-cli.test.ts ("status reports a fresh open round…" / "…no open rounds"), which NFR-1
15354
+ // requires to stay byte-identical and unmodified.
15355
+ if (stateRootExplicit) write(`state root: ${stateRoot}`);
15356
+ write(states.length === 0
15357
+ ? 'открытых кругов нет'
15358
+ : `открытых кругов: ${states.length}, старше ${olderThan} мин: ${rows.length}`);
15359
+ for (const row of rows) {
15360
+ const live = row.pidAlive === true ? 'alive' : row.pidAlive === false ? 'dead' : 'unknown';
15361
+ write(`${row.state.slug}#${row.state.round} · ${row.ageMinutes} min · pid ${row.state.pid} ${live} · ${row.state.topic}`);
15362
+ }
15363
+ for (const warning of staleExec) {
15364
+ write(`⚠ ${warning.slug}#${warning.round}: владелец завис в exec ${warning.minutes} мин — восстановите вручную (dz round exec вернул lock-busy при возврате владельца)`);
15365
+ }
15366
+ }
15367
+ return 0;
15368
+ }
15369
+
15370
+ emit('использование: dz round open|exec|close|status');
15371
+ return 2;
15372
+ }
15373
+
15374
+ /**
15375
+ * ledger-stage-minutes T2: the `ts` of the LAST ledger row (scanning from the end, so a duplicate
15376
+ * or out-of-order runId still finds the truly latest one) that carries the given `runId`. Every
15377
+ * failure mode — the file does not exist yet, a permission error — returns `null` rather than
15378
+ * throwing: this is a BEST-EFFORT observability lookup feeding a non-blocking field (ADR-003), never
15379
+ * a gate the write must pass.
15380
+ *
15381
+ * fix-round-1/AM-n (cross-family review B, MEDIUM): a torn or non-object line — `ledger-corrupt-line`
15382
+ * — is NOT silently skipped past. The original code `continue`d over it and kept scanning further
15383
+ * back, which could return an OLDER valid row for this `runId` while a NEWER one for the same run
15384
+ * sat hidden on the other side of the corrupt line (or was itself the corrupt line). Once the scan
15385
+ * hits a line it cannot parse as a JSON object, it can no longer prove which row is truly LAST for
15386
+ * this run, so it stops and reports `null` (⇒ `minutesSource: 'unavailable'`) rather than risk an
15387
+ * UNDERSTATED delta computed against a stale row.
15388
+ */
15389
+ function findPreviousLedgerRowTs(ledgerPath: string, runId: string): string | null {
15390
+ if (runId === '') return null;
15391
+ let body: string;
15392
+ try {
15393
+ body = readFileSync(ledgerPath, 'utf-8');
15394
+ } catch {
15395
+ return null;
15396
+ }
15397
+ const lines = body.split('\n').filter((l) => l !== '');
15398
+ for (let i = lines.length - 1; i >= 0; i--) {
15399
+ let parsed: unknown;
15400
+ try {
15401
+ parsed = JSON.parse(lines[i] as string);
15402
+ } catch {
15403
+ // ledger-corrupt-line: everything from here to the start of the file is unprovable — a real
15404
+ // match further back cannot be trusted to still be the LAST one, so this is `unavailable`,
15405
+ // never a guess made by skipping past what we could not read.
15406
+ return null;
15407
+ }
15408
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
15409
+ // Same reasoning as the parse failure above: a non-object line is exactly as untrustworthy.
15410
+ return null;
15411
+ }
15412
+ const row = parsed as Record<string, unknown>;
15413
+ if (typeof row['runId'] === 'string' && row['runId'].trim() === runId) {
15414
+ return typeof row['ts'] === 'string' && row['ts'].trim() !== '' ? row['ts'] : null;
15415
+ }
15416
+ }
15417
+ return null;
15418
+ }
15419
+
14082
15420
  function cmdFeatureAdrRecord(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
14083
15421
  const json = flags.has('json');
14084
15422
  // `--backfill` is a different verb on the same store: it fills the ledger's null cost fields from
@@ -14117,10 +15455,73 @@ function cmdFeatureAdrRecord(options: Map<string, string>, flags: Set<string>, c
14117
15455
  const markName = (options.get('mark') ?? '').trim();
14118
15456
  const markPath = markName === '' ? null : join(markDir, markName.replace(/[^\w.-]/g, '_'));
14119
15457
 
15458
+ // ledger-stage-minutes T2/FR-2: `--run-id` fills the payload's `runId` ONLY WHEN the payload does
15459
+ // not already carry one — the same gap-only stamping discipline `decideRecordWrite` already uses
15460
+ // for `runnerId`. "Absent" is deliberately wider than "missing key": `runId: null`, `runId: ''`
15461
+ // and a non-string `runId` (a number, an object — never a real join key) are ALL gaps too, exactly
15462
+ // the `isRunnerGap` rule one seam over — fixed-round-1/AM-n confirmed this is the INTENDED contract
15463
+ // ("missing when absent or blank"), not a bug: only a genuine non-empty string counts as "the
15464
+ // caller already knew it", so any of those gap shapes are correctly overwritten by the flag. A
15465
+ // malformed --row is left untouched here: decideRecordWrite reports the real JSON parse error,
15466
+ // this merge step must never invent a different one.
15467
+ const isRunIdArgGap = (v: unknown): boolean => v === null || v === undefined || typeof v !== 'string' || v.trim() === '';
15468
+ let effectivePayloadRaw = payloadRaw;
15469
+ const explicitRunId = (options.get('run-id') ?? '').trim();
15470
+ if (kind === 'ledger' && explicitRunId !== '') {
15471
+ try {
15472
+ const parsed: unknown = JSON.parse(payloadRaw);
15473
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
15474
+ const rowObj = parsed as Record<string, unknown>;
15475
+ if (isRunIdArgGap(rowObj['runId'])) {
15476
+ // fix-round-1/AM-n (cross-family review B, MEDIUM): the flag-filled runId now carries its
15477
+ // provenance, the same discipline `resolved-at-write` already applies to the OTHER runId
15478
+ // source (write-time auto-resolution below) — an un-sourced runId looked exactly like one
15479
+ // the caller supplied. A non-empty `runIdSource` the payload already carries (an odd shape,
15480
+ // since `runId` itself was a gap) is left alone rather than overwritten with a guess.
15481
+ const hasRunIdSource = typeof rowObj['runIdSource'] === 'string' && rowObj['runIdSource'].trim() !== '';
15482
+ effectivePayloadRaw = JSON.stringify({
15483
+ ...rowObj,
15484
+ runId: explicitRunId,
15485
+ ...(hasRunIdSource ? {} : { runIdSource: 'cli-flag' }),
15486
+ });
15487
+ }
15488
+ }
15489
+ } catch { /* decideRecordWrite reports the parse error itself */ }
15490
+ }
15491
+
15492
+ // FR-2/FR-3: find the runId this row will carry (explicit flag, or one the payload already had),
15493
+ // then read the ledger BEST-EFFORT for the last row of that same run and its `ts`. A read failure
15494
+ // (file absent, unreadable, a torn or malformed line) is an honest `previousRowTs: null` — never
15495
+ // a thrown error, because a record write must never fail on an OBSERVABILITY lookup (ADR-003).
15496
+ let runIdForLookup = '';
15497
+ try {
15498
+ const parsed: unknown = JSON.parse(effectivePayloadRaw);
15499
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
15500
+ const v = (parsed as Record<string, unknown>)['runId'];
15501
+ if (typeof v === 'string' && v.trim() !== '') runIdForLookup = v.trim();
15502
+ }
15503
+ } catch { /* decideRecordWrite reports the parse error itself */ }
15504
+ // Lead edit after re-review (Codex B): the pipeline's own rows have no runId in the payload — it is
15505
+ // resolved at write time below. Resolve it HERE as well (same resolver, same registry) so the
15506
+ // previous-row lookup and the minutes delta cover the main path, not only explicit ids.
15507
+ let resolvedRunIdPre: string | null = null;
15508
+ if (kind === 'ledger' && runIdForLookup === '') {
15509
+ try {
15510
+ const parsed: unknown = JSON.parse(effectivePayloadRaw);
15511
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
15512
+ resolvedRunIdPre = resolveLedgerRunId(parsed as Record<string, unknown>, listCostLedgerRuns());
15513
+ if (resolvedRunIdPre !== null) runIdForLookup = resolvedRunIdPre.trim();
15514
+ }
15515
+ } catch { /* resolution is an ENRICHMENT; the row is written regardless */ }
15516
+ }
15517
+ const previousRowTs = kind === 'ledger' && runIdForLookup !== '' ? findPreviousLedgerRowTs(target, runIdForLookup) : null;
15518
+
14120
15519
  const decision = decideRecordWrite({
14121
15520
  kind,
14122
- payloadRaw,
15521
+ payloadRaw: effectivePayloadRaw,
14123
15522
  stage,
15523
+ previousRowTs,
15524
+ effectiveRunId: runIdForLookup !== '' ? runIdForLookup : null,
14124
15525
  stageProducedResult: flags.has('no-result') ? false : true,
14125
15526
  markExists: markPath !== null && existsSync(markPath),
14126
15527
  targetExists: existsSync(target),
@@ -14174,10 +15575,23 @@ function cmdFeatureAdrRecord(options: Map<string, string>, flags: Set<string>, c
14174
15575
  const parsed: unknown = JSON.parse(decision.line);
14175
15576
  if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
14176
15577
  const rowObj = parsed as Record<string, unknown>;
14177
- const resolved = resolveLedgerRunId(rowObj, listCostLedgerRuns());
15578
+ // Lead edit after review #3 (Codex B): ONE resolution per write — reuse the id resolved
15579
+ // before the decision (the same one the minutes delta was measured against) instead of
15580
+ // resolving again; two resolutions could disagree if the run registry moved in between.
15581
+ const resolved = resolvedRunIdPre !== null ? resolvedRunIdPre : resolveLedgerRunId(rowObj, listCostLedgerRuns());
14178
15582
  if (resolved !== null) {
14179
15583
  // Marked, because a resolved run id is our inference, not something the pipeline knew.
14180
- lineToWrite = JSON.stringify({ ...rowObj, runId: resolved, runIdSource: 'resolved-at-write' });
15584
+ // Keep the minutes fields LAST (NFR-1 of ledger-stage-minutes): splice runId/runIdSource in
15585
+ // right before `ts` when the decided row already carries the stamped tail.
15586
+ const ordered: Record<string, unknown> = {};
15587
+ let spliced = false;
15588
+ for (const [k, v] of Object.entries(rowObj)) {
15589
+ if (k === 'ts' && !spliced) { ordered['runId'] = resolved; ordered['runIdSource'] = 'resolved-at-write'; spliced = true; }
15590
+ if (k === 'runId' || k === 'runIdSource') continue;
15591
+ ordered[k] = v;
15592
+ }
15593
+ if (!spliced) { ordered['runId'] = resolved; ordered['runIdSource'] = 'resolved-at-write'; }
15594
+ lineToWrite = JSON.stringify(ordered);
14181
15595
  }
14182
15596
  }
14183
15597
  } catch { /* resolution is an ENRICHMENT; a failure must never cost the row itself */ }
@@ -18107,7 +19521,10 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
18107
19521
  // `dz recap` — a refusal built on either list would reject working commands, which is a worse
18108
19522
  // failure than the one being fixed. Goes to STDERR so a `--json` consumer's stdout stays clean.
18109
19523
  if (command !== 'contract-check') {
18110
- for (const notice of unknownFlagNotice([...flags, ...options.keys()].filter((k) => !k.startsWith('_positional_')), KNOWN_CLI_FLAGS)) {
19524
+ for (const notice of unknownFlagNotice(
19525
+ [...flags, ...options.keys()].filter((k) => !k.startsWith('_positional_')),
19526
+ KNOWN_CLI_FLAGS,
19527
+ )) {
18111
19528
  writeErr(notice.line);
18112
19529
  }
18113
19530
  }
@@ -18264,11 +19681,11 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
18264
19681
  case 'auto-canonicalize':
18265
19682
  return await cmdAutoCanonicalize(options, cwd, write);
18266
19683
  case 'publish':
18267
- return cmdPublish(options, flags, cwd, write);
19684
+ return cmdPublish(options, flags, cwd, write, io.publishMirrorRunner);
18268
19685
  case 'release':
18269
19686
  return cmdRelease(options, flags, cwd, write, io.releaseRunner);
18270
19687
  case 'parity':
18271
- return cmdParity(options, flags, write, writeErr);
19688
+ return cmdParity(options, flags, write, writeErr, cwd);
18272
19689
  case 'registry':
18273
19690
  return cmdRegistry(options, cwd, write);
18274
19691
  case 'benchmark':
@@ -18353,6 +19770,8 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
18353
19770
  return cmdJournal(options, flags, cwd, write, io.journalIo);
18354
19771
  case 'feature-adr-record':
18355
19772
  return cmdFeatureAdrRecord(options, flags, cwd, write);
19773
+ case 'round':
19774
+ return await cmdRound(options, optionLists, flags, cwd, write, io);
18356
19775
  case 'runs':
18357
19776
  return cmdRuns(options, flags, cwd, write);
18358
19777
  case 'runs-clean':