@dzhechkov/harness-cli 0.8.22 → 0.8.24

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,
@@ -61,6 +92,7 @@ import {
61
92
  generatePlugin,
62
93
  publishPackages,
63
94
  runSetup,
95
+ memoryBackendSourceLabel,
64
96
  runMigrate,
65
97
  searchRegistry,
66
98
  runSync,
@@ -98,10 +130,15 @@ import {
98
130
  hasPolicyFence,
99
131
  TARGET_NAMES,
100
132
  buildParityMatrix,
133
+ computeParity,
134
+ PARITY_FEATURES,
101
135
  downgradeForStaleEvidence,
102
136
  findStaleTranscriptEvidence,
103
137
  TARGET_CAPABILITIES,
104
138
  TARGET_SHORT_LABELS,
139
+ applyLegStatus,
140
+ applyLegReasonMessage,
141
+ resolveAgentdbPath,
105
142
  WORKFLOW_TEMPLATES_RETIRED_MESSAGE,
106
143
  parsePlan,
107
144
  isParseErrors,
@@ -174,7 +211,7 @@ import {
174
211
  type StoreCountSnapshot,
175
212
  type RunSegment,
176
213
  type StageSample,
177
- computeUsage,
214
+ computeSpendReport,
178
215
  deriveCostLedger,
179
216
  planLedgerBackfill,
180
217
  listCostLedgerRuns,
@@ -186,10 +223,7 @@ import {
186
223
  verifyCostLedgerReport,
187
224
  writeCostLedgerJsonl,
188
225
  COST_LEDGER_SCOPE,
189
- deriveUsageCalibration,
190
- normalizeClaudeUsageModelKey,
191
- readUsageLimits,
192
- parseWeeklyResetAnchor,
226
+ spendReport,
193
227
  claimCheck,
194
228
  summarize,
195
229
  BUNDLED_SLOP_REGISTRY_URL,
@@ -210,6 +244,10 @@ import {
210
244
  recordToPattern,
211
245
  bundleSkills,
212
246
  brainHome,
247
+ brainAgentdbPath,
248
+ listPreReindexSnapshots,
249
+ rotatePreReindexSnapshots,
250
+ scanSnapshotDir,
213
251
  listBrain,
214
252
  bookKbPath,
215
253
  promoteProjectToBrain,
@@ -281,6 +319,11 @@ import {
281
319
  verifyManifest,
282
320
  hashPackBytes,
283
321
  rewriteWorkspaceSpecs,
322
+ detectSiblingDrift,
323
+ planPackedInstallSmoke,
324
+ judgePackedInstallSmoke,
325
+ type FetchPublished,
326
+ type PackedInstallExecution,
284
327
  listPackFiles,
285
328
  listSignablePackFiles,
286
329
  assertKeyOutsideTree,
@@ -418,6 +461,7 @@ import {
418
461
  countRecallEventsForRun,
419
462
  unknownFlagNotice,
420
463
  mirrorWriterExplanation,
464
+ mirrorWriterReason,
421
465
  appendRecallUsage,
422
466
  closenessLine,
423
467
  anyAboveFloor,
@@ -510,6 +554,8 @@ import {
510
554
  renderReqeList,
511
555
  REQE_SCOPE,
512
556
  // Mutation gate (feature ha-mutation-gate) — break each named protection, run the suite, require red.
557
+ REGISTRY_SELFCHECK_TESTS,
558
+ buildMutationTestCommand,
513
559
  parseMutationRegistry,
514
560
  applyMutationToText,
515
561
  attributeBaselineRedness,
@@ -593,7 +639,7 @@ import type { SetupSpec } from '@dzhechkov/harness-core';
593
639
  import type { LogTail } from '@dzhechkov/harness-core';
594
640
  import type { DeadwoodInventoryItem } from '@dzhechkov/harness-core';
595
641
  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';
642
+ import type { ProvenanceMode, PackVerdict, PatternRecord, RecallPatternsOptions, TeachGuardResult, TargetName, IntegrationOutcome, BookKU, HarmonizeReport, ClaimFinding, RecallUsagePatternRow, GateExecution, GateStep, SlopFinding, SlopLintConfig, SlopRegistry, PackedTarballArtifact, PackedTransportSmokeVerdict } from '@dzhechkov/harness-core';
597
643
  import { getPreset, PRESET_NAMES } from '@dzhechkov/harness-presets';
598
644
  import { scanGitHub, analyzeRepo, generateReport, deepAnalyze, scanAllSources, ScoutMemory } from '@dzhechkov/scout';
599
645
 
@@ -637,7 +683,7 @@ export const DZ_COMMANDS: readonly string[] = [
637
683
  'epoch-replay', 'score', 'recap', 'cadence', 'qe-rounds', 'restart-advisor', 'tg-post',
638
684
  'name-check', 'brief-check', 'provenance-check', 'journal', 'feature-adr-record', 'runs', 'runs-record', 'runs-clean', 'amendment-check', 'contract-check',
639
685
  'feature-adr-checkpoint', 'profile', 'reqe', 'qe-bridge', 'backlog', 'routing',
640
- 'bto-optimize', 'dashboard', 'roam', 'import-ecc', 'chain',
686
+ 'bto-optimize', 'dashboard', 'roam', 'import-ecc', 'chain', 'round',
641
687
  ];
642
688
 
643
689
  const USAGE = `dz - DZ cross-platform harness CLI
@@ -699,7 +745,8 @@ Usage:
699
745
  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
746
  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
747
  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)
748
+ 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)
749
+ 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
750
  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
751
  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
752
  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 +779,8 @@ Usage:
732
779
  dz brain query "<q>" [--source <slug>] [--limit <N>] [--any] [--rerank] [--json] (cross-source recall; --any = OR match; --rerank reorders top-K)
733
780
  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
781
  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)
782
+ dz brain reindex [--json] (snapshot, re-embed book-KU brain vectors, stamp current model; also rotates old pre-reindex snapshots)
783
+ 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
784
  dz brain primer <slug> [--json] (print a source's capability card — KU-type histogram + top decision moments)
737
785
  dz brain export --source <slug> --out <file> (export ONE source as a portable, lexical-only books.sqlite slice)
738
786
  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 +789,7 @@ Usage:
741
789
  dz statusline [--json] [--install] [--project <dir>] (live self-learning panel for Claude Code's status bar; reads the CC JSON payload from STDIN)
742
790
  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
791
  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)
792
+ dz usage [--json] [--project <dir>] (7-day UTC spend from local Claude Code + subagent transcripts; provider-limit routing disabled by design)
745
793
  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
794
  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
795
  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 +849,12 @@ export interface MutationGateRunnerObservation {
801
849
 
802
850
  export type MutationGateRunner = (
803
851
  command: string,
804
- options: { readonly cwd: string; readonly timeoutMs: number },
852
+ options: {
853
+ readonly cwd: string;
854
+ readonly timeoutMs: number;
855
+ readonly phase: 'baseline' | 'mutation' | 'rebaseline' | 'final-rebaseline';
856
+ readonly entryId?: string;
857
+ },
805
858
  ) => MutationGateRunnerObservation;
806
859
 
807
860
  /** Output sink + working directory — injectable so the CLI is testable. */
@@ -830,10 +883,45 @@ export interface CliIo {
830
883
  readonly interactive?: boolean;
831
884
  /** Fault seam proving that class-form recall degrades to specific recall with a stderr receipt. */
832
885
  readonly classMatcher?: RecallPatternsOptions['classMatcher'];
886
+ /** Focused-round seams: production still uses the real store, writer, ledger tail and pid probe. */
887
+ readonly roundNow?: () => number;
888
+ readonly roundPid?: number;
889
+ readonly roundRecall?: (projectRoot: string, topic: string, options: {
890
+ readonly limit: number;
891
+ readonly runId?: string;
892
+ }) => Promise<readonly {
893
+ readonly id: string;
894
+ readonly reward: number;
895
+ readonly domain: string;
896
+ readonly text: string;
897
+ }[]>;
898
+ readonly roundLessonExists?: (projectRoot: string, id: string) => boolean;
899
+ readonly roundLedgerWriter?: (projectRoot: string, row: RoundLedgerRow | RoundExecLedgerRow) => unknown;
900
+ readonly roundLedgerReader?: (projectRoot: string) => string;
901
+ readonly roundPidProbe?: (pid: number) => boolean | null;
902
+ readonly roundRunRegistryReader?: (projectRoot: string) => string;
903
+ readonly roundKillGraceMs?: number;
904
+ /** round-state-lock NFR-2: overrides `withNamedLockSync`'s acquisition deadline for `dz round`
905
+ * mutations so a test can force `lock busy` deterministically. Omitted in production. */
906
+ readonly roundLockTimeoutMs?: number;
907
+ readonly roundSpawn?: (request: {
908
+ readonly command: 'codex';
909
+ readonly args: readonly string[];
910
+ readonly cwd: string;
911
+ readonly logPath: string;
912
+ readonly timeoutMs: number;
913
+ readonly killGraceMs?: number;
914
+ }) => Promise<{
915
+ readonly exitCode: number | null;
916
+ readonly timedOut: boolean;
917
+ readonly signal: NodeJS.Signals | null;
918
+ readonly errorCode?: string;
919
+ readonly error?: string;
920
+ }>;
833
921
  /** Guard decision seam; production always uses the real vector-backed teach guard. */
834
922
  readonly teachGuardRunner?: (projectRoot: string, text: string, opts: { readonly reward?: number }) => Promise<TeachGuardResult>;
835
923
  /** Reinforcement flush seam paired with `teachGuardRunner`; production uses the configured backend. */
836
- readonly teachReinforceRunner?: (projectRoot: string, dzId: string, reward: number) => Promise<{ readonly flushed: number }>;
924
+ readonly teachReinforceRunner?: (projectRoot: string, dzId: string, reward?: number) => Promise<{ readonly flushed: number; readonly dzId?: string }>;
837
925
  /**
838
926
  * Test seam for `dz release`: overrides subprocess execution for gate steps and the
839
927
  * gh/git side channels (production leaves it unset → real `execSync`, stdio piped).
@@ -843,6 +931,30 @@ export interface CliIo {
843
931
  readonly releaseRunner?: ReleaseExecRunner;
844
932
  /** Post-publish mirror command seam; production uses synchronous shell execution. */
845
933
  readonly publishMirrorRunner?: PublishMirrorRunner;
934
+ /**
935
+ * Test seam for `dz publish`'s sibling-drift gate (feature publish-sibling-drift-gate):
936
+ * overrides the registry fetch (production leaves it unset → real `npm pack` + extract into a
937
+ * temp dir). Tests inject a local directory instead of hitting the real registry.
938
+ */
939
+ readonly publishSiblingDriftFetcher?: FetchPublished;
940
+ /**
941
+ * Test seam for `dz publish`'s packed-install smoke: overrides the pack/install/`--version`
942
+ * subprocesses (production leaves it unset → real `execSync`, stdio piped). Mirrors
943
+ * {@link CliIo.releaseRunner}.
944
+ */
945
+ readonly publishPackedInstallRunner?: ReleaseExecRunner;
946
+ /**
947
+ * AM-1 (feature publish-sibling-drift-gate): overrides EVERY subprocess `publishPackages` would
948
+ * run on a LIVE publish — build, the `npm pack`/`npm publish <tgz>` packedTransport commands, and
949
+ * the `npm view` registry probes (production leaves it unset → real `execSync`, stdio piped).
950
+ * Threaded into `publishPackages`'s `exec` option so a test can drive the FULL live+packedTransport
951
+ * `cmdPublish` path (pack → smoke → publish → registry-confirm) with zero network and zero real
952
+ * `npm publish`.
953
+ */
954
+ readonly publishExecRunner?: (
955
+ command: string,
956
+ options: { cwd?: string | URL | undefined; stdio?: unknown; encoding?: unknown; timeout?: number | undefined; env?: NodeJS.ProcessEnv | undefined },
957
+ ) => string;
846
958
  /**
847
959
  * Test seam for `dz install`: overrides the `npm install` subprocess (production leaves
848
960
  * it unset → real `execSync`, stdio piped). A stub runner that pre-stages a fixture
@@ -2994,10 +3106,13 @@ function cmdStatusline(
2994
3106
  : `🎓 dz: ${data.patterns} (${breakdown.active} active${breakdown.quarantined > 0
2995
3107
  ? ` · ${breakdown.quarantined} quarantined${breakdown.attention ? ' ⚠' : ''}`
2996
3108
  : ''})${breakdown.tierDelta !== undefined ? ` ⚠ tiers Δ${breakdown.tierDelta}` : ''}`;
2997
- // Показатель зеркала печатается и здесь: `dz statusline` та же панель, и показатель,
2998
- // живущий только во вспомогательном скрипте, для этой поверхности просто не существовал.
2999
- if (data.patternMirror?.state === 'unavailable') line += ' (mirror unreadable ⚠)';
3000
- else if (data.patternMirror?.state === 'different') line += ` (mirror ${data.patternMirror.vector})`;
3109
+ // Зеркало самостоятельный источник панели. Отсутствие печатается явно; нечитаемый файл
3110
+ // сохраняет прежнее отдельное состояние, чтобы отказ инструмента не выглядел как настройка off.
3111
+ line += data.patternMirror?.state === 'unavailable'
3112
+ ? ' · mirror: unreadable'
3113
+ : data.mirror.available
3114
+ ? ` · mirror: ${data.mirror.lessons} lessons (pending ${data.mirror.pending})`
3115
+ : ' · mirror: absent';
3001
3116
  if (data.storeHealth?.verdict === 'collapsed') {
3002
3117
  line += ` ⛔ COLLAPSE: was ${data.storeHealth.previousMax ?? '?'} · dz store-guard --reset`;
3003
3118
  } else if (data.storeHealth?.verdict === 'cold-start-over-existing') {
@@ -3041,172 +3156,6 @@ function cmdStatusline(
3041
3156
  }
3042
3157
  }
3043
3158
 
3044
- function isJsonRecord(value: unknown): value is Record<string, unknown> {
3045
- return typeof value === 'object' && value !== null && !Array.isArray(value);
3046
- }
3047
-
3048
- function usageConfigPath(projectRoot: string): string {
3049
- return join(projectRoot, '.dz', 'config.json');
3050
- }
3051
-
3052
- function readProjectConfigForUsage(projectRoot: string): { config: Record<string, unknown>; warning?: string } {
3053
- const path = usageConfigPath(projectRoot);
3054
- try {
3055
- if (!existsSync(path)) return { config: {} };
3056
- const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown;
3057
- if (isJsonRecord(parsed)) return { config: parsed };
3058
- return { config: {}, warning: 'existing config is not a JSON object; writing a minimal config' };
3059
- } catch {
3060
- return { config: {}, warning: 'existing config could not be parsed; writing a minimal config' };
3061
- }
3062
- }
3063
-
3064
- function applyUsageCalibrationToConfig(config: Record<string, unknown>, plan: UsageCalibrationPlan): Record<string, unknown> {
3065
- const next: Record<string, unknown> = { ...config };
3066
- const memory = isJsonRecord(next['memory']) ? { ...next['memory'] } : {};
3067
- const usage = isJsonRecord(memory['usage']) ? { ...memory['usage'] } : {};
3068
-
3069
- for (const change of plan.changes) {
3070
- if (change.key === 'session') {
3071
- usage['sessionTokenLimit'] = change.after;
3072
- } else if (change.key === 'weekly') {
3073
- usage['weeklyTokenLimit'] = change.after;
3074
- } else {
3075
- const model = normalizeClaudeUsageModelKey(change.key);
3076
- if (model) {
3077
- const existingByModel = isJsonRecord(usage['weeklyTokenLimitByModel']) ? { ...usage['weeklyTokenLimitByModel'] } : {};
3078
- existingByModel[model] = change.after;
3079
- usage['weeklyTokenLimitByModel'] = existingByModel;
3080
- }
3081
- }
3082
- }
3083
-
3084
- if (plan.changes.length > 0) {
3085
- usage['calibratedAt'] = plan.after.calibratedAt;
3086
- usage['source'] = plan.after.source;
3087
- // A fresh calibration re-arms routing for THIS account and clears the legacy free-text switch:
3088
- // the calibration is the very act the disable-note demanded.
3089
- usage['calibrationAccount'] = plan.after.calibrationAccount ?? null;
3090
- }
3091
-
3092
- memory['usage'] = usage;
3093
- next['memory'] = memory;
3094
- return next;
3095
- }
3096
-
3097
- function parseUsageModelArgs(modelArgs: readonly string[]): { modelPct: Record<string, unknown>; skipped: string[] } {
3098
- const modelPct: Record<string, unknown> = {};
3099
- const skipped: string[] = [];
3100
- for (const raw of modelArgs) {
3101
- const eq = raw.indexOf('=');
3102
- if (eq <= 0 || eq === raw.length - 1) {
3103
- skipped.push(`model ${raw}: skipped malformed model=pct argument`);
3104
- continue;
3105
- }
3106
- const modelName = raw.slice(0, eq).trim();
3107
- const model = normalizeClaudeUsageModelKey(modelName);
3108
- if (!model) {
3109
- skipped.push(`model ${modelName}: skipped unknown model`);
3110
- continue;
3111
- }
3112
- modelPct[model] = raw.slice(eq + 1).trim();
3113
- }
3114
- return { modelPct, skipped };
3115
- }
3116
-
3117
- function writeUsageCalibrationSummary(opts: {
3118
- projectRoot: string;
3119
- plan: UsageCalibrationPlan;
3120
- preSkipped: readonly string[];
3121
- configWarning?: string | undefined;
3122
- wrote: boolean;
3123
- write: Write;
3124
- }): void {
3125
- opts.write('usage calibrate: estimated local transcript counts; claude.ai/settings/usage is authoritative');
3126
- opts.write(`usage calibrate: project ${opts.projectRoot}`);
3127
- if (opts.configWarning) opts.write(`usage calibrate: ${opts.configWarning}`);
3128
- for (const change of opts.plan.changes) {
3129
- opts.write(
3130
- `usage calibrate: ${change.key} tokens=${change.tokens} pct=${change.pct}% limit ${change.before ?? 'null'} -> ${change.after}`,
3131
- );
3132
- }
3133
- const skipped = [...opts.preSkipped, ...opts.plan.skipped];
3134
- for (const item of skipped) opts.write(`usage calibrate: skipped ${item}`);
3135
- if (opts.wrote) {
3136
- opts.write('usage calibrate: wrote .dz/config.json with source claude.ai/settings/usage');
3137
- } else {
3138
- opts.write('usage calibrate: no config changes written');
3139
- }
3140
- }
3141
-
3142
- function cmdUsageCalibrate(
3143
- options: Map<string, string>,
3144
- optionLists: Map<string, string[]>,
3145
- cwd: string,
3146
- write: Write,
3147
- ): number {
3148
- const projectRoot = resolve(cwd, options.get('project') ?? '.');
3149
- const suppliedModels = optionLists.get('model') ?? [];
3150
- const parsedModels = parseUsageModelArgs(suppliedModels);
3151
- const modelPct = parsedModels.modelPct;
3152
- const hasModelPct = Object.keys(modelPct).length > 0;
3153
- const input = {
3154
- ...(options.has('session') ? { sessionPct: options.get('session') } : {}),
3155
- ...(options.has('weekly') ? { weeklyPct: options.get('weekly') } : {}),
3156
- ...(hasModelPct ? { modelPct } : {}),
3157
- calibratedAt: new Date().toISOString(),
3158
- source: 'claude.ai/settings/usage' as const,
3159
- };
3160
- const missingInputs: string[] = [];
3161
- if (!options.has('session') && !options.has('weekly') && !hasModelPct) {
3162
- missingInputs.push('no calibration percentages supplied');
3163
- }
3164
-
3165
- try {
3166
- const current = computeUsage(projectRoot);
3167
- const before = readUsageLimits(projectRoot);
3168
- const plan = deriveUsageCalibration(current, before, input);
3169
- if (plan.changes.length === 0) {
3170
- writeUsageCalibrationSummary({
3171
- projectRoot,
3172
- plan,
3173
- preSkipped: [...parsedModels.skipped, ...missingInputs],
3174
- wrote: false,
3175
- write,
3176
- });
3177
- return 0;
3178
- }
3179
-
3180
- const existing = readProjectConfigForUsage(projectRoot);
3181
- const nextConfig = applyUsageCalibrationToConfig(existing.config, plan);
3182
- try {
3183
- mkdirSync(join(projectRoot, '.dz'), { recursive: true });
3184
- writeFileSync(usageConfigPath(projectRoot), JSON.stringify(nextConfig, null, 2) + '\n');
3185
- writeUsageCalibrationSummary({
3186
- projectRoot,
3187
- plan,
3188
- preSkipped: [...parsedModels.skipped, ...missingInputs],
3189
- configWarning: existing.warning,
3190
- wrote: true,
3191
- write,
3192
- });
3193
- } catch {
3194
- writeUsageCalibrationSummary({
3195
- projectRoot,
3196
- plan,
3197
- preSkipped: [...parsedModels.skipped, ...missingInputs, 'write failed'],
3198
- configWarning: existing.warning,
3199
- wrote: false,
3200
- write,
3201
- });
3202
- }
3203
- return 0;
3204
- } catch {
3205
- write('usage calibrate: skipped internal error; no config changes written');
3206
- return 0;
3207
- }
3208
- }
3209
-
3210
3159
  /**
3211
3160
  * `dz usage --by-stage` — the per-stage cost ledger for one feature-adr run (feature `cost-ledger`).
3212
3161
  *
@@ -3265,17 +3214,6 @@ function cmdUsageByStage(
3265
3214
  return 0;
3266
3215
  }
3267
3216
 
3268
- /**
3269
- * `dz usage` — print an ESTIMATE of Claude session + weekly usage from fixed reset windows,
3270
- * aggregated READONLY from the local transcript store (see {@link computeUsage}). `--json` emits
3271
- * the single-line contract the feature-adr usage-probe agent parses; `--calibrate` is the only
3272
- * write path and records human-transcribed claude.ai percentages in `.dz/config.json`.
3273
- *
3274
- * **Exit code is 0 ALWAYS** — including on internal error the whole body is guarded and prints the
3275
- * all-null JSON, so a probe can NEVER distinguish "usage unknown" from "command failed" via a
3276
- * non-zero exit. `--project <dir>` scopes ONLY the `.dz/config.json` read/write; measurement is
3277
- * account-wide (all projects).
3278
- */
3279
3217
  /**
3280
3218
  * dz qe-rounds — how many Step-8 review rounds has one feature already had?
3281
3219
  *
@@ -3454,6 +3392,52 @@ function cmdRestartAdvisor(options: Map<string, string>, flags: Set<string>, cwd
3454
3392
  }));
3455
3393
  }
3456
3394
 
3395
+ function packageCommitCount(root: string, sinceIso: string): number | null {
3396
+ try {
3397
+ // Assemble git's flag so the CLI flag-inventory scanner does not mistake a child-process option
3398
+ // for a user-facing dz option. The argv delivered to git is still exactly `--count`.
3399
+ const raw = execFileSync('git', ['rev-list', '--' + 'count', `--since=${sinceIso}`, 'HEAD', '--', 'packages/'], {
3400
+ cwd: root,
3401
+ encoding: 'utf8',
3402
+ stdio: ['ignore', 'pipe', 'pipe'],
3403
+ }).trim();
3404
+ return /^\d+$/.test(raw) ? Number(raw) : null;
3405
+ } catch {
3406
+ return null;
3407
+ }
3408
+ }
3409
+
3410
+ function roundTraceSince(root: string): string | null {
3411
+ let firstDate: string | null = null;
3412
+ let lastRoundDate: string | null = null;
3413
+ try {
3414
+ const rows = readFileSync(join(root, '.dz', 'feature-adr', 'run-cost-ledger.jsonl'), 'utf8').split('\n');
3415
+ for (const line of rows) {
3416
+ if (line.trim() === '') continue;
3417
+ let row: Record<string, unknown>;
3418
+ try {
3419
+ const parsed = JSON.parse(line) as unknown;
3420
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) continue;
3421
+ row = parsed as Record<string, unknown>;
3422
+ } catch { continue; }
3423
+ const date = typeof row['date'] === 'string' && Number.isFinite(Date.parse(row['date'])) ? row['date'] : null;
3424
+ if (date === null) continue;
3425
+ if (firstDate === null) firstDate = date;
3426
+ if (row['stage'] === 'round' || row['stage'] === 'round-exec') lastRoundDate = date;
3427
+ }
3428
+ } catch { return null; }
3429
+ return lastRoundDate ?? firstDate;
3430
+ }
3431
+
3432
+ function roundsTracingEnabled(root: string): boolean {
3433
+ try {
3434
+ const parsed = JSON.parse(readFileSync(join(root, '.dz', 'config.json'), 'utf8')) as { rounds?: { traced?: unknown } };
3435
+ return parsed?.rounds?.traced !== false;
3436
+ } catch {
3437
+ return true;
3438
+ }
3439
+ }
3440
+
3457
3441
  function cmdCadence(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
3458
3442
  const root = resolve(cwd, options.get('project') ?? '.');
3459
3443
  const windowRaw = (options.get('window') ?? 'week').trim() as CadenceWindow;
@@ -3461,7 +3445,9 @@ function cmdCadence(options: Map<string, string>, flags: Set<string>, cwd: strin
3461
3445
  write('dz cadence: --window must be one of ' + Object.keys(CADENCE_WINDOW_DAYS).join('|'));
3462
3446
  return 1;
3463
3447
  }
3464
- const r = buildCadenceReport(root, windowRaw);
3448
+ const now = Date.now();
3449
+ const windowStartIso = new Date(now - CADENCE_WINDOW_DAYS[windowRaw] * 86_400_000).toISOString();
3450
+ const r = buildCadenceReport(root, windowRaw, now, packageCommitCount(root, windowStartIso));
3465
3451
  if (flags.has('json')) { write(JSON.stringify(r)); return r.decision.ok ? 0 : 2; }
3466
3452
  write('dz cadence — window ' + r.window + ', record depth ' + r.depthDays + ' day(s)');
3467
3453
  if (!r.decision.ok) {
@@ -3475,145 +3461,105 @@ function cmdCadence(options: Map<string, string>, flags: Set<string>, cwd: strin
3475
3461
  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));
3476
3462
  }
3477
3463
  write(' graded ' + r.shipments.gradedTotal + ' (' + Object.entries(r.shipments.byGrade).sort().map(([g, n]) => g + '×' + n).join(', ') + ') · UNGRADED ' + r.shipments.ungraded + ' (named, not hidden)');
3464
+ const roundCount = r.rounds.byStage.round;
3465
+ const roundPart = roundCount === 0
3466
+ ? 'rounds 0 (ни одной строки круга в окне)'
3467
+ : `rounds ${roundCount} (shipped ${r.rounds.byOutcome.shipped} · refuted ${r.rounds.byOutcome.refuted} · blocked ${r.rounds.byOutcome.blocked} · abandoned ${r.rounds.byOutcome.abandoned})`;
3468
+ 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'}`);
3469
+ for (const round of r.rounds.unfinished) {
3470
+ write(` ✗ ${round.slug}#${round.round} ${round.outcome} — ${round.reason ?? 'причина не названа'}`);
3471
+ }
3478
3472
  if (r.guard.decay.length > 0) {
3479
3473
  write(' guard repeat decay (FIXED set — rules with pre-window history only):');
3480
3474
  for (const d of r.guard.decay.slice(0, 8)) write(' ' + d.rule.padEnd(28) + 'before×' + d.before + ' → in-window×' + d.inWindow);
3481
3475
  }
3482
3476
  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(', '));
3483
- for (const dgr of [r.npmPublishes.degraded, r.guard.degraded, r.recalls.degraded]) if (dgr) write(' DEGRADED: ' + dgr);
3477
+ for (const dgr of [r.npmPublishes.degraded, r.guard.degraded, r.recalls.degraded, r.rounds.degraded]) if (dgr) write(' DEGRADED: ' + dgr);
3484
3478
  return 0;
3485
3479
  }
3486
3480
 
3487
3481
  function cmdUsage(
3488
3482
  options: Map<string, string>,
3489
- optionLists: Map<string, string[]>,
3483
+ _optionLists: Map<string, string[]>,
3490
3484
  flags: Set<string>,
3491
3485
  cwd: string,
3492
3486
  write: Write,
3493
3487
  ): number {
3494
3488
  const projectRoot = resolve(cwd, options.get('project') ?? '.');
3495
- const nullContract = () =>
3496
- JSON.stringify({
3497
- sessionPct: null,
3498
- weeklyPct: null,
3499
- sessionTokens: 0,
3500
- weeklyTokens: 0,
3501
- resetsAt: { session: null, weekly: null },
3502
- limits: { session: null, weekly: null },
3503
- estimated: true,
3504
- });
3489
+ const reqeDue = (): number => {
3490
+ try {
3491
+ return scanReqeDebts(projectRoot).debts.length;
3492
+ } catch {
3493
+ return 0;
3494
+ }
3495
+ };
3496
+ const jsonContract = (spend: ReturnType<typeof computeSpendReport>): string => JSON.stringify({
3497
+ sessionPct: null,
3498
+ weeklyPct: null,
3499
+ routing: 'disabled-by-design',
3500
+ spend,
3501
+ reqeDue: reqeDue(),
3502
+ });
3503
+ const number = (value: number): string =>
3504
+ (Number.isInteger(value) ? String(value) : String(Math.round(value * 100) / 100));
3505
3505
  try {
3506
- if (flags.has('calibrate')) return cmdUsageCalibrate(options, optionLists, cwd, write);
3506
+ if (flags.has('calibrate')) {
3507
+ // Keep the retired mode's value flags known so its one-line removal receipt is not polluted
3508
+ // by generic unknown-flag notices before dispatch.
3509
+ void ['--session', '--weekly'];
3510
+ 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');
3511
+ return 2;
3512
+ }
3507
3513
  if (flags.has('by-stage')) return cmdUsageByStage(options, flags, write);
3508
3514
 
3509
- const u = computeUsage(projectRoot);
3510
- const lim = readUsageLimits(projectRoot);
3511
- const modelLimits = lim.weeklyTokenLimitByModel;
3512
- const hasModelLimits = modelLimits !== undefined && Object.keys(modelLimits).length > 0;
3515
+ const spend = computeSpendReport();
3513
3516
  if (flags.has('json')) {
3514
- const limitsPayload: {
3515
- session: number | null;
3516
- weekly: number | null;
3517
- weeklyByModel?: Partial<Record<ClaudeUsageModel, number>>;
3518
- } = { session: lim.sessionTokenLimit ?? null, weekly: lim.weeklyTokenLimit ?? null };
3519
- if (hasModelLimits) limitsPayload.weeklyByModel = { ...modelLimits };
3520
- const payload: {
3521
- sessionPct: number | null;
3522
- weeklyPct: number | null;
3523
- sessionTokens: number;
3524
- weeklyTokens: number;
3525
- resetsAt: { session: string | null; weekly: string | null };
3526
- limits: typeof limitsPayload;
3527
- weeklyByModel?: typeof u.weeklyByModel;
3528
- estimated: true;
3529
- reqeDue?: number;
3530
- notEstablished?: readonly string[];
3531
- estimatesNotForRouting?: { sessionPct: number | null; weeklyPct: number | null };
3532
- } = {
3533
- sessionPct: u.sessionPct,
3534
- weeklyPct: u.weeklyPct,
3535
- sessionTokens: u.sessionTokens,
3536
- weeklyTokens: u.weeklyTokens,
3537
- resetsAt: { session: u.sessionResetsAt, weekly: u.weeklyResetsAt },
3538
- limits: limitsPayload,
3539
- estimated: true,
3540
- };
3541
- // ADR-001 usage-honesty: a consumer that reads null pcts deserves the WHY (closed reason
3542
- // set), and a human deserves the raw estimates when POLICY (not measurement) nulled them.
3543
- if (u.notEstablished.length > 0) payload.notEstablished = u.notEstablished;
3544
- if (u.estimatesNotForRouting !== undefined) payload.estimatesNotForRouting = u.estimatesNotForRouting;
3545
- if (hasModelLimits && u.weeklyByModel !== undefined) payload.weeklyByModel = u.weeklyByModel;
3546
- // re-QE debt surfacing (backlog 6b40e667 — QE #9: the json contract must carry the debt too,
3547
- // a probe is exactly the consumer that needs it). The field appears ONLY when a debt exists,
3548
- // so the zero-debt contract stays byte-identical to the pinned legacy shape. Best-effort.
3549
- try {
3550
- const reqeCount = scanReqeDebts(resolve(cwd, options.get('project') ?? '.')).debts.length;
3551
- if (reqeCount > 0) payload.reqeDue = reqeCount;
3552
- } catch { /* advisory only */ }
3553
- write(
3554
- JSON.stringify(payload),
3555
- );
3517
+ write(jsonContract(spend));
3556
3518
  return 0;
3557
3519
  }
3558
- if (u.sessionPct === null && u.weeklyPct === null && u.notEstablished.length > 0) {
3559
- // Limits may be fully configured and the pcts STILL null — that is the honesty, not a config
3560
- // gap. Say why, and show the human the raw estimates when only policy nulled them.
3561
- write('usage: not established — ' + u.notEstablished.join(', '));
3562
- if (u.estimatesNotForRouting !== undefined) {
3563
- const e = u.estimatesNotForRouting;
3564
- write(' estimates (NOT for routing): session ~' + (e.sessionPct ?? '?') + '% · week ~' + (e.weeklyPct ?? '?') + '% — recalibrate on THIS account: dz usage --calibrate --session <pct> --weekly <pct>');
3565
- } else {
3566
- 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');
3520
+ write('usage spend last 7 UTC days');
3521
+ write('date weighted input output cache-read cache-write events');
3522
+ for (const day of spend.days) {
3523
+ write(`${day.date} ${number(day.weightedTokens)} ${number(day.input)} ${number(day.output)} ${number(day.cacheRead)} ${number(day.cacheWrite)} ${day.events}`);
3524
+ }
3525
+ const total = spend.total7d;
3526
+ write(`7-day total ${number(total.weightedTokens)} ${number(total.input)} ${number(total.output)} ${number(total.cacheRead)} ${number(total.cacheWrite)} ${total.events}`);
3527
+ // "unknown" = `event.model ?? 'unknown'` in `spendReport` — an event with NO model field AT
3528
+ // ALL, or one whose model string matched none of the four recognized substrings (in practice
3529
+ // almost always `<synthetic>`). Fix-round-1 (Codex review, MEDIUM #3): a prior wording here and
3530
+ // in the README said "not an event without a model", which is the OPPOSITE of what the code
3531
+ // does — corrected to name both causes.
3532
+ write('by model — weighted share (0..1) (7-day window; "unknown" = event with no model, or an unrecognized model string e.g. "<synthetic>")');
3533
+ const models = Object.entries(spend.byModel);
3534
+ if (models.length === 0) write(' (no events)');
3535
+ for (const [model, row] of models) {
3536
+ write(` ${model} ${number(row.weightedTokens)} ${number(row.sharePct / 100)}`);
3537
+ }
3538
+ const today = spend.daysByModel.at(-1);
3539
+ if (today !== undefined) {
3540
+ // Fix-round-1 (Codex review, MEDIUM #1): this block used to print weighted tokens only, so
3541
+ // AC-5's "today block shows Sonnet's share of today" had nothing to read it off of. The share
3542
+ // denominator is TODAY's own total (`spend.days.at(-1)`, the same last entry as `today` by
3543
+ // construction — both arrays are built from the same `days` in `spendReport`), not the 7-day
3544
+ // total — a day's share of a week would silently understate every model.
3545
+ write(`today (${today.date}) by model — weighted share-of-day (0..1)`);
3546
+ const todayModels = Object.entries(today.models);
3547
+ const todayTotal = spend.days.at(-1)?.weightedTokens ?? 0;
3548
+ if (todayModels.length === 0) write(' (no events)');
3549
+ for (const [model, weightedTokens] of todayModels) {
3550
+ const shareOfDay = todayTotal > 0 ? weightedTokens / todayTotal : 0;
3551
+ write(` ${model} ${number(weightedTokens)} ${number(shareOfDay)}`);
3567
3552
  }
3568
- try {
3569
- const reqe = scanReqeDebts(resolve(cwd, options.get('project') ?? '.'));
3570
- 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');
3571
- } catch { /* advisory only */ }
3572
- return 0;
3573
- }
3574
- if (u.sessionPct === null && u.weeklyPct === null) {
3575
- write(
3576
- 'usage: unconfigured — set memory.usage.sessionTokenLimit / weeklyTokenLimit in .dz/config.json (percentages are ESTIMATES calibrated from observed exhaustion)',
3577
- );
3578
- try {
3579
- const reqe = scanReqeDebts(resolve(cwd, options.get('project') ?? '.'));
3580
- 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');
3581
- } catch { /* advisory only */ }
3582
- return 0;
3583
3553
  }
3584
- // Compact human line — a short HH:MM / weekday hint on the resets, best-effort.
3585
- const clock = (iso: string | null): string => {
3586
- if (!iso) return '?';
3587
- try {
3588
- return new Date(iso).toISOString().slice(11, 16);
3589
- } catch {
3590
- return '?';
3591
- }
3592
- };
3593
- const s = u.sessionPct === null ? 'n/a' : '~' + u.sessionPct + '%';
3594
- const binding = hasModelLimits && u.weeklyBindingModel !== undefined ? ' ' + u.weeklyBindingModel + '-bound' : '';
3595
- const w = u.weeklyPct === null ? 'n/a' : '~' + u.weeklyPct + '%' + binding;
3596
- // The weekly reset is WEEKLY: print the anchor verbatim (weekday + offset), not a bare clock
3597
- // time — 'resets 08:59' reads as daily and hides the weekday (idea c8513be9: the bare form
3598
- // misread a Monday reading as '41 minutes after the boundary' when the boundary was Wednesday's).
3599
- const weeklyAnchorLabel = typeof lim.weeklyResetAnchor === 'string' && lim.weeklyResetAnchor !== ''
3600
- ? lim.weeklyResetAnchor
3601
- : clock(u.weeklyResetsAt);
3602
- write('usage: session ' + s + ' (resets ' + clock(u.sessionResetsAt) + ') · week ' + w + ' (resets ' + weeklyAnchorLabel + ') · estimated');
3603
- if (typeof lim.weeklyResetAnchor === 'string' && parseWeeklyResetAnchor(lim.weeklyResetAnchor)?.offsetMinutes === undefined) {
3604
- 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');
3605
- }
3606
- // re-QE debt surfacing (backlog 6b40e667): the moment someone checks usage is the moment a
3607
- // usage-switched self-review debt should be visible. Best-effort — never breaks the contract.
3608
- try {
3609
- const reqe = scanReqeDebts(resolve(cwd, options.get('project') ?? '.'));
3610
- 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');
3611
- } catch { /* advisory only */ }
3554
+ write('source: local Claude Code + subagent transcripts, cost-weighted');
3612
3555
  return 0;
3613
3556
  } catch {
3614
- // never let a probe see a non-zero exit — print the all-null contract and exit 0.
3615
- if (flags.has('json')) write(nullContract());
3616
- else write('usage: unconfigured — set memory.usage.sessionTokenLimit / weeklyTokenLimit in .dz/config.json');
3557
+ const empty = spendReport([], { nowMs: Date.now(), days: 7 });
3558
+ if (flags.has('json')) write(jsonContract(empty));
3559
+ else {
3560
+ write('usage spend — last 7 UTC days');
3561
+ write('source: local Claude Code + subagent transcripts, cost-weighted');
3562
+ }
3617
3563
  return 0;
3618
3564
  }
3619
3565
  }
@@ -3943,24 +3889,30 @@ async function cmdStoreGuard(
3943
3889
  async function runTeachGuardReinforcement(
3944
3890
  projectRoot: string,
3945
3891
  dzId: string,
3946
- reward: number,
3892
+ reward?: number,
3947
3893
  preserveQuarantine = false,
3948
- ): Promise<{ readonly flushed: number }> {
3894
+ ): Promise<{ readonly flushed: number; readonly dzId?: string }> {
3895
+ const matchedDzId = loadStoreRecords(projectRoot)
3896
+ .find((record) => record.id === dzId || record.text === dzId)?.id;
3949
3897
  const backend = resolveLearningBackend(projectRoot);
3950
3898
  backend.addSample({
3951
3899
  dzId,
3952
3900
  kind: preserveQuarantine ? 'recall-hit' : 'reinforce',
3953
- reward,
3901
+ ...(reward !== undefined ? { reward } : {}),
3954
3902
  ts: new Date().toISOString(),
3955
3903
  });
3956
- return backend.train();
3904
+ const trained = await backend.train();
3905
+ return {
3906
+ ...trained,
3907
+ ...(trained.flushed > 0 && matchedDzId !== undefined ? { dzId: matchedDzId } : {}),
3908
+ };
3957
3909
  }
3958
3910
 
3959
3911
  async function cmdTeach(
3960
3912
  options: Map<string, string>, flags: Set<string>, cwd: string, write: Write,
3961
3913
  writeErr: WriteErr = (line) => { console.error(line); }, interactive = false,
3962
3914
  guardRunner: (projectRoot: string, text: string, opts: { readonly reward?: number }) => Promise<TeachGuardResult> = teachGuard,
3963
- reinforceRunner: (projectRoot: string, dzId: string, reward: number, preserveQuarantine?: boolean) => Promise<{ readonly flushed: number }> = runTeachGuardReinforcement,
3915
+ reinforceRunner: (projectRoot: string, dzId: string, reward?: number, preserveQuarantine?: boolean) => Promise<{ readonly flushed: number; readonly dzId?: string }> = runTeachGuardReinforcement,
3964
3916
  ): Promise<number> {
3965
3917
  // WHICH store this lesson belongs to, and WHO decided (teach-chooses-its-store).
3966
3918
  // `--to` → `DZ_LEARN` → `.dz/config.json` learning.teachTo → project. The owner asked for a
@@ -3996,20 +3948,58 @@ async function cmdTeach(
3996
3948
  // (D3) — an unconfigured project runs ZERO vector code and its output stays byte-identical
3997
3949
  // to the pre-feature baseline (AC-1). Failures are queued + logged by the service itself and
3998
3950
  // NOT printed on the default path (teach must stay quiet/scriptable); only success emits.
3951
+ // AM-4 (dz-harness-hub issue #10 defect 4, feature setup-installs-apply-leg): a mirror attempt
3952
+ // that produced ZERO rows, resolved NO working engine (`receipt.engine === undefined` — deps
3953
+ // missing/unusable, the ABI-115 failure AM-2 fixes being the measured cause), AND left the
3954
+ // agentdb store file still absent is not "nothing to report" — it is the vector tier having
3955
+ // never come into being, and a lesson taught in that window has nowhere to mirror into until
3956
+ // `dz consolidate`/a later teach (once the store exists) runs. BOTH signals are required so this
3957
+ // never misfires for an rvf-configured project (whose store is not `.dz/agentdb.db` at all) or
3958
+ // for the ordinary "already mirrored, nothing new" case (which resolves an engine successfully).
3959
+ const emitVectorTierAbsentIfNeeded = (root: string, receipt: { readonly engine?: string | undefined }): void => {
3960
+ if (receipt.engine === undefined && !existsSync(resolveAgentdbPath(root))) {
3961
+ write(' ↳ vector tier absent — run dz consolidate');
3962
+ }
3963
+ };
3964
+ // AM-9/AM-10 (issue #10 defect 6, feature setup-installs-apply-leg): `vectorMirrorEnabled(root)`
3965
+ // alone used to decide "say nothing" for every disabled reason alike, including a config that
3966
+ // CLAIMS agentdb via a top-level `backend` key (`{"backend":"agentdb"}` instead of
3967
+ // `{"memory":{"backend":"agentdb"}}`) — a real, readable intent this silently dropped on the
3968
+ // floor. Named for `config-unreadable` / `legacy-shape` — both are a config that TRIED to say
3969
+ // something and got it wrong. THREE reasons stay silent: `engine-off` (deliberate), `no-config`
3970
+ // (the pre-existing AC-1 contract — a NAMED test in `cli.test.ts`/`teach-chooses-its-store.test.ts`
3971
+ // — printing there broke both, MEASURED), and `not-enabled` (AM-10, narrower than the amendment's
3972
+ // literal instruction — MEASURED: `not-enabled` is ALSO the state of the ORDINARY, first-class
3973
+ // jsonl backend `dz setup` produces by default, and printing there added a line to the single most
3974
+ // common `dz teach` invocation shape, reproducer: `mkdir .dz && echo '{"memory":{"backend":
3975
+ // "jsonl"}}' > .dz/config.json && dz teach "x"` → new line `↳ vector tier OFF: …` on the DEFAULT,
3976
+ // fully-supported jsonl path. `not-enabled` cannot distinguish "chose jsonl on purpose" from "typo'd
3977
+ // a backend name", so it is grouped with the other legitimate-quiet states rather than with the
3978
+ // two states that are unambiguously a mistake.
3979
+ const emitMirrorOffIfNeeded = (root: string): boolean => {
3980
+ const reason = mirrorWriterReason(root);
3981
+ if (reason.state !== 'config-unreadable' && reason.state !== 'legacy-shape') return false;
3982
+ write(` ↳ vector tier OFF: ${mirrorWriterExplanation(reason.state)}`);
3983
+ return true;
3984
+ };
3999
3985
  const emitMirror = async (root: string, records: readonly PatternRecord[], source: string): Promise<void> => {
4000
- if (flags.has('no-mirror') || records.length === 0 || !vectorMirrorEnabled(root)) return;
3986
+ if (flags.has('no-mirror') || records.length === 0) return;
3987
+ if (!vectorMirrorEnabled(root)) { emitMirrorOffIfNeeded(root); return; }
4001
3988
  const receipt = await mirrorPatternsToVector(root, records, source);
4002
3989
  if (receipt.mirrored > 0) write(` ↳ mirrored to vector tier (${receipt.engine ?? 'vector'})`);
3990
+ else emitVectorTierAbsentIfNeeded(root, receipt);
4003
3991
  };
4004
3992
  // lesson-quarantine FR-8: the fresh-teach mirror carries the qStatus marker so the hook daemon
4005
3993
  // (which reads only the mirror's metadata) can exclude unproven lessons from auto-inject.
4006
3994
  const emitMirrorQ = async (root: string, records: readonly PatternRecord[], source: string, quarantined: boolean): Promise<void> => {
4007
- if (flags.has('no-mirror') || records.length === 0 || !vectorMirrorEnabled(root)) return;
3995
+ if (flags.has('no-mirror') || records.length === 0) return;
3996
+ if (!vectorMirrorEnabled(root)) { emitMirrorOffIfNeeded(root); return; }
4008
3997
  const entries = records
4009
3998
  .map((r) => patternVectorEntry(r, source, quarantined ? { quarantined: true } : {}))
4010
3999
  .filter((e): e is NonNullable<typeof e> => e !== undefined);
4011
4000
  const receipt = await mirrorEntriesToVector(root, entries);
4012
4001
  if (receipt.mirrored > 0) write(` ↳ mirrored to vector tier (${receipt.engine ?? 'vector'})${quarantined ? ' [quarantined]' : ''}`);
4002
+ else emitVectorTierAbsentIfNeeded(root, receipt);
4013
4003
  };
4014
4004
 
4015
4005
  // `dz teach --harmonize` — documented ALIAS of `dz vector harmonize`: SEMANTIC dedup of the
@@ -4127,20 +4117,23 @@ async function cmdTeach(
4127
4117
 
4128
4118
  const reinforce = options.get('reinforce');
4129
4119
  if (reinforce !== undefined && reinforce.trim() !== '') {
4130
- const backend = resolveLearningBackend(storeRoot);
4131
4120
  const sampleReward = options.has('reward') ? parseFloat(options.get('reward') ?? '0.8') : undefined;
4132
- backend.addSample({
4133
- dzId: reinforce,
4134
- kind: 'reinforce',
4135
- ts: new Date().toISOString(),
4136
- ...(sampleReward !== undefined ? { reward: sampleReward } : {}),
4137
- });
4138
- const trained = await backend.train();
4121
+ const trained = await reinforceRunner(storeRoot, reinforce, sampleReward);
4139
4122
  if (trained.flushed > 0) {
4140
- write(`↳ reinforced ${reinforce}`);
4123
+ const records = loadStoreRecords(storeRoot);
4124
+ const reinforcedDzId = trained.dzId
4125
+ ?? findExactLesson(records, reinforce)?.id
4126
+ ?? records.find((record) => record.id === reinforce)?.id;
4127
+ write(reinforcedDzId !== undefined && reinforcedDzId !== reinforce
4128
+ ? `↳ reinforced ${reinforcedDzId} (matched by text)`
4129
+ : `↳ reinforced ${reinforcedDzId ?? reinforce}`);
4141
4130
  // lesson-quarantine: reinforcement IS promotion — keep the hook daemon's mirror in step.
4142
- const clearedQ = clearAgentdbQuarantine(storeRoot, [reinforce]);
4143
- if (clearedQ.cleared > 0) write(`promoted out of quarantine (mirror updated)`);
4131
+ if (reinforcedDzId === undefined) {
4132
+ write('mirror quarantine NOT cleared: matched pattern has no dzId');
4133
+ } else {
4134
+ const clearedQ = clearAgentdbQuarantine(storeRoot, [reinforcedDzId]);
4135
+ if (clearedQ.cleared > 0) write(` ↳ promoted out of quarantine (mirror updated)`);
4136
+ }
4144
4137
  write(storeLine('written'));
4145
4138
  refreshLearningStoreMark(storeRoot, writeErr, 'dz teach --reinforce');
4146
4139
  return 0;
@@ -5492,6 +5485,7 @@ Usage:
5492
5485
  dz brain add --from-kus <file.json> --slug <s> [--kind repo|book|paper] [--license <spdx>] [--override] [--json]
5493
5486
  dz brain update <slug> [--project <dir>] [--json]
5494
5487
  dz brain reindex [--json]
5488
+ dz brain snapshots [--keep <N>] [--prune] [--json] [--project <dir>]
5495
5489
  dz brain primer <slug> [--json]
5496
5490
  dz brain export --source <slug> --out <file>
5497
5491
  dz brain ground [<prompt>] [--k <N>] [--source <slug>] [--text] [--budget <N>] [--full]
@@ -5930,9 +5924,98 @@ async function cmdBrain(
5930
5924
  }
5931
5925
  write(`dz brain reindex: re-embedded ${result.reembedded} KU vector(s) with ${result.model} (manifest v${result.version})`);
5932
5926
  if (result.backupPath !== undefined) write(` snapshot: ${result.backupPath}`);
5927
+ if (result.snapshots !== undefined) {
5928
+ const mb = (result.snapshots.removedBytes / (1024 * 1024)).toFixed(1);
5929
+ write(` ↳ snapshots: kept ${result.snapshots.kept.length}, removed ${result.snapshots.removed.length} (${mb} MB)`);
5930
+ if (result.snapshots.errors !== undefined && result.snapshots.errors.length > 0) {
5931
+ write(` ⚠ snapshot rotation error(s): ${result.snapshots.errors.join('; ')}`);
5932
+ }
5933
+ if (result.snapshots.scanErrors !== undefined && result.snapshots.scanErrors.length > 0) {
5934
+ write(` ⚠ snapshot scan error(s), nothing removed this call: ${result.snapshots.scanErrors.join('; ')}`);
5935
+ }
5936
+ if (result.snapshots.partialFamilies !== undefined && result.snapshots.partialFamilies.length > 0) {
5937
+ write(` ⚠ .bak preserved after a sibling failure in famil(y/ies): ${result.snapshots.partialFamilies.join(', ')}`);
5938
+ }
5939
+ }
5933
5940
  return 0;
5934
5941
  }
5935
5942
 
5943
+ // ── dz brain snapshots [--keep N] [--prune] [--json] ────────────────────────────────────────
5944
+ // Manual rotation of the brain's OWN pre-reindex snapshots — independent of `dz brain reindex`
5945
+ // (FR-7). The owner's hub forbids running a live reindex there today, and 13 snapshots / 50 MB
5946
+ // sit unrotated regardless; this command reaches the same family-aware rotation without one.
5947
+ // Without --prune it only LISTS families (dry, never deletes); --prune applies FR-1..FR-5.
5948
+ if (sub === 'snapshots') {
5949
+ // Lead edit after acceptance (2026-09-13): the owner's hub keeps its 13 families next to the
5950
+ // PROJECT store (.dz/agentdb.db, written by the vector-tier reindex), not the home brain —
5951
+ // `--project <dir>` addresses that store; without it the home brain is the target as before.
5952
+ const projectArg = options.get('project');
5953
+ const dbFile = projectArg !== undefined ? resolveAgentdbPath(resolve(cwd, projectArg)) : brainAgentdbPath(brainHome());
5954
+ const keepRaw = options.get('keep');
5955
+ let keep = 3;
5956
+ if (keepRaw !== undefined) {
5957
+ // AM-1 (fix-round, Codex review Grade D): `Number('')` is `0` and `Number(' 2')` is `2` —
5958
+ // both used to validate as an ordinary non-negative integer, silently accepting empty/
5959
+ // whitespace input. Only the literal digit-string shape is accepted; no trimming.
5960
+ if (!/^(0|[1-9]\d*)$/.test(keepRaw)) {
5961
+ write(`dz brain snapshots: --keep must be a non-negative integer (got '${keepRaw}')`);
5962
+ return 2;
5963
+ }
5964
+ keep = Number(keepRaw);
5965
+ // Lead edit after re-review (Codex C): a digit string can still overflow a safe integer.
5966
+ if (!Number.isSafeInteger(keep)) {
5967
+ write(`dz brain snapshots: --keep is out of range (got '${keepRaw}')`);
5968
+ return 2;
5969
+ }
5970
+ }
5971
+ if (!flags.has('prune')) {
5972
+ // Lead edit after re-review: the list is only trustworthy when the scan was complete —
5973
+ // an unreadable directory is reported with ⚠ and exit 1, never as "no families".
5974
+ const { families, scanErrors } = scanSnapshotDir(dbFile);
5975
+ if (asJson) {
5976
+ write(JSON.stringify({ keep, families: families.map((f) => ({ ms: f.ms, files: f.files.map((file) => file.name), bytes: f.bytes })), scanErrors }));
5977
+ return scanErrors.length > 0 ? 1 : 0;
5978
+ }
5979
+ if (scanErrors.length > 0) write(` ⚠ scan error(s) — the list below may be incomplete: ${scanErrors.join('; ')}`);
5980
+ if (families.length === 0) {
5981
+ write(`dz brain snapshots: no pre-reindex snapshot families next to ${dbFile}`);
5982
+ return scanErrors.length > 0 ? 1 : 0;
5983
+ }
5984
+ write(`dz brain snapshots — ${families.length} family(-ies) @ ${dbFile}`);
5985
+ for (const f of families) {
5986
+ const mb = (f.bytes / (1024 * 1024)).toFixed(1);
5987
+ write(` ${new Date(f.ms).toISOString()} ms=${f.ms} ${f.files.length} file(s) ${mb} MB`);
5988
+ }
5989
+ write(' (dry run — pass --prune to remove families older than --keep)');
5990
+ return scanErrors.length > 0 ? 1 : 0;
5991
+ }
5992
+ const report = rotatePreReindexSnapshots(dbFile, { keep });
5993
+ const scanFailed = report.scanErrors !== undefined && report.scanErrors.length > 0;
5994
+ // agentdb-snapshot-lock FR-4: a busy snapshot lock is reported exactly like a scan failure —
5995
+ // nothing removed, ⚠, exit 1 — never a silent "kept N, removed 0" that reads like an empty rotation.
5996
+ const lockBusy = report.errors !== undefined && report.errors.some((e) => e.startsWith('lock busy'));
5997
+ if (asJson) { write(JSON.stringify(report)); return scanFailed || lockBusy ? 1 : 0; }
5998
+ const mb = (report.removedBytes / (1024 * 1024)).toFixed(1);
5999
+ write(`dz brain snapshots: kept ${report.kept.length}, removed ${report.removed.length} (${mb} MB)`);
6000
+ if (report.removed.length > 0) write(` removed: ${report.removed.join(', ')}`);
6001
+ if (report.errors !== undefined && report.errors.length > 0) {
6002
+ write(` ⚠ ${report.errors.length} error(s): ${report.errors.join('; ')}`);
6003
+ }
6004
+ // AM-4: an incomplete scan means NOTHING was removed this call — say so, never silently.
6005
+ if (report.scanErrors !== undefined && report.scanErrors.length > 0) {
6006
+ write(` ⚠ scan error(s), nothing removed this call: ${report.scanErrors.join('; ')}`);
6007
+ }
6008
+ // AM-2: a family whose .bak survived only because a sibling failed to unlink.
6009
+ if (report.partialFamilies !== undefined && report.partialFamilies.length > 0) {
6010
+ write(` ⚠ .bak preserved after a sibling failure in famil(y/ies): ${report.partialFamilies.join(', ')}`);
6011
+ }
6012
+ // FR-3: a live reindex marker rescued a family, or an expired one was cleaned up — honest, never an error.
6013
+ if (report.notes !== undefined && report.notes.length > 0) {
6014
+ write(` note: ${report.notes.join('; ')}`);
6015
+ }
6016
+ return scanFailed || lockBusy ? 1 : 0;
6017
+ }
6018
+
5936
6019
  // ── dz brain ground [<prompt>] ───────────────────────────────────────────────────────────────
5937
6020
  // The UserPromptSubmit hook entrypoint. ALWAYS exits 0 — grounding is advisory and must never
5938
6021
  // fail a prompt. Emits nothing (silent) unless the brain has relevant citations for the prompt.
@@ -6147,17 +6230,41 @@ async function cmdSetup(options: Map<string, string>, flags: Set<string>, cwd: s
6147
6230
 
6148
6231
  // Step 3: Run setup (hooks + memory + config)
6149
6232
  write(`║ 3. Setting up learning environment... ║`);
6150
- const memoryOpt = options.get('memory');
6233
+ const memoryOptRaw = options.get('memory');
6234
+ // FR-1/T3 (feature `setup-backend-from-config`): pass `--memory` through AS-IS — `agentdb`,
6235
+ // `jsonl`, or `undefined` — never collapsed to `undefined` on anything but agentdb. The prior
6236
+ // `memoryOpt === 'agentdb' ? 'agentdb' : undefined` made an explicit `--memory jsonl` INDISTINCT
6237
+ // from "no flag at all", so `runSetup`'s config-aware default (FR-2's downgrade path) could never
6238
+ // fire from the CLI. An unrecognised value (neither `agentdb` nor `jsonl`) still reads as
6239
+ // "no flag" — the same permissive fallback as before.
6240
+ const memoryOpt: 'agentdb' | 'jsonl' | undefined =
6241
+ memoryOptRaw === 'agentdb' ? 'agentdb' : memoryOptRaw === 'jsonl' ? 'jsonl' : undefined;
6242
+ // ADR-001 Decision 2 (feature setup-installs-apply-leg): bake THIS CLI's own installed
6243
+ // @dzhechkov/harness-core into the generated apply-leg hooks — the installation actually running
6244
+ // `dz setup` is the one a consumer's project can always reach, unlike a hard-coded npm prefix
6245
+ // (FR-3). Best-effort: an unresolvable core (should not happen — the CLI depends on it) falls
6246
+ // back to core's own self-resolution inside `runSetup`, never a crash.
6247
+ let coreDistDir: string | undefined;
6248
+ try {
6249
+ const corePkgJson = createRequire(import.meta.url).resolve('@dzhechkov/harness-core/package.json');
6250
+ coreDistDir = join(dirname(corePkgJson), 'dist');
6251
+ } catch {
6252
+ coreDistDir = undefined;
6253
+ }
6151
6254
  const setupResult = runSetup({
6152
6255
  projectRoot,
6153
6256
  target,
6154
6257
  preset,
6155
- memory: memoryOpt === 'agentdb' ? 'agentdb' : undefined,
6258
+ memory: memoryOpt,
6156
6259
  noHooks: flags.has('no-hooks'),
6157
6260
  noMemory: flags.has('no-memory'),
6158
6261
  force: flags.has('force'),
6159
6262
  installDriver: flags.has('install-driver'),
6263
+ coreDistDir,
6160
6264
  });
6265
+ // FR-3: name the source of the backend actually used — never left to be inferred from the flag
6266
+ // alone, since the backend may now come from `.dz/config.json` or the jsonl default.
6267
+ write(`dz setup: memory backend: ${setupResult.memoryBackend} (${memoryBackendSourceLabel(setupResult.memoryBackendSource)})`);
6161
6268
 
6162
6269
  for (const step of setupResult.steps) {
6163
6270
  const icon = step.status === 'done' ? '✓' : step.status === 'skipped' ? '○' : '✗';
@@ -6209,7 +6316,11 @@ async function cmdSetup(options: Map<string, string>, flags: Set<string>, cwd: s
6209
6316
  // not from package presence — a skipped hook/MCP step must not let the summary claim a store
6210
6317
  // nothing writes to (audit code#3).
6211
6318
  const wiring = setupResult.steps.find((s) => s.name === 'agentdb wiring');
6212
- const backendLabel = memoryOpt === 'agentdb'
6319
+ // Keyed off the RESOLVED backend (setupResult.memoryBackend), not the raw flag: FR-1 means the
6320
+ // flag can be absent while the actual backend is still agentdb (config-sourced) — the old
6321
+ // `memoryOpt === 'agentdb'` check would have mislabeled that run as jsonl right after fixing the
6322
+ // underlying steps to keep it agentdb.
6323
+ const backendLabel = setupResult.memoryBackend === 'agentdb'
6213
6324
  ? (wiring?.status === 'done' ? 'agentdb (.dz/agentdb.db + .dz/agentdb-mcp.db, separate stores)' : `agentdb INCOMPLETE — see setup steps`)
6214
6325
  : 'sessions.jsonl + patterns.jsonl';
6215
6326
  write(`║ Learning: ${backendLabel.padEnd(41)}║`);
@@ -6909,12 +7020,33 @@ function mirrorFailureMessage(error: unknown): string {
6909
7020
  return String(error);
6910
7021
  }
6911
7022
 
7023
+ /**
7024
+ * Scratch root for the packed-install smoke's pack/install dirs (feature
7025
+ * publish-sibling-drift-gate). MEASURED 2026-09-13: npm resolves a LOCAL tarball path (`npm
7026
+ * install <path-to.tgz>`) relative to `os.tmpdir()` — not to cwd — whenever that path sits
7027
+ * INSIDE `os.tmpdir()`, and does the same for the install dir; put pack and install dirs both
7028
+ * under `tmpdir()` and the recorded `file:` spec loses its `tmpdir()` prefix entirely (reproducer:
7029
+ * a fresh `npm pack <src> --pack-destination "$T/pack"` + `cd "$T/install" && npm install
7030
+ * "$T/pack/x.tgz"` with `$T` under `/tmp` silently installs NOTHING — "changed 1 package", empty
7031
+ * node_modules, `reify moves {}` in `--loglevel silly`; the identical commands under `/var/tmp`
7032
+ * install correctly). A directory outside `os.tmpdir()` sidesteps the quirk entirely.
7033
+ */
7034
+ function packedInstallScratchRoot(): string {
7035
+ return existsSync('/var/tmp') ? '/var/tmp' : tmpdir();
7036
+ }
7037
+
6912
7038
  function cmdPublish(
6913
7039
  options: Map<string, string>,
6914
7040
  flags: Set<string>,
6915
7041
  cwd: string,
6916
7042
  writeOutput: Write,
6917
7043
  mirrorRunner?: PublishMirrorRunner,
7044
+ siblingDriftFetcher?: FetchPublished,
7045
+ packedInstallRunner?: ReleaseExecRunner,
7046
+ publishExecRunner?: (
7047
+ command: string,
7048
+ options: { cwd?: string | URL | undefined; stdio?: unknown; encoding?: unknown; timeout?: number | undefined; env?: NodeJS.ProcessEnv | undefined },
7049
+ ) => string,
6918
7050
  ): number {
6919
7051
  const json = flags.has('json');
6920
7052
  // Under --json stdout carries exactly one JSON document, so every human line — guard notes, refusals,
@@ -6923,9 +7055,9 @@ function cmdPublish(
6923
7055
  const write: Write = json ? (line) => { process.stderr.write(`${line}\n`); } : writeOutput;
6924
7056
  // Reject unknown flags/options so a typo (e.g. `--dry-rum`) can NEVER be
6925
7057
  // silently swallowed and flip the command into live-publish mode.
6926
- const allowedFlags = new Set(['dry-run', 'no-dry-run', 'yes', 'confirm', 'bump-only', 'help', 'require-signing', 'provenance', 'no-provenance', 'json', 'no-mirror']);
7058
+ const allowedFlags = new Set(['dry-run', 'no-dry-run', 'yes', 'confirm', 'bump-only', 'help', 'require-signing', 'provenance', 'no-provenance', 'json', 'no-mirror', 'allow-sibling-drift', 'include-drifted']);
6927
7059
  const allowedOptions = new Set(['filter', 'claim-check', 'no-guard', 'sign-key', 'mirror-cmd']);
6928
- 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)';
7060
+ 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), --allow-sibling-drift (override the sibling-drift gate; logged), --include-drifted (auto-extend the batch with a drifted sibling)';
6929
7061
  for (const flag of flags) {
6930
7062
  if (!allowedFlags.has(flag)) {
6931
7063
  write(`dz publish: unknown option --${flag}`);
@@ -7007,12 +7139,269 @@ function cmdPublish(
7007
7139
  const claimCheckOpt = (claimCheckRaw as 'off' | 'warn' | 'error' | undefined) ?? 'warn';
7008
7140
 
7009
7141
  const bumpOnly = flags.has('bump-only');
7010
-
7011
7142
  // SAFETY: dry-run is the DEFAULT. A real publish requires an EXPLICIT opt-in
7012
7143
  // via --yes, --confirm, or --no-dry-run. Without one, we never bump or publish.
7144
+ // Computed HERE (moved up from below the gates, AM-5) so both gates can see it: a dry run keeps
7145
+ // previewing packed-install with the CURRENT pre-bump tarball (nothing to compare a LIVE publish
7146
+ // against yet), while a live run defers the real packed-install-smoke into `publishPackages`'s
7147
+ // `packedTransport` — the one that tests the ACTUAL bytes about to ship (AM-1).
7013
7148
  const wantsLive = flags.has('yes') || flags.has('confirm') || flags.has('no-dry-run');
7014
7149
  const dryRun = !wantsLive;
7015
7150
 
7151
+ // ── FR-1..FR-4 — sibling-drift gate, then packed-install smoke (feature
7152
+ // publish-sibling-drift-gate, ADR-001). The sibling-drift gate runs before the signature gate
7153
+ // and the live-publish banner (so a --include-drifted-expanded batch is checked and shown too).
7154
+ // AM-5: on a DRY RUN both gates always print their verdict, even once sibling-drift already
7155
+ // blocks — the whole point of a preview is full information before anything ships. On a LIVE
7156
+ // run, sibling-drift still refuses immediately (packing/installing a doomed batch wastes real
7157
+ // time); its own packed-install smoke is deferred into `publishPackages`'s `packedTransport`
7158
+ // (AM-1) — the one gate that tests the tarball bytes actually handed to `npm publish`.
7159
+ const allowSiblingDrift = flags.has('allow-sibling-drift');
7160
+ const includeDrifted = flags.has('include-drifted');
7161
+ const allPackages = discoverPackages(cwd);
7162
+ const workspaceVersions = new Map(allPackages.map((p) => [p.name, p.version]));
7163
+ const workspaceDirs = new Map(allPackages.map((p) => [p.name, p.dir]));
7164
+ const matchesFilter = (pk: { name: string; dir: string }): boolean =>
7165
+ filter === undefined || filter.length === 0 || filter.some((f) => pk.name.includes(f) || pk.dir.includes(f));
7166
+ let targets = allPackages.filter(matchesFilter);
7167
+ let batchNames = new Set(targets.map((p) => p.name));
7168
+
7169
+ // Production default: `npm pack <name>@<version>` into a temp dir, extracted. Tests inject a
7170
+ // local directory (ADR-001, "fetchPublished … в тестах — локальный каталог").
7171
+ const fetchPublished: FetchPublished =
7172
+ siblingDriftFetcher ??
7173
+ ((name, version) => {
7174
+ try {
7175
+ const tmp = mkdtempSync(join(tmpdir(), 'dz-sibling-drift-'));
7176
+ execSync(`npm pack ${name}@${version} --pack-destination ${JSON.stringify(tmp)}`, {
7177
+ stdio: 'pipe',
7178
+ encoding: 'utf-8',
7179
+ timeout: 60_000,
7180
+ });
7181
+ const tarball = readdirSync(tmp).find((f) => f.endsWith('.tgz'));
7182
+ if (tarball === undefined) return null;
7183
+ execSync(`tar -xzf ${JSON.stringify(join(tmp, tarball))} -C ${JSON.stringify(tmp)}`, { stdio: 'pipe', timeout: 60_000 });
7184
+ return { dir: join(tmp, 'package') };
7185
+ } catch {
7186
+ return null;
7187
+ }
7188
+ });
7189
+
7190
+ // AM-6: an override (--allow-sibling-drift) is only real once its audit row is DURABLE. A write
7191
+ // failure must refuse the publish rather than print "(logged)" about a log entry that never
7192
+ // landed — the same "absence of a receipt is not success" lesson the registry-probe gate already
7193
+ // enforces for a publish's own confirmation.
7194
+ const auditedOverride = (detail: string, humanMessage: string, pkgNameForBlock: string): boolean => {
7195
+ const wrote = appendPublishGateAudit(cwd, 'sibling-drift', 'warn', detail, '--allow-sibling-drift');
7196
+ if (wrote) {
7197
+ write(`dz publish: ⚠ ${humanMessage} — allowed via --allow-sibling-drift (logged)`);
7198
+ return false;
7199
+ }
7200
+ write(`dz publish: BLOCKED ${pkgNameForBlock} — ${humanMessage}, and the override could not be recorded (audit write failed); refusing rather than proceeding unlogged`);
7201
+ return true;
7202
+ };
7203
+
7204
+ let driftBlocked = 0;
7205
+ const extraBatch = new Set<string>();
7206
+ // AM-2: --include-drifted must reach a FIXED POINT over transitive drifted siblings — a sibling
7207
+ // folded into the batch can itself depend on a drifted sibling outside it, and the round-1 review
7208
+ // (finding 2) showed the single pass never re-checked an EXPANDED batch's own new edges. Capped at
7209
+ // `allPackages.length + 1` rounds (the plan's own "цикл с потолком = число пакетов").
7210
+ const maxRounds = allPackages.length + 1;
7211
+ for (let round = 0; round < maxRounds; round++) {
7212
+ let addedThisRound = false;
7213
+ for (const pk of targets) {
7214
+ let manifestObj: {
7215
+ dependencies?: Record<string, string>;
7216
+ peerDependencies?: Record<string, string>;
7217
+ optionalDependencies?: Record<string, string>;
7218
+ } | undefined;
7219
+ try {
7220
+ manifestObj = JSON.parse(readFileSync(join(pk.dir, 'package.json'), 'utf-8'));
7221
+ } catch (err) {
7222
+ // AM-3: an unreadable/invalid package.json for a BATCH package is an input this HARD gate
7223
+ // cannot build — it must BLOCK, never silently degrade to "no dependencies" (which used to
7224
+ // read as a clean n/a).
7225
+ const reason = `package.json unreadable/invalid (${(err as Error).message.split('\n')[0]})`;
7226
+ if (allowSiblingDrift) {
7227
+ if (auditedOverride(`${pk.name}: ${reason}`, `sibling drift check unavailable for ${pk.name} (${reason})`, pk.name)) driftBlocked++;
7228
+ } else {
7229
+ write(`dz publish: BLOCKED ${pk.name} — sibling drift check unavailable (${reason}); add --allow-sibling-drift to override (logged) or fix the manifest`);
7230
+ driftBlocked++;
7231
+ }
7232
+ continue;
7233
+ }
7234
+ const deps = manifestObj?.dependencies ?? {};
7235
+ const peerDeps = manifestObj?.peerDependencies ?? {};
7236
+ const optionalDeps = manifestObj?.optionalDependencies ?? {};
7237
+
7238
+ // AM-6: a package with no workspace: dependency at all is n/a for THIS gate — recorded as a
7239
+ // pass note, not silence (FR-6 compatibility: output stays unchanged for such a batch).
7240
+ const anyWorkspaceDep = [...Object.values(deps), ...Object.values(peerDeps), ...Object.values(optionalDeps)]
7241
+ .some((spec) => String(spec).startsWith('workspace:'));
7242
+ if (!anyWorkspaceDep) {
7243
+ appendPublishGateAudit(cwd, 'sibling-drift', 'pass', `${pk.name}: n/a — no workspace: dependency declared`);
7244
+ continue;
7245
+ }
7246
+
7247
+ const drifts = detectSiblingDrift({
7248
+ dependencies: deps,
7249
+ peerDependencies: peerDeps,
7250
+ optionalDependencies: optionalDeps,
7251
+ workspaceVersions,
7252
+ workspaceDirs,
7253
+ batch: batchNames,
7254
+ fetchPublished,
7255
+ });
7256
+
7257
+ for (const r of drifts) {
7258
+ if (r.status === 'same') {
7259
+ appendPublishGateAudit(cwd, 'sibling-drift', 'pass', `${r.name}@${r.version} = workspace (dependent: ${pk.name})`);
7260
+ write(`dz publish: ✓ sibling drift: none (${r.name}@${r.version} = workspace)`);
7261
+ } else if (r.status === 'unavailable') {
7262
+ if (allowSiblingDrift) {
7263
+ if (auditedOverride(`${r.name}@${r.version}: ${r.reason}`, `sibling drift check unavailable for ${r.name}@${r.version} (${r.reason})`, pk.name)) driftBlocked++;
7264
+ } else {
7265
+ write(`dz publish: BLOCKED ${pk.name} — sibling drift check unavailable (${r.reason}); add --allow-sibling-drift to override (logged) or check network/registry access`);
7266
+ driftBlocked++;
7267
+ }
7268
+ } else if (includeDrifted) {
7269
+ if (!batchNames.has(r.name) && !extraBatch.has(r.name)) {
7270
+ extraBatch.add(r.name);
7271
+ addedThisRound = true;
7272
+ write(`dz publish: → sibling drift: ${r.name}@${r.version} differs from the workspace (${r.changedFiles.length} file(s)) — adding to the batch via --include-drifted${r.missingExports.length > 0 ? ` (missing exports: ${r.missingExports.join(', ')})` : ''}`);
7273
+ }
7274
+ } else if (allowSiblingDrift) {
7275
+ if (auditedOverride(`${r.name}@${r.version}: ${r.changedFiles.length} file(s) differ from the workspace`, `sibling drift: ${r.name}@${r.version} differs from the workspace (${r.changedFiles.length} file(s))`, pk.name)) driftBlocked++;
7276
+ } else {
7277
+ appendPublishGateAudit(cwd, 'sibling-drift', 'block', `${pk.name} depends on ${r.name}@${r.version}; ${r.changedFiles.length} file(s) differ from the workspace`);
7278
+ const suggestFilter = filterStr !== undefined ? `${filterStr},${r.name}` : `${pk.name},${r.name}`;
7279
+ write(`dz publish: BLOCKED ${pk.name} — sibling drift: @dzhechkov/${r.name.replace(/^@dzhechkov\//, '')}@${r.version} on the registry differs from the workspace (${r.changedFiles.length} file(s)); add ${r.name} to the batch (--filter ${suggestFilter}) or publish it first`);
7280
+ driftBlocked++;
7281
+ }
7282
+ }
7283
+ }
7284
+
7285
+ if (driftBlocked > 0) break; // nothing to expand into a run that already refuses
7286
+ if (!includeDrifted || !addedThisRound) break; // no auto-expand requested, or fixed point reached
7287
+
7288
+ // FR-4: --include-drifted folds the drifted sibling(s) into the batch — they bump patch like
7289
+ // any other package in `publishPackages`' own (unchanged) bump logic. Re-loop: the newly
7290
+ // folded-in sibling(s) may themselves depend on a drifted sibling outside the (now bigger) batch.
7291
+ filter = filter === undefined ? [...batchNames, ...extraBatch] : [...filter, ...extraBatch];
7292
+ targets = allPackages.filter(matchesFilter);
7293
+ batchNames = new Set(targets.map((p) => p.name));
7294
+ }
7295
+
7296
+ const siblingDriftFailed = driftBlocked > 0;
7297
+ if (siblingDriftFailed && !dryRun) {
7298
+ write(`dz publish: refusing to publish (${driftBlocked} sibling-drift violation(s))`);
7299
+ return 1;
7300
+ }
7301
+
7302
+ // FR-3 — packed-install smoke: pack the WHOLE (possibly --include-drifted-expanded) batch,
7303
+ // install every tarball together in a CLEAN dir (out-of-batch siblings resolve from the
7304
+ // registry, exactly like a fresh user's install), then boot every bin with --version.
7305
+ // "n/a" (FR-6) when nothing in the batch has a bin. AM-8: a bin is collected here whether or not
7306
+ // its target file exists YET — a manifest that declares one but ships nothing must BLOCK after a
7307
+ // real install, never silently vanish from the plan (which used to read as n/a, or even skip the
7308
+ // whole gate when it was the batch's only bin).
7309
+ const bins: { pkg: string; binName: string; relPath: string }[] = [];
7310
+ for (const pk of targets) {
7311
+ let manifest: { bin?: string | Record<string, string> } = {};
7312
+ try { manifest = JSON.parse(readFileSync(join(pk.dir, 'package.json'), 'utf-8')); } catch { /* no bin info available */ }
7313
+ if (typeof manifest.bin === 'string') {
7314
+ bins.push({ pkg: pk.name, binName: pk.name.split('/').pop() ?? pk.name, relPath: manifest.bin.replace(/^\.\//, '') });
7315
+ } else if (manifest.bin !== undefined && manifest.bin !== null && typeof manifest.bin === 'object') {
7316
+ for (const [name, relRaw] of Object.entries(manifest.bin)) {
7317
+ bins.push({ pkg: pk.name, binName: name, relPath: String(relRaw).replace(/^\.\//, '') });
7318
+ }
7319
+ }
7320
+ }
7321
+
7322
+ // AM-1/AM-5: the packed-install-smoke PREVIEW below runs on a DRY RUN only, against whatever is
7323
+ // CURRENTLY on disk (pre-bump) — it cannot be the "same bytes that ship" gate AM-1 requires,
7324
+ // because a dry run never bumps/builds/packs anything real to compare against. On a LIVE run the
7325
+ // real gate is `packedTransport` (wired at the `publishPackages` call below), which packs ONCE
7326
+ // post-bump and smokes exactly those tarballs — this preview is skipped entirely then, so its
7327
+ // digest is never confused with the one that actually ships.
7328
+ let packedInstallSmokePreviewFailed = false;
7329
+ if (dryRun) {
7330
+ if (bins.length === 0) {
7331
+ appendPublishGateAudit(cwd, 'packed-install-smoke', 'pass', 'n/a — nothing in the batch declares a bin');
7332
+ write('dz publish: ○ packed install smoke: n/a (nothing in the batch declares a bin)');
7333
+ } else {
7334
+ const scratchRoot = packedInstallScratchRoot();
7335
+ const packDir = mkdtempSync(join(scratchRoot, 'dz-publish-pack-'));
7336
+ const installDir = mkdtempSync(join(scratchRoot, 'dz-publish-install-'));
7337
+ const runSmoke: ReleaseExecRunner =
7338
+ packedInstallRunner ??
7339
+ ((cmd, o) => {
7340
+ try {
7341
+ const stdout = execSync(cmd, { cwd: o.cwd, stdio: 'pipe', encoding: 'utf-8', timeout: o.timeoutMs });
7342
+ return { exitCode: 0, stdout: stdout == null ? '' : String(stdout), stderr: '' };
7343
+ } catch (err) {
7344
+ const e = err as Error & { status?: number | null; signal?: string | null; killed?: boolean; stdout?: unknown; stderr?: unknown };
7345
+ const timedOut = (e.status === null || e.status === undefined) && (e.signal != null || e.killed === true);
7346
+ return {
7347
+ exitCode: typeof e.status === 'number' ? e.status : 1,
7348
+ stdout: e.stdout == null ? '' : String(e.stdout),
7349
+ stderr: e.stderr == null || String(e.stderr).trim() === '' ? formatPublishError(e) : String(e.stderr),
7350
+ timedOut,
7351
+ };
7352
+ }
7353
+ });
7354
+ const smokePlan = planPackedInstallSmoke({
7355
+ packages: targets.map((p) => ({ name: p.name, dir: p.dir, version: p.version })),
7356
+ bins,
7357
+ packDir,
7358
+ installDir,
7359
+ });
7360
+ const smokeExecutions: PackedInstallExecution[] = [];
7361
+ // Lead edit after the live dry-run (13.09 12:05): the preview packed the WORKING directory with
7362
+ // `workspace:^` specs still inside, so `npm install <tgz>` died with EUNSUPPORTEDPROTOCOL — the
7363
+ // preview must stage package.json exactly as the live packedTransport does (sibling pins via
7364
+ // rewriteWorkspaceSpecs, prepublishOnly dropped) and restore the originals afterwards.
7365
+ const stagedOriginals: Array<{ path: string; text: string }> = [];
7366
+ try {
7367
+ for (const p of targets) {
7368
+ const pkgJsonPath = join(p.dir, 'package.json');
7369
+ const original = readFileSync(pkgJsonPath, 'utf-8');
7370
+ const rewritten = JSON.parse(rewriteWorkspaceSpecs(original, workspaceVersions)) as Record<string, unknown>;
7371
+ const scripts = rewritten['scripts'];
7372
+ if (scripts !== null && typeof scripts === 'object' && !Array.isArray(scripts)) delete (scripts as Record<string, unknown>)['prepublishOnly'];
7373
+ stagedOriginals.push({ path: pkgJsonPath, text: original });
7374
+ writeFileSync(pkgJsonPath, JSON.stringify(rewritten, null, 2) + '\n');
7375
+ }
7376
+ for (const step of smokePlan.steps) {
7377
+ const r = runSmoke(step.cmd, { cwd: step.cwd, timeoutMs: step.timeoutMs });
7378
+ smokeExecutions.push({ stepId: step.id, exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr, ...(r.timedOut !== undefined ? { timedOut: r.timedOut } : {}) });
7379
+ }
7380
+ } finally {
7381
+ for (const o of stagedOriginals) { try { writeFileSync(o.path, o.text); } catch (err) { write(`dz publish: ⚠ could not restore ${o.path} after the preview smoke: ${formatPublishError(err)}`); } }
7382
+ }
7383
+ const smokeVerdict = judgePackedInstallSmoke(smokePlan, smokeExecutions);
7384
+ try { rmSync(packDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
7385
+ try { rmSync(installDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
7386
+
7387
+ if (smokeVerdict.ok) {
7388
+ appendPublishGateAudit(cwd, 'packed-install-smoke', 'pass', 'preview: pack/install/--version all clean');
7389
+ write('dz publish: ✓ packed install smoke (preview)');
7390
+ } else {
7391
+ const detail = smokeVerdict.failureDetail ?? smokeVerdict.bins.find((b) => !b.ok)?.detail ?? '(no detail)';
7392
+ appendPublishGateAudit(cwd, 'packed-install-smoke', 'block', detail);
7393
+ write(`dz publish: BLOCKED — packed install smoke failed (preview): ${detail}`);
7394
+ for (const b of smokeVerdict.bins.filter((b) => !b.ok)) write(` ✗ ${b.pkg} (${b.binName}): ${b.detail ?? '(no detail)'}`);
7395
+ packedInstallSmokePreviewFailed = true;
7396
+ }
7397
+ }
7398
+ }
7399
+
7400
+ if (siblingDriftFailed || packedInstallSmokePreviewFailed) {
7401
+ write(`dz publish: refusing to publish (${driftBlocked} sibling-drift violation(s)${packedInstallSmokePreviewFailed ? ', packed install smoke failed' : ''})`);
7402
+ return 1;
7403
+ }
7404
+
7016
7405
  if (!dryRun) {
7017
7406
  // Loud confirmation banner listing exactly what is about to be published.
7018
7407
  const targets = discoverPackages(cwd).filter((p) =>
@@ -7105,12 +7494,77 @@ function cmdPublish(
7105
7494
  // longer exist. Default to the same path `dz sign --init` writes, so the ordinary operator needs no
7106
7495
  // new flag; `--sign-key` overrides it.
7107
7496
  const signKey = (options.get('sign-key') ?? join(homedir(), '.dz', 'keys', 'dz.key')).trim();
7497
+ // AM-1: the packedTransport smoke closure and the actual `npm publish <tgz>` inside
7498
+ // `publishPackages` both read from THIS SAME directory — created once, cleaned up once, after
7499
+ // publishPackages returns (it needs the tarballs on disk through its own publish step).
7500
+ const packedTransportPackDestDir = mkdtempSync(join(packedInstallScratchRoot(), 'dz-publish-packed-'));
7108
7501
  const publishReport = publishPackages(cwd, {
7109
7502
  provenance,
7110
7503
  dryRun,
7111
7504
  filter,
7112
7505
  bumpOnly,
7113
7506
  claimGate: claimCheckOpt,
7507
+ exec: publishExecRunner,
7508
+ packedTransport: {
7509
+ packDestDir: packedTransportPackDestDir,
7510
+ // AM-1: judged ONCE, over every package's packed artifact — nothing in the batch publishes
7511
+ // until this returns ok:true. `bins` (AM-8-fixed: declared bins are collected whether or not
7512
+ // their target file exists yet) was already computed above from the same `targets` this
7513
+ // batch resolves to.
7514
+ smoke: (artifacts): { ok: boolean; reason?: string } => {
7515
+ for (const a of artifacts) write(`dz publish: tarball ${a.name}@${a.newVersion} sha256:${a.sha256}`);
7516
+ if (bins.length === 0) {
7517
+ appendPublishGateAudit(cwd, 'packed-install-smoke', 'pass', 'n/a — nothing in the batch declares a bin');
7518
+ write('dz publish: ○ packed install smoke: n/a (nothing in the batch declares a bin)');
7519
+ return { ok: true };
7520
+ }
7521
+ const scratchRoot = packedInstallScratchRoot();
7522
+ const installDir = mkdtempSync(join(scratchRoot, 'dz-publish-install-'));
7523
+ const runSmoke: ReleaseExecRunner =
7524
+ packedInstallRunner ??
7525
+ ((cmd, o) => {
7526
+ try {
7527
+ const stdout = execSync(cmd, { cwd: o.cwd, stdio: 'pipe', encoding: 'utf-8', timeout: o.timeoutMs });
7528
+ return { exitCode: 0, stdout: stdout == null ? '' : String(stdout), stderr: '' };
7529
+ } catch (err) {
7530
+ const e = err as Error & { status?: number | null; signal?: string | null; killed?: boolean; stdout?: unknown; stderr?: unknown };
7531
+ const timedOut = (e.status === null || e.status === undefined) && (e.signal != null || e.killed === true);
7532
+ return {
7533
+ exitCode: typeof e.status === 'number' ? e.status : 1,
7534
+ stdout: e.stdout == null ? '' : String(e.stdout),
7535
+ stderr: e.stderr == null || String(e.stderr).trim() === '' ? formatPublishError(e) : String(e.stderr),
7536
+ timedOut,
7537
+ };
7538
+ }
7539
+ });
7540
+ const smokePlan = planPackedInstallSmoke({
7541
+ // skipPack (AM-1): these tarballs are ALREADY packed (by publishPackages, above) — a
7542
+ // second, different pack here would smoke bytes other than the ones about to publish.
7543
+ packages: artifacts.map((a) => ({ name: a.name, dir: '(packed already — see skipPack)', version: a.newVersion })),
7544
+ bins,
7545
+ packDir: packedTransportPackDestDir,
7546
+ installDir,
7547
+ skipPack: true,
7548
+ });
7549
+ const smokeExecutions: PackedInstallExecution[] = [];
7550
+ for (const step of smokePlan.steps) {
7551
+ const r = runSmoke(step.cmd, { cwd: step.cwd, timeoutMs: step.timeoutMs });
7552
+ smokeExecutions.push({ stepId: step.id, exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr, ...(r.timedOut !== undefined ? { timedOut: r.timedOut } : {}) });
7553
+ }
7554
+ const verdict = judgePackedInstallSmoke(smokePlan, smokeExecutions);
7555
+ try { rmSync(installDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
7556
+ if (verdict.ok) {
7557
+ appendPublishGateAudit(cwd, 'packed-install-smoke', 'pass', 'pack/install/--version all clean (live, packedTransport)');
7558
+ write('dz publish: ✓ packed install smoke');
7559
+ return { ok: true };
7560
+ }
7561
+ const detail = verdict.failureDetail ?? verdict.bins.find((b) => !b.ok)?.detail ?? '(no detail)';
7562
+ appendPublishGateAudit(cwd, 'packed-install-smoke', 'block', detail);
7563
+ write(`dz publish: BLOCKED — packed install smoke failed: ${detail}`);
7564
+ for (const b of verdict.bins.filter((b) => !b.ok)) write(` ✗ ${b.pkg} (${b.binName}): ${b.detail ?? '(no detail)'}`);
7565
+ return { ok: false, reason: detail };
7566
+ },
7567
+ },
7114
7568
  signKey: signKey === '' ? undefined : resolve(cwd, signKey),
7115
7569
  verifyAfterSign: (packDir: string): { ok: boolean; trustRootPresent: boolean; pack?: string } => {
7116
7570
  // Verify the OUTCOME against the trust root a CONSUMER would use — an existing key may be the
@@ -7184,6 +7638,7 @@ function cmdPublish(
7184
7638
  }
7185
7639
  },
7186
7640
  });
7641
+ try { rmSync(packedTransportPackDestDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
7187
7642
 
7188
7643
  const configMirror = mirrorCommandFromConfig(cwd);
7189
7644
  const configuredCommand = (options.get('mirror-cmd') ?? configMirror.command ?? '').trim();
@@ -7274,6 +7729,9 @@ function cmdPublish(
7274
7729
  ? ` (confirmed by registry after ${pkg.registryProbes} probes)`
7275
7730
  : '';
7276
7731
  write(` ${icon} ${pkg.name.padEnd(35)} ${pkg.oldVersion} → ${pkg.newVersion} ${pkg.status}${receipt}${detail}`);
7732
+ // AM-1: the digest of the EXACT tarball bytes that were smoke-tested AND published — present
7733
+ // only for a packedTransport publish, so "the smoke tested what shipped" is checkable here too.
7734
+ if (pkg.status === 'published' && pkg.sha256 !== undefined) write(` sha256:${pkg.sha256}`);
7277
7735
  if (pkg.status === 'error' && pkg.error) {
7278
7736
  for (const line of pkg.error.split('\n')) write(` ${line}`);
7279
7737
  }
@@ -7332,7 +7790,7 @@ function cmdPublish(
7332
7790
  /* ADR-001): computed from the declarative model, never hand-written */
7333
7791
  /* ------------------------------------------------------------------ */
7334
7792
 
7335
- function cmdParity(options: Map<string, string>, flags: Set<string>, write: Write, writeErr: WriteErr): number {
7793
+ function cmdParity(options: Map<string, string>, flags: Set<string>, write: Write, writeErr: WriteErr, cwd: string): number {
7336
7794
  const json = flags.has('json');
7337
7795
  if (flags.has('help')) {
7338
7796
  write('dz parity [--target <name>] [--json] — the computed feature×target map (never hand-written)');
@@ -7360,7 +7818,31 @@ function cmdParity(options: Map<string, string>, flags: Set<string>, write: Writ
7360
7818
  }
7361
7819
  }
7362
7820
 
7363
- const matrix = buildParityMatrix();
7821
+ // ADR-001 Decision 3 (feature setup-installs-apply-leg): `learning-apply` on `claude-code` is
7822
+ // MEASURED, not declared — `hooks-prompt` is present for that ONE target only when
7823
+ // `applyLegStatus(root).installed`. `computeParity` itself is untouched (FR-5); only the
7824
+ // capability SET fed into it for this one cell differs from the static `TARGET_CAPABILITIES`.
7825
+ // `applyLegStatus` never throws (fix round 1, Q3 finding: an unreadable helper used to be able to
7826
+ // crash this command rather than degrade to a named remedy).
7827
+ const applyLegStatusVal = applyLegStatus(cwd);
7828
+ const applyLegInstalled = applyLegStatusVal.installed;
7829
+ const matrix = buildParityMatrix().map((row) => {
7830
+ if (row.feature.id !== 'learning-apply' || applyLegInstalled) return row;
7831
+ const claudeCodeCaps = TARGET_CAPABILITIES['claude-code'].filter((c) => c !== 'hooks-prompt');
7832
+ return { feature: row.feature, cells: { ...row.cells, 'claude-code': computeParity(row.feature, claudeCodeCaps) } };
7833
+ });
7834
+ // The "not installed" remedy — named ONLY for the one cell whose grant is a live measurement,
7835
+ // never a blanket note for every `manual` cell (most targets are manual by DESIGN, not absence).
7836
+ // `stale-version`/`unreadable` route through `applyLegReasonMessage` — the SAME text-producing
7837
+ // function `dz doctor` uses for those two reasons (fix round 1, HIGH finding 2 / Q3 finding 7), so
7838
+ // the two instruments cannot disagree about WHY a stale or broken install is not "full".
7839
+ const applyLegRemedy = (featureId: string, t: TargetName): string => {
7840
+ if (featureId !== 'learning-apply' || t !== 'claude-code' || applyLegInstalled) return '';
7841
+ if (applyLegStatusVal.reason === 'stale-version' || applyLegStatusVal.reason === 'unreadable') {
7842
+ return ` — ${applyLegReasonMessage(applyLegStatusVal)}`;
7843
+ }
7844
+ return ' — not installed — run dz setup --target claude-code --memory agentdb';
7845
+ };
7364
7846
  // EVIDENCE staleness, folded into the report (fix round 2, R2-3). Derived from the records
7365
7847
  // themselves — no `codex --version`, no subprocess, so `dz parity` stays a deterministic function
7366
7848
  // of the model. A cell whose deciding form rests on a transcript that is older than the newest
@@ -7402,8 +7884,12 @@ function cmdParity(options: Map<string, string>, flags: Set<string>, write: Writ
7402
7884
  if (json) {
7403
7885
  const shown = target !== undefined ? [target] : TARGET_NAMES;
7404
7886
  const rows = matrix.map((r) => {
7405
- const cells: Record<string, ParityReportCell> = {};
7406
- for (const t of shown) cells[t] = reportCell(r.feature, t, r.cells[t]);
7887
+ const cells: Record<string, ParityReportCell & { note?: string }> = {};
7888
+ for (const t of shown) {
7889
+ const cell = reportCell(r.feature, t, r.cells[t]);
7890
+ const remedy = applyLegRemedy(r.feature.id, t);
7891
+ cells[t] = remedy === '' ? cell : { ...cell, note: remedy.replace(/^ — /, '') };
7892
+ }
7407
7893
  return { id: r.feature.id, title: r.feature.title, cells };
7408
7894
  });
7409
7895
  // A filtered response stays internally consistent: capabilities are filtered too (Codex QE gap 9).
@@ -7430,7 +7916,7 @@ function cmdParity(options: Map<string, string>, flags: Set<string>, write: Writ
7430
7916
  : c.level === 'inconclusive'
7431
7917
  ? `via ${c.via ?? ''} — INCONCLUSIVE: stale evidence for ${(c.staleEvidence ?? []).join(', ')}`
7432
7918
  : `via ${c.via ?? ''}`;
7433
- write(` ${icon} ${r.feature.title.padEnd(58)} ${detail}`);
7919
+ write(` ${icon} ${r.feature.title.padEnd(58)} ${detail}${applyLegRemedy(r.feature.id, t)}`);
7434
7920
  }
7435
7921
  write('\n ✓ full (the complete experience) ◐ manual (works, you drive it by hand) ? evidence stale (re-probe) — not available');
7436
7922
  for (const line of staleNote(t)) write(line);
@@ -7582,14 +8068,31 @@ function cmdRelease(options: Map<string, string>, flags: Set<string>, cwd: strin
7582
8068
  factsList = selected;
7583
8069
  }
7584
8070
 
8071
+ // FR-6 (feature publish-sibling-drift-gate): real tmp dirs for the packed-install smoke — only
8072
+ // when something in the set actually has a bin to boot (packing bin-less siblings proves
8073
+ // nothing this gate exists to catch). Planning stays pure (planReleaseGates never mkdtemps
8074
+ // itself); these are cleaned up on every exit path below, dry-run included.
8075
+ const packedInstallEligible = factsList.some((f) => f.bins.some((b) => b.exists));
8076
+ const releaseScratchRoot = packedInstallScratchRoot();
8077
+ const packedInstallDirs = packedInstallEligible
8078
+ ? { packDir: mkdtempSync(join(releaseScratchRoot, 'dz-release-pack-')), installDir: mkdtempSync(join(releaseScratchRoot, 'dz-release-install-')) }
8079
+ : undefined;
8080
+ const cleanupPackedInstallDirs = (): void => {
8081
+ if (packedInstallDirs === undefined) return;
8082
+ try { rmSync(packedInstallDirs.packDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
8083
+ try { rmSync(packedInstallDirs.installDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
8084
+ };
8085
+
7585
8086
  const plan = planReleaseGates(factsList, {
7586
8087
  monorepoRoot: cwd,
7587
8088
  pnpmLockPresent: existsSync(join(cwd, 'pnpm-lock.yaml')),
7588
8089
  includeDevDeps: flags.has('audit-dev'),
8090
+ packedInstall: packedInstallDirs,
7589
8091
  });
7590
8092
 
7591
8093
  // --dry-run: print the full plan, execute NOTHING (deterministic, byte-testable preview).
7592
8094
  if (flags.has('dry-run')) {
8095
+ cleanupPackedInstallDirs();
7593
8096
  if (json) {
7594
8097
  write(JSON.stringify({ dryRun: true, packages: plan.packages, steps: plan.steps, skips: plan.skips, warnings }, null, 2));
7595
8098
  return 0;
@@ -7634,6 +8137,7 @@ function cmdRelease(options: Map<string, string>, flags: Set<string>, cwd: strin
7634
8137
  if (smokeTmp !== undefined) {
7635
8138
  try { rmSync(smokeTmp, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
7636
8139
  }
8140
+ cleanupPackedInstallDirs();
7637
8141
 
7638
8142
  const verdict = classifyGateExecutions(plan, executions);
7639
8143
 
@@ -9667,6 +10171,36 @@ function gatherGuardFacts(op: string, root: string, text: string | undefined, st
9667
10171
  const facts: Record<string, unknown> = { op };
9668
10172
  const publishPackageRoots: string[] = [];
9669
10173
  if (op === 'publish') {
10174
+ try {
10175
+ const roundsDir = join(root, '.dz', 'rounds');
10176
+ const states = readdirSync(roundsDir)
10177
+ .filter((name) => name.endsWith('.json'))
10178
+ .map((name) => readRoundState(join(roundsDir, name)))
10179
+ .filter((state): state is RoundState => state !== null);
10180
+ facts['openRounds'] = listRounds(states, {
10181
+ now: Date.now(),
10182
+ olderThanMinutes: 120,
10183
+ isPidAlive: probePid,
10184
+ isRunAlive: (runId) => roundRunOwnerAlive(root, runId, Date.now()),
10185
+ }).map((row) => ({
10186
+ slug: row.state.slug,
10187
+ round: row.state.round,
10188
+ ageMinutes: row.ageMinutes,
10189
+ pidAlive: row.pidAlive,
10190
+ }));
10191
+ } catch { /* absent/unreadable round state is no fabricated violation */ }
10192
+ const since = roundTraceSince(root);
10193
+ const enabled = roundsTracingEnabled(root);
10194
+ if (!enabled) {
10195
+ facts['codeCommitsSinceLastRound'] = { commits: null, since, enabled: false };
10196
+ } else if (since !== null) {
10197
+ facts['codeCommitsSinceLastRound'] = { commits: packageCommitCount(root, since), since };
10198
+ } else if (existsSync(join(root, '.dz', 'feature-adr', 'run-cost-ledger.jsonl'))) {
10199
+ // The ledger EXISTS but carries no dated row: that is a measurable absence and gets a note.
10200
+ // No ledger file at all is a fresh project — the rule stays not-established silently, so a
10201
+ // note that every new repo would carry does not drown the ones that mean something.
10202
+ facts['codeCommitsSinceLastRound'] = { commits: null, since: null };
10203
+ }
9670
10204
  // Advisory I/O: unreadable telemetry or fed state is absence of evidence, never a fabricated
9671
10205
  // stale finding and never a publish blocker.
9672
10206
  try {
@@ -10331,19 +10865,56 @@ function runGuardEvaluation(root: string, op: string, text: string | undefined,
10331
10865
  return result;
10332
10866
  }
10333
10867
 
10334
- function renderGuardObservation(observation: GuardObservation): string {
10335
- const tag = observation.status === 'unknown' ? 'note' : 'observe';
10336
- return ` [${tag}] ${observation.rule} ${observation.scope}: ${observation.detail} [${observation.status}]`;
10337
- }
10338
-
10339
10868
  /**
10340
- * The tail facts of an append-only log, read from its END — O(1) in the file size, which is what
10341
- * lets the chain be extended on every append without a full-file scan (FR-2). Anything unreadable
10342
- * yields {@link EMPTY_LOG_TAIL}; the caller then starts a marked segment rather than blocking.
10869
+ * Feature `publish-sibling-drift-gate` (FR-5/AM-6): both the sibling-drift and packed-install-smoke
10870
+ * gates write to the SAME append-only, hash-chained `.dz/guard-audit.jsonl` the declarative
10871
+ * `dz guard` rules use visibility for `dz guard promote`/`dz compounding` never depends on
10872
+ * which mechanism produced the finding. `pass` records go through as an informational `note`
10873
+ * (never a violation, so they can never flip the row's own verdict) so a clean check is ALSO on
10874
+ * the record, not just a block or an override (AM-6: "аудит без записи = не аудит").
10875
+ *
10876
+ * Returns whether the write actually landed. Most callers are best-effort (a write failure never
10877
+ * blocks a verdict already decided) — the one exception is an `--allow-sibling-drift` OVERRIDE,
10878
+ * whose caller MUST check this return value: an override is not real without a durable row behind
10879
+ * it (AM-6's load-bearing property — see `auditedOverride` in `cmdPublish`).
10343
10880
  */
10344
- function readLogTail(path: string): LogTail {
10345
- let fd: number | undefined;
10346
- try {
10881
+ function appendPublishGateAudit(
10882
+ root: string,
10883
+ rule: 'sibling-drift' | 'packed-install-smoke',
10884
+ verdict: 'pass' | 'warn' | 'block',
10885
+ detail: string,
10886
+ overrideReason?: string,
10887
+ ): boolean {
10888
+ try {
10889
+ const rec = auditRecord(
10890
+ verdict === 'pass'
10891
+ ? { op: 'publish', verdict, violations: [], checked: [rule], notEstablished: [], notes: [`${rule}: ${detail}`] }
10892
+ : { op: 'publish', verdict, violations: [{ rule, severity: 'hard', detail }], checked: [rule], notEstablished: [] },
10893
+ new Date().toISOString(),
10894
+ overrideReason !== undefined ? { reason: overrideReason } : undefined,
10895
+ );
10896
+ mkdirSync(join(root, '.dz'), { recursive: true });
10897
+ const auditPath = join(root, '.dz', 'guard-audit.jsonl');
10898
+ writeFileSync(auditPath, appendChainedLines([rec], readLogTail(auditPath)), { flag: 'a' });
10899
+ return true;
10900
+ } catch {
10901
+ return false; // audit write failed — the caller decides whether that itself is refusable (AM-6)
10902
+ }
10903
+ }
10904
+
10905
+ function renderGuardObservation(observation: GuardObservation): string {
10906
+ const tag = observation.status === 'unknown' ? 'note' : 'observe';
10907
+ return ` [${tag}] ${observation.rule} ${observation.scope}: ${observation.detail} [${observation.status}]`;
10908
+ }
10909
+
10910
+ /**
10911
+ * The tail facts of an append-only log, read from its END — O(1) in the file size, which is what
10912
+ * lets the chain be extended on every append without a full-file scan (FR-2). Anything unreadable
10913
+ * yields {@link EMPTY_LOG_TAIL}; the caller then starts a marked segment rather than blocking.
10914
+ */
10915
+ function readLogTail(path: string): LogTail {
10916
+ let fd: number | undefined;
10917
+ try {
10347
10918
  if (!existsSync(path)) return EMPTY_LOG_TAIL;
10348
10919
  fd = openSync(path, 'r');
10349
10920
  const size = fstatSync(fd).size;
@@ -11879,6 +12450,118 @@ function parseCheckMutatedFile(absFile: string, text: string): MutationParseChec
11879
12450
  }
11880
12451
  }
11881
12452
 
12453
+ const MUTATION_GATE_OUTPUT_TAIL_MAX_LINES = 20;
12454
+ const MUTATION_GATE_OUTPUT_TAIL_MAX_BYTES = 2 * 1024;
12455
+
12456
+ export function boundedMutationGateOutputTail(output: string): string | undefined {
12457
+ const normalized = output.replace(/\r\n?/g, '\n').replace(/\n+$/, '');
12458
+ if (normalized === '') return undefined;
12459
+
12460
+ let tail = normalized.split('\n').slice(-MUTATION_GATE_OUTPUT_TAIL_MAX_LINES).join('\n');
12461
+ const encoded = Buffer.from(tail, 'utf8');
12462
+ if (encoded.byteLength <= MUTATION_GATE_OUTPUT_TAIL_MAX_BYTES) return tail;
12463
+
12464
+ const codePoints = Array.from(tail);
12465
+ let start = codePoints.length;
12466
+ let byteLength = 0;
12467
+ while (start > 0) {
12468
+ const nextByteLength = Buffer.byteLength(codePoints[start - 1]!, 'utf8');
12469
+ if (byteLength + nextByteLength > MUTATION_GATE_OUTPUT_TAIL_MAX_BYTES) break;
12470
+ byteLength += nextByteLength;
12471
+ start -= 1;
12472
+ }
12473
+ return codePoints.slice(start).join('');
12474
+ }
12475
+
12476
+ // ── Full-output capture for a RED baseline/rebaseline line (gate-stability, 2026-09-12) ────────
12477
+ // The bounded tail above is a diagnostic teaser (3-20 lines); under a multi-entry gate run the
12478
+ // tail was measured to hand back an unrelated neighbour's stderr, leaving OVER_FAILING/
12479
+ // INCONCLUSIVE undiagnosable. Only the baseline and rebaseline lines write here — the per-entry
12480
+ // mutation run is EXPECTED to redden and already carries a bounded tail; this is for the lines
12481
+ // whose redness means "the copy itself is broken", where the full transcript is the only way to
12482
+ // tell what actually happened.
12483
+
12484
+ const MUTATION_GATE_OUTPUT_FILE_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
12485
+
12486
+ function mutationGateOutputDir(): string {
12487
+ return process.env.DZ_MUTGATE_OUTPUT_DIR ?? join(tmpdir(), 'dz-mutgate-output');
12488
+ }
12489
+
12490
+ /** own filename prefix (fix-round-1 HIGH finding) — see isMutationGateOutputFile. */
12491
+ const MUTATION_GATE_OUTPUT_FILE_PREFIX = 'dz-mutgate-';
12492
+ /** exact shape of `new Date().toISOString().replace(/:/g, '-')`, e.g. `2026-09-12T20-00-00.000Z`. */
12493
+ const MUTATION_GATE_OUTPUT_TS_PATTERN = String.raw`\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.\d{3}Z`;
12494
+ const MUTATION_GATE_OUTPUT_FILE_RE = new RegExp(
12495
+ `^${MUTATION_GATE_OUTPUT_FILE_PREFIX}.+-(baseline|rebaseline|final-rebaseline)-${MUTATION_GATE_OUTPUT_TS_PATTERN}\\.log$`,
12496
+ );
12497
+
12498
+ /**
12499
+ * true only for a filename THIS executor could have written — rotation never touches a foreign
12500
+ * file. Fix-round-1 HIGH finding (Codex review, gate-stability): the prior
12501
+ * `^.+-(baseline|rebaseline|final-rebaseline)-.+\.log$` had no own prefix and accepted ANY
12502
+ * trailing text as the "timestamp", so a pre-existing unrelated file dropped into a shared
12503
+ * `DZ_MUTGATE_OUTPUT_DIR` (e.g. `service-baseline-backup.log`) matched and could be rotated away.
12504
+ * Now BOTH the `dz-mutgate-` prefix AND the exact ISO-timestamp shape we ourselves write are
12505
+ * required — a foreign file can accidentally share the prefix but essentially never our precise
12506
+ * timestamp format, and a file we did NOT write never carries both.
12507
+ */
12508
+ function isMutationGateOutputFile(name: string): boolean {
12509
+ return MUTATION_GATE_OUTPUT_FILE_RE.test(name);
12510
+ }
12511
+
12512
+ function rotateMutationGateOutputDir(dir: string): void {
12513
+ let names: string[];
12514
+ try { names = readdirSync(dir); } catch { return; }
12515
+ const cutoff = Date.now() - MUTATION_GATE_OUTPUT_FILE_RETENTION_MS;
12516
+ for (const name of names) {
12517
+ if (!isMutationGateOutputFile(name)) continue; // "чужие файлы не трогаются" — own prefix only
12518
+ const full = join(dir, name);
12519
+ try {
12520
+ if (statSync(full).mtimeMs < cutoff) rmSync(full, { force: true });
12521
+ } catch { /* best effort — a listing race is not this executor's problem */ }
12522
+ }
12523
+ }
12524
+
12525
+ /** Discriminated outcome of a save attempt — a red run either saved (path) or did not (error);
12526
+ * never both. See writeMutationGateOutputOnRed. */
12527
+ type MutationGateOutputWrite = { readonly path: string } | { readonly error: string };
12528
+
12529
+ /**
12530
+ * Saves the FULL stdout+stderr of a RED baseline/rebaseline run and returns `{ path }`, or
12531
+ * `{ error }` on any I/O failure (EACCES/ENOSPC/EROFS/ENOTDIR and the like — never blocks the gate
12532
+ * on a logging problem: fix-round-1 MEDIUM finding, the prior silent `catch { return undefined; }`
12533
+ * made a failed save indistinguishable from "nothing to save"), or `undefined` when exitCode is 0
12534
+ * (nothing written on green — NFR-1 byte-identity).
12535
+ */
12536
+ function writeMutationGateOutputOnRed(
12537
+ entryId: string | undefined,
12538
+ phase: 'baseline' | 'rebaseline' | 'final-rebaseline',
12539
+ exitCode: number | null,
12540
+ output: string,
12541
+ ): MutationGateOutputWrite | undefined {
12542
+ if (exitCode === 0) return undefined;
12543
+ try {
12544
+ const dir = mutationGateOutputDir();
12545
+ mkdirSync(dir, { recursive: true });
12546
+ rotateMutationGateOutputDir(dir);
12547
+ const ts = new Date().toISOString().replace(/:/g, '-');
12548
+ const full = join(dir, `${MUTATION_GATE_OUTPUT_FILE_PREFIX}${entryId ?? 'baseline'}-${phase}-${ts}.log`);
12549
+ writeFileSync(full, output);
12550
+ return { path: full };
12551
+ } catch (e) {
12552
+ return { error: String((e as Error)?.message ?? e) };
12553
+ }
12554
+ }
12555
+
12556
+ /** Unpacks a `writeMutationGateOutputOnRed` result into the `{outputPath, outputError}` shape the
12557
+ * pure engine (classifyBaseline / MutationObservation) consumes. */
12558
+ function splitMutationGateOutputWrite(
12559
+ result: MutationGateOutputWrite | undefined,
12560
+ ): { outputPath?: string; outputError?: string } {
12561
+ if (result === undefined) return {};
12562
+ return 'path' in result ? { outputPath: result.path } : { outputError: result.error };
12563
+ }
12564
+
11882
12565
  function cmdMutationGate(
11883
12566
  options: Map<string, string>,
11884
12567
  flags: Set<string>,
@@ -11943,6 +12626,12 @@ function cmdMutationGate(
11943
12626
  const testCmdRaw = options.get('test-cmd') ?? parsed.registry.testCommand ?? 'npm test';
11944
12627
  if (/[\0\n\r]/.test(testCmdRaw)) return fail('--test-cmd may not contain NUL or newline characters');
11945
12628
  const testCmd = testCmdRaw;
12629
+ const excludedSelfChecks = REGISTRY_SELFCHECK_TESTS.filter((testFile) =>
12630
+ entries.some((entry) => buildMutationTestCommand(testCmd, entry).excluded.includes(testFile)),
12631
+ );
12632
+ if (!json) {
12633
+ write(`mutation-gate: self-check excluded from mutant runs: ${excludedSelfChecks.join(', ') || '(none)'}`);
12634
+ }
11946
12635
 
11947
12636
  const timeoutOpt = Number(options.get('timeout') ?? '300000');
11948
12637
  const timeout = Number.isFinite(timeoutOpt) && timeoutOpt > 0 ? timeoutOpt : 300000;
@@ -12021,11 +12710,20 @@ function cmdMutationGate(
12021
12710
  const requireCompletionReceipt = parsed.registry.requireCompletionReceipt === true;
12022
12711
 
12023
12712
  type SuiteRun = MutationGateRunnerObservation & { readonly internalAttemptLog?: string };
12024
- const invokeSuite = (): MutationGateRunnerObservation => {
12713
+ const invokeSuite = (
12714
+ suiteCommand: string,
12715
+ phase: 'baseline' | 'mutation' | 'rebaseline' | 'final-rebaseline',
12716
+ entryId?: string,
12717
+ ): MutationGateRunnerObservation => {
12025
12718
  if (injectedRunner !== undefined) {
12026
- return injectedRunner(testCmd, { cwd: copyDir, timeoutMs: timeout });
12719
+ return injectedRunner(suiteCommand, {
12720
+ cwd: copyDir,
12721
+ timeoutMs: timeout,
12722
+ phase,
12723
+ ...(entryId !== undefined ? { entryId } : {}),
12724
+ });
12027
12725
  }
12028
- const run = spawnSync(testCmd, {
12726
+ const run = spawnSync(suiteCommand, {
12029
12727
  cwd: copyDir,
12030
12728
  shell: true,
12031
12729
  encoding: 'utf-8',
@@ -12061,8 +12759,9 @@ function cmdMutationGate(
12061
12759
  const runSuite = (
12062
12760
  phase: 'baseline' | 'mutation' | 'rebaseline' | 'final-rebaseline',
12063
12761
  entryId?: string,
12762
+ suiteCommand = testCmd,
12064
12763
  ): SuiteRun => {
12065
- const retried = runWithOneInternalRetry(invokeSuite);
12764
+ const retried = runWithOneInternalRetry(() => invokeSuite(suiteCommand, phase, entryId));
12066
12765
  const loggedAttempts = retried.attempts.map((attempt) => {
12067
12766
  if (attempt.outcome !== 'completed' || retried.value === null) return attempt;
12068
12767
  const outcome = retried.value.exitCode === null
@@ -12098,12 +12797,16 @@ function cmdMutationGate(
12098
12797
  // result would be this gate shipping the defect class it exists to catch.
12099
12798
  if (!json) write(`mutation-gate: baseline suite in scratch copy of ${pkgDir} …`);
12100
12799
  const base = runSuite('baseline');
12800
+ const { outputPath: baseOutputPath, outputError: baseOutputError } =
12801
+ splitMutationGateOutputWrite(writeMutationGateOutputOnRed(undefined, 'baseline', base.exitCode, base.output));
12101
12802
  baseline = classifyBaseline(
12102
12803
  base.exitCode,
12103
12804
  base.failureReason,
12104
12805
  base.exitCode !== null && base.exitCode !== 0
12105
12806
  ? attributeBaselineRedness(base.output, entries.map((entry) => entry.file))
12106
12807
  : undefined,
12808
+ baseOutputPath,
12809
+ baseOutputError,
12107
12810
  );
12108
12811
  if (!baseline.ok) {
12109
12812
  if (json) { write(JSON.stringify({ packageDir: pkgDir, registryPath, testCommand: testCmd, baseline, results, internalRetries, exitCode: 1 }, null, 2)); return 1; }
@@ -12165,7 +12868,7 @@ function cmdMutationGate(
12165
12868
  if (check.error !== undefined) {
12166
12869
  parseError = check.error; // no suite run: the verdict is MUTATION_UNPARSEABLE regardless
12167
12870
  } else if (parseInternalFailureReason === undefined) {
12168
- run = runSuite('mutation', entry.id);
12871
+ run = runSuite('mutation', entry.id, buildMutationTestCommand(testCmd, entry).testCommand);
12169
12872
  }
12170
12873
  } finally {
12171
12874
  writeFileSync(filePath, sourceText); // restore the COPY so the next entry starts pristine
@@ -12200,6 +12903,9 @@ function cmdMutationGate(
12200
12903
  let rebaselineExitCode: number | null | undefined;
12201
12904
  let rebaselineFailureReason: string | undefined;
12202
12905
  let rebaselineAttribution: ReturnType<typeof attributeBaselineRedness> | undefined;
12906
+ let rebaselineOutputTail: string | undefined;
12907
+ let rebaselineOutputPath: string | undefined;
12908
+ let rebaselineOutputError: string | undefined;
12203
12909
  let rebaselineInternalAttemptLog: string | undefined;
12204
12910
  if (rebaselineMode === 'per-entry' && run !== null && run.exitCode !== null && run.exitCode !== 0
12205
12911
  && fileLoadFailure === undefined && outputUnrecognised === undefined && receiptMismatch === undefined) {
@@ -12208,11 +12914,16 @@ function cmdMutationGate(
12208
12914
  rebaselineExitCode = rebaselineRun.exitCode;
12209
12915
  rebaselineFailureReason = rebaselineRun.failureReason;
12210
12916
  rebaselineInternalAttemptLog = rebaselineRun.internalAttemptLog;
12211
- if (rebaselineRun.exitCode !== null && rebaselineRun.exitCode !== 0) {
12212
- rebaselineAttribution = attributeBaselineRedness(
12213
- rebaselineRun.output,
12214
- entries.map((candidate) => candidate.file),
12215
- );
12917
+ if (rebaselineRun.exitCode !== 0) {
12918
+ rebaselineOutputTail = boundedMutationGateOutputTail(rebaselineRun.output);
12919
+ ({ outputPath: rebaselineOutputPath, outputError: rebaselineOutputError } =
12920
+ splitMutationGateOutputWrite(writeMutationGateOutputOnRed(entry.id, 'rebaseline', rebaselineRun.exitCode, rebaselineRun.output)));
12921
+ if (rebaselineRun.exitCode !== null) {
12922
+ rebaselineAttribution = attributeBaselineRedness(
12923
+ rebaselineRun.output,
12924
+ entries.map((candidate) => candidate.file),
12925
+ );
12926
+ }
12216
12927
  }
12217
12928
  }
12218
12929
  const entryRunFailureReason = run?.failureReason ?? parseInternalFailureReason;
@@ -12233,6 +12944,9 @@ function cmdMutationGate(
12233
12944
  ...(rebaselineExitCode !== undefined ? { rebaselineExitCode } : {}),
12234
12945
  ...(rebaselineFailureReason !== undefined ? { rebaselineFailureReason } : {}),
12235
12946
  ...(rebaselineAttribution !== undefined ? { rebaselineAttribution } : {}),
12947
+ ...(rebaselineOutputTail !== undefined ? { rebaselineOutputTail } : {}),
12948
+ ...(rebaselineOutputPath !== undefined ? { outputPath: rebaselineOutputPath } : {}),
12949
+ ...(rebaselineOutputError !== undefined ? { outputError: rebaselineOutputError } : {}),
12236
12950
  };
12237
12951
  observations.push(obs);
12238
12952
  results.push(classifyMutationOutcome(obs));
@@ -12248,6 +12962,9 @@ function cmdMutationGate(
12248
12962
  const finalRun = runSuite('final-rebaseline');
12249
12963
  const finalExit = finalRun.exitCode;
12250
12964
  if (finalExit !== 0) {
12965
+ const finalOutputTail = boundedMutationGateOutputTail(finalRun.output);
12966
+ const { outputPath: finalOutputPath, outputError: finalOutputError } =
12967
+ splitMutationGateOutputWrite(writeMutationGateOutputOnRed(undefined, 'final-rebaseline', finalExit, finalRun.output));
12251
12968
  const what = finalExit === null ? `no exit code: ${finalRun.failureReason ?? 'unknown timeout / spawn failure'}` : `exit ${finalExit}`;
12252
12969
  warnings.push(`final re-baseline NOT green (${what}) — the suite is flaky; red-based verdicts downgraded to INCONCLUSIVE`);
12253
12970
  if (!json) write(`mutation-gate: final re-baseline NOT green (${what}) — red-based verdicts downgraded to INCONCLUSIVE`);
@@ -12261,6 +12978,9 @@ function cmdMutationGate(
12261
12978
  ...(finalExit !== null && finalExit !== 0
12262
12979
  ? { rebaselineAttribution: attributeBaselineRedness(finalRun.output, entries.map((entry) => entry.file)) }
12263
12980
  : {}),
12981
+ ...(finalOutputTail !== undefined ? { rebaselineOutputTail: finalOutputTail } : {}),
12982
+ ...(finalOutputPath !== undefined ? { outputPath: finalOutputPath } : {}),
12983
+ ...(finalOutputError !== undefined ? { outputError: finalOutputError } : {}),
12264
12984
  }));
12265
12985
  results.length = 0;
12266
12986
  results.push(...reclassified);
@@ -14222,6 +14942,928 @@ function cmdRunsRecord(options: Map<string, string>, flags: Set<string>, cwd: st
14222
14942
  }
14223
14943
  }
14224
14944
 
14945
+ const ROUND_LEDGER_REL = join('.dz', 'feature-adr', 'run-cost-ledger.jsonl');
14946
+
14947
+ /**
14948
+ * round-state-root FR-1/FR-2: where `dz round` state (and its ledger, FR-4) lives — flag beats env
14949
+ * beats cwd. `--project` is untouched by this and stays recall-only (lesson 2ac30a70). Only an
14950
+ * EXPLICIT flag/env value is validated for absoluteness; the cwd fallback is `resolve(cwd)`, exactly
14951
+ * what every subcommand used before this feature (NFR-1: byte-identical when neither is set).
14952
+ */
14953
+ function resolveRoundStateRoot(
14954
+ options: Map<string, string>,
14955
+ env: NodeJS.ProcessEnv,
14956
+ cwd: string,
14957
+ ): { readonly ok: true; readonly root: string; readonly source: 'flag' | 'env' | 'cwd' }
14958
+ | { readonly ok: false; readonly reason: string } {
14959
+ const flagRaw = options.get('state-root');
14960
+ if (flagRaw !== undefined) {
14961
+ if (!isAbsolute(flagRaw)) return { ok: false, reason: `--state-root должен быть абсолютным путём: ${flagRaw}` };
14962
+ return { ok: true, root: flagRaw, source: 'flag' };
14963
+ }
14964
+ const envRaw = env['DZ_ROUND_STATE_ROOT'];
14965
+ if (envRaw !== undefined) {
14966
+ // A variable that is SET but blank is a misconfiguration, not an absence: falling back to cwd
14967
+ // here would be exactly the stray-write this flag exists to prevent (Codex review, 2026-09-13).
14968
+ if (envRaw.trim() === '') return { ok: false, reason: 'DZ_ROUND_STATE_ROOT задана, но пуста — укажите абсолютный путь или снимите переменную' };
14969
+ if (!isAbsolute(envRaw)) return { ok: false, reason: `DZ_ROUND_STATE_ROOT должен быть абсолютным путём: ${envRaw}` };
14970
+ return { ok: true, root: envRaw, source: 'env' };
14971
+ }
14972
+ return { ok: true, root: resolve(cwd), source: 'cwd' };
14973
+ }
14974
+
14975
+ function roundStatePath(root: string, slug: string, round: number): string {
14976
+ return join(root, '.dz', 'rounds', `${slug}-${round}.json`);
14977
+ }
14978
+
14979
+ /** round-state-lock T2: parses raw JSON text into a `RoundState`, shared by `readRoundState` (reads
14980
+ * from disk) and the AC-1 recheck-under-lock (compares a raw string captured before recall against
14981
+ * one read again inside the lock, so it needs to parse the SAME raw text twice without a third
14982
+ * disk read). */
14983
+ function parseRoundState(raw: string): RoundState | null {
14984
+ try {
14985
+ const row = JSON.parse(raw) as Partial<RoundState>;
14986
+ if (typeof row.slug !== 'string' || !Number.isInteger(row.round) || typeof row.topic !== 'string'
14987
+ || typeof row.startedAt !== 'string' || !Number.isInteger(row.pid) || !Array.isArray(row.recalled)
14988
+ || row.recalled.some((id) => typeof id !== 'string')) return null;
14989
+ if (row.execs !== undefined && (!Array.isArray(row.execs) || row.execs.some((entry) =>
14990
+ typeof entry.startedAt !== 'string' || typeof entry.endedAt !== 'string'
14991
+ || (entry.exitCode !== null && !Number.isInteger(entry.exitCode))
14992
+ || typeof entry.outcome !== 'string'
14993
+ || (entry.tokens !== null && !Number.isInteger(entry.tokens))))) return null;
14994
+ return row as RoundState;
14995
+ } catch {
14996
+ return null;
14997
+ }
14998
+ }
14999
+
15000
+ function readRoundState(path: string): RoundState | null {
15001
+ try {
15002
+ return parseRoundState(readFileSync(path, 'utf8'));
15003
+ } catch {
15004
+ return null;
15005
+ }
15006
+ }
15007
+
15008
+ /** round-state-lock: the raw bytes at `path`, or `null` when absent/unreadable. Used to detect
15009
+ * whether the state file changed between a check made BEFORE the (long, unlocked) recall and one
15010
+ * made again INSIDE the round-state lock — a byte-identical read means nothing raced us. */
15011
+ function readRawRoundState(path: string): string | null {
15012
+ try {
15013
+ return readFileSync(path, 'utf8');
15014
+ } catch {
15015
+ return null;
15016
+ }
15017
+ }
15018
+
15019
+ /** round-state-lock fix-round AM-1: 16 random hex chars, minted once per `open`. */
15020
+ function generateRoundStateId(): string {
15021
+ return randomBytes(8).toString('hex');
15022
+ }
15023
+
15024
+ /** Refusal shape shared by `exec`'s claim AND restore sections (AM-1): the state this section
15025
+ * expected to still be there — identified by `expectedStateId`, not by pid or by "did the file
15026
+ * change" — is either gone (`'gone'`) or has been replaced by something with a DIFFERENT identity
15027
+ * (`'replaced'`). Both cases leave the file untouched: writing over either would be exactly the
15028
+ * lost-update/resurrection bug this fix-round exists to close. */
15029
+ type RoundStateGone = { readonly refused: 'gone' };
15030
+ type RoundStateReplaced = { readonly refused: 'replaced'; readonly stateId: string | undefined; readonly execClaimId?: string | undefined };
15031
+
15032
+ /** Lead edit after Codex re-review: a LEGACY state (written before stateId existed) must not be
15033
+ * matched by `undefined === undefined` — under the lock, the first exec/close that meets it mints
15034
+ * an id, writes it back, and continues with that id as the identity of THIS operation. */
15035
+ function ensureStateId(path: string, fresh: RoundState): RoundState {
15036
+ if (fresh.stateId !== undefined) return fresh;
15037
+ const minted = { ...fresh, stateId: randomBytes(8).toString('hex') };
15038
+ writeJsonAtomic(path, minted);
15039
+ return minted;
15040
+ }
15041
+
15042
+ function readStateOrRefuse(
15043
+ path: string,
15044
+ expectedStateId: string | undefined,
15045
+ ): RoundState | RoundStateGone | RoundStateReplaced {
15046
+ const fresh = readRoundState(path);
15047
+ if (fresh === null) return { refused: 'gone' };
15048
+ if (expectedStateId === undefined && fresh.stateId === undefined) return ensureStateId(path, fresh);
15049
+ if (fresh.stateId !== expectedStateId) return { refused: 'replaced', stateId: fresh.stateId };
15050
+ return fresh;
15051
+ }
15052
+
15053
+ /** round-state-lock fix-round AM-2: the same "gone vs replaced" shape as `readStateOrRefuse`, but
15054
+ * `close`'s missing-file case is NOT a failure — a round the ledger row was already witnessed for,
15055
+ * whose state file is already gone, is exactly `close`'s own success postcondition reached by a
15056
+ * different path (e.g. a prior invocation's delete step landed after this one read the ledger tail).
15057
+ * Kept as a separate type (not reused from `readStateOrRefuse`) because the two `refused` tags carry
15058
+ * different exit codes and messages — collapsing them would make a future edit to one silently reuse
15059
+ * the other's wording. */
15060
+ type RoundStateAlreadyClosed = { readonly refused: 'closed-already' };
15061
+
15062
+ function readStateForCloseOrRefuse(
15063
+ path: string,
15064
+ expectedStateId: string | undefined,
15065
+ ): RoundState | RoundStateAlreadyClosed | RoundStateReplaced {
15066
+ const fresh = readRoundState(path);
15067
+ if (fresh === null) return { refused: 'closed-already' };
15068
+ if (expectedStateId === undefined && fresh.stateId === undefined) return ensureStateId(path, fresh);
15069
+ if (fresh.stateId !== expectedStateId) return { refused: 'replaced', stateId: fresh.stateId };
15070
+ return fresh;
15071
+ }
15072
+
15073
+ /** round-state-lock fix-round AM-4: the exact ledger-row marker `closeRound` (harness-core) will
15074
+ * compute for THIS close attempt, predicted from the same three inputs (slug, round, closedAt)
15075
+ * BEFORE calling it — so a retried `close` with the same injected `roundNow` (same `closedAt`) can
15076
+ * detect "the ledger already carries this attempt's row" and skip writing a duplicate. Mirrors
15077
+ * `closeRound`'s own marker formula in harness-core/src/round.ts exactly; a drift between the two
15078
+ * would only defeat the RETRY-dedup check (closeRound's own success postcondition, verified by
15079
+ * rereading the ledger tail, is unaffected either way). Deliberately NOT keyed on `stateId`: the
15080
+ * run-cost ledger row schema (`RoundLedgerRow`) has no such column, and adding one is out of this
15081
+ * fix's scope (round.ts stays untouched) — (slug, round, closedAt) is the identity already exposed
15082
+ * through the marker, and it is exactly as unique for a genuine retry (same close command, same
15083
+ * injected clock) as a `stateId` would be. */
15084
+ function predictedRoundCloseMarker(slug: string, round: number, closedAtIso: string): string {
15085
+ const closedMs = Date.parse(closedAtIso);
15086
+ const compactTs = new Date(closedMs).toISOString().replace(/[-:.]/g, '');
15087
+ return `round-${slug}-${round}-${compactTs}`;
15088
+ }
15089
+
15090
+ /** round-state-lock fix-round AM-5: `open`/`status` warn when a round has been sitting with
15091
+ * `ownerKind: 'exec'` for more than this many minutes — the shape of a restore-section that
15092
+ * exhausted its lock-busy retries (see `ROUND_RESTORE_LOCK_ATTEMPTS`) and left the round claimed by
15093
+ * an `exec` that already finished. There is no separate "since when has this been exec" timestamp on
15094
+ * `RoundState`, so this measures from `startedAt` (the round's own start) — a deliberate
15095
+ * approximation: an `exec` that ran briefly near round-open would read as "young" even if its
15096
+ * restore failed just now. Good enough to surface the stuck case at all; not a claim of precision. */
15097
+ const ROUND_EXEC_STALE_MINUTES = 10;
15098
+
15099
+ function roundExecStaleAgeMinutes(state: RoundState, now: number): number | null {
15100
+ if (state.ownerKind !== 'exec') return null;
15101
+ // Lead edit after Codex re-review: count from the exec claim, not from the round's own start —
15102
+ // a fresh exec inside an old round is not stuck. Legacy states without the field fall back.
15103
+ const claimedMs = Date.parse(state.execClaimedAt ?? state.startedAt);
15104
+ if (!Number.isFinite(claimedMs)) return null;
15105
+ const minutes = Math.floor((now - claimedMs) / 60_000);
15106
+ return minutes >= ROUND_EXEC_STALE_MINUTES ? minutes : null;
15107
+ }
15108
+
15109
+ function readRoundLedgerTail(root: string): string {
15110
+ try {
15111
+ const body = readFileSync(join(root, ROUND_LEDGER_REL), 'utf8');
15112
+ return body.slice(-64 * 1024);
15113
+ } catch {
15114
+ return '';
15115
+ }
15116
+ }
15117
+
15118
+ function readRoundLedger(root: string): string {
15119
+ try {
15120
+ return readFileSync(join(root, ROUND_LEDGER_REL), 'utf8');
15121
+ } catch {
15122
+ return '';
15123
+ }
15124
+ }
15125
+
15126
+ function roundRunOwnerAlive(
15127
+ root: string,
15128
+ runId: string,
15129
+ now: number,
15130
+ registryReader?: (projectRoot: string) => string,
15131
+ pidProbe: (pid: number) => boolean | null = probePid,
15132
+ ): boolean | null {
15133
+ const registry = readRunRegistry(root, registryReader === undefined
15134
+ ? runRegistryIO
15135
+ : { ...runRegistryIO, read: () => registryReader(root) });
15136
+ if (registry.status !== 'readable') return null;
15137
+ const decision = liveness(registry.runs.find((run) => run.runId === runId), now, pidProbe);
15138
+ return decision.state === 'live' || decision.state === 'stalled' ? true : decision.state === 'orphaned' ? false : null;
15139
+ }
15140
+
15141
+ function nextRoundNumber(ledger: string, slug: string): number {
15142
+ let count = 0;
15143
+ for (const line of ledger.split('\n')) {
15144
+ try {
15145
+ const row = JSON.parse(line) as { slug?: unknown; stage?: unknown };
15146
+ if (row.slug === slug && row.stage === 'round') count++;
15147
+ } catch { /* malformed and torn rows are not completed rounds */ }
15148
+ }
15149
+ return count + 1;
15150
+ }
15151
+
15152
+ type RoundSpawnReceipt = {
15153
+ readonly exitCode: number | null;
15154
+ readonly timedOut: boolean;
15155
+ readonly signal: NodeJS.Signals | null;
15156
+ readonly errorCode?: string;
15157
+ readonly error?: string;
15158
+ };
15159
+
15160
+ export async function spawnRoundCodex(request: {
15161
+ readonly command: string;
15162
+ readonly args: readonly string[];
15163
+ readonly cwd: string;
15164
+ readonly logPath: string;
15165
+ readonly timeoutMs: number;
15166
+ readonly killGraceMs?: number;
15167
+ }): Promise<RoundSpawnReceipt> {
15168
+ mkdirSync(dirname(request.logPath), { recursive: true });
15169
+ const logFd = openSync(request.logPath, 'w');
15170
+ return await new Promise<RoundSpawnReceipt>((resolveRun) => {
15171
+ let settled = false;
15172
+ let timedOut = false;
15173
+ let escalation: NodeJS.Timeout | undefined;
15174
+ let child: ChildProcess | undefined;
15175
+ const finish = (receipt: Omit<RoundSpawnReceipt, 'timedOut'>): void => {
15176
+ if (settled) return;
15177
+ settled = true;
15178
+ clearTimeout(deadline);
15179
+ if (escalation !== undefined) clearTimeout(escalation);
15180
+ try { closeSync(logFd); } catch { /* the subprocess receipt remains authoritative */ }
15181
+ resolveRun({ ...receipt, timedOut });
15182
+ };
15183
+ const deadline = setTimeout(() => {
15184
+ timedOut = true;
15185
+ try { child?.kill('SIGTERM'); } catch { /* SIGKILL below is the bounded fallback */ }
15186
+ escalation = setTimeout(() => {
15187
+ try { child?.kill('SIGKILL'); } catch { /* close/error decides the receipt */ }
15188
+ }, request.killGraceMs ?? 10_000);
15189
+ }, request.timeoutMs);
15190
+ try {
15191
+ child = spawn(request.command, [...request.args], {
15192
+ cwd: request.cwd,
15193
+ stdio: ['ignore', logFd, logFd],
15194
+ });
15195
+ } catch (error) {
15196
+ const err = error as NodeJS.ErrnoException;
15197
+ finish({ exitCode: null, signal: null, ...(err.code === undefined ? {} : { errorCode: err.code }), error: err.message });
15198
+ return;
15199
+ }
15200
+ child.on('error', (error: NodeJS.ErrnoException) => {
15201
+ finish({ exitCode: null, signal: null, ...(error.code === undefined ? {} : { errorCode: error.code }), error: error.message });
15202
+ });
15203
+ child.on('close', (code, signal) => finish({ exitCode: code, signal }));
15204
+ });
15205
+ }
15206
+
15207
+ function roundExecReceiptFound(tail: string, expected: RoundExecLedgerRow): boolean {
15208
+ for (const line of tail.split('\n')) {
15209
+ try {
15210
+ const row = JSON.parse(line) as Partial<RoundExecLedgerRow>;
15211
+ if (row.stage === 'round-exec' && row.slug === expected.slug && row.round === expected.round
15212
+ && row.startedAt === expected.startedAt && row.endedAt === expected.endedAt
15213
+ && row.outcome === expected.outcome && row.exitCode === expected.exitCode) return true;
15214
+ } catch { /* a torn or unrelated line is not this receipt */ }
15215
+ }
15216
+ return false;
15217
+ }
15218
+
15219
+ /** Refusal shape returned by {@link withRoundStateLock} in place of throwing, so every `dz round`
15220
+ * mutation observes the SAME lock-busy contract (FR-4): a `NamedLockTimeoutError` becomes `exit 1`,
15221
+ * a `lock busy: …` message, and a `{ refused: 'lock-busy' }` JSON field — never a bare stack trace,
15222
+ * and never a silent fall-through that would let a caller mistake absence-of-error for success. */
15223
+ type RoundLockBusy = { readonly refused: 'lock-busy'; readonly reason: string };
15224
+
15225
+ /**
15226
+ * round-state-lock T1 — the one named lock every `.dz/rounds/*.json` mutation goes through
15227
+ * (`<stateRoot>/.dz/locks/round-state.lock`, `withNamedLockSync` from `@dzhechkov/harness-core`).
15228
+ *
15229
+ * `fn` MUST be short and synchronous (the same caveat `withNamedLockSync` itself carries): it may
15230
+ * reread state and write it, never spawn a subprocess or await anything — the recall step and the
15231
+ * ledger write stay OUTSIDE the lock by design (teach:0ea46034), and the long-running `codex exec`
15232
+ * child in `round exec` runs between two separate short lock holds, not inside one.
15233
+ *
15234
+ * `io.roundLockTimeoutMs` (NFR-2) lets tests force a small deadline instead of the real default;
15235
+ * omitting it keeps production behaviour (and every existing test) byte-identical.
15236
+ */
15237
+ function withRoundStateLock<T>(stateRoot: string, fn: () => T, io: CliIo): T | RoundLockBusy {
15238
+ try {
15239
+ return withNamedLockSync(
15240
+ stateRoot,
15241
+ 'round-state',
15242
+ fn,
15243
+ io.roundLockTimeoutMs === undefined ? {} : { timeoutMs: io.roundLockTimeoutMs },
15244
+ );
15245
+ } catch (error) {
15246
+ if (error instanceof NamedLockTimeoutError) {
15247
+ return { refused: 'lock-busy', reason: error.message };
15248
+ }
15249
+ throw error;
15250
+ }
15251
+ }
15252
+
15253
+ /** round-state-lock fix-round AM-5: the restore-section retry budget — `exec`'s SECOND lock hold
15254
+ * (returning ownership after the codex child exits) tries up to this many times, with the SAME
15255
+ * per-attempt timeout, before it gives up and leaves the round `ownerKind: 'exec'` for a human to
15256
+ * notice (via the `open`/`status` staleness warning) rather than looping forever against a lock that
15257
+ * may never free up. */
15258
+ const ROUND_RESTORE_LOCK_ATTEMPTS = 4; // 1 attempt + 3 retries (AM-5; lead edit after re-review)
15259
+
15260
+ function withRoundStateLockRetried<T>(stateRoot: string, fn: () => T, io: CliIo, attempts: number): T | RoundLockBusy {
15261
+ let lastBusy: RoundLockBusy | null = null;
15262
+ for (let attempt = 0; attempt < attempts; attempt++) {
15263
+ const result = withRoundStateLock(stateRoot, fn, io);
15264
+ if (!(typeof result === 'object' && result !== null && 'refused' in result && result.refused === 'lock-busy')) {
15265
+ return result;
15266
+ }
15267
+ lastBusy = result;
15268
+ }
15269
+ return lastBusy!;
15270
+ }
15271
+
15272
+ async function cmdRound(
15273
+ options: Map<string, string>,
15274
+ optionLists: Map<string, string[]>,
15275
+ flags: Set<string>,
15276
+ cwd: string,
15277
+ write: Write,
15278
+ io: CliIo,
15279
+ ): Promise<number> {
15280
+ const sub = options.get('_positional_0') ?? '';
15281
+ const json = flags.has('json');
15282
+ const stateRootResolution = resolveRoundStateRoot(options, process.env, cwd);
15283
+ if (!stateRootResolution.ok) {
15284
+ write(json ? JSON.stringify({ message: stateRootResolution.reason }) : stateRootResolution.reason);
15285
+ return 2;
15286
+ }
15287
+ const stateRoot = stateRootResolution.root;
15288
+ const stateRootExplicit = stateRootResolution.source !== 'cwd';
15289
+ const projectRoot = resolve(cwd, options.get('project') ?? '.');
15290
+ const now = io.roundNow?.() ?? Date.now();
15291
+ const emit = (message: string, extra: Record<string, unknown> = {}): void => {
15292
+ write(json ? JSON.stringify({ message, ...extra }) : message);
15293
+ };
15294
+ const address = (roundOverride?: number): { slug: string; round: number } | null => {
15295
+ const slug = options.get('slug') ?? '';
15296
+ const round = roundOverride ?? Number(options.get('round'));
15297
+ if (!/^[a-z0-9][a-z0-9._-]*$/i.test(slug) || !Number.isInteger(round) || round < 1) return null;
15298
+ return { slug, round };
15299
+ };
15300
+
15301
+ if (sub === 'open') {
15302
+ const slug = options.get('slug') ?? '';
15303
+ const roundRaw = options.get('round');
15304
+ const autoRound = roundRaw === 'auto'
15305
+ ? nextRoundNumber(io.roundLedgerReader?.(stateRoot) ?? readRoundLedger(stateRoot), slug)
15306
+ : undefined;
15307
+ const at = address(autoRound);
15308
+ const topic = options.get('topic') ?? '';
15309
+ if (at === null || topic.trim() === '') {
15310
+ emit('нужны --slug --round --topic');
15311
+ return 2;
15312
+ }
15313
+ const ownerPidRaw = options.get('owner-pid');
15314
+ const ownerRunRaw = options.get('owner-run');
15315
+ if (ownerPidRaw !== undefined && ownerRunRaw !== undefined) {
15316
+ emit('--owner-pid и --owner-run взаимоисключающие');
15317
+ return 2;
15318
+ }
15319
+ if (ownerRunRaw !== undefined && ownerRunRaw.trim() === '') {
15320
+ emit('--owner-run пуст');
15321
+ return 2;
15322
+ }
15323
+ const ownerRun = ownerRunRaw?.trim();
15324
+ const ownerPid = ownerRunRaw !== undefined ? 0 : ownerPidRaw === undefined ? process.ppid : Number(ownerPidRaw);
15325
+ const ownerKind = ownerRunRaw !== undefined ? 'run' as const : ownerPidRaw === undefined ? 'parent' as const : 'explicit' as const;
15326
+ const path = roundStatePath(stateRoot, at.slug, at.round);
15327
+ // round-state-lock FR-3/AC-1: captured BEFORE the (long, unlocked) recall below, so the
15328
+ // recheck under the lock can tell "unchanged since this snapshot" from "a different process
15329
+ // opened it while we were recalling".
15330
+ const beforeRaw = readRawRoundState(path);
15331
+ const existing = beforeRaw === null
15332
+ ? null
15333
+ : parseRoundState(beforeRaw) ?? {
15334
+ slug: at.slug, round: at.round, topic: '', startedAt: new Date(now).toISOString(),
15335
+ pid: 1, ownerKind: 'explicit', recalled: [],
15336
+ };
15337
+ let existingOwnerAlive: boolean | null = null;
15338
+ if (existing !== null && flags.has('force') && existing.ownerKind !== 'run') {
15339
+ try { existingOwnerAlive = (io.roundPidProbe ?? probePid)(existing.pid); } catch { /* unavailable is unknown and refuses */ }
15340
+ }
15341
+ const isRunAlive = (runId: string): boolean | null => roundRunOwnerAlive(
15342
+ stateRoot, runId, now, io.roundRunRegistryReader, io.roundPidProbe ?? probePid,
15343
+ );
15344
+ const runId = options.get('run')?.trim();
15345
+ const recallOptions = { limit: 5, ...(runId === undefined || runId === '' ? {} : { runId }) };
15346
+ const preflight = openRound({
15347
+ ...at, topic, startedAt: new Date(now).toISOString(), ownerPid, ownerKind,
15348
+ ...(ownerRun === undefined || ownerRun === '' ? {} : { ownerRun }),
15349
+ ...(runId === undefined || runId === '' ? {} : { run: runId }), recalled: [], existing,
15350
+ force: flags.has('force'), existingOwnerAlive, isRunAlive,
15351
+ });
15352
+ if (!preflight.ok) {
15353
+ // AM-5: the round we are refusing to touch may itself be a stuck `exec` claim (its restore
15354
+ // section exhausted its lock-busy retries and left `ownerKind: 'exec'`) — name that out loud
15355
+ // rather than leaving the operator to guess why a pid that "shouldn't" be alive is blocking.
15356
+ const staleMinutes = existing === null ? null : roundExecStaleAgeMinutes(existing, now);
15357
+ const reason = staleMinutes === null
15358
+ ? preflight.reason
15359
+ : `${preflight.reason} (владелец завис в exec ${staleMinutes} мин)`;
15360
+ emit(reason, { round: at.round, ...(staleMinutes === null ? {} : { staleExecMinutes: staleMinutes }) });
15361
+ return preflight.exit;
15362
+ }
15363
+
15364
+ let lessons: readonly { id: string; reward: number; domain: string; text: string }[] = [];
15365
+ try {
15366
+ lessons = io.roundRecall !== undefined
15367
+ ? await io.roundRecall(projectRoot, topic, recallOptions)
15368
+ : (await recallHybrid(projectRoot, topic, recallOptions)).hits.slice(0, 5).map((hit) => ({
15369
+ id: patternRecordId(hit.pattern),
15370
+ reward: hit.pattern.reward,
15371
+ domain: hit.pattern.domain,
15372
+ text: hit.pattern.pattern,
15373
+ }));
15374
+ } catch {
15375
+ lessons = [];
15376
+ }
15377
+ const opened = openRound({
15378
+ ...at, topic, startedAt: new Date(now).toISOString(), ownerPid, ownerKind,
15379
+ ...(ownerRun === undefined || ownerRun === '' ? {} : { ownerRun }),
15380
+ ...(runId === undefined || runId === '' ? {} : { run: runId }),
15381
+ recalled: lessons.slice(0, 5).map((lesson) => lesson.id), existing: null,
15382
+ force: false, existingOwnerAlive: null, isRunAlive,
15383
+ });
15384
+ if (!opened.ok) { emit(opened.reason); return opened.exit; }
15385
+ const openedState: RoundState = { ...opened.state, execs: [], stateId: generateRoundStateId() };
15386
+ let archived: string | undefined;
15387
+ try {
15388
+ const locked = withRoundStateLock(stateRoot, () => {
15389
+ // AM-3/AM-6: recall ran unlocked and may have taken a while — reread NOW, under the lock,
15390
+ // and decide fresh from what is ACTUALLY there rather than from the pre-recall snapshot.
15391
+ //
15392
+ // AM-3 (was: refuse only when the bytes changed AND the foreign pid differed from ours):
15393
+ // `ppid` coincides for two `dz` launched from the same shell, and every run-owned state
15394
+ // carries pid 0 — so "same pid" proved nothing about identity. ANY change in raw bytes since
15395
+ // `beforeRaw` is now the refusal trigger; the foreign pid is reported for diagnostics only,
15396
+ // never consulted for the decision.
15397
+ //
15398
+ // AM-6 (was: an unconditional `readFileSync(path)` while archiving threw a bare ENOENT if
15399
+ // the target vanished mid-recall): a state that is simply GONE now is not a race to refuse —
15400
+ // it is exactly the "no existing round" case, --force or not. Re-decide fresh: no file under
15401
+ // the lock ⇒ ordinary open, no archive, regardless of what `beforeRaw`/`existing` said.
15402
+ const nowRaw = readRawRoundState(path);
15403
+ if (nowRaw === beforeRaw) {
15404
+ // Unchanged since the pre-recall snapshot: proceed exactly as `preflight` planned —
15405
+ // including the --force archive-a-dead-owner flow, which is safe here because nothing
15406
+ // touched `existing`'s bytes while we were recalling.
15407
+ if (preflight.archiveExisting && existing !== null) {
15408
+ const compactStartedAt = new Date(existing.startedAt).toISOString().replace(/[-:.]/g, '');
15409
+ archived = join(stateRoot, '.dz', 'rounds', 'archive', `${at.slug}-${at.round}-${compactStartedAt}.json`);
15410
+ mkdirSync(dirname(archived), { recursive: true });
15411
+ writeFileSync(archived, readFileSync(path), { flag: 'wx' });
15412
+ }
15413
+ writeJsonAtomic(path, openedState);
15414
+ return { ok: true as const };
15415
+ }
15416
+ if (nowRaw === null) {
15417
+ // AM-6: vanished under us — nothing left to conflict with or to archive.
15418
+ writeJsonAtomic(path, openedState);
15419
+ return { ok: true as const };
15420
+ }
15421
+ // Something is there now, and it is byte-different from what we planned around: refuse.
15422
+ // The pid below is diagnostic only (AM-3) — it never gates the decision.
15423
+ const foreign = parseRoundState(nowRaw);
15424
+ return { refused: 'already-open' as const, pid: foreign?.pid ?? -1 };
15425
+ }, io);
15426
+ if ('refused' in locked) {
15427
+ if (locked.refused === 'lock-busy') {
15428
+ emit(`lock busy: ${locked.reason}`, { refused: 'lock-busy' });
15429
+ return 1;
15430
+ }
15431
+ emit(`круг уже открыт (pid ${locked.pid}) — состояние не перезаписано`, { refused: 'already-open', pid: locked.pid });
15432
+ return 1;
15433
+ }
15434
+ } catch (error) {
15435
+ emit(`круг не открыт: ${error instanceof Error ? error.message : String(error)}`);
15436
+ return 1;
15437
+ }
15438
+ const owner = openedState.ownerKind === 'run'
15439
+ ? `владелец: run ${openedState.ownerRun} (run)`
15440
+ : `владелец: pid ${openedState.pid} (${openedState.ownerKind})`;
15441
+ if (json) {
15442
+ emit('круг открыт', { state: openedState, owner, stateRoot, lessons: lessons.slice(0, 5), ...(archived === undefined ? {} : { archived }) });
15443
+ } else {
15444
+ if (archived !== undefined) write(`архивировано: ${archived}`);
15445
+ write(`=== КРУГ ОТКРЫТ: ${at.slug} круг ${at.round}`);
15446
+ write(`state root: ${stateRoot}`);
15447
+ write(owner);
15448
+ write(`--- уроки для брифа (${lessons.slice(0, 5).length} поднято):`);
15449
+ for (const lesson of lessons.slice(0, 5)) {
15450
+ const oneLine = lesson.text.replace(/[\r\n\u2028\u2029\u0085\v\f]+/g, ' ⏎ ');
15451
+ write(` [${lesson.reward.toFixed(2)}] (${lesson.domain}) ${oneLine.slice(0, 160)}`);
15452
+ }
15453
+ }
15454
+ return 0;
15455
+ }
15456
+
15457
+ if (sub === 'exec') {
15458
+ const at = address();
15459
+ const briefArg = options.get('brief') ?? '';
15460
+ const timeoutRaw = options.get('timeout-min') ?? '30';
15461
+ const timeoutMinutes = Number(timeoutRaw);
15462
+ if (at === null || briefArg.trim() === '' || !Number.isInteger(timeoutMinutes) || timeoutMinutes <= 0) {
15463
+ emit('нужны --slug --round --brief; --timeout-min должен быть целым числом больше нуля');
15464
+ return 2;
15465
+ }
15466
+ const briefPath = resolve(cwd, briefArg);
15467
+ let briefText: string;
15468
+ try {
15469
+ briefText = readFileSync(briefPath, 'utf8');
15470
+ } catch {
15471
+ emit(`brief не читается: ${briefArg}`);
15472
+ return 2;
15473
+ }
15474
+ const path = roundStatePath(stateRoot, at.slug, at.round);
15475
+ let state = readRoundState(path);
15476
+ if (state === null) {
15477
+ emit(existsSync(path) ? 'состояние круга не читается' : 'круг не открыт');
15478
+ return 1;
15479
+ }
15480
+
15481
+ const model = options.get('model') ?? 'gpt-5.6-sol';
15482
+ const effort = options.get('effort') ?? 'high';
15483
+ const logArg = options.get('log') ?? join('.dz', 'rounds', `${at.slug}-${at.round}.exec.log`);
15484
+ const logPath = resolve(cwd, logArg);
15485
+ const startedMs = io.roundNow?.() ?? Date.now();
15486
+ const startedAt = new Date(startedMs).toISOString();
15487
+ const request = {
15488
+ command: 'codex' as const,
15489
+ args: [
15490
+ 'exec',
15491
+ '-c', `model=${model}`,
15492
+ '-c', `model_reasoning_effort=${effort}`,
15493
+ '--dangerously-bypass-approvals-and-sandbox',
15494
+ briefText,
15495
+ ],
15496
+ cwd: stateRoot,
15497
+ logPath,
15498
+ timeoutMs: timeoutMinutes * 60_000,
15499
+ killGraceMs: io.roundKillGraceMs ?? 10_000,
15500
+ };
15501
+ let execClaimId = '';
15502
+ try {
15503
+ // T3/FR-1, fix-round AM-1: reread state under the lock immediately before claiming ownership
15504
+ // — a short, synchronous critical section, released before the (possibly long) codex child
15505
+ // below runs. NO fallback to the pre-lock `state` snapshot (that was the resurrection bug:
15506
+ // `readRoundState(path) ?? state!` would recreate a round that had been closed in the
15507
+ // meantime). The claim proceeds ONLY when the state currently under the lock still carries the
15508
+ // exact `stateId` we read before acquiring it — pid/ppid can coincide across processes, but a
15509
+ // `stateId` never does.
15510
+ execClaimId = randomBytes(8).toString('hex');
15511
+ const claimed = withRoundStateLock(stateRoot, () => {
15512
+ const outcome = readStateOrRefuse(path, state!.stateId);
15513
+ if ('refused' in outcome) return outcome;
15514
+ if (outcome.ownerKind === 'exec' && outcome.execClaimId !== undefined) {
15515
+ return { refused: 'exec-in-progress' as const, execClaimId: outcome.execClaimId };
15516
+ }
15517
+ writeJsonAtomic(path, { ...outcome, pid: io.roundPid ?? process.pid, ownerKind: 'exec', execClaimId, execClaimedAt: new Date(io.roundNow?.() ?? Date.now()).toISOString() });
15518
+ return { ok: true as const, base: outcome };
15519
+ }, io);
15520
+ if ('refused' in claimed) {
15521
+ if (claimed.refused === 'lock-busy') {
15522
+ emit(`exec не запущен: владелец круга не обновлён: lock busy: ${claimed.reason}`, { refused: 'lock-busy' });
15523
+ return 1;
15524
+ }
15525
+ if (claimed.refused === 'gone') {
15526
+ emit('exec не запущен: круг закрыт во время exec, владелец не менялся', { refused: 'gone' });
15527
+ return 1;
15528
+ }
15529
+ if (claimed.refused === 'exec-in-progress') {
15530
+ emit(`exec не запущен: у круга уже идёт exec (claim ${claimed.execClaimId})`, { refused: 'exec-in-progress', execClaimId: claimed.execClaimId });
15531
+ return 1;
15532
+ }
15533
+ const replaced = claimed as RoundStateReplaced;
15534
+ emit(
15535
+ `exec не запущен: состояние круга заменено (stateId ${replaced.stateId ?? 'unknown'}), возврат владельца пропущен`,
15536
+ { refused: 'replaced', stateId: replaced.stateId },
15537
+ );
15538
+ return 1;
15539
+ }
15540
+ state = claimed.base;
15541
+ } catch (error) {
15542
+ emit(`exec не запущен: владелец круга не обновлён: ${error instanceof Error ? error.message : String(error)}`);
15543
+ return 1;
15544
+ }
15545
+ let receipt: RoundSpawnReceipt;
15546
+ try {
15547
+ try {
15548
+ receipt = await (io.roundSpawn ?? spawnRoundCodex)(request);
15549
+ } catch (error) {
15550
+ const err = error as NodeJS.ErrnoException;
15551
+ receipt = { exitCode: null, timedOut: false, signal: null, ...(err.code === undefined ? {} : { errorCode: err.code }), error: err.message };
15552
+ }
15553
+ } finally {
15554
+ try {
15555
+ // T3/FR-1, fix-round AM-1/AM-5: the return leg — a second short lock hold, symmetric with
15556
+ // the claim above, and gated by the SAME stateId check (the child may have run long enough
15557
+ // for someone else to close or replace this round while it was running). AM-5: a busy lock
15558
+ // here gets up to ROUND_RESTORE_LOCK_ATTEMPTS tries with the same timeout before giving up —
15559
+ // a codex child can legitimately run for a while, so ownership recovery deserves more than
15560
+ // one attempt before leaving the round stuck at `ownerKind: 'exec'`.
15561
+ const restored = withRoundStateLockRetried(stateRoot, () => {
15562
+ const outcome = readStateOrRefuse(path, state!.stateId);
15563
+ if ('refused' in outcome) return outcome;
15564
+ // Lead edit after Codex re-review: restore only OUR claim — another exec of the same round
15565
+ // instance has its own execClaimId and must not be wiped by our base state.
15566
+ if (outcome.execClaimId !== execClaimId) {
15567
+ return { refused: 'replaced' as const, stateId: outcome.stateId, execClaimId: outcome.execClaimId };
15568
+ }
15569
+ writeJsonAtomic(path, state);
15570
+ return { ok: true as const };
15571
+ }, io, ROUND_RESTORE_LOCK_ATTEMPTS);
15572
+ if ('refused' in restored) {
15573
+ if (restored.refused === 'lock-busy') {
15574
+ // AM-5: no new flag or command is added — this names the manual remedy in prose (a
15575
+ // literal `--flag`-shaped token here would be caught by known-flags-drift.test.ts as an
15576
+ // undocumented flag, which would be exactly the wrong signal for text naming no flag at
15577
+ // all). The durable fix is that `open`/`status` surface the resulting stuck
15578
+ // `ownerKind: 'exec'` on their own (roundExecStaleAgeMinutes), so it is never silently
15579
+ // left for someone to trip over.
15580
+ emit(
15581
+ 'владелец круга не восстановлен (ownerKind=exec остался): повторите dz round exec для этого круга, когда блокировка освободится',
15582
+ { refused: 'lock-busy', ownerKind: 'exec' },
15583
+ );
15584
+ return 1;
15585
+ }
15586
+ if (restored.refused === 'gone') {
15587
+ emit('круг закрыт во время exec, владелец не менялся', { refused: 'gone' });
15588
+ return 1;
15589
+ }
15590
+ emit(
15591
+ `состояние круга заменено (stateId ${restored.stateId ?? 'unknown'}), возврат владельца пропущен`,
15592
+ { refused: 'replaced', stateId: restored.stateId },
15593
+ );
15594
+ return 1;
15595
+ }
15596
+ } catch (error) {
15597
+ emit(`exec завершён, но владелец круга не восстановлен: ${error instanceof Error ? error.message : String(error)}`);
15598
+ return 1;
15599
+ }
15600
+ }
15601
+ const endedMs = io.roundNow?.() ?? Date.now();
15602
+ const endedAt = new Date(endedMs).toISOString();
15603
+ let logBuffer = Buffer.alloc(0);
15604
+ try { logBuffer = readFileSync(logPath); } catch { /* no output is an empty receipt */ }
15605
+ const logText = logBuffer.toString('utf8');
15606
+ const bytes = logBuffer.byteLength;
15607
+ const tokens = parseCodexTokens(logText);
15608
+ const outcome = classifyRoundExecOutcome({
15609
+ exitCode: receipt.exitCode,
15610
+ timedOut: receipt.timedOut,
15611
+ bytes,
15612
+ tail: logBuffer.subarray(Math.max(0, bytes - 4096)).toString('utf8'),
15613
+ });
15614
+ const row = buildRoundExecRow({
15615
+ ...at,
15616
+ model,
15617
+ effort,
15618
+ minutes: Math.max(0, Math.floor((endedMs - startedMs) / 60_000)),
15619
+ tokens,
15620
+ outcome,
15621
+ exitCode: receipt.exitCode,
15622
+ bytes,
15623
+ startedAt,
15624
+ endedAt,
15625
+ log: logArg,
15626
+ brief: briefArg,
15627
+ });
15628
+ if (io.roundLedgerWriter !== undefined) io.roundLedgerWriter(stateRoot, row);
15629
+ else cmdFeatureAdrRecord(new Map([
15630
+ ['kind', 'ledger'], ['stage', 'round-exec'], ['slug', state.slug], ['row', JSON.stringify(row)], ['project', stateRoot],
15631
+ ]), new Set(), stateRoot, () => undefined);
15632
+ const ledgerTail = io.roundLedgerReader?.(stateRoot) ?? readRoundLedgerTail(stateRoot);
15633
+ if (!roundExecReceiptFound(ledgerTail, row)) {
15634
+ emit('строка round-exec не найдена — результат НЕ подтверждён');
15635
+ return 1;
15636
+ }
15637
+ try {
15638
+ writeJsonAtomic(path, {
15639
+ ...state,
15640
+ execs: [...(state.execs ?? []), { startedAt, endedAt, exitCode: receipt.exitCode, outcome, tokens }],
15641
+ });
15642
+ } catch (error) {
15643
+ emit(`строка round-exec подтверждена, но состояние не обновлено: ${error instanceof Error ? error.message : String(error)}`);
15644
+ return 1;
15645
+ }
15646
+ if (receipt.errorCode === 'ENOENT') emit('codex не найден', { row });
15647
+ else emit(`round exec: ${row.minutes} min; exit ${row.exitCode ?? 'null'}; ${row.bytes} bytes; tokens ${row.tokens ?? 'не найдены'}; ${row.outcome}`, { row });
15648
+ return outcome === 'done' ? 0 : 1;
15649
+ }
15650
+
15651
+ if (sub === 'close') {
15652
+ const at = address();
15653
+ if (at === null || !options.has('outcome')) {
15654
+ emit('нужны --slug --round --outcome');
15655
+ return 2;
15656
+ }
15657
+ const path = roundStatePath(stateRoot, at.slug, at.round);
15658
+ const state = readRoundState(path);
15659
+ if (state === null) {
15660
+ emit(existsSync(path) ? 'состояние круга не читается — круг НЕ закрыт' : 'круг не открыт');
15661
+ return 1;
15662
+ }
15663
+ const lessons = optionLists.get('lesson') ?? [];
15664
+ const knownLessonIds = lessons.filter((id) => {
15665
+ try {
15666
+ return io.roundLessonExists !== undefined
15667
+ ? io.roundLessonExists(projectRoot, id)
15668
+ : loadStoreRecords(projectRoot).some((record) => record.id === id);
15669
+ } catch { return false; }
15670
+ });
15671
+ const numeric = (key: string): number | undefined => options.has(key) ? Number(options.get(key)) : undefined;
15672
+ const closedAtIso = new Date(now).toISOString();
15673
+ // AM-4: predict the marker `closeRound` will compute for THIS attempt (same slug/round/closedAt
15674
+ // it will use) and check whether the ledger already carries it BEFORE calling `closeRound` —
15675
+ // this is what makes a retried `close` idempotent: if a prior invocation's write already landed
15676
+ // (this run's own tail read, not trusted from the earlier failed attempt's own belief), skip the
15677
+ // write below instead of appending a duplicate row.
15678
+ const predictedMarker = predictedRoundCloseMarker(at.slug, at.round, closedAtIso);
15679
+ const tailBeforeWrite = io.roundLedgerReader?.(stateRoot) ?? readRoundLedgerTail(stateRoot);
15680
+ // Lead edit after Codex re-review: a retried close carries a NEW clock, so the marker alone never
15681
+ // matches — the row's stateId (identity of the state instance) is what makes the retry idempotent.
15682
+ const alreadyRecorded = tailBeforeWrite.includes(predictedMarker)
15683
+ || (state.stateId !== undefined && tailBeforeWrite.includes(`"stateId":"${state.stateId}"`));
15684
+ // Lead edit after Codex re-review: a retry whose row is already in the ledger (same stateId) must
15685
+ // not re-run closeRound's postcondition against a marker computed from the NEW clock — the earlier
15686
+ // row is the receipt; only the state-file removal remains.
15687
+ const closed = alreadyRecorded
15688
+ ? { ok: true as const, row: undefined, marker: `already-recorded:${state.stateId ?? predictedMarker}` }
15689
+ : closeRound({
15690
+ state,
15691
+ outcome: options.get('outcome') ?? '',
15692
+ ...(options.has('reason') ? { reason: options.get('reason') } : {}),
15693
+ lessons,
15694
+ knownLessonIds,
15695
+ ...(options.has('no-new-knowledge') ? { noNewKnowledge: options.get('no-new-knowledge') } : {}),
15696
+ ...(options.has('tokens') ? { tokens: numeric('tokens') } : {}),
15697
+ ...(options.has('agents') ? { agents: numeric('agents') } : {}),
15698
+ ...(options.has('coder') ? { coder: options.get('coder') } : {}),
15699
+ ...(options.has('reviewer') ? { reviewer: options.get('reviewer') } : {}),
15700
+ ...(options.has('note') ? { note: options.get('note') } : {}),
15701
+ ...(flags.has('no-cost') ? { noCost: true } : {}),
15702
+ closedAt: closedAtIso,
15703
+ ...(state.stateId !== undefined ? { stateId: state.stateId } : {}),
15704
+ }, {
15705
+ writeLedger: (row) => {
15706
+ // AM-4 idempotent retry: the row for this attempt was already witnessed in the tail read
15707
+ // above — do not append a second one. `closeRound`'s own postcondition (rereading the tail
15708
+ // and checking it contains the marker) still passes, because the marker is already there.
15709
+ if (alreadyRecorded) return undefined;
15710
+ if (io.roundLedgerWriter !== undefined) return io.roundLedgerWriter(stateRoot, row);
15711
+ return cmdFeatureAdrRecord(new Map([
15712
+ ['kind', 'ledger'], ['stage', 'round'], ['slug', state.slug], ['row', JSON.stringify(row)], ['project', stateRoot],
15713
+ ]), new Set(), stateRoot, () => undefined);
15714
+ },
15715
+ readLedgerTail: () => io.roundLedgerReader?.(stateRoot) ?? readRoundLedgerTail(stateRoot),
15716
+ });
15717
+ if (!closed.ok) { emit(closed.reason); return closed.exit; }
15718
+ try {
15719
+ // T4/FR-1/FR-2, fix-round AM-2: the ledger write above (via `closed`) stays OUTSIDE the lock
15720
+ // (teach:0ea46034); only the final reread-and-delete is a lock-guarded critical section, and it
15721
+ // now deletes ONLY the exact state instance the ledger row above was written for — identified
15722
+ // by `state.stateId`, read before the lock was ever taken.
15723
+ const deleted = withRoundStateLock(stateRoot, () => {
15724
+ const outcome = readStateForCloseOrRefuse(path, state.stateId);
15725
+ if ('refused' in outcome) return outcome;
15726
+ unlinkSync(path);
15727
+ return { ok: true as const };
15728
+ }, io);
15729
+ if ('refused' in deleted) {
15730
+ if (deleted.refused === 'lock-busy') {
15731
+ // AM-4: the ledger row is ALREADY written by the time this lock is even attempted (see
15732
+ // above) — so a busy lock here never leaves the outcome unrecorded, only the round's OWN
15733
+ // state file open. Say exactly that, and make the retry path explicit.
15734
+ emit(
15735
+ 'строка леджера записана, состояние круга осталось открытым — повторите close',
15736
+ { refused: 'lock-busy', ledgerWritten: true },
15737
+ );
15738
+ return 1;
15739
+ }
15740
+ if (deleted.refused === 'closed-already') {
15741
+ // AM-2: the state file is already gone — this close's own ledger row is written (above, or
15742
+ // by a previous invocation of this same idempotent attempt), so this is the same round
15743
+ // reaching its already-closed postcondition by a different path, not a failure.
15744
+ emit('круг уже закрыт (строка леджера записана)', { closed: true, alreadyClosed: true, marker: closed.marker });
15745
+ return 0;
15746
+ }
15747
+ // AM-2: something else's state sits at this path now (a different stateId) — never delete it.
15748
+ emit('состояние заменено, не удалено', { refused: 'replaced', stateId: deleted.stateId });
15749
+ return 1;
15750
+ }
15751
+ } catch (error) {
15752
+ emit(`строка подтверждена, но состояние не удалено — круг НЕ закрыт: ${error instanceof Error ? error.message : String(error)}`);
15753
+ return 1;
15754
+ }
15755
+ emit(`✓ строка круга в леджере подтверждена чтением (${closed.marker})`, { row: closed.row, marker: closed.marker });
15756
+ return 0;
15757
+ }
15758
+
15759
+ if (sub === 'status') {
15760
+ const rawThreshold = options.get('older-than') ?? '120';
15761
+ const olderThan = Number(rawThreshold);
15762
+ if (!Number.isInteger(olderThan) || olderThan < 0) {
15763
+ emit('--older-than должен быть целым числом минут не меньше нуля', { open: [] });
15764
+ return 0;
15765
+ }
15766
+ const dir = join(stateRoot, '.dz', 'rounds');
15767
+ const states: RoundState[] = [];
15768
+ try {
15769
+ for (const name of readdirSync(dir).filter((entry) => entry.endsWith('.json')).sort()) {
15770
+ const state = readRoundState(join(dir, name));
15771
+ if (state !== null) states.push(state);
15772
+ }
15773
+ } catch { /* no state directory is an honestly empty report */ }
15774
+ const rows = listRounds(states, {
15775
+ now,
15776
+ olderThanMinutes: olderThan,
15777
+ isPidAlive: io.roundPidProbe ?? probePid,
15778
+ isRunAlive: (runId) => roundRunOwnerAlive(
15779
+ stateRoot, runId, now, io.roundRunRegistryReader, io.roundPidProbe ?? probePid,
15780
+ ),
15781
+ });
15782
+ // AM-5: independent of the `--older-than` filter above (a stuck exec claim is worth flagging at
15783
+ // 10 minutes regardless of the round's own age threshold) — computed over ALL open states, and
15784
+ // additive: when none apply, neither branch below emits anything extra, so the two byte-pinned
15785
+ // zero-rounds lines (NFR-1, see the comment below) stay untouched.
15786
+ const staleExec = states
15787
+ .map((state) => {
15788
+ const minutes = roundExecStaleAgeMinutes(state, now);
15789
+ return minutes === null ? null : { slug: state.slug, round: state.round, minutes };
15790
+ })
15791
+ .filter((warning): warning is { slug: string; round: number; minutes: number } => warning !== null);
15792
+ if (json) {
15793
+ emit(rows.length > 0 ? `⚠ ${rows.length} open round(s) older than ${olderThan} min` : 'нет старых открытых кругов', {
15794
+ stateRoot, olderThan, open: rows, ...(staleExec.length === 0 ? {} : { staleExec }),
15795
+ });
15796
+ } else {
15797
+ // FR-3 prints `state root: <dir>` on open unconditionally; here it is printed only when the
15798
+ // root was EXPLICITLY chosen (--state-root / DZ_ROUND_STATE_ROOT). Printing it unconditionally
15799
+ // would change the two default-cwd zero-rounds lines pinned exactly by
15800
+ // round-cli.test.ts ("status reports a fresh open round…" / "…no open rounds"), which NFR-1
15801
+ // requires to stay byte-identical and unmodified.
15802
+ if (stateRootExplicit) write(`state root: ${stateRoot}`);
15803
+ write(states.length === 0
15804
+ ? 'открытых кругов нет'
15805
+ : `открытых кругов: ${states.length}, старше ${olderThan} мин: ${rows.length}`);
15806
+ for (const row of rows) {
15807
+ const live = row.pidAlive === true ? 'alive' : row.pidAlive === false ? 'dead' : 'unknown';
15808
+ write(`${row.state.slug}#${row.state.round} · ${row.ageMinutes} min · pid ${row.state.pid} ${live} · ${row.state.topic}`);
15809
+ }
15810
+ for (const warning of staleExec) {
15811
+ write(`⚠ ${warning.slug}#${warning.round}: владелец завис в exec ${warning.minutes} мин — восстановите вручную (dz round exec вернул lock-busy при возврате владельца)`);
15812
+ }
15813
+ }
15814
+ return 0;
15815
+ }
15816
+
15817
+ emit('использование: dz round open|exec|close|status');
15818
+ return 2;
15819
+ }
15820
+
15821
+ /**
15822
+ * ledger-stage-minutes T2: the `ts` of the LAST ledger row (scanning from the end, so a duplicate
15823
+ * or out-of-order runId still finds the truly latest one) that carries the given `runId`. Every
15824
+ * failure mode — the file does not exist yet, a permission error — returns `null` rather than
15825
+ * throwing: this is a BEST-EFFORT observability lookup feeding a non-blocking field (ADR-003), never
15826
+ * a gate the write must pass.
15827
+ *
15828
+ * fix-round-1/AM-n (cross-family review B, MEDIUM): a torn or non-object line — `ledger-corrupt-line`
15829
+ * — is NOT silently skipped past. The original code `continue`d over it and kept scanning further
15830
+ * back, which could return an OLDER valid row for this `runId` while a NEWER one for the same run
15831
+ * sat hidden on the other side of the corrupt line (or was itself the corrupt line). Once the scan
15832
+ * hits a line it cannot parse as a JSON object, it can no longer prove which row is truly LAST for
15833
+ * this run, so it stops and reports `null` (⇒ `minutesSource: 'unavailable'`) rather than risk an
15834
+ * UNDERSTATED delta computed against a stale row.
15835
+ */
15836
+ function findPreviousLedgerRowTs(ledgerPath: string, runId: string): string | null {
15837
+ if (runId === '') return null;
15838
+ let body: string;
15839
+ try {
15840
+ body = readFileSync(ledgerPath, 'utf-8');
15841
+ } catch {
15842
+ return null;
15843
+ }
15844
+ const lines = body.split('\n').filter((l) => l !== '');
15845
+ for (let i = lines.length - 1; i >= 0; i--) {
15846
+ let parsed: unknown;
15847
+ try {
15848
+ parsed = JSON.parse(lines[i] as string);
15849
+ } catch {
15850
+ // ledger-corrupt-line: everything from here to the start of the file is unprovable — a real
15851
+ // match further back cannot be trusted to still be the LAST one, so this is `unavailable`,
15852
+ // never a guess made by skipping past what we could not read.
15853
+ return null;
15854
+ }
15855
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
15856
+ // Same reasoning as the parse failure above: a non-object line is exactly as untrustworthy.
15857
+ return null;
15858
+ }
15859
+ const row = parsed as Record<string, unknown>;
15860
+ if (typeof row['runId'] === 'string' && row['runId'].trim() === runId) {
15861
+ return typeof row['ts'] === 'string' && row['ts'].trim() !== '' ? row['ts'] : null;
15862
+ }
15863
+ }
15864
+ return null;
15865
+ }
15866
+
14225
15867
  function cmdFeatureAdrRecord(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
14226
15868
  const json = flags.has('json');
14227
15869
  // `--backfill` is a different verb on the same store: it fills the ledger's null cost fields from
@@ -14260,10 +15902,73 @@ function cmdFeatureAdrRecord(options: Map<string, string>, flags: Set<string>, c
14260
15902
  const markName = (options.get('mark') ?? '').trim();
14261
15903
  const markPath = markName === '' ? null : join(markDir, markName.replace(/[^\w.-]/g, '_'));
14262
15904
 
15905
+ // ledger-stage-minutes T2/FR-2: `--run-id` fills the payload's `runId` ONLY WHEN the payload does
15906
+ // not already carry one — the same gap-only stamping discipline `decideRecordWrite` already uses
15907
+ // for `runnerId`. "Absent" is deliberately wider than "missing key": `runId: null`, `runId: ''`
15908
+ // and a non-string `runId` (a number, an object — never a real join key) are ALL gaps too, exactly
15909
+ // the `isRunnerGap` rule one seam over — fixed-round-1/AM-n confirmed this is the INTENDED contract
15910
+ // ("missing when absent or blank"), not a bug: only a genuine non-empty string counts as "the
15911
+ // caller already knew it", so any of those gap shapes are correctly overwritten by the flag. A
15912
+ // malformed --row is left untouched here: decideRecordWrite reports the real JSON parse error,
15913
+ // this merge step must never invent a different one.
15914
+ const isRunIdArgGap = (v: unknown): boolean => v === null || v === undefined || typeof v !== 'string' || v.trim() === '';
15915
+ let effectivePayloadRaw = payloadRaw;
15916
+ const explicitRunId = (options.get('run-id') ?? '').trim();
15917
+ if (kind === 'ledger' && explicitRunId !== '') {
15918
+ try {
15919
+ const parsed: unknown = JSON.parse(payloadRaw);
15920
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
15921
+ const rowObj = parsed as Record<string, unknown>;
15922
+ if (isRunIdArgGap(rowObj['runId'])) {
15923
+ // fix-round-1/AM-n (cross-family review B, MEDIUM): the flag-filled runId now carries its
15924
+ // provenance, the same discipline `resolved-at-write` already applies to the OTHER runId
15925
+ // source (write-time auto-resolution below) — an un-sourced runId looked exactly like one
15926
+ // the caller supplied. A non-empty `runIdSource` the payload already carries (an odd shape,
15927
+ // since `runId` itself was a gap) is left alone rather than overwritten with a guess.
15928
+ const hasRunIdSource = typeof rowObj['runIdSource'] === 'string' && rowObj['runIdSource'].trim() !== '';
15929
+ effectivePayloadRaw = JSON.stringify({
15930
+ ...rowObj,
15931
+ runId: explicitRunId,
15932
+ ...(hasRunIdSource ? {} : { runIdSource: 'cli-flag' }),
15933
+ });
15934
+ }
15935
+ }
15936
+ } catch { /* decideRecordWrite reports the parse error itself */ }
15937
+ }
15938
+
15939
+ // FR-2/FR-3: find the runId this row will carry (explicit flag, or one the payload already had),
15940
+ // then read the ledger BEST-EFFORT for the last row of that same run and its `ts`. A read failure
15941
+ // (file absent, unreadable, a torn or malformed line) is an honest `previousRowTs: null` — never
15942
+ // a thrown error, because a record write must never fail on an OBSERVABILITY lookup (ADR-003).
15943
+ let runIdForLookup = '';
15944
+ try {
15945
+ const parsed: unknown = JSON.parse(effectivePayloadRaw);
15946
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
15947
+ const v = (parsed as Record<string, unknown>)['runId'];
15948
+ if (typeof v === 'string' && v.trim() !== '') runIdForLookup = v.trim();
15949
+ }
15950
+ } catch { /* decideRecordWrite reports the parse error itself */ }
15951
+ // Lead edit after re-review (Codex B): the pipeline's own rows have no runId in the payload — it is
15952
+ // resolved at write time below. Resolve it HERE as well (same resolver, same registry) so the
15953
+ // previous-row lookup and the minutes delta cover the main path, not only explicit ids.
15954
+ let resolvedRunIdPre: string | null = null;
15955
+ if (kind === 'ledger' && runIdForLookup === '') {
15956
+ try {
15957
+ const parsed: unknown = JSON.parse(effectivePayloadRaw);
15958
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
15959
+ resolvedRunIdPre = resolveLedgerRunId(parsed as Record<string, unknown>, listCostLedgerRuns());
15960
+ if (resolvedRunIdPre !== null) runIdForLookup = resolvedRunIdPre.trim();
15961
+ }
15962
+ } catch { /* resolution is an ENRICHMENT; the row is written regardless */ }
15963
+ }
15964
+ const previousRowTs = kind === 'ledger' && runIdForLookup !== '' ? findPreviousLedgerRowTs(target, runIdForLookup) : null;
15965
+
14263
15966
  const decision = decideRecordWrite({
14264
15967
  kind,
14265
- payloadRaw,
15968
+ payloadRaw: effectivePayloadRaw,
14266
15969
  stage,
15970
+ previousRowTs,
15971
+ effectiveRunId: runIdForLookup !== '' ? runIdForLookup : null,
14267
15972
  stageProducedResult: flags.has('no-result') ? false : true,
14268
15973
  markExists: markPath !== null && existsSync(markPath),
14269
15974
  targetExists: existsSync(target),
@@ -14317,10 +16022,23 @@ function cmdFeatureAdrRecord(options: Map<string, string>, flags: Set<string>, c
14317
16022
  const parsed: unknown = JSON.parse(decision.line);
14318
16023
  if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
14319
16024
  const rowObj = parsed as Record<string, unknown>;
14320
- const resolved = resolveLedgerRunId(rowObj, listCostLedgerRuns());
16025
+ // Lead edit after review #3 (Codex B): ONE resolution per write — reuse the id resolved
16026
+ // before the decision (the same one the minutes delta was measured against) instead of
16027
+ // resolving again; two resolutions could disagree if the run registry moved in between.
16028
+ const resolved = resolvedRunIdPre !== null ? resolvedRunIdPre : resolveLedgerRunId(rowObj, listCostLedgerRuns());
14321
16029
  if (resolved !== null) {
14322
16030
  // Marked, because a resolved run id is our inference, not something the pipeline knew.
14323
- lineToWrite = JSON.stringify({ ...rowObj, runId: resolved, runIdSource: 'resolved-at-write' });
16031
+ // Keep the minutes fields LAST (NFR-1 of ledger-stage-minutes): splice runId/runIdSource in
16032
+ // right before `ts` when the decided row already carries the stamped tail.
16033
+ const ordered: Record<string, unknown> = {};
16034
+ let spliced = false;
16035
+ for (const [k, v] of Object.entries(rowObj)) {
16036
+ if (k === 'ts' && !spliced) { ordered['runId'] = resolved; ordered['runIdSource'] = 'resolved-at-write'; spliced = true; }
16037
+ if (k === 'runId' || k === 'runIdSource') continue;
16038
+ ordered[k] = v;
16039
+ }
16040
+ if (!spliced) { ordered['runId'] = resolved; ordered['runIdSource'] = 'resolved-at-write'; }
16041
+ lineToWrite = JSON.stringify(ordered);
14324
16042
  }
14325
16043
  }
14326
16044
  } catch { /* resolution is an ENRICHMENT; a failure must never cost the row itself */ }
@@ -15632,7 +17350,17 @@ async function cmdQeBridge(options: Map<string, string>, flags: Set<string>, cwd
15632
17350
  );
15633
17351
  }
15634
17352
 
15635
- const recordText = (reportWritten: boolean): string => `${JSON.stringify(buildBridgeSignoffRecord(signoff, {
17353
+ // writeSequence (qe-bridge-signoff-order): diagnostic sequencing metadata — a self-reported
17354
+ // process-local trace with monotonic stamps taken at each named event (start of the first record
17355
+ // write; after the report landed; just before the atomic update). It replaces a wall-clock
17356
+ // mtime comparison that was a race (the record is rewritten AFTER the report by design). It does
17357
+ // NOT prove write order or crash safety: those are proven by the failpoint test (R4-1) and the
17358
+ // report-failure test. Lead edit after Codex review 2026-09-13: honest step names.
17359
+ const seq: Array<{ step: 'signoff-write-started' | 'report-written' | 'record-update-prepared'; monotonicNs: string }> = [
17360
+ { step: 'signoff-write-started', monotonicNs: String(process.hrtime.bigint()) },
17361
+ ];
17362
+
17363
+ const recordText = (reportWritten: boolean, writeSequence: typeof seq): string => `${JSON.stringify(buildBridgeSignoffRecord(signoff, {
15636
17364
  runId,
15637
17365
  claudeBin: resolvedBin,
15638
17366
  binOverride,
@@ -15641,12 +17369,13 @@ async function cmdQeBridge(options: Map<string, string>, flags: Set<string>, cwd
15641
17369
  rawStdoutFile,
15642
17370
  promptSha256,
15643
17371
  ...(parsed.channels === undefined ? {} : { channels: parsed.channels }),
17372
+ writeSequence,
15644
17373
  }), null, 2)}\n`;
15645
17374
 
15646
17375
  let signoffPath: string;
15647
17376
  try {
15648
17377
  signoffPath = uniquePath(join(stateDir, `signoff-${runId}`), '.json');
15649
- writeNewFileOrThrow(signoffPath, recordText(false));
17378
+ writeNewFileOrThrow(signoffPath, recordText(false, seq));
15650
17379
  } catch (error) {
15651
17380
  return failRun(
15652
17381
  'audit-write-failed',
@@ -15664,6 +17393,10 @@ async function cmdQeBridge(options: Map<string, string>, flags: Set<string>, cwd
15664
17393
  }
15665
17394
 
15666
17395
  if (reportError === null) {
17396
+ // the report is on disk: the sequence gains a step BEFORE the `reportWritten:true` record
17397
+ // write, not after — an observer reading the eventual writeSequence must see the report step
17398
+ // land before the record-update step that persists it.
17399
+ seq.push({ step: 'report-written', monotonicNs: String(process.hrtime.bigint()) });
15667
17400
  // the ONLY moment `reportWritten:true` may appear: after the report is on disk
15668
17401
  try {
15669
17402
  // ATOMIC (R4-1): write a sibling temp file, then rename() over the original. On the same
@@ -15672,7 +17405,8 @@ async function cmdQeBridge(options: Map<string, string>, flags: Set<string>, cwd
15672
17405
  // which made the "a crash leaves a record that is true or pessimistic" claim untrue in the
15673
17406
  // one case it was about.
15674
17407
  const tmpPath = `${signoffPath}.tmp.${process.pid}`;
15675
- writeNewFileOrThrow(tmpPath, recordText(true));
17408
+ seq.push({ step: 'record-update-prepared', monotonicNs: String(process.hrtime.bigint()) });
17409
+ writeNewFileOrThrow(tmpPath, recordText(true, seq));
15676
17410
  if (process.env[QE_BRIDGE_FAILPOINT_ENV] === 'hang-before-rename') {
15677
17411
  // test-only: stop dead INSIDE the window, so a SIGKILL can prove the property
15678
17412
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 600_000);
@@ -18410,11 +20144,11 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
18410
20144
  case 'auto-canonicalize':
18411
20145
  return await cmdAutoCanonicalize(options, cwd, write);
18412
20146
  case 'publish':
18413
- return cmdPublish(options, flags, cwd, write, io.publishMirrorRunner);
20147
+ return cmdPublish(options, flags, cwd, write, io.publishMirrorRunner, io.publishSiblingDriftFetcher, io.publishPackedInstallRunner, io.publishExecRunner);
18414
20148
  case 'release':
18415
20149
  return cmdRelease(options, flags, cwd, write, io.releaseRunner);
18416
20150
  case 'parity':
18417
- return cmdParity(options, flags, write, writeErr);
20151
+ return cmdParity(options, flags, write, writeErr, cwd);
18418
20152
  case 'registry':
18419
20153
  return cmdRegistry(options, cwd, write);
18420
20154
  case 'benchmark':
@@ -18499,6 +20233,8 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
18499
20233
  return cmdJournal(options, flags, cwd, write, io.journalIo);
18500
20234
  case 'feature-adr-record':
18501
20235
  return cmdFeatureAdrRecord(options, flags, cwd, write);
20236
+ case 'round':
20237
+ return await cmdRound(options, optionLists, flags, cwd, write, io);
18502
20238
  case 'runs':
18503
20239
  return cmdRuns(options, flags, cwd, write);
18504
20240
  case 'runs-clean':