@dzhechkov/harness-cli 0.8.21 → 0.8.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -18,19 +18,20 @@ import { createRequire } from 'node:module';
18
18
  import { isDeepStrictEqual } from 'node:util';
19
19
  import { JOURNAL_KINDS, formatLine, parseLine, selectWindow, appendWitnessed } from '@dzhechkov/harness-core';
20
20
  import { appendRunEvent, readRunRegistry, liveParents, liveness, probePid, settleDeadRuns, planRegistryArchive, planWorktreeCleanup, renderCleanupPlan, worktreeRemovalsToApply } from '@dzhechkov/harness-core';
21
+ import { openRound, closeRound, listRounds, parseCodexTokens, classifyRoundExecOutcome, buildRoundExecRow, } from '@dzhechkov/harness-core';
21
22
  import { createSkill, getSkillInfo, listSkillsDetailed, formatSkillLoadFailures, formatSkillApplyFailures, resolveTargetName, formatTargetProblem, formatTargetAliasNote, TARGET_NAMES_SORTED, runDoctor, runInit, discoverSkillIds, resolveSelection, formatSelectRefusal, runIntegrationsVerify, resolvePackageSkillRoots, PACKAGE_SKILL_LAYOUTS, benchmarkSkill, benchmarkSkills, scanMcp, reconcileCapabilities, RECONCILE_BANNER, buildRegistry, discoverSkillPackDirs, discoverVerifiablePackDirs, checkUpstream, compareSkills, checkAllUpstream, sweepSkillDrift, syncCanonicalSkill, checkUpgrades, discoverPackages, discoverSourcePackages, fetchAllDownloads, filterByCategory, pretrain, recommend, generatePlugin, publishPackages, runSetup, runMigrate, searchRegistry, runSync, runVerify, runInitAgentsMd, runInitGeminiMd, runSyncAgentsPolicy, runSyncCodexHooks, resolveCodexHome, withNamedLockSync,
22
23
  // dz workflow run (feature dz-workflow-run): the pure scheduler + the dispatch adapters.
23
- TRACE_RUNID_RE, WF_RUN_OWNER_HOST, preflight, runWorkflow, makeClaudePDispatcher, makeCodexExecDispatcher, NamedLockTimeoutError, NamedLockCompromisedError, POLICY_SOURCES, detectPolicyDrift, hasPolicyFence, TARGET_NAMES, buildParityMatrix, downgradeForStaleEvidence, findStaleTranscriptEvidence, TARGET_CAPABILITIES, TARGET_SHORT_LABELS, WORKFLOW_TEMPLATES_RETIRED_MESSAGE, parsePlan, isParseErrors, validatePlan, normalizePlan, planDigest, toTraceProjection, renderPlan, mergeRender, lint, lintExitCode, LOOP_BLOBS, parseTrace, assembleTimeline, runInvariants, deriveAttestation, stampAttestation, corroborate, NOT_WITNESSED, renderTimelineHtml, importEcc, recordPattern, recordLessonForms, normalizeLessonForms, resolveLearningBackend, storeStats, consolidateSessions, pruneNoisePatterns, lessonDeltaReport, removePatternsByIds, snapshotStore, recallHybrid, teachGuard, mirrorPatternsToVector, mirrorEntriesToVector, patternVectorEntry, readMemoryLearningConfig, promotePatterns, quarantineExpiryCandidates, pruneQuarantinePatterns, clearAgentdbQuarantine, vectorMirrorEnabled, vectorTierStatus, resolveVectorEngine, reindexVectorStore, harmonizeVectorStore, importRvfCheckpoint, renderFeatureAdrPhaseLine, statuslineData, countLearningStoreRowsReadonly, readStoreMark, writeStoreMark, resetStoreMark, checkStoreHealth, storeGuardPath, storeSnapshotPath, writeFeatureAdrState, writeFeatureAdrStateDetailed, CHECKPOINT_STAGES, estimateEta, extractStageSamples, formatEta, parseCheckpointLines, segmentRun, computeUsage, deriveCostLedger, planLedgerBackfill, listCostLedgerRuns, resolveLedgerRunId, AMBIGUOUS, stampCheckpointLine, LEDGER_FILL_SOURCE, renderCostLedger, verifyCostLedgerReport, writeCostLedgerJsonl, COST_LEDGER_SCOPE, deriveUsageCalibration, normalizeClaudeUsageModelKey, readUsageLimits, parseWeeklyResetAnchor, claimCheck, summarize, BUNDLED_SLOP_REGISTRY_URL, DEFAULT_SLOP_CONFIG, parseSlopRegistry, slopLint, validateSlopLintConfig, queryBookKnowledge, loadStorePatternsSync, patternRecordId, patternIdentityOf, mergeLessonMatchedForms, SWARM_BRIEF_CONTRACT, checkSwarmBrief, visibleText, loadStoreRecords, findExactLesson, recordToPattern, bundleSkills, brainHome, listBrain, bookKbPath, promoteProjectToBrain, updateBrainSource, queryBrain, groundPrompt, expandKu, reindexBrainVectors, buildPrimer, exportBrainSlice, importBrainSlice, registerKusToBrain, RECALL_USAGE_LOG_RELATIVE, RECALL_USAGE_LOG_MAX_BYTES, parseRecallUsageLog, buildRecallUsageReport, EVENT_CHAIN_TAIL_BYTES, EMPTY_LOG_TAIL, readTailInfo, appendChainedLines, verifyEventChainText, classifyChainDefects, CHAINED_JOURNALS, buildManifest, buildSbom, resolveTrustRoot, decideVerifyPolicy, generateSigningKeypair, appendTransition, evaluateGuard, resolveRules, auditRecord, guardExitCode, DEFAULT_RULES, parsePnpmLockImporters, scannableStubPath,
24
+ TRACE_RUNID_RE, WF_RUN_OWNER_HOST, preflight, runWorkflow, makeClaudePDispatcher, makeCodexExecDispatcher, NamedLockTimeoutError, NamedLockCompromisedError, POLICY_SOURCES, detectPolicyDrift, hasPolicyFence, TARGET_NAMES, buildParityMatrix, computeParity, PARITY_FEATURES, downgradeForStaleEvidence, findStaleTranscriptEvidence, TARGET_CAPABILITIES, TARGET_SHORT_LABELS, applyLegStatus, applyLegReasonMessage, resolveAgentdbPath, WORKFLOW_TEMPLATES_RETIRED_MESSAGE, parsePlan, isParseErrors, validatePlan, normalizePlan, planDigest, toTraceProjection, renderPlan, mergeRender, lint, lintExitCode, LOOP_BLOBS, parseTrace, assembleTimeline, runInvariants, deriveAttestation, stampAttestation, corroborate, NOT_WITNESSED, renderTimelineHtml, importEcc, recordPattern, recordLessonForms, normalizeLessonForms, resolveLearningBackend, storeStats, consolidateSessions, pruneNoisePatterns, lessonDeltaReport, removePatternsByIds, snapshotStore, recallHybrid, teachGuard, mirrorPatternsToVector, mirrorEntriesToVector, patternVectorEntry, readMemoryLearningConfig, promotePatterns, quarantineExpiryCandidates, pruneQuarantinePatterns, clearAgentdbQuarantine, vectorMirrorEnabled, vectorTierStatus, resolveVectorEngine, reindexVectorStore, harmonizeVectorStore, importRvfCheckpoint, renderFeatureAdrPhaseLine, statuslineData, countLearningStoreRowsReadonly, readStoreMark, writeStoreMark, resetStoreMark, checkStoreHealth, storeGuardPath, storeSnapshotPath, writeFeatureAdrState, writeFeatureAdrStateDetailed, CHECKPOINT_STAGES, estimateEta, extractStageSamples, formatEta, parseCheckpointLines, segmentRun, computeSpendReport, deriveCostLedger, planLedgerBackfill, listCostLedgerRuns, resolveLedgerRunId, AMBIGUOUS, stampCheckpointLine, LEDGER_FILL_SOURCE, renderCostLedger, verifyCostLedgerReport, writeCostLedgerJsonl, COST_LEDGER_SCOPE, spendReport, claimCheck, summarize, BUNDLED_SLOP_REGISTRY_URL, DEFAULT_SLOP_CONFIG, parseSlopRegistry, slopLint, validateSlopLintConfig, queryBookKnowledge, loadStorePatternsSync, patternRecordId, patternIdentityOf, mergeLessonMatchedForms, SWARM_BRIEF_CONTRACT, checkSwarmBrief, visibleText, loadStoreRecords, findExactLesson, recordToPattern, bundleSkills, brainHome, brainAgentdbPath, listPreReindexSnapshots, rotatePreReindexSnapshots, scanSnapshotDir, listBrain, bookKbPath, promoteProjectToBrain, updateBrainSource, queryBrain, groundPrompt, expandKu, reindexBrainVectors, buildPrimer, exportBrainSlice, importBrainSlice, registerKusToBrain, RECALL_USAGE_LOG_RELATIVE, RECALL_USAGE_LOG_MAX_BYTES, parseRecallUsageLog, buildRecallUsageReport, EVENT_CHAIN_TAIL_BYTES, EMPTY_LOG_TAIL, readTailInfo, appendChainedLines, verifyEventChainText, classifyChainDefects, CHAINED_JOURNALS, buildManifest, buildSbom, resolveTrustRoot, decideVerifyPolicy, generateSigningKeypair, appendTransition, evaluateGuard, resolveRules, auditRecord, guardExitCode, DEFAULT_RULES, parsePnpmLockImporters, scannableStubPath,
24
25
  // guard-promotion (feature guard-promotion, scout idea #1)
25
26
  assembleCandidates, renderPromotionReport, renderPromotionAdr, normalizePromotionState, nextPromotionState, recordPromotionRunEvidence, isLessonRuleContentAnchor, isOffsetIsoTimestamp, globMatch, promotionAdrRelPath, DEFAULT_WINDOW_DAYS, DEFAULT_PERIODS, MAX_CONTENT_FETCHES, BUILTIN_COVERAGE, decideProvenance, isInsideTree, signManifest, verifyManifest, hashPackBytes, rewriteWorkspaceSpecs, listPackFiles, listSignablePackFiles, assertKeyOutsideTree, decidePublishGate, collectPackageFacts, planReleaseGates, selectAffectedPackages, classifyGateExecutions, buildFailureIssue, buildReleaseNotes, releaseTagName, firstOutputLine, formatPublishError, MANIFEST_NAME, SBOM_NAME, buildArchitectureMap, renderMapHuman, findArchitectureDrift, renderDriftReport, scanWorkspacePackages, loadSubsystemManifest, loadProductVision, checkFeatureAgainstArchitecture, renderArchCheck, planProjectSkills, guidanceForStage, renderInjectionReport, analyzeCorpus, renderRakeReport, renderCriticSection, rakeAsLesson, rakeReward, DEFAULT_RAKE_THRESHOLDS, streamSessionEvents, findLatestTranscript, resolveScanTailTranscript, detectProcessRakes, buildRetro, renderRetro, retroLessonText, PROCESS_SIGNATURES, RETRO_DOMAIN, runRetroTailScan, scanForSetup, buildSetupPlan, scaffoldFromSpec, renderScaffoldPreview, readExistingForScaffold, assembleChallengeContext, buildChallengeBrief, planDiscriminationCheck, classifyDiscrimination, classifyExecutionEvidence, pickAdversaryModel, CHALLENGE_QUESTIONS, loadOutcomes, renderOutcomes, statsForKey, selectAutoCost, recordProvisional, finalizeOutcome, harvestStageOutcomes, recommendModels, planFeed, unfedRuns, GRADE_SUCCESS_FLOOR, COST_LADDER, splitScenarios, budgetPlan, selectWinner, proseScopeOk, renderProseDiff, readScenarioIds, DEFAULT_MAX_JUDGE_RUNS, collectDeliveryFacts, planDeliveryCheck, renderDeliveryBrief, classifyDelivery, isUsablePlaneResult, renderDeliveryReview, scanSkillsLayout, declaredPluginSurface, parseInitFacts, verifyRegistration, buildContentProbePrompt, classifyContentProbe, renderContentProbe, findNonRegistrableSkillDirs, assembleCompoundingReport, buildDeadwoodReport, compactCmdUsageIfNeeded, measureCmdUsageDepthDays, recordCommandInvocation, resolveCmdUsageRoot, renderDeadwoodReport, CMD_USAGE_LOG_RELATIVE, banditStats, narrowBanditReport, renderBanditHealth,
26
27
  // Cold-vs-warm EPOCH RUNNER (feature epoch-replay) — orchestrates + scores, never calls a model.
27
- replayableInstances, buildWorkOrder, buildJudgePrompts, unblindJudgments, verifyWorkOrder, isValidMargin, DIGEST_HONEST_SCOPE, scoreEpochReplay, generateMockOutcomes, renderEpochReplayResult, renderWorkOrderSummary, renderJudgePromptsSummary, WORK_ORDER_KIND, DEFAULT_MOCK_N, DEFAULT_MOCK_SEED, scoreRun, readQeGrade, scoreReceiptToAggregateRow, readScoreAggregateRows, dedupeScoreAggregateRows, buildScoreAggregateReport, renderScoreAggregateReport, recapWindow, decideHorizon, withinWindow, buildRecap, renderRecap, parseSourceManifest, tgPostHtmlIssues, tgVisibleLength, decideTgSend, TG_TEXT_LIMIT, countRecallEventsForRun, unknownFlagNotice, mirrorWriterExplanation, appendRecallUsage, closenessLine, anyAboveFloor, decideNameCheck, renderNameCheck, exportedNamesIn, dispatchedCommandsIn, decideSourceProvenance, renderSourceProvenance, REFUSED_HORIZONS, renderScorecard, renderCompoundingReport, readReinforcementState, readQuarantineState, registrationExitCode, renderRegistrationReport,
28
+ replayableInstances, buildWorkOrder, buildJudgePrompts, unblindJudgments, verifyWorkOrder, isValidMargin, DIGEST_HONEST_SCOPE, scoreEpochReplay, generateMockOutcomes, renderEpochReplayResult, renderWorkOrderSummary, renderJudgePromptsSummary, WORK_ORDER_KIND, DEFAULT_MOCK_N, DEFAULT_MOCK_SEED, scoreRun, readQeGrade, scoreReceiptToAggregateRow, readScoreAggregateRows, dedupeScoreAggregateRows, buildScoreAggregateReport, renderScoreAggregateReport, recapWindow, decideHorizon, withinWindow, buildRecap, renderRecap, parseSourceManifest, tgPostHtmlIssues, tgVisibleLength, decideTgSend, TG_TEXT_LIMIT, countRecallEventsForRun, unknownFlagNotice, mirrorWriterExplanation, mirrorWriterReason, appendRecallUsage, closenessLine, anyAboveFloor, decideNameCheck, renderNameCheck, exportedNamesIn, dispatchedCommandsIn, decideSourceProvenance, renderSourceProvenance, REFUSED_HORIZONS, renderScorecard, renderCompoundingReport, readReinforcementState, readQuarantineState, registrationExitCode, renderRegistrationReport,
28
29
  // Smart Backlog (feature smart-backlog) — goal-directed idea pipeline over the Brain vector engine.
29
30
  readBacklogConfig, readIdeas, writeIdeas, ideaId, dedupIdea, readGoalMap, readGoalMapDetailed, parseEffort, ensureBacklogGitignored, isSafeId, alignIdea, mirrorIdeaVector, ensureBacklogEmbedForm, readBacklogEmbedFormVersion, recordAbsorption, DEDUP_EMBED_FORM_VERSION, snapshotIdeas, spinRoulette, rankRoulette, seededRng, eligibleIdeas, stageEnrichment, buildJiraDraft, resolveJiraAdapter, makeBacklogIO, harmonizeBacklog, transitionIdeas, editIdea, clearEmbedStale, BACKLOG_BACKENDS, applyDomainBoost, DZ_OWNED_TASK_TYPES, applyExportHoldout, DEFAULT_HELD_OUT_DOMAINS, canonicalDomainKey, readAgentdbRowsByTaskType, heldOutAfterOptIn, renderHoldoutNote, renderSharedStoreAdvice, decideVectorExport, countDisplacedByCut, renderDomainBoostNote, renderDomainCutNote, parseReqeDebt,
30
31
  // qe-bridge (feature qe-bridge-claude, ADR-001): the pure half of the reverse QE bridge.
31
32
  KNOWN_CLAUDE, isSafeClaudeId, claudeProbeArgs, claudeReviewArgs, interpretClaudeProbe, modelFamily, buildBridgePrompt, parseBridgeOutput, buildBridgeFailureRecord, buildBridgeSignoffRecord, renderBridgeReport, isSafeSlug, hasUnsafePathChars, hasDotDotSegment, buildReqeBrief, settleReqeDebt, renderReqeList, REQE_SCOPE,
32
33
  // Mutation gate (feature ha-mutation-gate) — break each named protection, run the suite, require red.
33
- parseMutationRegistry, applyMutationToText, attributeBaselineRedness, countFailingTests, detectSuiteCompletionReceipt, detectSuiteReceiptMismatch, classifyBaseline, classifyRunFailure, classifyMutationOutcome, mutationGateExitCode, summarizeMutationResults, renderMutationReport, runWithOneInternalRetry, TRACE_BUNDLE_LEDGER_PATH, TRACE_BUNDLE_SCHEMA, TRACE_BUNDLE_RUN_META_FILE, buildBundle, serializeBundle, parseBundle, planImport, decideCheckpointWrite, amendmentSection, amendmentSectionCount, amendmentDeclarationAmbiguity, planSaysNoAmendments, parseAmendments, resolveAmendments, decideAmendmentOutcome, amendmentVerdictLine, amendmentsMissingFromPlan, AMENDMENT_VACUITY_NOTE, extractContractChecklist, readFeatureTier, parseContractVerdictReport, verifyContractVerdicts, decideSignableSet, signableSetLine, decideRecordWrite, decideReadBack, recordVerdictLine, buildCadenceReport, tgVisibleSha256, CADENCE_WINDOW_DAYS, readQeRounds, QE_ROUNDS_DEFAULT_CEILING, adviseRestart, describeStoreLocation, storeLocationLine, resolveTeachTarget, teachReasonPhrase, readTeachToConfig, TeachTargetError, mergeStoreHits, sameStore, globalStoreRoot, storeCountLabel,
34
+ REGISTRY_SELFCHECK_TESTS, buildMutationTestCommand, parseMutationRegistry, applyMutationToText, attributeBaselineRedness, countFailingTests, detectSuiteCompletionReceipt, detectSuiteReceiptMismatch, classifyBaseline, classifyRunFailure, classifyMutationOutcome, mutationGateExitCode, summarizeMutationResults, renderMutationReport, runWithOneInternalRetry, TRACE_BUNDLE_LEDGER_PATH, TRACE_BUNDLE_SCHEMA, TRACE_BUNDLE_RUN_META_FILE, buildBundle, serializeBundle, parseBundle, planImport, decideCheckpointWrite, amendmentSection, amendmentSectionCount, amendmentDeclarationAmbiguity, planSaysNoAmendments, parseAmendments, resolveAmendments, decideAmendmentOutcome, amendmentVerdictLine, amendmentsMissingFromPlan, AMENDMENT_VACUITY_NOTE, extractContractChecklist, readFeatureTier, parseContractVerdictReport, verifyContractVerdicts, decideSignableSet, signableSetLine, decideRecordWrite, decideReadBack, recordVerdictLine, buildCadenceReport, tgVisibleSha256, CADENCE_WINDOW_DAYS, readQeRounds, QE_ROUNDS_DEFAULT_CEILING, adviseRestart, describeStoreLocation, storeLocationLine, resolveTeachTarget, teachReasonPhrase, readTeachToConfig, TeachTargetError, mergeStoreHits, sameStore, globalStoreRoot, storeCountLabel,
34
35
  // operator-profile (ADR-001): per-user 0600 store + marked block in ~/.claude/CLAUDE.md
35
36
  renderProfileBlock, readProfile, writeProfile, syncProfileBlock, checkProfileDrift, parseRegister, registerOwnerWord, profileAgeDays, parseDomainList, domainListText, parseYesNo, REGISTERS, } from '@dzhechkov/harness-core';
36
37
  import { getPreset, PRESET_NAMES } from '@dzhechkov/harness-presets';
@@ -73,7 +74,7 @@ export const DZ_COMMANDS = [
73
74
  'epoch-replay', 'score', 'recap', 'cadence', 'qe-rounds', 'restart-advisor', 'tg-post',
74
75
  'name-check', 'brief-check', 'provenance-check', 'journal', 'feature-adr-record', 'runs', 'runs-record', 'runs-clean', 'amendment-check', 'contract-check',
75
76
  'feature-adr-checkpoint', 'profile', 'reqe', 'qe-bridge', 'backlog', 'routing',
76
- 'bto-optimize', 'dashboard', 'roam', 'import-ecc', 'chain',
77
+ 'bto-optimize', 'dashboard', 'roam', 'import-ecc', 'chain', 'round',
77
78
  ];
78
79
  const USAGE = `dz - DZ cross-platform harness CLI
79
80
  dz runs [--settle] [--stall-minutes N] [--json] [--project <dir>] [--probe-pid <pid>] (run registry: live, stalled, orphaned, inconclusive or finished; PID probe prints true|false|unknown)
@@ -104,7 +105,7 @@ Usage:
104
105
  dz upgrade [--target <name>] [--pubkey <path>] [--require-signing] (a TAMPERED pack aborts the upgrade)
105
106
  dz sign --pack <dir> --key <path-outside-repo> (Ed25519 manifest + CycloneDX SBOM for a pack)
106
107
  dz verify-pack --pack <dir> [--pubkey <path>] (signature check; fail-closed; key from the repo, never the pack)
107
- dz publish [--filter <name>] [--bump-only] [--claim-check <off|warn|error>] [--require-signing] [--provenance|--no-provenance] (dry-run by default; pass --yes/--confirm/--no-dry-run to go live; claim-check gate default warn — surfaces README claim findings, never blocks; error fails an offending package)
108
+ dz publish [--filter <name>] [--bump-only] [--claim-check <off|warn|error>] [--mirror-cmd <cmd>|--no-mirror] [--require-signing] [--provenance|--no-provenance] (dry-run by default; pass --yes/--confirm/--no-dry-run to go live; claim-check gate default warn — surfaces README claim findings, never blocks; error fails an offending package)
108
109
  dz release [--filter <name>] [--tag] [--publish] [--json] [--dry-run] [--no-issue] (VERIFIED release: 4 HARD gates in FRONT of dz publish — full package test suites, audit >=high, node --check of every dist/bin file, bin smoke-boot via "node <bin> --help" — any red gate STOPS the release (exit 1) + best-effort gh issue; all green ⇒ re-sign reminder, then prints the ready dz publish command (or chains with --publish); never duplicates publish's own gates)
109
110
  dz parity [--target <name>] [--json] (the honest feature×target map, COMPUTED from the capability model — which harness feature is full / manual / absent on each of the ${TARGET_NAMES.length} targets, and via which form)
110
111
  dz delivery-check --slug <slug> [--context-only] [--findings <f.json>] [--strict] [--author <model>] [--json] (portable Step-10 Delivery Gate: prints the 4-plane review brief + artifact probes; --findings classifies a fed-back review into a fail-closed ready|blocked hand-off and writes features/<slug>/10_delivery_review.md; --strict exits 1 on blocked)
@@ -134,7 +135,8 @@ Usage:
134
135
  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)
135
136
  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)
136
137
  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)
137
- 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)
138
+ 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)
139
+ 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)
138
140
  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)
139
141
  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)
140
142
  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)
@@ -167,7 +169,8 @@ Usage:
167
169
  dz brain query "<q>" [--source <slug>] [--limit <N>] [--any] [--rerank] [--json] (cross-source recall; --any = OR match; --rerank reorders top-K)
168
170
  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)
169
171
  dz brain update <slug> [--project <dir>] [--json] (non-destructive refresh: re-mirror a re-ingested source into the brain)
170
- dz brain reindex [--json] (snapshot, re-embed book-KU brain vectors, stamp current model)
172
+ dz brain reindex [--json] (snapshot, re-embed book-KU brain vectors, stamp current model; also rotates old pre-reindex snapshots)
173
+ 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)
171
174
  dz brain primer <slug> [--json] (print a source's capability card — KU-type histogram + top decision moments)
172
175
  dz brain export --source <slug> --out <file> (export ONE source as a portable, lexical-only books.sqlite slice)
173
176
  dz brain ground [<prompt>] [--k <N>] [--source <slug>] [--text] [--budget <N>] [--full] (UserPromptSubmit hook; --budget inlines top-K KUs within ~N tokens; --full = ~8000)
@@ -176,7 +179,7 @@ Usage:
176
179
  dz statusline [--json] [--install] [--project <dir>] (live self-learning panel for Claude Code's status bar; reads the CC JSON payload from STDIN)
177
180
  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)
178
181
  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)
179
- 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)
182
+ dz usage [--json] [--project <dir>] (7-day UTC spend from local Claude Code + subagent transcripts; provider-limit routing disabled by design)
180
183
  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)
181
184
  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)
182
185
  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)
@@ -2315,12 +2318,13 @@ function cmdStatusline(options, flags, cwd, write, readStdin, writeErr) {
2315
2318
  : `🎓 dz: ${data.patterns} (${breakdown.active} active${breakdown.quarantined > 0
2316
2319
  ? ` · ${breakdown.quarantined} quarantined${breakdown.attention ? ' ⚠' : ''}`
2317
2320
  : ''})${breakdown.tierDelta !== undefined ? ` ⚠ tiers Δ${breakdown.tierDelta}` : ''}`;
2318
- // Показатель зеркала печатается и здесь: `dz statusline` та же панель, и показатель,
2319
- // живущий только во вспомогательном скрипте, для этой поверхности просто не существовал.
2320
- if (data.patternMirror?.state === 'unavailable')
2321
- line += ' (mirror unreadable ⚠)';
2322
- else if (data.patternMirror?.state === 'different')
2323
- line += ` (mirror ${data.patternMirror.vector} )`;
2321
+ // Зеркало самостоятельный источник панели. Отсутствие печатается явно; нечитаемый файл
2322
+ // сохраняет прежнее отдельное состояние, чтобы отказ инструмента не выглядел как настройка off.
2323
+ line += data.patternMirror?.state === 'unavailable'
2324
+ ? ' · mirror: unreadable ⚠'
2325
+ : data.mirror.available
2326
+ ? ` · mirror: ${data.mirror.lessons} lessons (pending ${data.mirror.pending})`
2327
+ : ' · mirror: absent';
2324
2328
  if (data.storeHealth?.verdict === 'collapsed') {
2325
2329
  line += ` ⛔ COLLAPSE: was ${data.storeHealth.previousMax ?? '?'} · dz store-guard --reset`;
2326
2330
  }
@@ -2331,6 +2335,9 @@ function cmdStatusline(options, flags, cwd, write, readStdin, writeErr) {
2331
2335
  line += ` ⛔ STORE UNREADABLE${data.storeHealth.unreadableFiles !== undefined && data.storeHealth.unreadableFiles.length > 0
2332
2336
  ? `: ${data.storeHealth.unreadableFiles.join(', ')}` : ''}`;
2333
2337
  }
2338
+ else if (data.storeHealth?.verdict === 'busy') {
2339
+ line += ' ⏳ STORE BUSY';
2340
+ }
2334
2341
  else if (data.storeHealth?.verdict === 'source-changed') {
2335
2342
  line += ' ⚠ store source changed';
2336
2343
  }
@@ -2365,156 +2372,6 @@ function cmdStatusline(options, flags, cwd, write, readStdin, writeErr) {
2365
2372
  return 0;
2366
2373
  }
2367
2374
  }
2368
- function isJsonRecord(value) {
2369
- return typeof value === 'object' && value !== null && !Array.isArray(value);
2370
- }
2371
- function usageConfigPath(projectRoot) {
2372
- return join(projectRoot, '.dz', 'config.json');
2373
- }
2374
- function readProjectConfigForUsage(projectRoot) {
2375
- const path = usageConfigPath(projectRoot);
2376
- try {
2377
- if (!existsSync(path))
2378
- return { config: {} };
2379
- const parsed = JSON.parse(readFileSync(path, 'utf-8'));
2380
- if (isJsonRecord(parsed))
2381
- return { config: parsed };
2382
- return { config: {}, warning: 'existing config is not a JSON object; writing a minimal config' };
2383
- }
2384
- catch {
2385
- return { config: {}, warning: 'existing config could not be parsed; writing a minimal config' };
2386
- }
2387
- }
2388
- function applyUsageCalibrationToConfig(config, plan) {
2389
- const next = { ...config };
2390
- const memory = isJsonRecord(next['memory']) ? { ...next['memory'] } : {};
2391
- const usage = isJsonRecord(memory['usage']) ? { ...memory['usage'] } : {};
2392
- for (const change of plan.changes) {
2393
- if (change.key === 'session') {
2394
- usage['sessionTokenLimit'] = change.after;
2395
- }
2396
- else if (change.key === 'weekly') {
2397
- usage['weeklyTokenLimit'] = change.after;
2398
- }
2399
- else {
2400
- const model = normalizeClaudeUsageModelKey(change.key);
2401
- if (model) {
2402
- const existingByModel = isJsonRecord(usage['weeklyTokenLimitByModel']) ? { ...usage['weeklyTokenLimitByModel'] } : {};
2403
- existingByModel[model] = change.after;
2404
- usage['weeklyTokenLimitByModel'] = existingByModel;
2405
- }
2406
- }
2407
- }
2408
- if (plan.changes.length > 0) {
2409
- usage['calibratedAt'] = plan.after.calibratedAt;
2410
- usage['source'] = plan.after.source;
2411
- // A fresh calibration re-arms routing for THIS account and clears the legacy free-text switch:
2412
- // the calibration is the very act the disable-note demanded.
2413
- usage['calibrationAccount'] = plan.after.calibrationAccount ?? null;
2414
- }
2415
- memory['usage'] = usage;
2416
- next['memory'] = memory;
2417
- return next;
2418
- }
2419
- function parseUsageModelArgs(modelArgs) {
2420
- const modelPct = {};
2421
- const skipped = [];
2422
- for (const raw of modelArgs) {
2423
- const eq = raw.indexOf('=');
2424
- if (eq <= 0 || eq === raw.length - 1) {
2425
- skipped.push(`model ${raw}: skipped malformed model=pct argument`);
2426
- continue;
2427
- }
2428
- const modelName = raw.slice(0, eq).trim();
2429
- const model = normalizeClaudeUsageModelKey(modelName);
2430
- if (!model) {
2431
- skipped.push(`model ${modelName}: skipped unknown model`);
2432
- continue;
2433
- }
2434
- modelPct[model] = raw.slice(eq + 1).trim();
2435
- }
2436
- return { modelPct, skipped };
2437
- }
2438
- function writeUsageCalibrationSummary(opts) {
2439
- opts.write('usage calibrate: estimated local transcript counts; claude.ai/settings/usage is authoritative');
2440
- opts.write(`usage calibrate: project ${opts.projectRoot}`);
2441
- if (opts.configWarning)
2442
- opts.write(`usage calibrate: ${opts.configWarning}`);
2443
- for (const change of opts.plan.changes) {
2444
- opts.write(`usage calibrate: ${change.key} tokens=${change.tokens} pct=${change.pct}% limit ${change.before ?? 'null'} -> ${change.after}`);
2445
- }
2446
- const skipped = [...opts.preSkipped, ...opts.plan.skipped];
2447
- for (const item of skipped)
2448
- opts.write(`usage calibrate: skipped ${item}`);
2449
- if (opts.wrote) {
2450
- opts.write('usage calibrate: wrote .dz/config.json with source claude.ai/settings/usage');
2451
- }
2452
- else {
2453
- opts.write('usage calibrate: no config changes written');
2454
- }
2455
- }
2456
- function cmdUsageCalibrate(options, optionLists, cwd, write) {
2457
- const projectRoot = resolve(cwd, options.get('project') ?? '.');
2458
- const suppliedModels = optionLists.get('model') ?? [];
2459
- const parsedModels = parseUsageModelArgs(suppliedModels);
2460
- const modelPct = parsedModels.modelPct;
2461
- const hasModelPct = Object.keys(modelPct).length > 0;
2462
- const input = {
2463
- ...(options.has('session') ? { sessionPct: options.get('session') } : {}),
2464
- ...(options.has('weekly') ? { weeklyPct: options.get('weekly') } : {}),
2465
- ...(hasModelPct ? { modelPct } : {}),
2466
- calibratedAt: new Date().toISOString(),
2467
- source: 'claude.ai/settings/usage',
2468
- };
2469
- const missingInputs = [];
2470
- if (!options.has('session') && !options.has('weekly') && !hasModelPct) {
2471
- missingInputs.push('no calibration percentages supplied');
2472
- }
2473
- try {
2474
- const current = computeUsage(projectRoot);
2475
- const before = readUsageLimits(projectRoot);
2476
- const plan = deriveUsageCalibration(current, before, input);
2477
- if (plan.changes.length === 0) {
2478
- writeUsageCalibrationSummary({
2479
- projectRoot,
2480
- plan,
2481
- preSkipped: [...parsedModels.skipped, ...missingInputs],
2482
- wrote: false,
2483
- write,
2484
- });
2485
- return 0;
2486
- }
2487
- const existing = readProjectConfigForUsage(projectRoot);
2488
- const nextConfig = applyUsageCalibrationToConfig(existing.config, plan);
2489
- try {
2490
- mkdirSync(join(projectRoot, '.dz'), { recursive: true });
2491
- writeFileSync(usageConfigPath(projectRoot), JSON.stringify(nextConfig, null, 2) + '\n');
2492
- writeUsageCalibrationSummary({
2493
- projectRoot,
2494
- plan,
2495
- preSkipped: [...parsedModels.skipped, ...missingInputs],
2496
- configWarning: existing.warning,
2497
- wrote: true,
2498
- write,
2499
- });
2500
- }
2501
- catch {
2502
- writeUsageCalibrationSummary({
2503
- projectRoot,
2504
- plan,
2505
- preSkipped: [...parsedModels.skipped, ...missingInputs, 'write failed'],
2506
- configWarning: existing.warning,
2507
- wrote: false,
2508
- write,
2509
- });
2510
- }
2511
- return 0;
2512
- }
2513
- catch {
2514
- write('usage calibrate: skipped internal error; no config changes written');
2515
- return 0;
2516
- }
2517
- }
2518
2375
  /**
2519
2376
  * `dz usage --by-stage` — the per-stage cost ledger for one feature-adr run (feature `cost-ledger`).
2520
2377
  *
@@ -2566,17 +2423,6 @@ function cmdUsageByStage(options, flags, write) {
2566
2423
  }
2567
2424
  return 0;
2568
2425
  }
2569
- /**
2570
- * `dz usage` — print an ESTIMATE of Claude session + weekly usage from fixed reset windows,
2571
- * aggregated READONLY from the local transcript store (see {@link computeUsage}). `--json` emits
2572
- * the single-line contract the feature-adr usage-probe agent parses; `--calibrate` is the only
2573
- * write path and records human-transcribed claude.ai percentages in `.dz/config.json`.
2574
- *
2575
- * **Exit code is 0 ALWAYS** — including on internal error the whole body is guarded and prints the
2576
- * all-null JSON, so a probe can NEVER distinguish "usage unknown" from "command failed" via a
2577
- * non-zero exit. `--project <dir>` scopes ONLY the `.dz/config.json` read/write; measurement is
2578
- * account-wide (all projects).
2579
- */
2580
2426
  /**
2581
2427
  * dz qe-rounds — how many Step-8 review rounds has one feature already had?
2582
2428
  *
@@ -2751,6 +2597,62 @@ function cmdRestartAdvisor(options, flags, cwd, write) {
2751
2597
  roundsOrigin,
2752
2598
  }));
2753
2599
  }
2600
+ function packageCommitCount(root, sinceIso) {
2601
+ try {
2602
+ // Assemble git's flag so the CLI flag-inventory scanner does not mistake a child-process option
2603
+ // for a user-facing dz option. The argv delivered to git is still exactly `--count`.
2604
+ const raw = execFileSync('git', ['rev-list', '--' + 'count', `--since=${sinceIso}`, 'HEAD', '--', 'packages/'], {
2605
+ cwd: root,
2606
+ encoding: 'utf8',
2607
+ stdio: ['ignore', 'pipe', 'pipe'],
2608
+ }).trim();
2609
+ return /^\d+$/.test(raw) ? Number(raw) : null;
2610
+ }
2611
+ catch {
2612
+ return null;
2613
+ }
2614
+ }
2615
+ function roundTraceSince(root) {
2616
+ let firstDate = null;
2617
+ let lastRoundDate = null;
2618
+ try {
2619
+ const rows = readFileSync(join(root, '.dz', 'feature-adr', 'run-cost-ledger.jsonl'), 'utf8').split('\n');
2620
+ for (const line of rows) {
2621
+ if (line.trim() === '')
2622
+ continue;
2623
+ let row;
2624
+ try {
2625
+ const parsed = JSON.parse(line);
2626
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
2627
+ continue;
2628
+ row = parsed;
2629
+ }
2630
+ catch {
2631
+ continue;
2632
+ }
2633
+ const date = typeof row['date'] === 'string' && Number.isFinite(Date.parse(row['date'])) ? row['date'] : null;
2634
+ if (date === null)
2635
+ continue;
2636
+ if (firstDate === null)
2637
+ firstDate = date;
2638
+ if (row['stage'] === 'round' || row['stage'] === 'round-exec')
2639
+ lastRoundDate = date;
2640
+ }
2641
+ }
2642
+ catch {
2643
+ return null;
2644
+ }
2645
+ return lastRoundDate ?? firstDate;
2646
+ }
2647
+ function roundsTracingEnabled(root) {
2648
+ try {
2649
+ const parsed = JSON.parse(readFileSync(join(root, '.dz', 'config.json'), 'utf8'));
2650
+ return parsed?.rounds?.traced !== false;
2651
+ }
2652
+ catch {
2653
+ return true;
2654
+ }
2655
+ }
2754
2656
  function cmdCadence(options, flags, cwd, write) {
2755
2657
  const root = resolve(cwd, options.get('project') ?? '.');
2756
2658
  const windowRaw = (options.get('window') ?? 'week').trim();
@@ -2758,7 +2660,9 @@ function cmdCadence(options, flags, cwd, write) {
2758
2660
  write('dz cadence: --window must be one of ' + Object.keys(CADENCE_WINDOW_DAYS).join('|'));
2759
2661
  return 1;
2760
2662
  }
2761
- const r = buildCadenceReport(root, windowRaw);
2663
+ const now = Date.now();
2664
+ const windowStartIso = new Date(now - CADENCE_WINDOW_DAYS[windowRaw] * 86_400_000).toISOString();
2665
+ const r = buildCadenceReport(root, windowRaw, now, packageCommitCount(root, windowStartIso));
2762
2666
  if (flags.has('json')) {
2763
2667
  write(JSON.stringify(r));
2764
2668
  return r.decision.ok ? 0 : 2;
@@ -2776,6 +2680,14 @@ function cmdCadence(options, flags, cwd, write) {
2776
2680
  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));
2777
2681
  }
2778
2682
  write(' graded ' + r.shipments.gradedTotal + ' (' + Object.entries(r.shipments.byGrade).sort().map(([g, n]) => g + '×' + n).join(', ') + ') · UNGRADED ' + r.shipments.ungraded + ' (named, not hidden)');
2683
+ const roundCount = r.rounds.byStage.round;
2684
+ const roundPart = roundCount === 0
2685
+ ? 'rounds 0 (ни одной строки круга в окне)'
2686
+ : `rounds ${roundCount} (shipped ${r.rounds.byOutcome.shipped} · refuted ${r.rounds.byOutcome.refuted} · blocked ${r.rounds.byOutcome.blocked} · abandoned ${r.rounds.byOutcome.abandoned})`;
2687
+ 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'}`);
2688
+ for (const round of r.rounds.unfinished) {
2689
+ write(` ✗ ${round.slug}#${round.round} ${round.outcome} — ${round.reason ?? 'причина не названа'}`);
2690
+ }
2779
2691
  if (r.guard.decay.length > 0) {
2780
2692
  write(' guard repeat decay (FIXED set — rules with pre-window history only):');
2781
2693
  for (const d of r.guard.decay.slice(0, 8))
@@ -2783,133 +2695,91 @@ function cmdCadence(options, flags, cwd, write) {
2783
2695
  }
2784
2696
  if (r.guard.excludedNewborn.length > 0)
2785
2697
  write(' excluded newborn rule(s) (no pre-window history — a zero here would be youth, not virtue): ' + r.guard.excludedNewborn.join(', '));
2786
- for (const dgr of [r.npmPublishes.degraded, r.guard.degraded, r.recalls.degraded])
2698
+ for (const dgr of [r.npmPublishes.degraded, r.guard.degraded, r.recalls.degraded, r.rounds.degraded])
2787
2699
  if (dgr)
2788
2700
  write(' DEGRADED: ' + dgr);
2789
2701
  return 0;
2790
2702
  }
2791
- function cmdUsage(options, optionLists, flags, cwd, write) {
2703
+ function cmdUsage(options, _optionLists, flags, cwd, write) {
2792
2704
  const projectRoot = resolve(cwd, options.get('project') ?? '.');
2793
- const nullContract = () => JSON.stringify({
2705
+ const reqeDue = () => {
2706
+ try {
2707
+ return scanReqeDebts(projectRoot).debts.length;
2708
+ }
2709
+ catch {
2710
+ return 0;
2711
+ }
2712
+ };
2713
+ const jsonContract = (spend) => JSON.stringify({
2794
2714
  sessionPct: null,
2795
2715
  weeklyPct: null,
2796
- sessionTokens: 0,
2797
- weeklyTokens: 0,
2798
- resetsAt: { session: null, weekly: null },
2799
- limits: { session: null, weekly: null },
2800
- estimated: true,
2716
+ routing: 'disabled-by-design',
2717
+ spend,
2718
+ reqeDue: reqeDue(),
2801
2719
  });
2720
+ const number = (value) => (Number.isInteger(value) ? String(value) : String(Math.round(value * 100) / 100));
2802
2721
  try {
2803
- if (flags.has('calibrate'))
2804
- return cmdUsageCalibrate(options, optionLists, cwd, write);
2722
+ if (flags.has('calibrate')) {
2723
+ // Keep the retired mode's value flags known so its one-line removal receipt is not polluted
2724
+ // by generic unknown-flag notices before dispatch.
2725
+ void ['--session', '--weekly'];
2726
+ 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');
2727
+ return 2;
2728
+ }
2805
2729
  if (flags.has('by-stage'))
2806
2730
  return cmdUsageByStage(options, flags, write);
2807
- const u = computeUsage(projectRoot);
2808
- const lim = readUsageLimits(projectRoot);
2809
- const modelLimits = lim.weeklyTokenLimitByModel;
2810
- const hasModelLimits = modelLimits !== undefined && Object.keys(modelLimits).length > 0;
2731
+ const spend = computeSpendReport();
2811
2732
  if (flags.has('json')) {
2812
- const limitsPayload = { session: lim.sessionTokenLimit ?? null, weekly: lim.weeklyTokenLimit ?? null };
2813
- if (hasModelLimits)
2814
- limitsPayload.weeklyByModel = { ...modelLimits };
2815
- const payload = {
2816
- sessionPct: u.sessionPct,
2817
- weeklyPct: u.weeklyPct,
2818
- sessionTokens: u.sessionTokens,
2819
- weeklyTokens: u.weeklyTokens,
2820
- resetsAt: { session: u.sessionResetsAt, weekly: u.weeklyResetsAt },
2821
- limits: limitsPayload,
2822
- estimated: true,
2823
- };
2824
- // ADR-001 usage-honesty: a consumer that reads null pcts deserves the WHY (closed reason
2825
- // set), and a human deserves the raw estimates when POLICY (not measurement) nulled them.
2826
- if (u.notEstablished.length > 0)
2827
- payload.notEstablished = u.notEstablished;
2828
- if (u.estimatesNotForRouting !== undefined)
2829
- payload.estimatesNotForRouting = u.estimatesNotForRouting;
2830
- if (hasModelLimits && u.weeklyByModel !== undefined)
2831
- payload.weeklyByModel = u.weeklyByModel;
2832
- // re-QE debt surfacing (backlog 6b40e667 — QE #9: the json contract must carry the debt too,
2833
- // a probe is exactly the consumer that needs it). The field appears ONLY when a debt exists,
2834
- // so the zero-debt contract stays byte-identical to the pinned legacy shape. Best-effort.
2835
- try {
2836
- const reqeCount = scanReqeDebts(resolve(cwd, options.get('project') ?? '.')).debts.length;
2837
- if (reqeCount > 0)
2838
- payload.reqeDue = reqeCount;
2839
- }
2840
- catch { /* advisory only */ }
2841
- write(JSON.stringify(payload));
2842
- return 0;
2843
- }
2844
- if (u.sessionPct === null && u.weeklyPct === null && u.notEstablished.length > 0) {
2845
- // Limits may be fully configured and the pcts STILL null — that is the honesty, not a config
2846
- // gap. Say why, and show the human the raw estimates when only policy nulled them.
2847
- write('usage: not established — ' + u.notEstablished.join(', '));
2848
- if (u.estimatesNotForRouting !== undefined) {
2849
- const e = u.estimatesNotForRouting;
2850
- write(' estimates (NOT for routing): session ~' + (e.sessionPct ?? '?') + '% · week ~' + (e.weeklyPct ?? '?') + '% — recalibrate on THIS account: dz usage --calibrate --session <pct> --weekly <pct>');
2851
- }
2852
- else {
2853
- 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');
2854
- }
2855
- try {
2856
- const reqe = scanReqeDebts(resolve(cwd, options.get('project') ?? '.'));
2857
- if (reqe.debts.length > 0)
2858
- write('re-QE due: ' + reqe.debts.length + ' usage-switched run(s) kept same-family QE — run `dz reqe` for the cross-family pass');
2859
- }
2860
- catch { /* advisory only */ }
2861
- return 0;
2862
- }
2863
- if (u.sessionPct === null && u.weeklyPct === null) {
2864
- write('usage: unconfigured — set memory.usage.sessionTokenLimit / weeklyTokenLimit in .dz/config.json (percentages are ESTIMATES calibrated from observed exhaustion)');
2865
- try {
2866
- const reqe = scanReqeDebts(resolve(cwd, options.get('project') ?? '.'));
2867
- if (reqe.debts.length > 0)
2868
- write('re-QE due: ' + reqe.debts.length + ' usage-switched run(s) kept same-family QE — run `dz reqe` for the cross-family pass');
2869
- }
2870
- catch { /* advisory only */ }
2733
+ write(jsonContract(spend));
2871
2734
  return 0;
2872
2735
  }
2873
- // Compact human line a short HH:MM / weekday hint on the resets, best-effort.
2874
- const clock = (iso) => {
2875
- if (!iso)
2876
- return '?';
2877
- try {
2878
- return new Date(iso).toISOString().slice(11, 16);
2879
- }
2880
- catch {
2881
- return '?';
2882
- }
2883
- };
2884
- const s = u.sessionPct === null ? 'n/a' : '~' + u.sessionPct + '%';
2885
- const binding = hasModelLimits && u.weeklyBindingModel !== undefined ? ' ' + u.weeklyBindingModel + '-bound' : '';
2886
- const w = u.weeklyPct === null ? 'n/a' : '~' + u.weeklyPct + '%' + binding;
2887
- // The weekly reset is WEEKLY: print the anchor verbatim (weekday + offset), not a bare clock
2888
- // time — 'resets 08:59' reads as daily and hides the weekday (idea c8513be9: the bare form
2889
- // misread a Monday reading as '41 minutes after the boundary' when the boundary was Wednesday's).
2890
- const weeklyAnchorLabel = typeof lim.weeklyResetAnchor === 'string' && lim.weeklyResetAnchor !== ''
2891
- ? lim.weeklyResetAnchor
2892
- : clock(u.weeklyResetsAt);
2893
- write('usage: session ' + s + ' (resets ' + clock(u.sessionResetsAt) + ') · week ' + w + ' (resets ' + weeklyAnchorLabel + ') · estimated');
2894
- if (typeof lim.weeklyResetAnchor === 'string' && parseWeeklyResetAnchor(lim.weeklyResetAnchor)?.offsetMinutes === undefined) {
2895
- 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');
2896
- }
2897
- // re-QE debt surfacing (backlog 6b40e667): the moment someone checks usage is the moment a
2898
- // usage-switched self-review debt should be visible. Best-effort never breaks the contract.
2899
- try {
2900
- const reqe = scanReqeDebts(resolve(cwd, options.get('project') ?? '.'));
2901
- if (reqe.debts.length > 0)
2902
- write('re-QE due: ' + reqe.debts.length + ' usage-switched run(s) kept same-family QE — run `dz reqe` for the cross-family pass');
2903
- }
2904
- catch { /* advisory only */ }
2736
+ write('usage spendlast 7 UTC days');
2737
+ write('date weighted input output cache-read cache-write events');
2738
+ for (const day of spend.days) {
2739
+ write(`${day.date} ${number(day.weightedTokens)} ${number(day.input)} ${number(day.output)} ${number(day.cacheRead)} ${number(day.cacheWrite)} ${day.events}`);
2740
+ }
2741
+ const total = spend.total7d;
2742
+ write(`7-day total ${number(total.weightedTokens)} ${number(total.input)} ${number(total.output)} ${number(total.cacheRead)} ${number(total.cacheWrite)} ${total.events}`);
2743
+ // "unknown" = `event.model ?? 'unknown'` in `spendReport` — an event with NO model field AT
2744
+ // ALL, or one whose model string matched none of the four recognized substrings (in practice
2745
+ // almost always `<synthetic>`). Fix-round-1 (Codex review, MEDIUM #3): a prior wording here and
2746
+ // in the README said "not an event without a model", which is the OPPOSITE of what the code
2747
+ // does corrected to name both causes.
2748
+ write('by model weighted share (0..1) (7-day window; "unknown" = event with no model, or an unrecognized model string e.g. "<synthetic>")');
2749
+ const models = Object.entries(spend.byModel);
2750
+ if (models.length === 0)
2751
+ write(' (no events)');
2752
+ for (const [model, row] of models) {
2753
+ write(` ${model} ${number(row.weightedTokens)} ${number(row.sharePct / 100)}`);
2754
+ }
2755
+ const today = spend.daysByModel.at(-1);
2756
+ if (today !== undefined) {
2757
+ // Fix-round-1 (Codex review, MEDIUM #1): this block used to print weighted tokens only, so
2758
+ // AC-5's "today block shows Sonnet's share of today" had nothing to read it off of. The share
2759
+ // denominator is TODAY's own total (`spend.days.at(-1)`, the same last entry as `today` by
2760
+ // construction both arrays are built from the same `days` in `spendReport`), not the 7-day
2761
+ // total a day's share of a week would silently understate every model.
2762
+ write(`today (${today.date}) by model — weighted share-of-day (0..1)`);
2763
+ const todayModels = Object.entries(today.models);
2764
+ const todayTotal = spend.days.at(-1)?.weightedTokens ?? 0;
2765
+ if (todayModels.length === 0)
2766
+ write(' (no events)');
2767
+ for (const [model, weightedTokens] of todayModels) {
2768
+ const shareOfDay = todayTotal > 0 ? weightedTokens / todayTotal : 0;
2769
+ write(` ${model} ${number(weightedTokens)} ${number(shareOfDay)}`);
2770
+ }
2771
+ }
2772
+ write('source: local Claude Code + subagent transcripts, cost-weighted');
2905
2773
  return 0;
2906
2774
  }
2907
2775
  catch {
2908
- // never let a probe see a non-zero exit — print the all-null contract and exit 0.
2776
+ const empty = spendReport([], { nowMs: Date.now(), days: 7 });
2909
2777
  if (flags.has('json'))
2910
- write(nullContract());
2911
- else
2912
- write('usage: unconfiguredset memory.usage.sessionTokenLimit / weeklyTokenLimit in .dz/config.json');
2778
+ write(jsonContract(empty));
2779
+ else {
2780
+ write('usage spendlast 7 UTC days');
2781
+ write('source: local Claude Code + subagent transcripts, cost-weighted');
2782
+ }
2913
2783
  return 0;
2914
2784
  }
2915
2785
  }
@@ -2940,9 +2810,9 @@ function learningStoreLine(storeRoot, projectOption, target, verb) {
2940
2810
  const reason = teachReasonPhrase(target.reason);
2941
2811
  return storeLocationLine(describeStoreLocation(storeRoot, projectOption, target.store === 'global' ? 'global' : undefined), verb) + (reason ? ' [' + reason + ']' : '');
2942
2812
  }
2943
- function inspectLearningStore(projectRoot) {
2813
+ function inspectLearningStore(projectRoot, countOptions) {
2944
2814
  const mark = readStoreMark(projectRoot);
2945
- const rows = countLearningStoreRowsReadonly(projectRoot);
2815
+ const rows = countLearningStoreRowsReadonly(projectRoot, countOptions);
2946
2816
  return { mark, rows, health: checkStoreHealth({ projectRoot, ...rows, mark }) };
2947
2817
  }
2948
2818
  function shellQuote(value) {
@@ -3014,7 +2884,8 @@ function refreshLearningStoreMark(projectRoot, writeErr, command, options = {})
3014
2884
  const rows = countLearningStoreRowsReadonly(projectRoot);
3015
2885
  const counts = observedRows(rows);
3016
2886
  if (counts === undefined) {
3017
- writeErr(`⚠ dz store guard: ${options.reader ? 'reader observation' : 'store operation'} completed but the external mark was not updated — a store tier is unreadable`);
2887
+ const busy = rows.lexicalRows === 'busy' || rows.vectorRows === 'busy';
2888
+ writeErr(`⚠ dz store guard: ${options.reader ? 'reader observation' : 'store operation'} completed but the external mark was not updated — ${busy ? 'store busy; health not measured this run' : 'a store tier is unreadable'}`);
3018
2889
  return;
3019
2890
  }
3020
2891
  writeStoreMark(projectRoot, {
@@ -3045,7 +2916,7 @@ function storeGuardResetReminder(projectRoot, writeErr, command) {
3045
2916
  function allowLearningStoreWrite(projectRoot, flags, writeErr, command) {
3046
2917
  let inspection;
3047
2918
  try {
3048
- inspection = inspectLearningStore(projectRoot);
2919
+ inspection = inspectLearningStore(projectRoot, { busyTimeoutMs: 250, attempts: 3 });
3049
2920
  }
3050
2921
  catch (error) {
3051
2922
  const health = {
@@ -3063,6 +2934,10 @@ function allowLearningStoreWrite(projectRoot, flags, writeErr, command) {
3063
2934
  // own row had landed.
3064
2935
  return true;
3065
2936
  }
2937
+ if (inspection.health.verdict === 'busy') {
2938
+ writeErr(`dz store guard: NOT MEASURED — ${inspection.health.reason}`);
2939
+ return true;
2940
+ }
3066
2941
  if (inspection.health.verdict === 'source-changed') {
3067
2942
  const counts = observedRows(inspection.rows);
3068
2943
  if (counts === undefined)
@@ -3105,6 +2980,8 @@ function warnLearningStoreRead(projectRoot, writeErr, command) {
3105
2980
  writeErr(line);
3106
2981
  return;
3107
2982
  }
2983
+ if (health.verdict === 'busy')
2984
+ return;
3108
2985
  const counts = observedRows(rows);
3109
2986
  if (counts !== undefined && counts.lexicalRows + counts.vectorRows > 0
3110
2987
  && (mark === undefined || mark.lexicalLast !== counts.lexicalRows || mark.vectorLast !== counts.vectorRows
@@ -3194,14 +3071,20 @@ async function cmdStoreGuard(options, flags, cwd, write, writeErr, stdinText, in
3194
3071
  }
3195
3072
  }
3196
3073
  async function runTeachGuardReinforcement(projectRoot, dzId, reward, preserveQuarantine = false) {
3074
+ const matchedDzId = loadStoreRecords(projectRoot)
3075
+ .find((record) => record.id === dzId || record.text === dzId)?.id;
3197
3076
  const backend = resolveLearningBackend(projectRoot);
3198
3077
  backend.addSample({
3199
3078
  dzId,
3200
3079
  kind: preserveQuarantine ? 'recall-hit' : 'reinforce',
3201
- reward,
3080
+ ...(reward !== undefined ? { reward } : {}),
3202
3081
  ts: new Date().toISOString(),
3203
3082
  });
3204
- return backend.train();
3083
+ const trained = await backend.train();
3084
+ return {
3085
+ ...trained,
3086
+ ...(trained.flushed > 0 && matchedDzId !== undefined ? { dzId: matchedDzId } : {}),
3087
+ };
3205
3088
  }
3206
3089
  async function cmdTeach(options, flags, cwd, write, writeErr = (line) => { console.error(line); }, interactive = false, guardRunner = teachGuard, reinforceRunner = runTeachGuardReinforcement) {
3207
3090
  // WHICH store this lesson belongs to, and WHO decided (teach-chooses-its-store).
@@ -3241,24 +3124,71 @@ async function cmdTeach(options, flags, cwd, write, writeErr = (line) => { conso
3241
3124
  // (D3) — an unconfigured project runs ZERO vector code and its output stays byte-identical
3242
3125
  // to the pre-feature baseline (AC-1). Failures are queued + logged by the service itself and
3243
3126
  // NOT printed on the default path (teach must stay quiet/scriptable); only success emits.
3127
+ // AM-4 (dz-harness-hub issue #10 defect 4, feature setup-installs-apply-leg): a mirror attempt
3128
+ // that produced ZERO rows, resolved NO working engine (`receipt.engine === undefined` — deps
3129
+ // missing/unusable, the ABI-115 failure AM-2 fixes being the measured cause), AND left the
3130
+ // agentdb store file still absent is not "nothing to report" — it is the vector tier having
3131
+ // never come into being, and a lesson taught in that window has nowhere to mirror into until
3132
+ // `dz consolidate`/a later teach (once the store exists) runs. BOTH signals are required so this
3133
+ // never misfires for an rvf-configured project (whose store is not `.dz/agentdb.db` at all) or
3134
+ // for the ordinary "already mirrored, nothing new" case (which resolves an engine successfully).
3135
+ const emitVectorTierAbsentIfNeeded = (root, receipt) => {
3136
+ if (receipt.engine === undefined && !existsSync(resolveAgentdbPath(root))) {
3137
+ write(' ↳ vector tier absent — run dz consolidate');
3138
+ }
3139
+ };
3140
+ // AM-9/AM-10 (issue #10 defect 6, feature setup-installs-apply-leg): `vectorMirrorEnabled(root)`
3141
+ // alone used to decide "say nothing" for every disabled reason alike, including a config that
3142
+ // CLAIMS agentdb via a top-level `backend` key (`{"backend":"agentdb"}` instead of
3143
+ // `{"memory":{"backend":"agentdb"}}`) — a real, readable intent this silently dropped on the
3144
+ // floor. Named for `config-unreadable` / `legacy-shape` — both are a config that TRIED to say
3145
+ // something and got it wrong. THREE reasons stay silent: `engine-off` (deliberate), `no-config`
3146
+ // (the pre-existing AC-1 contract — a NAMED test in `cli.test.ts`/`teach-chooses-its-store.test.ts`
3147
+ // — printing there broke both, MEASURED), and `not-enabled` (AM-10, narrower than the amendment's
3148
+ // literal instruction — MEASURED: `not-enabled` is ALSO the state of the ORDINARY, first-class
3149
+ // jsonl backend `dz setup` produces by default, and printing there added a line to the single most
3150
+ // common `dz teach` invocation shape, reproducer: `mkdir .dz && echo '{"memory":{"backend":
3151
+ // "jsonl"}}' > .dz/config.json && dz teach "x"` → new line `↳ vector tier OFF: …` on the DEFAULT,
3152
+ // fully-supported jsonl path. `not-enabled` cannot distinguish "chose jsonl on purpose" from "typo'd
3153
+ // a backend name", so it is grouped with the other legitimate-quiet states rather than with the
3154
+ // two states that are unambiguously a mistake.
3155
+ const emitMirrorOffIfNeeded = (root) => {
3156
+ const reason = mirrorWriterReason(root);
3157
+ if (reason.state !== 'config-unreadable' && reason.state !== 'legacy-shape')
3158
+ return false;
3159
+ write(` ↳ vector tier OFF: ${mirrorWriterExplanation(reason.state)}`);
3160
+ return true;
3161
+ };
3244
3162
  const emitMirror = async (root, records, source) => {
3245
- if (flags.has('no-mirror') || records.length === 0 || !vectorMirrorEnabled(root))
3163
+ if (flags.has('no-mirror') || records.length === 0)
3246
3164
  return;
3165
+ if (!vectorMirrorEnabled(root)) {
3166
+ emitMirrorOffIfNeeded(root);
3167
+ return;
3168
+ }
3247
3169
  const receipt = await mirrorPatternsToVector(root, records, source);
3248
3170
  if (receipt.mirrored > 0)
3249
3171
  write(` ↳ mirrored to vector tier (${receipt.engine ?? 'vector'})`);
3172
+ else
3173
+ emitVectorTierAbsentIfNeeded(root, receipt);
3250
3174
  };
3251
3175
  // lesson-quarantine FR-8: the fresh-teach mirror carries the qStatus marker so the hook daemon
3252
3176
  // (which reads only the mirror's metadata) can exclude unproven lessons from auto-inject.
3253
3177
  const emitMirrorQ = async (root, records, source, quarantined) => {
3254
- if (flags.has('no-mirror') || records.length === 0 || !vectorMirrorEnabled(root))
3178
+ if (flags.has('no-mirror') || records.length === 0)
3179
+ return;
3180
+ if (!vectorMirrorEnabled(root)) {
3181
+ emitMirrorOffIfNeeded(root);
3255
3182
  return;
3183
+ }
3256
3184
  const entries = records
3257
3185
  .map((r) => patternVectorEntry(r, source, quarantined ? { quarantined: true } : {}))
3258
3186
  .filter((e) => e !== undefined);
3259
3187
  const receipt = await mirrorEntriesToVector(root, entries);
3260
3188
  if (receipt.mirrored > 0)
3261
3189
  write(` ↳ mirrored to vector tier (${receipt.engine ?? 'vector'})${quarantined ? ' [quarantined]' : ''}`);
3190
+ else
3191
+ emitVectorTierAbsentIfNeeded(root, receipt);
3262
3192
  };
3263
3193
  // `dz teach --harmonize` — documented ALIAS of `dz vector harmonize`: SEMANTIC dedup of the
3264
3194
  // learned store. ONE implementation (harmonizeVectorStore), two entry points (QR-6). Routed
@@ -3378,21 +3308,25 @@ async function cmdTeach(options, flags, cwd, write, writeErr = (line) => { conso
3378
3308
  }
3379
3309
  const reinforce = options.get('reinforce');
3380
3310
  if (reinforce !== undefined && reinforce.trim() !== '') {
3381
- const backend = resolveLearningBackend(storeRoot);
3382
3311
  const sampleReward = options.has('reward') ? parseFloat(options.get('reward') ?? '0.8') : undefined;
3383
- backend.addSample({
3384
- dzId: reinforce,
3385
- kind: 'reinforce',
3386
- ts: new Date().toISOString(),
3387
- ...(sampleReward !== undefined ? { reward: sampleReward } : {}),
3388
- });
3389
- const trained = await backend.train();
3312
+ const trained = await reinforceRunner(storeRoot, reinforce, sampleReward);
3390
3313
  if (trained.flushed > 0) {
3391
- write(`↳ reinforced ${reinforce}`);
3314
+ const records = loadStoreRecords(storeRoot);
3315
+ const reinforcedDzId = trained.dzId
3316
+ ?? findExactLesson(records, reinforce)?.id
3317
+ ?? records.find((record) => record.id === reinforce)?.id;
3318
+ write(reinforcedDzId !== undefined && reinforcedDzId !== reinforce
3319
+ ? `↳ reinforced ${reinforcedDzId} (matched by text)`
3320
+ : `↳ reinforced ${reinforcedDzId ?? reinforce}`);
3392
3321
  // lesson-quarantine: reinforcement IS promotion — keep the hook daemon's mirror in step.
3393
- const clearedQ = clearAgentdbQuarantine(storeRoot, [reinforce]);
3394
- if (clearedQ.cleared > 0)
3395
- write(` ↳ promoted out of quarantine (mirror updated)`);
3322
+ if (reinforcedDzId === undefined) {
3323
+ write(' ↳ mirror quarantine NOT cleared: matched pattern has no dzId');
3324
+ }
3325
+ else {
3326
+ const clearedQ = clearAgentdbQuarantine(storeRoot, [reinforcedDzId]);
3327
+ if (clearedQ.cleared > 0)
3328
+ write(` ↳ promoted out of quarantine (mirror updated)`);
3329
+ }
3396
3330
  write(storeLine('written'));
3397
3331
  refreshLearningStoreMark(storeRoot, writeErr, 'dz teach --reinforce');
3398
3332
  return 0;
@@ -4737,6 +4671,7 @@ Usage:
4737
4671
  dz brain add --from-kus <file.json> --slug <s> [--kind repo|book|paper] [--license <spdx>] [--override] [--json]
4738
4672
  dz brain update <slug> [--project <dir>] [--json]
4739
4673
  dz brain reindex [--json]
4674
+ dz brain snapshots [--keep <N>] [--prune] [--json] [--project <dir>]
4740
4675
  dz brain primer <slug> [--json]
4741
4676
  dz brain export --source <slug> --out <file>
4742
4677
  dz brain ground [<prompt>] [--k <N>] [--source <slug>] [--text] [--budget <N>] [--full]
@@ -5205,8 +5140,101 @@ async function cmdBrain(options, flags, cwd, write, readStdin) {
5205
5140
  write(`dz brain reindex: re-embedded ${result.reembedded} KU vector(s) with ${result.model} (manifest v${result.version})`);
5206
5141
  if (result.backupPath !== undefined)
5207
5142
  write(` snapshot: ${result.backupPath}`);
5143
+ if (result.snapshots !== undefined) {
5144
+ const mb = (result.snapshots.removedBytes / (1024 * 1024)).toFixed(1);
5145
+ write(` ↳ snapshots: kept ${result.snapshots.kept.length}, removed ${result.snapshots.removed.length} (${mb} MB)`);
5146
+ if (result.snapshots.errors !== undefined && result.snapshots.errors.length > 0) {
5147
+ write(` ⚠ snapshot rotation error(s): ${result.snapshots.errors.join('; ')}`);
5148
+ }
5149
+ if (result.snapshots.scanErrors !== undefined && result.snapshots.scanErrors.length > 0) {
5150
+ write(` ⚠ snapshot scan error(s), nothing removed this call: ${result.snapshots.scanErrors.join('; ')}`);
5151
+ }
5152
+ if (result.snapshots.partialFamilies !== undefined && result.snapshots.partialFamilies.length > 0) {
5153
+ write(` ⚠ .bak preserved after a sibling failure in famil(y/ies): ${result.snapshots.partialFamilies.join(', ')}`);
5154
+ }
5155
+ }
5208
5156
  return 0;
5209
5157
  }
5158
+ // ── dz brain snapshots [--keep N] [--prune] [--json] ────────────────────────────────────────
5159
+ // Manual rotation of the brain's OWN pre-reindex snapshots — independent of `dz brain reindex`
5160
+ // (FR-7). The owner's hub forbids running a live reindex there today, and 13 snapshots / 50 MB
5161
+ // sit unrotated regardless; this command reaches the same family-aware rotation without one.
5162
+ // Without --prune it only LISTS families (dry, never deletes); --prune applies FR-1..FR-5.
5163
+ if (sub === 'snapshots') {
5164
+ // Lead edit after acceptance (2026-09-13): the owner's hub keeps its 13 families next to the
5165
+ // PROJECT store (.dz/agentdb.db, written by the vector-tier reindex), not the home brain —
5166
+ // `--project <dir>` addresses that store; without it the home brain is the target as before.
5167
+ const projectArg = options.get('project');
5168
+ const dbFile = projectArg !== undefined ? resolveAgentdbPath(resolve(cwd, projectArg)) : brainAgentdbPath(brainHome());
5169
+ const keepRaw = options.get('keep');
5170
+ let keep = 3;
5171
+ if (keepRaw !== undefined) {
5172
+ // AM-1 (fix-round, Codex review Grade D): `Number('')` is `0` and `Number(' 2')` is `2` —
5173
+ // both used to validate as an ordinary non-negative integer, silently accepting empty/
5174
+ // whitespace input. Only the literal digit-string shape is accepted; no trimming.
5175
+ if (!/^(0|[1-9]\d*)$/.test(keepRaw)) {
5176
+ write(`dz brain snapshots: --keep must be a non-negative integer (got '${keepRaw}')`);
5177
+ return 2;
5178
+ }
5179
+ keep = Number(keepRaw);
5180
+ // Lead edit after re-review (Codex C): a digit string can still overflow a safe integer.
5181
+ if (!Number.isSafeInteger(keep)) {
5182
+ write(`dz brain snapshots: --keep is out of range (got '${keepRaw}')`);
5183
+ return 2;
5184
+ }
5185
+ }
5186
+ if (!flags.has('prune')) {
5187
+ // Lead edit after re-review: the list is only trustworthy when the scan was complete —
5188
+ // an unreadable directory is reported with ⚠ and exit 1, never as "no families".
5189
+ const { families, scanErrors } = scanSnapshotDir(dbFile);
5190
+ if (asJson) {
5191
+ write(JSON.stringify({ keep, families: families.map((f) => ({ ms: f.ms, files: f.files.map((file) => file.name), bytes: f.bytes })), scanErrors }));
5192
+ return scanErrors.length > 0 ? 1 : 0;
5193
+ }
5194
+ if (scanErrors.length > 0)
5195
+ write(` ⚠ scan error(s) — the list below may be incomplete: ${scanErrors.join('; ')}`);
5196
+ if (families.length === 0) {
5197
+ write(`dz brain snapshots: no pre-reindex snapshot families next to ${dbFile}`);
5198
+ return scanErrors.length > 0 ? 1 : 0;
5199
+ }
5200
+ write(`dz brain snapshots — ${families.length} family(-ies) @ ${dbFile}`);
5201
+ for (const f of families) {
5202
+ const mb = (f.bytes / (1024 * 1024)).toFixed(1);
5203
+ write(` ${new Date(f.ms).toISOString()} ms=${f.ms} ${f.files.length} file(s) ${mb} MB`);
5204
+ }
5205
+ write(' (dry run — pass --prune to remove families older than --keep)');
5206
+ return scanErrors.length > 0 ? 1 : 0;
5207
+ }
5208
+ const report = rotatePreReindexSnapshots(dbFile, { keep });
5209
+ const scanFailed = report.scanErrors !== undefined && report.scanErrors.length > 0;
5210
+ // agentdb-snapshot-lock FR-4: a busy snapshot lock is reported exactly like a scan failure —
5211
+ // nothing removed, ⚠, exit 1 — never a silent "kept N, removed 0" that reads like an empty rotation.
5212
+ const lockBusy = report.errors !== undefined && report.errors.some((e) => e.startsWith('lock busy'));
5213
+ if (asJson) {
5214
+ write(JSON.stringify(report));
5215
+ return scanFailed || lockBusy ? 1 : 0;
5216
+ }
5217
+ const mb = (report.removedBytes / (1024 * 1024)).toFixed(1);
5218
+ write(`dz brain snapshots: kept ${report.kept.length}, removed ${report.removed.length} (${mb} MB)`);
5219
+ if (report.removed.length > 0)
5220
+ write(` removed: ${report.removed.join(', ')}`);
5221
+ if (report.errors !== undefined && report.errors.length > 0) {
5222
+ write(` ⚠ ${report.errors.length} error(s): ${report.errors.join('; ')}`);
5223
+ }
5224
+ // AM-4: an incomplete scan means NOTHING was removed this call — say so, never silently.
5225
+ if (report.scanErrors !== undefined && report.scanErrors.length > 0) {
5226
+ write(` ⚠ scan error(s), nothing removed this call: ${report.scanErrors.join('; ')}`);
5227
+ }
5228
+ // AM-2: a family whose .bak survived only because a sibling failed to unlink.
5229
+ if (report.partialFamilies !== undefined && report.partialFamilies.length > 0) {
5230
+ write(` ⚠ .bak preserved after a sibling failure in famil(y/ies): ${report.partialFamilies.join(', ')}`);
5231
+ }
5232
+ // FR-3: a live reindex marker rescued a family, or an expired one was cleaned up — honest, never an error.
5233
+ if (report.notes !== undefined && report.notes.length > 0) {
5234
+ write(` note: ${report.notes.join('; ')}`);
5235
+ }
5236
+ return scanFailed || lockBusy ? 1 : 0;
5237
+ }
5210
5238
  // ── dz brain ground [<prompt>] ───────────────────────────────────────────────────────────────
5211
5239
  // The UserPromptSubmit hook entrypoint. ALWAYS exits 0 — grounding is advisory and must never
5212
5240
  // fail a prompt. Emits nothing (silent) unless the brain has relevant citations for the prompt.
@@ -5416,6 +5444,19 @@ async function cmdSetup(options, flags, cwd, write, writeErr) {
5416
5444
  // Step 3: Run setup (hooks + memory + config)
5417
5445
  write(`║ 3. Setting up learning environment... ║`);
5418
5446
  const memoryOpt = options.get('memory');
5447
+ // ADR-001 Decision 2 (feature setup-installs-apply-leg): bake THIS CLI's own installed
5448
+ // @dzhechkov/harness-core into the generated apply-leg hooks — the installation actually running
5449
+ // `dz setup` is the one a consumer's project can always reach, unlike a hard-coded npm prefix
5450
+ // (FR-3). Best-effort: an unresolvable core (should not happen — the CLI depends on it) falls
5451
+ // back to core's own self-resolution inside `runSetup`, never a crash.
5452
+ let coreDistDir;
5453
+ try {
5454
+ const corePkgJson = createRequire(import.meta.url).resolve('@dzhechkov/harness-core/package.json');
5455
+ coreDistDir = join(dirname(corePkgJson), 'dist');
5456
+ }
5457
+ catch {
5458
+ coreDistDir = undefined;
5459
+ }
5419
5460
  const setupResult = runSetup({
5420
5461
  projectRoot,
5421
5462
  target,
@@ -5425,6 +5466,7 @@ async function cmdSetup(options, flags, cwd, write, writeErr) {
5425
5466
  noMemory: flags.has('no-memory'),
5426
5467
  force: flags.has('force'),
5427
5468
  installDriver: flags.has('install-driver'),
5469
+ coreDistDir,
5428
5470
  });
5429
5471
  for (const step of setupResult.steps) {
5430
5472
  const icon = step.status === 'done' ? '✓' : step.status === 'skipped' ? '○' : '✗';
@@ -6140,7 +6182,33 @@ function cmdSbom(options, flags, cwd, write) {
6140
6182
  write(out);
6141
6183
  return 0;
6142
6184
  }
6143
- function cmdPublish(options, flags, cwd, writeOutput) {
6185
+ function mirrorCommandFromConfig(cwd) {
6186
+ const configPath = join(cwd, '.dz', 'config.json');
6187
+ if (!existsSync(configPath))
6188
+ return {};
6189
+ try {
6190
+ const config = JSON.parse(readFileSync(configPath, 'utf8'));
6191
+ const command = typeof config.publish?.mirrorCommand === 'string' ? config.publish.mirrorCommand.trim() : '';
6192
+ return command === '' ? {} : { command };
6193
+ }
6194
+ catch (error) {
6195
+ const reason = error instanceof Error ? error.message : String(error);
6196
+ return { warning: `.dz/config.json unreadable — ${reason}` };
6197
+ }
6198
+ }
6199
+ function mirrorShellToken(value) {
6200
+ if (value === '')
6201
+ return "''";
6202
+ if (!/^[A-Za-z0-9@/.,+_-]+$/.test(value))
6203
+ throw new Error('published package/version list is not shell-safe');
6204
+ return value;
6205
+ }
6206
+ function mirrorFailureMessage(error) {
6207
+ if (error instanceof Error && error.message.trim() !== '')
6208
+ return error.message.trim().split(/\r?\n/, 1)[0] ?? 'mirror command failed';
6209
+ return String(error);
6210
+ }
6211
+ function cmdPublish(options, flags, cwd, writeOutput, mirrorRunner) {
6144
6212
  const json = flags.has('json');
6145
6213
  // Under --json stdout carries exactly one JSON document, so every human line — guard notes, refusals,
6146
6214
  // progress — goes to stderr instead of being dropped: a refusal that prints nothing is the silent
@@ -6148,9 +6216,9 @@ function cmdPublish(options, flags, cwd, writeOutput) {
6148
6216
  const write = json ? (line) => { process.stderr.write(`${line}\n`); } : writeOutput;
6149
6217
  // Reject unknown flags/options so a typo (e.g. `--dry-rum`) can NEVER be
6150
6218
  // silently swallowed and flip the command into live-publish mode.
6151
- const allowedFlags = new Set(['dry-run', 'no-dry-run', 'yes', 'confirm', 'bump-only', 'help', 'require-signing', 'provenance', 'no-provenance', 'json']);
6152
- const allowedOptions = new Set(['filter', 'claim-check', 'no-guard', 'sign-key']);
6153
- const allowedHelp = ' allowed: --dry-run (default), --yes/--confirm/--no-dry-run (go live), --bump-only, --filter <substr>, --claim-check <off|warn|error>, --no-guard "<reason>" (skip the guard pre-flight; logged)';
6219
+ const allowedFlags = new Set(['dry-run', 'no-dry-run', 'yes', 'confirm', 'bump-only', 'help', 'require-signing', 'provenance', 'no-provenance', 'json', 'no-mirror']);
6220
+ const allowedOptions = new Set(['filter', 'claim-check', 'no-guard', 'sign-key', 'mirror-cmd']);
6221
+ 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)';
6154
6222
  for (const flag of flags) {
6155
6223
  if (!allowedFlags.has(flag)) {
6156
6224
  write(`dz publish: unknown option --${flag}`);
@@ -6332,7 +6400,7 @@ function cmdPublish(options, flags, cwd, writeOutput) {
6332
6400
  // longer exist. Default to the same path `dz sign --init` writes, so the ordinary operator needs no
6333
6401
  // new flag; `--sign-key` overrides it.
6334
6402
  const signKey = (options.get('sign-key') ?? join(homedir(), '.dz', 'keys', 'dz.key')).trim();
6335
- const report = publishPackages(cwd, {
6403
+ const publishReport = publishPackages(cwd, {
6336
6404
  provenance,
6337
6405
  dryRun,
6338
6406
  filter,
@@ -6414,9 +6482,82 @@ function cmdPublish(options, flags, cwd, writeOutput) {
6414
6482
  }
6415
6483
  },
6416
6484
  });
6485
+ const configMirror = mirrorCommandFromConfig(cwd);
6486
+ const configuredCommand = (options.get('mirror-cmd') ?? configMirror.command ?? '').trim();
6487
+ const publishedVersions = publishReport.packages
6488
+ .filter((pkg) => pkg.status === 'published')
6489
+ .map((pkg) => `${pkg.name}@${pkg.newVersion}`);
6490
+ const expected = publishedVersions.join(',');
6491
+ const fullMirrorCommand = configuredCommand === ''
6492
+ ? ''
6493
+ : `${configuredCommand} --expect ${mirrorShellToken(expected)} --json`;
6494
+ let mirror;
6495
+ // This exact conjunction is the Step-7/8 mutation anchor: an epilogue is eligible only after a
6496
+ // live sweep that actually landed at least one package. Other explicit skip states are handled
6497
+ // before command resolution so each reason remains distinguishable in text and JSON.
6498
+ const mirrorEligible = !dryRun && publishReport.published >= 1;
6499
+ if (bumpOnly) {
6500
+ mirror = { status: 'skipped', command: fullMirrorCommand, reason: 'bump-only' };
6501
+ }
6502
+ else if (!mirrorEligible) {
6503
+ mirror = {
6504
+ status: 'skipped',
6505
+ command: fullMirrorCommand,
6506
+ reason: dryRun ? 'dry-run' : 'published=0',
6507
+ };
6508
+ }
6509
+ else if (flags.has('no-mirror')) {
6510
+ mirror = { status: 'skipped', command: fullMirrorCommand, reason: '--no-mirror' };
6511
+ }
6512
+ else if (configMirror.warning !== undefined && options.get('mirror-cmd') === undefined) {
6513
+ mirror = { status: 'not-configured', command: '', warning: configMirror.warning };
6514
+ }
6515
+ else if (configuredCommand === '') {
6516
+ mirror = { status: 'not-configured', command: '' };
6517
+ }
6518
+ else {
6519
+ const runMirror = mirrorRunner
6520
+ ?? ((command, runnerOptions) => execSync(command, {
6521
+ cwd: runnerOptions.cwd,
6522
+ env: runnerOptions.env,
6523
+ encoding: 'utf8',
6524
+ stdio: 'pipe',
6525
+ }));
6526
+ try {
6527
+ const stdout = runMirror(fullMirrorCommand, {
6528
+ cwd,
6529
+ env: { ...process.env, DZ_PUBLISHED: expected },
6530
+ });
6531
+ const parsed = JSON.parse(stdout);
6532
+ if (parsed.ok !== true)
6533
+ throw new Error(typeof parsed.error === 'string' ? parsed.error : 'mirror command returned ok:false');
6534
+ if (typeof parsed.commit !== 'string' || parsed.commit === '')
6535
+ throw new Error('mirror command returned no commit');
6536
+ if (typeof parsed.receipt?.manifestUrl !== 'string'
6537
+ || typeof parsed.receipt.confirmedAt !== 'string'
6538
+ || typeof parsed.receipt.waitedMs !== 'number') {
6539
+ throw new Error('mirror command returned no live-manifest receipt');
6540
+ }
6541
+ mirror = {
6542
+ status: 'confirmed',
6543
+ command: fullMirrorCommand,
6544
+ commit: parsed.commit,
6545
+ receipt: {
6546
+ manifestUrl: parsed.receipt.manifestUrl,
6547
+ confirmedAt: parsed.receipt.confirmedAt,
6548
+ waitedMs: parsed.receipt.waitedMs,
6549
+ },
6550
+ };
6551
+ }
6552
+ catch (error) {
6553
+ mirror = { status: 'unconfirmed', command: fullMirrorCommand, error: mirrorFailureMessage(error) };
6554
+ }
6555
+ }
6556
+ const report = { ...publishReport, mirror };
6557
+ const exitCode = report.errors > 0 ? 1 : mirror.status === 'unconfirmed' ? 3 : 0;
6417
6558
  if (json) {
6418
6559
  writeOutput(JSON.stringify(report));
6419
- return report.errors > 0 ? 1 : 0;
6560
+ return exitCode;
6420
6561
  }
6421
6562
  write(`\ndz publish${dryRun ? ' --dry-run' : ''}${bumpOnly ? ' --bump-only' : ''}${claimCheckOpt !== 'warn' ? ` --claim-check ${claimCheckOpt}` : ''}`);
6422
6563
  write(` Published: ${report.published} Skipped: ${report.skipped} Errors: ${report.errors}\n`);
@@ -6463,13 +6604,30 @@ function cmdPublish(options, flags, cwd, writeOutput) {
6463
6604
  write(` ⚠ warning: ${warning}`);
6464
6605
  for (const path of report.releaseLineSynced ?? [])
6465
6606
  write(` ↳ release line synced: ${path}`);
6466
- return report.errors > 0 ? 1 : 0;
6607
+ if (mirror.status === 'confirmed') {
6608
+ write(` ✓ mirror: confirmed — ${mirror.commit} (${mirror.receipt?.manifestUrl})`);
6609
+ }
6610
+ else if (mirror.status === 'not-configured') {
6611
+ if (mirror.warning !== undefined)
6612
+ write(` ⚠ mirror: ${mirror.warning}`);
6613
+ else
6614
+ write(' ℹ mirror: not configured');
6615
+ }
6616
+ else if (mirror.status === 'skipped') {
6617
+ write(` ℹ mirror: skipped (${mirror.reason})`);
6618
+ }
6619
+ else {
6620
+ write(` ✗ mirror: unconfirmed — ${mirror.error}`);
6621
+ const rerun = mirror.command || 'configure publish.mirrorCommand, then run it';
6622
+ write(`dz publish: published, mirror NOT confirmed — ${mirror.error}; re-run: ${rerun}`);
6623
+ }
6624
+ return exitCode;
6467
6625
  }
6468
6626
  /* ------------------------------------------------------------------ */
6469
6627
  /* dz parity — the honest feature×target map (target-parity-matrix, */
6470
6628
  /* ADR-001): computed from the declarative model, never hand-written */
6471
6629
  /* ------------------------------------------------------------------ */
6472
- function cmdParity(options, flags, write, writeErr) {
6630
+ function cmdParity(options, flags, write, writeErr, cwd) {
6473
6631
  const json = flags.has('json');
6474
6632
  if (flags.has('help')) {
6475
6633
  write('dz parity [--target <name>] [--json] — the computed feature×target map (never hand-written)');
@@ -6497,7 +6655,33 @@ function cmdParity(options, flags, write, writeErr) {
6497
6655
  return 1;
6498
6656
  }
6499
6657
  }
6500
- const matrix = buildParityMatrix();
6658
+ // ADR-001 Decision 3 (feature setup-installs-apply-leg): `learning-apply` on `claude-code` is
6659
+ // MEASURED, not declared — `hooks-prompt` is present for that ONE target only when
6660
+ // `applyLegStatus(root).installed`. `computeParity` itself is untouched (FR-5); only the
6661
+ // capability SET fed into it for this one cell differs from the static `TARGET_CAPABILITIES`.
6662
+ // `applyLegStatus` never throws (fix round 1, Q3 finding: an unreadable helper used to be able to
6663
+ // crash this command rather than degrade to a named remedy).
6664
+ const applyLegStatusVal = applyLegStatus(cwd);
6665
+ const applyLegInstalled = applyLegStatusVal.installed;
6666
+ const matrix = buildParityMatrix().map((row) => {
6667
+ if (row.feature.id !== 'learning-apply' || applyLegInstalled)
6668
+ return row;
6669
+ const claudeCodeCaps = TARGET_CAPABILITIES['claude-code'].filter((c) => c !== 'hooks-prompt');
6670
+ return { feature: row.feature, cells: { ...row.cells, 'claude-code': computeParity(row.feature, claudeCodeCaps) } };
6671
+ });
6672
+ // The "not installed" remedy — named ONLY for the one cell whose grant is a live measurement,
6673
+ // never a blanket note for every `manual` cell (most targets are manual by DESIGN, not absence).
6674
+ // `stale-version`/`unreadable` route through `applyLegReasonMessage` — the SAME text-producing
6675
+ // function `dz doctor` uses for those two reasons (fix round 1, HIGH finding 2 / Q3 finding 7), so
6676
+ // the two instruments cannot disagree about WHY a stale or broken install is not "full".
6677
+ const applyLegRemedy = (featureId, t) => {
6678
+ if (featureId !== 'learning-apply' || t !== 'claude-code' || applyLegInstalled)
6679
+ return '';
6680
+ if (applyLegStatusVal.reason === 'stale-version' || applyLegStatusVal.reason === 'unreadable') {
6681
+ return ` — ${applyLegReasonMessage(applyLegStatusVal)}`;
6682
+ }
6683
+ return ' — not installed — run dz setup --target claude-code --memory agentdb';
6684
+ };
6501
6685
  // EVIDENCE staleness, folded into the report (fix round 2, R2-3). Derived from the records
6502
6686
  // themselves — no `codex --version`, no subprocess, so `dz parity` stays a deterministic function
6503
6687
  // of the model. A cell whose deciding form rests on a transcript that is older than the newest
@@ -6541,8 +6725,11 @@ function cmdParity(options, flags, write, writeErr) {
6541
6725
  const shown = target !== undefined ? [target] : TARGET_NAMES;
6542
6726
  const rows = matrix.map((r) => {
6543
6727
  const cells = {};
6544
- for (const t of shown)
6545
- cells[t] = reportCell(r.feature, t, r.cells[t]);
6728
+ for (const t of shown) {
6729
+ const cell = reportCell(r.feature, t, r.cells[t]);
6730
+ const remedy = applyLegRemedy(r.feature.id, t);
6731
+ cells[t] = remedy === '' ? cell : { ...cell, note: remedy.replace(/^ — /, '') };
6732
+ }
6546
6733
  return { id: r.feature.id, title: r.feature.title, cells };
6547
6734
  });
6548
6735
  // A filtered response stays internally consistent: capabilities are filtered too (Codex QE gap 9).
@@ -6568,7 +6755,7 @@ function cmdParity(options, flags, write, writeErr) {
6568
6755
  : c.level === 'inconclusive'
6569
6756
  ? `via ${c.via ?? ''} — INCONCLUSIVE: stale evidence for ${(c.staleEvidence ?? []).join(', ')}`
6570
6757
  : `via ${c.via ?? ''}`;
6571
- write(` ${icon} ${r.feature.title.padEnd(58)} ${detail}`);
6758
+ write(` ${icon} ${r.feature.title.padEnd(58)} ${detail}${applyLegRemedy(r.feature.id, t)}`);
6572
6759
  }
6573
6760
  write('\n ✓ full (the complete experience) ◐ manual (works, you drive it by hand) ? evidence stale (re-probe) — not available');
6574
6761
  for (const line of staleNote(t))
@@ -8807,6 +8994,39 @@ function gatherGuardFacts(op, root, text, storeCap, publishFilter) {
8807
8994
  const facts = { op };
8808
8995
  const publishPackageRoots = [];
8809
8996
  if (op === 'publish') {
8997
+ try {
8998
+ const roundsDir = join(root, '.dz', 'rounds');
8999
+ const states = readdirSync(roundsDir)
9000
+ .filter((name) => name.endsWith('.json'))
9001
+ .map((name) => readRoundState(join(roundsDir, name)))
9002
+ .filter((state) => state !== null);
9003
+ facts['openRounds'] = listRounds(states, {
9004
+ now: Date.now(),
9005
+ olderThanMinutes: 120,
9006
+ isPidAlive: probePid,
9007
+ isRunAlive: (runId) => roundRunOwnerAlive(root, runId, Date.now()),
9008
+ }).map((row) => ({
9009
+ slug: row.state.slug,
9010
+ round: row.state.round,
9011
+ ageMinutes: row.ageMinutes,
9012
+ pidAlive: row.pidAlive,
9013
+ }));
9014
+ }
9015
+ catch { /* absent/unreadable round state is no fabricated violation */ }
9016
+ const since = roundTraceSince(root);
9017
+ const enabled = roundsTracingEnabled(root);
9018
+ if (!enabled) {
9019
+ facts['codeCommitsSinceLastRound'] = { commits: null, since, enabled: false };
9020
+ }
9021
+ else if (since !== null) {
9022
+ facts['codeCommitsSinceLastRound'] = { commits: packageCommitCount(root, since), since };
9023
+ }
9024
+ else if (existsSync(join(root, '.dz', 'feature-adr', 'run-cost-ledger.jsonl'))) {
9025
+ // The ledger EXISTS but carries no dated row: that is a measurable absence and gets a note.
9026
+ // No ledger file at all is a fresh project — the rule stays not-established silently, so a
9027
+ // note that every new repo would carry does not drown the ones that mean something.
9028
+ facts['codeCommitsSinceLastRound'] = { commits: null, since: null };
9029
+ }
8810
9030
  // Advisory I/O: unreadable telemetry or fed state is absence of evidence, never a fabricated
8811
9031
  // stale finding and never a publish blocker.
8812
9032
  try {
@@ -11203,6 +11423,107 @@ function parseCheckMutatedFile(absFile, text) {
11203
11423
  return { skipped: `parse-check errored: ${String(e.message).slice(0, 120)}` };
11204
11424
  }
11205
11425
  }
11426
+ const MUTATION_GATE_OUTPUT_TAIL_MAX_LINES = 20;
11427
+ const MUTATION_GATE_OUTPUT_TAIL_MAX_BYTES = 2 * 1024;
11428
+ export function boundedMutationGateOutputTail(output) {
11429
+ const normalized = output.replace(/\r\n?/g, '\n').replace(/\n+$/, '');
11430
+ if (normalized === '')
11431
+ return undefined;
11432
+ let tail = normalized.split('\n').slice(-MUTATION_GATE_OUTPUT_TAIL_MAX_LINES).join('\n');
11433
+ const encoded = Buffer.from(tail, 'utf8');
11434
+ if (encoded.byteLength <= MUTATION_GATE_OUTPUT_TAIL_MAX_BYTES)
11435
+ return tail;
11436
+ const codePoints = Array.from(tail);
11437
+ let start = codePoints.length;
11438
+ let byteLength = 0;
11439
+ while (start > 0) {
11440
+ const nextByteLength = Buffer.byteLength(codePoints[start - 1], 'utf8');
11441
+ if (byteLength + nextByteLength > MUTATION_GATE_OUTPUT_TAIL_MAX_BYTES)
11442
+ break;
11443
+ byteLength += nextByteLength;
11444
+ start -= 1;
11445
+ }
11446
+ return codePoints.slice(start).join('');
11447
+ }
11448
+ // ── Full-output capture for a RED baseline/rebaseline line (gate-stability, 2026-09-12) ────────
11449
+ // The bounded tail above is a diagnostic teaser (3-20 lines); under a multi-entry gate run the
11450
+ // tail was measured to hand back an unrelated neighbour's stderr, leaving OVER_FAILING/
11451
+ // INCONCLUSIVE undiagnosable. Only the baseline and rebaseline lines write here — the per-entry
11452
+ // mutation run is EXPECTED to redden and already carries a bounded tail; this is for the lines
11453
+ // whose redness means "the copy itself is broken", where the full transcript is the only way to
11454
+ // tell what actually happened.
11455
+ const MUTATION_GATE_OUTPUT_FILE_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
11456
+ function mutationGateOutputDir() {
11457
+ return process.env.DZ_MUTGATE_OUTPUT_DIR ?? join(tmpdir(), 'dz-mutgate-output');
11458
+ }
11459
+ /** own filename prefix (fix-round-1 HIGH finding) — see isMutationGateOutputFile. */
11460
+ const MUTATION_GATE_OUTPUT_FILE_PREFIX = 'dz-mutgate-';
11461
+ /** exact shape of `new Date().toISOString().replace(/:/g, '-')`, e.g. `2026-09-12T20-00-00.000Z`. */
11462
+ const MUTATION_GATE_OUTPUT_TS_PATTERN = String.raw `\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.\d{3}Z`;
11463
+ const MUTATION_GATE_OUTPUT_FILE_RE = new RegExp(`^${MUTATION_GATE_OUTPUT_FILE_PREFIX}.+-(baseline|rebaseline|final-rebaseline)-${MUTATION_GATE_OUTPUT_TS_PATTERN}\\.log$`);
11464
+ /**
11465
+ * true only for a filename THIS executor could have written — rotation never touches a foreign
11466
+ * file. Fix-round-1 HIGH finding (Codex review, gate-stability): the prior
11467
+ * `^.+-(baseline|rebaseline|final-rebaseline)-.+\.log$` had no own prefix and accepted ANY
11468
+ * trailing text as the "timestamp", so a pre-existing unrelated file dropped into a shared
11469
+ * `DZ_MUTGATE_OUTPUT_DIR` (e.g. `service-baseline-backup.log`) matched and could be rotated away.
11470
+ * Now BOTH the `dz-mutgate-` prefix AND the exact ISO-timestamp shape we ourselves write are
11471
+ * required — a foreign file can accidentally share the prefix but essentially never our precise
11472
+ * timestamp format, and a file we did NOT write never carries both.
11473
+ */
11474
+ function isMutationGateOutputFile(name) {
11475
+ return MUTATION_GATE_OUTPUT_FILE_RE.test(name);
11476
+ }
11477
+ function rotateMutationGateOutputDir(dir) {
11478
+ let names;
11479
+ try {
11480
+ names = readdirSync(dir);
11481
+ }
11482
+ catch {
11483
+ return;
11484
+ }
11485
+ const cutoff = Date.now() - MUTATION_GATE_OUTPUT_FILE_RETENTION_MS;
11486
+ for (const name of names) {
11487
+ if (!isMutationGateOutputFile(name))
11488
+ continue; // "чужие файлы не трогаются" — own prefix only
11489
+ const full = join(dir, name);
11490
+ try {
11491
+ if (statSync(full).mtimeMs < cutoff)
11492
+ rmSync(full, { force: true });
11493
+ }
11494
+ catch { /* best effort — a listing race is not this executor's problem */ }
11495
+ }
11496
+ }
11497
+ /**
11498
+ * Saves the FULL stdout+stderr of a RED baseline/rebaseline run and returns `{ path }`, or
11499
+ * `{ error }` on any I/O failure (EACCES/ENOSPC/EROFS/ENOTDIR and the like — never blocks the gate
11500
+ * on a logging problem: fix-round-1 MEDIUM finding, the prior silent `catch { return undefined; }`
11501
+ * made a failed save indistinguishable from "nothing to save"), or `undefined` when exitCode is 0
11502
+ * (nothing written on green — NFR-1 byte-identity).
11503
+ */
11504
+ function writeMutationGateOutputOnRed(entryId, phase, exitCode, output) {
11505
+ if (exitCode === 0)
11506
+ return undefined;
11507
+ try {
11508
+ const dir = mutationGateOutputDir();
11509
+ mkdirSync(dir, { recursive: true });
11510
+ rotateMutationGateOutputDir(dir);
11511
+ const ts = new Date().toISOString().replace(/:/g, '-');
11512
+ const full = join(dir, `${MUTATION_GATE_OUTPUT_FILE_PREFIX}${entryId ?? 'baseline'}-${phase}-${ts}.log`);
11513
+ writeFileSync(full, output);
11514
+ return { path: full };
11515
+ }
11516
+ catch (e) {
11517
+ return { error: String(e?.message ?? e) };
11518
+ }
11519
+ }
11520
+ /** Unpacks a `writeMutationGateOutputOnRed` result into the `{outputPath, outputError}` shape the
11521
+ * pure engine (classifyBaseline / MutationObservation) consumes. */
11522
+ function splitMutationGateOutputWrite(result) {
11523
+ if (result === undefined)
11524
+ return {};
11525
+ return 'path' in result ? { outputPath: result.path } : { outputError: result.error };
11526
+ }
11206
11527
  function cmdMutationGate(options, flags, cwd, write, injectedRunner) {
11207
11528
  const json = flags.has('json');
11208
11529
  const fail = (what) => {
@@ -11258,6 +11579,10 @@ function cmdMutationGate(options, flags, cwd, write, injectedRunner) {
11258
11579
  if (/[\0\n\r]/.test(testCmdRaw))
11259
11580
  return fail('--test-cmd may not contain NUL or newline characters');
11260
11581
  const testCmd = testCmdRaw;
11582
+ const excludedSelfChecks = REGISTRY_SELFCHECK_TESTS.filter((testFile) => entries.some((entry) => buildMutationTestCommand(testCmd, entry).excluded.includes(testFile)));
11583
+ if (!json) {
11584
+ write(`mutation-gate: self-check excluded from mutant runs: ${excludedSelfChecks.join(', ') || '(none)'}`);
11585
+ }
11261
11586
  const timeoutOpt = Number(options.get('timeout') ?? '300000');
11262
11587
  const timeout = Number.isFinite(timeoutOpt) && timeoutOpt > 0 ? timeoutOpt : 300000;
11263
11588
  // Route-b guard mode: `per-entry` (default, strongest — each red entry re-baselines the restored
@@ -11335,11 +11660,16 @@ function cmdMutationGate(options, flags, cwd, write, injectedRunner) {
11335
11660
  // write below is asserted to RESOLVE inside this root before it happens.
11336
11661
  const realScratchRoot = realpathSync(copyDir);
11337
11662
  const requireCompletionReceipt = parsed.registry.requireCompletionReceipt === true;
11338
- const invokeSuite = () => {
11663
+ const invokeSuite = (suiteCommand, phase, entryId) => {
11339
11664
  if (injectedRunner !== undefined) {
11340
- return injectedRunner(testCmd, { cwd: copyDir, timeoutMs: timeout });
11665
+ return injectedRunner(suiteCommand, {
11666
+ cwd: copyDir,
11667
+ timeoutMs: timeout,
11668
+ phase,
11669
+ ...(entryId !== undefined ? { entryId } : {}),
11670
+ });
11341
11671
  }
11342
- const run = spawnSync(testCmd, {
11672
+ const run = spawnSync(suiteCommand, {
11343
11673
  cwd: copyDir,
11344
11674
  shell: true,
11345
11675
  encoding: 'utf-8',
@@ -11376,8 +11706,8 @@ function cmdMutationGate(options, flags, cwd, write, injectedRunner) {
11376
11706
  ...(failureReason !== undefined ? { failureReason } : {}),
11377
11707
  };
11378
11708
  };
11379
- const runSuite = (phase, entryId) => {
11380
- const retried = runWithOneInternalRetry(invokeSuite);
11709
+ const runSuite = (phase, entryId, suiteCommand = testCmd) => {
11710
+ const retried = runWithOneInternalRetry(() => invokeSuite(suiteCommand, phase, entryId));
11381
11711
  const loggedAttempts = retried.attempts.map((attempt) => {
11382
11712
  if (attempt.outcome !== 'completed' || retried.value === null)
11383
11713
  return attempt;
@@ -11415,9 +11745,10 @@ function cmdMutationGate(options, flags, cwd, write, injectedRunner) {
11415
11745
  if (!json)
11416
11746
  write(`mutation-gate: baseline suite in scratch copy of ${pkgDir} …`);
11417
11747
  const base = runSuite('baseline');
11748
+ const { outputPath: baseOutputPath, outputError: baseOutputError } = splitMutationGateOutputWrite(writeMutationGateOutputOnRed(undefined, 'baseline', base.exitCode, base.output));
11418
11749
  baseline = classifyBaseline(base.exitCode, base.failureReason, base.exitCode !== null && base.exitCode !== 0
11419
11750
  ? attributeBaselineRedness(base.output, entries.map((entry) => entry.file))
11420
- : undefined);
11751
+ : undefined, baseOutputPath, baseOutputError);
11421
11752
  if (!baseline.ok) {
11422
11753
  if (json) {
11423
11754
  write(JSON.stringify({ packageDir: pkgDir, registryPath, testCommand: testCmd, baseline, results, internalRetries, exitCode: 1 }, null, 2));
@@ -11490,7 +11821,7 @@ function cmdMutationGate(options, flags, cwd, write, injectedRunner) {
11490
11821
  parseError = check.error; // no suite run: the verdict is MUTATION_UNPARSEABLE regardless
11491
11822
  }
11492
11823
  else if (parseInternalFailureReason === undefined) {
11493
- run = runSuite('mutation', entry.id);
11824
+ run = runSuite('mutation', entry.id, buildMutationTestCommand(testCmd, entry).testCommand);
11494
11825
  }
11495
11826
  }
11496
11827
  finally {
@@ -11527,6 +11858,9 @@ function cmdMutationGate(options, flags, cwd, write, injectedRunner) {
11527
11858
  let rebaselineExitCode;
11528
11859
  let rebaselineFailureReason;
11529
11860
  let rebaselineAttribution;
11861
+ let rebaselineOutputTail;
11862
+ let rebaselineOutputPath;
11863
+ let rebaselineOutputError;
11530
11864
  let rebaselineInternalAttemptLog;
11531
11865
  if (rebaselineMode === 'per-entry' && run !== null && run.exitCode !== null && run.exitCode !== 0
11532
11866
  && fileLoadFailure === undefined && outputUnrecognised === undefined && receiptMismatch === undefined) {
@@ -11536,8 +11870,13 @@ function cmdMutationGate(options, flags, cwd, write, injectedRunner) {
11536
11870
  rebaselineExitCode = rebaselineRun.exitCode;
11537
11871
  rebaselineFailureReason = rebaselineRun.failureReason;
11538
11872
  rebaselineInternalAttemptLog = rebaselineRun.internalAttemptLog;
11539
- if (rebaselineRun.exitCode !== null && rebaselineRun.exitCode !== 0) {
11540
- rebaselineAttribution = attributeBaselineRedness(rebaselineRun.output, entries.map((candidate) => candidate.file));
11873
+ if (rebaselineRun.exitCode !== 0) {
11874
+ rebaselineOutputTail = boundedMutationGateOutputTail(rebaselineRun.output);
11875
+ ({ outputPath: rebaselineOutputPath, outputError: rebaselineOutputError } =
11876
+ splitMutationGateOutputWrite(writeMutationGateOutputOnRed(entry.id, 'rebaseline', rebaselineRun.exitCode, rebaselineRun.output)));
11877
+ if (rebaselineRun.exitCode !== null) {
11878
+ rebaselineAttribution = attributeBaselineRedness(rebaselineRun.output, entries.map((candidate) => candidate.file));
11879
+ }
11541
11880
  }
11542
11881
  }
11543
11882
  const entryRunFailureReason = run?.failureReason ?? parseInternalFailureReason;
@@ -11558,6 +11897,9 @@ function cmdMutationGate(options, flags, cwd, write, injectedRunner) {
11558
11897
  ...(rebaselineExitCode !== undefined ? { rebaselineExitCode } : {}),
11559
11898
  ...(rebaselineFailureReason !== undefined ? { rebaselineFailureReason } : {}),
11560
11899
  ...(rebaselineAttribution !== undefined ? { rebaselineAttribution } : {}),
11900
+ ...(rebaselineOutputTail !== undefined ? { rebaselineOutputTail } : {}),
11901
+ ...(rebaselineOutputPath !== undefined ? { outputPath: rebaselineOutputPath } : {}),
11902
+ ...(rebaselineOutputError !== undefined ? { outputError: rebaselineOutputError } : {}),
11561
11903
  };
11562
11904
  observations.push(obs);
11563
11905
  results.push(classifyMutationOutcome(obs));
@@ -11573,6 +11915,8 @@ function cmdMutationGate(options, flags, cwd, write, injectedRunner) {
11573
11915
  const finalRun = runSuite('final-rebaseline');
11574
11916
  const finalExit = finalRun.exitCode;
11575
11917
  if (finalExit !== 0) {
11918
+ const finalOutputTail = boundedMutationGateOutputTail(finalRun.output);
11919
+ const { outputPath: finalOutputPath, outputError: finalOutputError } = splitMutationGateOutputWrite(writeMutationGateOutputOnRed(undefined, 'final-rebaseline', finalExit, finalRun.output));
11576
11920
  const what = finalExit === null ? `no exit code: ${finalRun.failureReason ?? 'unknown timeout / spawn failure'}` : `exit ${finalExit}`;
11577
11921
  warnings.push(`final re-baseline NOT green (${what}) — the suite is flaky; red-based verdicts downgraded to INCONCLUSIVE`);
11578
11922
  if (!json)
@@ -11587,6 +11931,9 @@ function cmdMutationGate(options, flags, cwd, write, injectedRunner) {
11587
11931
  ...(finalExit !== null && finalExit !== 0
11588
11932
  ? { rebaselineAttribution: attributeBaselineRedness(finalRun.output, entries.map((entry) => entry.file)) }
11589
11933
  : {}),
11934
+ ...(finalOutputTail !== undefined ? { rebaselineOutputTail: finalOutputTail } : {}),
11935
+ ...(finalOutputPath !== undefined ? { outputPath: finalOutputPath } : {}),
11936
+ ...(finalOutputError !== undefined ? { outputError: finalOutputError } : {}),
11590
11937
  }));
11591
11938
  results.length = 0;
11592
11939
  results.push(...reclassified);
@@ -13744,6 +14091,893 @@ function cmdRunsRecord(options, flags, cwd, write) {
13744
14091
  return 2;
13745
14092
  }
13746
14093
  }
14094
+ const ROUND_LEDGER_REL = join('.dz', 'feature-adr', 'run-cost-ledger.jsonl');
14095
+ /**
14096
+ * round-state-root FR-1/FR-2: where `dz round` state (and its ledger, FR-4) lives — flag beats env
14097
+ * beats cwd. `--project` is untouched by this and stays recall-only (lesson 2ac30a70). Only an
14098
+ * EXPLICIT flag/env value is validated for absoluteness; the cwd fallback is `resolve(cwd)`, exactly
14099
+ * what every subcommand used before this feature (NFR-1: byte-identical when neither is set).
14100
+ */
14101
+ function resolveRoundStateRoot(options, env, cwd) {
14102
+ const flagRaw = options.get('state-root');
14103
+ if (flagRaw !== undefined) {
14104
+ if (!isAbsolute(flagRaw))
14105
+ return { ok: false, reason: `--state-root должен быть абсолютным путём: ${flagRaw}` };
14106
+ return { ok: true, root: flagRaw, source: 'flag' };
14107
+ }
14108
+ const envRaw = env['DZ_ROUND_STATE_ROOT'];
14109
+ if (envRaw !== undefined) {
14110
+ // A variable that is SET but blank is a misconfiguration, not an absence: falling back to cwd
14111
+ // here would be exactly the stray-write this flag exists to prevent (Codex review, 2026-09-13).
14112
+ if (envRaw.trim() === '')
14113
+ return { ok: false, reason: 'DZ_ROUND_STATE_ROOT задана, но пуста — укажите абсолютный путь или снимите переменную' };
14114
+ if (!isAbsolute(envRaw))
14115
+ return { ok: false, reason: `DZ_ROUND_STATE_ROOT должен быть абсолютным путём: ${envRaw}` };
14116
+ return { ok: true, root: envRaw, source: 'env' };
14117
+ }
14118
+ return { ok: true, root: resolve(cwd), source: 'cwd' };
14119
+ }
14120
+ function roundStatePath(root, slug, round) {
14121
+ return join(root, '.dz', 'rounds', `${slug}-${round}.json`);
14122
+ }
14123
+ /** round-state-lock T2: parses raw JSON text into a `RoundState`, shared by `readRoundState` (reads
14124
+ * from disk) and the AC-1 recheck-under-lock (compares a raw string captured before recall against
14125
+ * one read again inside the lock, so it needs to parse the SAME raw text twice without a third
14126
+ * disk read). */
14127
+ function parseRoundState(raw) {
14128
+ try {
14129
+ const row = JSON.parse(raw);
14130
+ if (typeof row.slug !== 'string' || !Number.isInteger(row.round) || typeof row.topic !== 'string'
14131
+ || typeof row.startedAt !== 'string' || !Number.isInteger(row.pid) || !Array.isArray(row.recalled)
14132
+ || row.recalled.some((id) => typeof id !== 'string'))
14133
+ return null;
14134
+ if (row.execs !== undefined && (!Array.isArray(row.execs) || row.execs.some((entry) => typeof entry.startedAt !== 'string' || typeof entry.endedAt !== 'string'
14135
+ || (entry.exitCode !== null && !Number.isInteger(entry.exitCode))
14136
+ || typeof entry.outcome !== 'string'
14137
+ || (entry.tokens !== null && !Number.isInteger(entry.tokens)))))
14138
+ return null;
14139
+ return row;
14140
+ }
14141
+ catch {
14142
+ return null;
14143
+ }
14144
+ }
14145
+ function readRoundState(path) {
14146
+ try {
14147
+ return parseRoundState(readFileSync(path, 'utf8'));
14148
+ }
14149
+ catch {
14150
+ return null;
14151
+ }
14152
+ }
14153
+ /** round-state-lock: the raw bytes at `path`, or `null` when absent/unreadable. Used to detect
14154
+ * whether the state file changed between a check made BEFORE the (long, unlocked) recall and one
14155
+ * made again INSIDE the round-state lock — a byte-identical read means nothing raced us. */
14156
+ function readRawRoundState(path) {
14157
+ try {
14158
+ return readFileSync(path, 'utf8');
14159
+ }
14160
+ catch {
14161
+ return null;
14162
+ }
14163
+ }
14164
+ /** round-state-lock fix-round AM-1: 16 random hex chars, minted once per `open`. */
14165
+ function generateRoundStateId() {
14166
+ return randomBytes(8).toString('hex');
14167
+ }
14168
+ /** Lead edit after Codex re-review: a LEGACY state (written before stateId existed) must not be
14169
+ * matched by `undefined === undefined` — under the lock, the first exec/close that meets it mints
14170
+ * an id, writes it back, and continues with that id as the identity of THIS operation. */
14171
+ function ensureStateId(path, fresh) {
14172
+ if (fresh.stateId !== undefined)
14173
+ return fresh;
14174
+ const minted = { ...fresh, stateId: randomBytes(8).toString('hex') };
14175
+ writeJsonAtomic(path, minted);
14176
+ return minted;
14177
+ }
14178
+ function readStateOrRefuse(path, expectedStateId) {
14179
+ const fresh = readRoundState(path);
14180
+ if (fresh === null)
14181
+ return { refused: 'gone' };
14182
+ if (expectedStateId === undefined && fresh.stateId === undefined)
14183
+ return ensureStateId(path, fresh);
14184
+ if (fresh.stateId !== expectedStateId)
14185
+ return { refused: 'replaced', stateId: fresh.stateId };
14186
+ return fresh;
14187
+ }
14188
+ function readStateForCloseOrRefuse(path, expectedStateId) {
14189
+ const fresh = readRoundState(path);
14190
+ if (fresh === null)
14191
+ return { refused: 'closed-already' };
14192
+ if (expectedStateId === undefined && fresh.stateId === undefined)
14193
+ return ensureStateId(path, fresh);
14194
+ if (fresh.stateId !== expectedStateId)
14195
+ return { refused: 'replaced', stateId: fresh.stateId };
14196
+ return fresh;
14197
+ }
14198
+ /** round-state-lock fix-round AM-4: the exact ledger-row marker `closeRound` (harness-core) will
14199
+ * compute for THIS close attempt, predicted from the same three inputs (slug, round, closedAt)
14200
+ * BEFORE calling it — so a retried `close` with the same injected `roundNow` (same `closedAt`) can
14201
+ * detect "the ledger already carries this attempt's row" and skip writing a duplicate. Mirrors
14202
+ * `closeRound`'s own marker formula in harness-core/src/round.ts exactly; a drift between the two
14203
+ * would only defeat the RETRY-dedup check (closeRound's own success postcondition, verified by
14204
+ * rereading the ledger tail, is unaffected either way). Deliberately NOT keyed on `stateId`: the
14205
+ * run-cost ledger row schema (`RoundLedgerRow`) has no such column, and adding one is out of this
14206
+ * fix's scope (round.ts stays untouched) — (slug, round, closedAt) is the identity already exposed
14207
+ * through the marker, and it is exactly as unique for a genuine retry (same close command, same
14208
+ * injected clock) as a `stateId` would be. */
14209
+ function predictedRoundCloseMarker(slug, round, closedAtIso) {
14210
+ const closedMs = Date.parse(closedAtIso);
14211
+ const compactTs = new Date(closedMs).toISOString().replace(/[-:.]/g, '');
14212
+ return `round-${slug}-${round}-${compactTs}`;
14213
+ }
14214
+ /** round-state-lock fix-round AM-5: `open`/`status` warn when a round has been sitting with
14215
+ * `ownerKind: 'exec'` for more than this many minutes — the shape of a restore-section that
14216
+ * exhausted its lock-busy retries (see `ROUND_RESTORE_LOCK_ATTEMPTS`) and left the round claimed by
14217
+ * an `exec` that already finished. There is no separate "since when has this been exec" timestamp on
14218
+ * `RoundState`, so this measures from `startedAt` (the round's own start) — a deliberate
14219
+ * approximation: an `exec` that ran briefly near round-open would read as "young" even if its
14220
+ * restore failed just now. Good enough to surface the stuck case at all; not a claim of precision. */
14221
+ const ROUND_EXEC_STALE_MINUTES = 10;
14222
+ function roundExecStaleAgeMinutes(state, now) {
14223
+ if (state.ownerKind !== 'exec')
14224
+ return null;
14225
+ // Lead edit after Codex re-review: count from the exec claim, not from the round's own start —
14226
+ // a fresh exec inside an old round is not stuck. Legacy states without the field fall back.
14227
+ const claimedMs = Date.parse(state.execClaimedAt ?? state.startedAt);
14228
+ if (!Number.isFinite(claimedMs))
14229
+ return null;
14230
+ const minutes = Math.floor((now - claimedMs) / 60_000);
14231
+ return minutes >= ROUND_EXEC_STALE_MINUTES ? minutes : null;
14232
+ }
14233
+ function readRoundLedgerTail(root) {
14234
+ try {
14235
+ const body = readFileSync(join(root, ROUND_LEDGER_REL), 'utf8');
14236
+ return body.slice(-64 * 1024);
14237
+ }
14238
+ catch {
14239
+ return '';
14240
+ }
14241
+ }
14242
+ function readRoundLedger(root) {
14243
+ try {
14244
+ return readFileSync(join(root, ROUND_LEDGER_REL), 'utf8');
14245
+ }
14246
+ catch {
14247
+ return '';
14248
+ }
14249
+ }
14250
+ function roundRunOwnerAlive(root, runId, now, registryReader, pidProbe = probePid) {
14251
+ const registry = readRunRegistry(root, registryReader === undefined
14252
+ ? runRegistryIO
14253
+ : { ...runRegistryIO, read: () => registryReader(root) });
14254
+ if (registry.status !== 'readable')
14255
+ return null;
14256
+ const decision = liveness(registry.runs.find((run) => run.runId === runId), now, pidProbe);
14257
+ return decision.state === 'live' || decision.state === 'stalled' ? true : decision.state === 'orphaned' ? false : null;
14258
+ }
14259
+ function nextRoundNumber(ledger, slug) {
14260
+ let count = 0;
14261
+ for (const line of ledger.split('\n')) {
14262
+ try {
14263
+ const row = JSON.parse(line);
14264
+ if (row.slug === slug && row.stage === 'round')
14265
+ count++;
14266
+ }
14267
+ catch { /* malformed and torn rows are not completed rounds */ }
14268
+ }
14269
+ return count + 1;
14270
+ }
14271
+ export async function spawnRoundCodex(request) {
14272
+ mkdirSync(dirname(request.logPath), { recursive: true });
14273
+ const logFd = openSync(request.logPath, 'w');
14274
+ return await new Promise((resolveRun) => {
14275
+ let settled = false;
14276
+ let timedOut = false;
14277
+ let escalation;
14278
+ let child;
14279
+ const finish = (receipt) => {
14280
+ if (settled)
14281
+ return;
14282
+ settled = true;
14283
+ clearTimeout(deadline);
14284
+ if (escalation !== undefined)
14285
+ clearTimeout(escalation);
14286
+ try {
14287
+ closeSync(logFd);
14288
+ }
14289
+ catch { /* the subprocess receipt remains authoritative */ }
14290
+ resolveRun({ ...receipt, timedOut });
14291
+ };
14292
+ const deadline = setTimeout(() => {
14293
+ timedOut = true;
14294
+ try {
14295
+ child?.kill('SIGTERM');
14296
+ }
14297
+ catch { /* SIGKILL below is the bounded fallback */ }
14298
+ escalation = setTimeout(() => {
14299
+ try {
14300
+ child?.kill('SIGKILL');
14301
+ }
14302
+ catch { /* close/error decides the receipt */ }
14303
+ }, request.killGraceMs ?? 10_000);
14304
+ }, request.timeoutMs);
14305
+ try {
14306
+ child = spawn(request.command, [...request.args], {
14307
+ cwd: request.cwd,
14308
+ stdio: ['ignore', logFd, logFd],
14309
+ });
14310
+ }
14311
+ catch (error) {
14312
+ const err = error;
14313
+ finish({ exitCode: null, signal: null, ...(err.code === undefined ? {} : { errorCode: err.code }), error: err.message });
14314
+ return;
14315
+ }
14316
+ child.on('error', (error) => {
14317
+ finish({ exitCode: null, signal: null, ...(error.code === undefined ? {} : { errorCode: error.code }), error: error.message });
14318
+ });
14319
+ child.on('close', (code, signal) => finish({ exitCode: code, signal }));
14320
+ });
14321
+ }
14322
+ function roundExecReceiptFound(tail, expected) {
14323
+ for (const line of tail.split('\n')) {
14324
+ try {
14325
+ const row = JSON.parse(line);
14326
+ if (row.stage === 'round-exec' && row.slug === expected.slug && row.round === expected.round
14327
+ && row.startedAt === expected.startedAt && row.endedAt === expected.endedAt
14328
+ && row.outcome === expected.outcome && row.exitCode === expected.exitCode)
14329
+ return true;
14330
+ }
14331
+ catch { /* a torn or unrelated line is not this receipt */ }
14332
+ }
14333
+ return false;
14334
+ }
14335
+ /**
14336
+ * round-state-lock T1 — the one named lock every `.dz/rounds/*.json` mutation goes through
14337
+ * (`<stateRoot>/.dz/locks/round-state.lock`, `withNamedLockSync` from `@dzhechkov/harness-core`).
14338
+ *
14339
+ * `fn` MUST be short and synchronous (the same caveat `withNamedLockSync` itself carries): it may
14340
+ * reread state and write it, never spawn a subprocess or await anything — the recall step and the
14341
+ * ledger write stay OUTSIDE the lock by design (teach:0ea46034), and the long-running `codex exec`
14342
+ * child in `round exec` runs between two separate short lock holds, not inside one.
14343
+ *
14344
+ * `io.roundLockTimeoutMs` (NFR-2) lets tests force a small deadline instead of the real default;
14345
+ * omitting it keeps production behaviour (and every existing test) byte-identical.
14346
+ */
14347
+ function withRoundStateLock(stateRoot, fn, io) {
14348
+ try {
14349
+ return withNamedLockSync(stateRoot, 'round-state', fn, io.roundLockTimeoutMs === undefined ? {} : { timeoutMs: io.roundLockTimeoutMs });
14350
+ }
14351
+ catch (error) {
14352
+ if (error instanceof NamedLockTimeoutError) {
14353
+ return { refused: 'lock-busy', reason: error.message };
14354
+ }
14355
+ throw error;
14356
+ }
14357
+ }
14358
+ /** round-state-lock fix-round AM-5: the restore-section retry budget — `exec`'s SECOND lock hold
14359
+ * (returning ownership after the codex child exits) tries up to this many times, with the SAME
14360
+ * per-attempt timeout, before it gives up and leaves the round `ownerKind: 'exec'` for a human to
14361
+ * notice (via the `open`/`status` staleness warning) rather than looping forever against a lock that
14362
+ * may never free up. */
14363
+ const ROUND_RESTORE_LOCK_ATTEMPTS = 4; // 1 attempt + 3 retries (AM-5; lead edit after re-review)
14364
+ function withRoundStateLockRetried(stateRoot, fn, io, attempts) {
14365
+ let lastBusy = null;
14366
+ for (let attempt = 0; attempt < attempts; attempt++) {
14367
+ const result = withRoundStateLock(stateRoot, fn, io);
14368
+ if (!(typeof result === 'object' && result !== null && 'refused' in result && result.refused === 'lock-busy')) {
14369
+ return result;
14370
+ }
14371
+ lastBusy = result;
14372
+ }
14373
+ return lastBusy;
14374
+ }
14375
+ async function cmdRound(options, optionLists, flags, cwd, write, io) {
14376
+ const sub = options.get('_positional_0') ?? '';
14377
+ const json = flags.has('json');
14378
+ const stateRootResolution = resolveRoundStateRoot(options, process.env, cwd);
14379
+ if (!stateRootResolution.ok) {
14380
+ write(json ? JSON.stringify({ message: stateRootResolution.reason }) : stateRootResolution.reason);
14381
+ return 2;
14382
+ }
14383
+ const stateRoot = stateRootResolution.root;
14384
+ const stateRootExplicit = stateRootResolution.source !== 'cwd';
14385
+ const projectRoot = resolve(cwd, options.get('project') ?? '.');
14386
+ const now = io.roundNow?.() ?? Date.now();
14387
+ const emit = (message, extra = {}) => {
14388
+ write(json ? JSON.stringify({ message, ...extra }) : message);
14389
+ };
14390
+ const address = (roundOverride) => {
14391
+ const slug = options.get('slug') ?? '';
14392
+ const round = roundOverride ?? Number(options.get('round'));
14393
+ if (!/^[a-z0-9][a-z0-9._-]*$/i.test(slug) || !Number.isInteger(round) || round < 1)
14394
+ return null;
14395
+ return { slug, round };
14396
+ };
14397
+ if (sub === 'open') {
14398
+ const slug = options.get('slug') ?? '';
14399
+ const roundRaw = options.get('round');
14400
+ const autoRound = roundRaw === 'auto'
14401
+ ? nextRoundNumber(io.roundLedgerReader?.(stateRoot) ?? readRoundLedger(stateRoot), slug)
14402
+ : undefined;
14403
+ const at = address(autoRound);
14404
+ const topic = options.get('topic') ?? '';
14405
+ if (at === null || topic.trim() === '') {
14406
+ emit('нужны --slug --round --topic');
14407
+ return 2;
14408
+ }
14409
+ const ownerPidRaw = options.get('owner-pid');
14410
+ const ownerRunRaw = options.get('owner-run');
14411
+ if (ownerPidRaw !== undefined && ownerRunRaw !== undefined) {
14412
+ emit('--owner-pid и --owner-run взаимоисключающие');
14413
+ return 2;
14414
+ }
14415
+ if (ownerRunRaw !== undefined && ownerRunRaw.trim() === '') {
14416
+ emit('--owner-run пуст');
14417
+ return 2;
14418
+ }
14419
+ const ownerRun = ownerRunRaw?.trim();
14420
+ const ownerPid = ownerRunRaw !== undefined ? 0 : ownerPidRaw === undefined ? process.ppid : Number(ownerPidRaw);
14421
+ const ownerKind = ownerRunRaw !== undefined ? 'run' : ownerPidRaw === undefined ? 'parent' : 'explicit';
14422
+ const path = roundStatePath(stateRoot, at.slug, at.round);
14423
+ // round-state-lock FR-3/AC-1: captured BEFORE the (long, unlocked) recall below, so the
14424
+ // recheck under the lock can tell "unchanged since this snapshot" from "a different process
14425
+ // opened it while we were recalling".
14426
+ const beforeRaw = readRawRoundState(path);
14427
+ const existing = beforeRaw === null
14428
+ ? null
14429
+ : parseRoundState(beforeRaw) ?? {
14430
+ slug: at.slug, round: at.round, topic: '', startedAt: new Date(now).toISOString(),
14431
+ pid: 1, ownerKind: 'explicit', recalled: [],
14432
+ };
14433
+ let existingOwnerAlive = null;
14434
+ if (existing !== null && flags.has('force') && existing.ownerKind !== 'run') {
14435
+ try {
14436
+ existingOwnerAlive = (io.roundPidProbe ?? probePid)(existing.pid);
14437
+ }
14438
+ catch { /* unavailable is unknown and refuses */ }
14439
+ }
14440
+ const isRunAlive = (runId) => roundRunOwnerAlive(stateRoot, runId, now, io.roundRunRegistryReader, io.roundPidProbe ?? probePid);
14441
+ const runId = options.get('run')?.trim();
14442
+ const recallOptions = { limit: 5, ...(runId === undefined || runId === '' ? {} : { runId }) };
14443
+ const preflight = openRound({
14444
+ ...at, topic, startedAt: new Date(now).toISOString(), ownerPid, ownerKind,
14445
+ ...(ownerRun === undefined || ownerRun === '' ? {} : { ownerRun }),
14446
+ ...(runId === undefined || runId === '' ? {} : { run: runId }), recalled: [], existing,
14447
+ force: flags.has('force'), existingOwnerAlive, isRunAlive,
14448
+ });
14449
+ if (!preflight.ok) {
14450
+ // AM-5: the round we are refusing to touch may itself be a stuck `exec` claim (its restore
14451
+ // section exhausted its lock-busy retries and left `ownerKind: 'exec'`) — name that out loud
14452
+ // rather than leaving the operator to guess why a pid that "shouldn't" be alive is blocking.
14453
+ const staleMinutes = existing === null ? null : roundExecStaleAgeMinutes(existing, now);
14454
+ const reason = staleMinutes === null
14455
+ ? preflight.reason
14456
+ : `${preflight.reason} (владелец завис в exec ${staleMinutes} мин)`;
14457
+ emit(reason, { round: at.round, ...(staleMinutes === null ? {} : { staleExecMinutes: staleMinutes }) });
14458
+ return preflight.exit;
14459
+ }
14460
+ let lessons = [];
14461
+ try {
14462
+ lessons = io.roundRecall !== undefined
14463
+ ? await io.roundRecall(projectRoot, topic, recallOptions)
14464
+ : (await recallHybrid(projectRoot, topic, recallOptions)).hits.slice(0, 5).map((hit) => ({
14465
+ id: patternRecordId(hit.pattern),
14466
+ reward: hit.pattern.reward,
14467
+ domain: hit.pattern.domain,
14468
+ text: hit.pattern.pattern,
14469
+ }));
14470
+ }
14471
+ catch {
14472
+ lessons = [];
14473
+ }
14474
+ const opened = openRound({
14475
+ ...at, topic, startedAt: new Date(now).toISOString(), ownerPid, ownerKind,
14476
+ ...(ownerRun === undefined || ownerRun === '' ? {} : { ownerRun }),
14477
+ ...(runId === undefined || runId === '' ? {} : { run: runId }),
14478
+ recalled: lessons.slice(0, 5).map((lesson) => lesson.id), existing: null,
14479
+ force: false, existingOwnerAlive: null, isRunAlive,
14480
+ });
14481
+ if (!opened.ok) {
14482
+ emit(opened.reason);
14483
+ return opened.exit;
14484
+ }
14485
+ const openedState = { ...opened.state, execs: [], stateId: generateRoundStateId() };
14486
+ let archived;
14487
+ try {
14488
+ const locked = withRoundStateLock(stateRoot, () => {
14489
+ // AM-3/AM-6: recall ran unlocked and may have taken a while — reread NOW, under the lock,
14490
+ // and decide fresh from what is ACTUALLY there rather than from the pre-recall snapshot.
14491
+ //
14492
+ // AM-3 (was: refuse only when the bytes changed AND the foreign pid differed from ours):
14493
+ // `ppid` coincides for two `dz` launched from the same shell, and every run-owned state
14494
+ // carries pid 0 — so "same pid" proved nothing about identity. ANY change in raw bytes since
14495
+ // `beforeRaw` is now the refusal trigger; the foreign pid is reported for diagnostics only,
14496
+ // never consulted for the decision.
14497
+ //
14498
+ // AM-6 (was: an unconditional `readFileSync(path)` while archiving threw a bare ENOENT if
14499
+ // the target vanished mid-recall): a state that is simply GONE now is not a race to refuse —
14500
+ // it is exactly the "no existing round" case, --force or not. Re-decide fresh: no file under
14501
+ // the lock ⇒ ordinary open, no archive, regardless of what `beforeRaw`/`existing` said.
14502
+ const nowRaw = readRawRoundState(path);
14503
+ if (nowRaw === beforeRaw) {
14504
+ // Unchanged since the pre-recall snapshot: proceed exactly as `preflight` planned —
14505
+ // including the --force archive-a-dead-owner flow, which is safe here because nothing
14506
+ // touched `existing`'s bytes while we were recalling.
14507
+ if (preflight.archiveExisting && existing !== null) {
14508
+ const compactStartedAt = new Date(existing.startedAt).toISOString().replace(/[-:.]/g, '');
14509
+ archived = join(stateRoot, '.dz', 'rounds', 'archive', `${at.slug}-${at.round}-${compactStartedAt}.json`);
14510
+ mkdirSync(dirname(archived), { recursive: true });
14511
+ writeFileSync(archived, readFileSync(path), { flag: 'wx' });
14512
+ }
14513
+ writeJsonAtomic(path, openedState);
14514
+ return { ok: true };
14515
+ }
14516
+ if (nowRaw === null) {
14517
+ // AM-6: vanished under us — nothing left to conflict with or to archive.
14518
+ writeJsonAtomic(path, openedState);
14519
+ return { ok: true };
14520
+ }
14521
+ // Something is there now, and it is byte-different from what we planned around: refuse.
14522
+ // The pid below is diagnostic only (AM-3) — it never gates the decision.
14523
+ const foreign = parseRoundState(nowRaw);
14524
+ return { refused: 'already-open', pid: foreign?.pid ?? -1 };
14525
+ }, io);
14526
+ if ('refused' in locked) {
14527
+ if (locked.refused === 'lock-busy') {
14528
+ emit(`lock busy: ${locked.reason}`, { refused: 'lock-busy' });
14529
+ return 1;
14530
+ }
14531
+ emit(`круг уже открыт (pid ${locked.pid}) — состояние не перезаписано`, { refused: 'already-open', pid: locked.pid });
14532
+ return 1;
14533
+ }
14534
+ }
14535
+ catch (error) {
14536
+ emit(`круг не открыт: ${error instanceof Error ? error.message : String(error)}`);
14537
+ return 1;
14538
+ }
14539
+ const owner = openedState.ownerKind === 'run'
14540
+ ? `владелец: run ${openedState.ownerRun} (run)`
14541
+ : `владелец: pid ${openedState.pid} (${openedState.ownerKind})`;
14542
+ if (json) {
14543
+ emit('круг открыт', { state: openedState, owner, stateRoot, lessons: lessons.slice(0, 5), ...(archived === undefined ? {} : { archived }) });
14544
+ }
14545
+ else {
14546
+ if (archived !== undefined)
14547
+ write(`архивировано: ${archived}`);
14548
+ write(`=== КРУГ ОТКРЫТ: ${at.slug} круг ${at.round}`);
14549
+ write(`state root: ${stateRoot}`);
14550
+ write(owner);
14551
+ write(`--- уроки для брифа (${lessons.slice(0, 5).length} поднято):`);
14552
+ for (const lesson of lessons.slice(0, 5)) {
14553
+ const oneLine = lesson.text.replace(/[\r\n\u2028\u2029\u0085\v\f]+/g, ' ⏎ ');
14554
+ write(` [${lesson.reward.toFixed(2)}] (${lesson.domain}) ${oneLine.slice(0, 160)}`);
14555
+ }
14556
+ }
14557
+ return 0;
14558
+ }
14559
+ if (sub === 'exec') {
14560
+ const at = address();
14561
+ const briefArg = options.get('brief') ?? '';
14562
+ const timeoutRaw = options.get('timeout-min') ?? '30';
14563
+ const timeoutMinutes = Number(timeoutRaw);
14564
+ if (at === null || briefArg.trim() === '' || !Number.isInteger(timeoutMinutes) || timeoutMinutes <= 0) {
14565
+ emit('нужны --slug --round --brief; --timeout-min должен быть целым числом больше нуля');
14566
+ return 2;
14567
+ }
14568
+ const briefPath = resolve(cwd, briefArg);
14569
+ let briefText;
14570
+ try {
14571
+ briefText = readFileSync(briefPath, 'utf8');
14572
+ }
14573
+ catch {
14574
+ emit(`brief не читается: ${briefArg}`);
14575
+ return 2;
14576
+ }
14577
+ const path = roundStatePath(stateRoot, at.slug, at.round);
14578
+ let state = readRoundState(path);
14579
+ if (state === null) {
14580
+ emit(existsSync(path) ? 'состояние круга не читается' : 'круг не открыт');
14581
+ return 1;
14582
+ }
14583
+ const model = options.get('model') ?? 'gpt-5.6-sol';
14584
+ const effort = options.get('effort') ?? 'high';
14585
+ const logArg = options.get('log') ?? join('.dz', 'rounds', `${at.slug}-${at.round}.exec.log`);
14586
+ const logPath = resolve(cwd, logArg);
14587
+ const startedMs = io.roundNow?.() ?? Date.now();
14588
+ const startedAt = new Date(startedMs).toISOString();
14589
+ const request = {
14590
+ command: 'codex',
14591
+ args: [
14592
+ 'exec',
14593
+ '-c', `model=${model}`,
14594
+ '-c', `model_reasoning_effort=${effort}`,
14595
+ '--dangerously-bypass-approvals-and-sandbox',
14596
+ briefText,
14597
+ ],
14598
+ cwd: stateRoot,
14599
+ logPath,
14600
+ timeoutMs: timeoutMinutes * 60_000,
14601
+ killGraceMs: io.roundKillGraceMs ?? 10_000,
14602
+ };
14603
+ let execClaimId = '';
14604
+ try {
14605
+ // T3/FR-1, fix-round AM-1: reread state under the lock immediately before claiming ownership
14606
+ // — a short, synchronous critical section, released before the (possibly long) codex child
14607
+ // below runs. NO fallback to the pre-lock `state` snapshot (that was the resurrection bug:
14608
+ // `readRoundState(path) ?? state!` would recreate a round that had been closed in the
14609
+ // meantime). The claim proceeds ONLY when the state currently under the lock still carries the
14610
+ // exact `stateId` we read before acquiring it — pid/ppid can coincide across processes, but a
14611
+ // `stateId` never does.
14612
+ execClaimId = randomBytes(8).toString('hex');
14613
+ const claimed = withRoundStateLock(stateRoot, () => {
14614
+ const outcome = readStateOrRefuse(path, state.stateId);
14615
+ if ('refused' in outcome)
14616
+ return outcome;
14617
+ if (outcome.ownerKind === 'exec' && outcome.execClaimId !== undefined) {
14618
+ return { refused: 'exec-in-progress', execClaimId: outcome.execClaimId };
14619
+ }
14620
+ writeJsonAtomic(path, { ...outcome, pid: io.roundPid ?? process.pid, ownerKind: 'exec', execClaimId, execClaimedAt: new Date(io.roundNow?.() ?? Date.now()).toISOString() });
14621
+ return { ok: true, base: outcome };
14622
+ }, io);
14623
+ if ('refused' in claimed) {
14624
+ if (claimed.refused === 'lock-busy') {
14625
+ emit(`exec не запущен: владелец круга не обновлён: lock busy: ${claimed.reason}`, { refused: 'lock-busy' });
14626
+ return 1;
14627
+ }
14628
+ if (claimed.refused === 'gone') {
14629
+ emit('exec не запущен: круг закрыт во время exec, владелец не менялся', { refused: 'gone' });
14630
+ return 1;
14631
+ }
14632
+ if (claimed.refused === 'exec-in-progress') {
14633
+ emit(`exec не запущен: у круга уже идёт exec (claim ${claimed.execClaimId})`, { refused: 'exec-in-progress', execClaimId: claimed.execClaimId });
14634
+ return 1;
14635
+ }
14636
+ const replaced = claimed;
14637
+ emit(`exec не запущен: состояние круга заменено (stateId ${replaced.stateId ?? 'unknown'}), возврат владельца пропущен`, { refused: 'replaced', stateId: replaced.stateId });
14638
+ return 1;
14639
+ }
14640
+ state = claimed.base;
14641
+ }
14642
+ catch (error) {
14643
+ emit(`exec не запущен: владелец круга не обновлён: ${error instanceof Error ? error.message : String(error)}`);
14644
+ return 1;
14645
+ }
14646
+ let receipt;
14647
+ try {
14648
+ try {
14649
+ receipt = await (io.roundSpawn ?? spawnRoundCodex)(request);
14650
+ }
14651
+ catch (error) {
14652
+ const err = error;
14653
+ receipt = { exitCode: null, timedOut: false, signal: null, ...(err.code === undefined ? {} : { errorCode: err.code }), error: err.message };
14654
+ }
14655
+ }
14656
+ finally {
14657
+ try {
14658
+ // T3/FR-1, fix-round AM-1/AM-5: the return leg — a second short lock hold, symmetric with
14659
+ // the claim above, and gated by the SAME stateId check (the child may have run long enough
14660
+ // for someone else to close or replace this round while it was running). AM-5: a busy lock
14661
+ // here gets up to ROUND_RESTORE_LOCK_ATTEMPTS tries with the same timeout before giving up —
14662
+ // a codex child can legitimately run for a while, so ownership recovery deserves more than
14663
+ // one attempt before leaving the round stuck at `ownerKind: 'exec'`.
14664
+ const restored = withRoundStateLockRetried(stateRoot, () => {
14665
+ const outcome = readStateOrRefuse(path, state.stateId);
14666
+ if ('refused' in outcome)
14667
+ return outcome;
14668
+ // Lead edit after Codex re-review: restore only OUR claim — another exec of the same round
14669
+ // instance has its own execClaimId and must not be wiped by our base state.
14670
+ if (outcome.execClaimId !== execClaimId) {
14671
+ return { refused: 'replaced', stateId: outcome.stateId, execClaimId: outcome.execClaimId };
14672
+ }
14673
+ writeJsonAtomic(path, state);
14674
+ return { ok: true };
14675
+ }, io, ROUND_RESTORE_LOCK_ATTEMPTS);
14676
+ if ('refused' in restored) {
14677
+ if (restored.refused === 'lock-busy') {
14678
+ // AM-5: no new flag or command is added — this names the manual remedy in prose (a
14679
+ // literal `--flag`-shaped token here would be caught by known-flags-drift.test.ts as an
14680
+ // undocumented flag, which would be exactly the wrong signal for text naming no flag at
14681
+ // all). The durable fix is that `open`/`status` surface the resulting stuck
14682
+ // `ownerKind: 'exec'` on their own (roundExecStaleAgeMinutes), so it is never silently
14683
+ // left for someone to trip over.
14684
+ emit('владелец круга не восстановлен (ownerKind=exec остался): повторите dz round exec для этого круга, когда блокировка освободится', { refused: 'lock-busy', ownerKind: 'exec' });
14685
+ return 1;
14686
+ }
14687
+ if (restored.refused === 'gone') {
14688
+ emit('круг закрыт во время exec, владелец не менялся', { refused: 'gone' });
14689
+ return 1;
14690
+ }
14691
+ emit(`состояние круга заменено (stateId ${restored.stateId ?? 'unknown'}), возврат владельца пропущен`, { refused: 'replaced', stateId: restored.stateId });
14692
+ return 1;
14693
+ }
14694
+ }
14695
+ catch (error) {
14696
+ emit(`exec завершён, но владелец круга не восстановлен: ${error instanceof Error ? error.message : String(error)}`);
14697
+ return 1;
14698
+ }
14699
+ }
14700
+ const endedMs = io.roundNow?.() ?? Date.now();
14701
+ const endedAt = new Date(endedMs).toISOString();
14702
+ let logBuffer = Buffer.alloc(0);
14703
+ try {
14704
+ logBuffer = readFileSync(logPath);
14705
+ }
14706
+ catch { /* no output is an empty receipt */ }
14707
+ const logText = logBuffer.toString('utf8');
14708
+ const bytes = logBuffer.byteLength;
14709
+ const tokens = parseCodexTokens(logText);
14710
+ const outcome = classifyRoundExecOutcome({
14711
+ exitCode: receipt.exitCode,
14712
+ timedOut: receipt.timedOut,
14713
+ bytes,
14714
+ tail: logBuffer.subarray(Math.max(0, bytes - 4096)).toString('utf8'),
14715
+ });
14716
+ const row = buildRoundExecRow({
14717
+ ...at,
14718
+ model,
14719
+ effort,
14720
+ minutes: Math.max(0, Math.floor((endedMs - startedMs) / 60_000)),
14721
+ tokens,
14722
+ outcome,
14723
+ exitCode: receipt.exitCode,
14724
+ bytes,
14725
+ startedAt,
14726
+ endedAt,
14727
+ log: logArg,
14728
+ brief: briefArg,
14729
+ });
14730
+ if (io.roundLedgerWriter !== undefined)
14731
+ io.roundLedgerWriter(stateRoot, row);
14732
+ else
14733
+ cmdFeatureAdrRecord(new Map([
14734
+ ['kind', 'ledger'], ['stage', 'round-exec'], ['slug', state.slug], ['row', JSON.stringify(row)], ['project', stateRoot],
14735
+ ]), new Set(), stateRoot, () => undefined);
14736
+ const ledgerTail = io.roundLedgerReader?.(stateRoot) ?? readRoundLedgerTail(stateRoot);
14737
+ if (!roundExecReceiptFound(ledgerTail, row)) {
14738
+ emit('строка round-exec не найдена — результат НЕ подтверждён');
14739
+ return 1;
14740
+ }
14741
+ try {
14742
+ writeJsonAtomic(path, {
14743
+ ...state,
14744
+ execs: [...(state.execs ?? []), { startedAt, endedAt, exitCode: receipt.exitCode, outcome, tokens }],
14745
+ });
14746
+ }
14747
+ catch (error) {
14748
+ emit(`строка round-exec подтверждена, но состояние не обновлено: ${error instanceof Error ? error.message : String(error)}`);
14749
+ return 1;
14750
+ }
14751
+ if (receipt.errorCode === 'ENOENT')
14752
+ emit('codex не найден', { row });
14753
+ else
14754
+ emit(`round exec: ${row.minutes} min; exit ${row.exitCode ?? 'null'}; ${row.bytes} bytes; tokens ${row.tokens ?? 'не найдены'}; ${row.outcome}`, { row });
14755
+ return outcome === 'done' ? 0 : 1;
14756
+ }
14757
+ if (sub === 'close') {
14758
+ const at = address();
14759
+ if (at === null || !options.has('outcome')) {
14760
+ emit('нужны --slug --round --outcome');
14761
+ return 2;
14762
+ }
14763
+ const path = roundStatePath(stateRoot, at.slug, at.round);
14764
+ const state = readRoundState(path);
14765
+ if (state === null) {
14766
+ emit(existsSync(path) ? 'состояние круга не читается — круг НЕ закрыт' : 'круг не открыт');
14767
+ return 1;
14768
+ }
14769
+ const lessons = optionLists.get('lesson') ?? [];
14770
+ const knownLessonIds = lessons.filter((id) => {
14771
+ try {
14772
+ return io.roundLessonExists !== undefined
14773
+ ? io.roundLessonExists(projectRoot, id)
14774
+ : loadStoreRecords(projectRoot).some((record) => record.id === id);
14775
+ }
14776
+ catch {
14777
+ return false;
14778
+ }
14779
+ });
14780
+ const numeric = (key) => options.has(key) ? Number(options.get(key)) : undefined;
14781
+ const closedAtIso = new Date(now).toISOString();
14782
+ // AM-4: predict the marker `closeRound` will compute for THIS attempt (same slug/round/closedAt
14783
+ // it will use) and check whether the ledger already carries it BEFORE calling `closeRound` —
14784
+ // this is what makes a retried `close` idempotent: if a prior invocation's write already landed
14785
+ // (this run's own tail read, not trusted from the earlier failed attempt's own belief), skip the
14786
+ // write below instead of appending a duplicate row.
14787
+ const predictedMarker = predictedRoundCloseMarker(at.slug, at.round, closedAtIso);
14788
+ const tailBeforeWrite = io.roundLedgerReader?.(stateRoot) ?? readRoundLedgerTail(stateRoot);
14789
+ // Lead edit after Codex re-review: a retried close carries a NEW clock, so the marker alone never
14790
+ // matches — the row's stateId (identity of the state instance) is what makes the retry idempotent.
14791
+ const alreadyRecorded = tailBeforeWrite.includes(predictedMarker)
14792
+ || (state.stateId !== undefined && tailBeforeWrite.includes(`"stateId":"${state.stateId}"`));
14793
+ // Lead edit after Codex re-review: a retry whose row is already in the ledger (same stateId) must
14794
+ // not re-run closeRound's postcondition against a marker computed from the NEW clock — the earlier
14795
+ // row is the receipt; only the state-file removal remains.
14796
+ const closed = alreadyRecorded
14797
+ ? { ok: true, row: undefined, marker: `already-recorded:${state.stateId ?? predictedMarker}` }
14798
+ : closeRound({
14799
+ state,
14800
+ outcome: options.get('outcome') ?? '',
14801
+ ...(options.has('reason') ? { reason: options.get('reason') } : {}),
14802
+ lessons,
14803
+ knownLessonIds,
14804
+ ...(options.has('no-new-knowledge') ? { noNewKnowledge: options.get('no-new-knowledge') } : {}),
14805
+ ...(options.has('tokens') ? { tokens: numeric('tokens') } : {}),
14806
+ ...(options.has('agents') ? { agents: numeric('agents') } : {}),
14807
+ ...(options.has('coder') ? { coder: options.get('coder') } : {}),
14808
+ ...(options.has('reviewer') ? { reviewer: options.get('reviewer') } : {}),
14809
+ ...(options.has('note') ? { note: options.get('note') } : {}),
14810
+ ...(flags.has('no-cost') ? { noCost: true } : {}),
14811
+ closedAt: closedAtIso,
14812
+ ...(state.stateId !== undefined ? { stateId: state.stateId } : {}),
14813
+ }, {
14814
+ writeLedger: (row) => {
14815
+ // AM-4 idempotent retry: the row for this attempt was already witnessed in the tail read
14816
+ // above — do not append a second one. `closeRound`'s own postcondition (rereading the tail
14817
+ // and checking it contains the marker) still passes, because the marker is already there.
14818
+ if (alreadyRecorded)
14819
+ return undefined;
14820
+ if (io.roundLedgerWriter !== undefined)
14821
+ return io.roundLedgerWriter(stateRoot, row);
14822
+ return cmdFeatureAdrRecord(new Map([
14823
+ ['kind', 'ledger'], ['stage', 'round'], ['slug', state.slug], ['row', JSON.stringify(row)], ['project', stateRoot],
14824
+ ]), new Set(), stateRoot, () => undefined);
14825
+ },
14826
+ readLedgerTail: () => io.roundLedgerReader?.(stateRoot) ?? readRoundLedgerTail(stateRoot),
14827
+ });
14828
+ if (!closed.ok) {
14829
+ emit(closed.reason);
14830
+ return closed.exit;
14831
+ }
14832
+ try {
14833
+ // T4/FR-1/FR-2, fix-round AM-2: the ledger write above (via `closed`) stays OUTSIDE the lock
14834
+ // (teach:0ea46034); only the final reread-and-delete is a lock-guarded critical section, and it
14835
+ // now deletes ONLY the exact state instance the ledger row above was written for — identified
14836
+ // by `state.stateId`, read before the lock was ever taken.
14837
+ const deleted = withRoundStateLock(stateRoot, () => {
14838
+ const outcome = readStateForCloseOrRefuse(path, state.stateId);
14839
+ if ('refused' in outcome)
14840
+ return outcome;
14841
+ unlinkSync(path);
14842
+ return { ok: true };
14843
+ }, io);
14844
+ if ('refused' in deleted) {
14845
+ if (deleted.refused === 'lock-busy') {
14846
+ // AM-4: the ledger row is ALREADY written by the time this lock is even attempted (see
14847
+ // above) — so a busy lock here never leaves the outcome unrecorded, only the round's OWN
14848
+ // state file open. Say exactly that, and make the retry path explicit.
14849
+ emit('строка леджера записана, состояние круга осталось открытым — повторите close', { refused: 'lock-busy', ledgerWritten: true });
14850
+ return 1;
14851
+ }
14852
+ if (deleted.refused === 'closed-already') {
14853
+ // AM-2: the state file is already gone — this close's own ledger row is written (above, or
14854
+ // by a previous invocation of this same idempotent attempt), so this is the same round
14855
+ // reaching its already-closed postcondition by a different path, not a failure.
14856
+ emit('круг уже закрыт (строка леджера записана)', { closed: true, alreadyClosed: true, marker: closed.marker });
14857
+ return 0;
14858
+ }
14859
+ // AM-2: something else's state sits at this path now (a different stateId) — never delete it.
14860
+ emit('состояние заменено, не удалено', { refused: 'replaced', stateId: deleted.stateId });
14861
+ return 1;
14862
+ }
14863
+ }
14864
+ catch (error) {
14865
+ emit(`строка подтверждена, но состояние не удалено — круг НЕ закрыт: ${error instanceof Error ? error.message : String(error)}`);
14866
+ return 1;
14867
+ }
14868
+ emit(`✓ строка круга в леджере подтверждена чтением (${closed.marker})`, { row: closed.row, marker: closed.marker });
14869
+ return 0;
14870
+ }
14871
+ if (sub === 'status') {
14872
+ const rawThreshold = options.get('older-than') ?? '120';
14873
+ const olderThan = Number(rawThreshold);
14874
+ if (!Number.isInteger(olderThan) || olderThan < 0) {
14875
+ emit('--older-than должен быть целым числом минут не меньше нуля', { open: [] });
14876
+ return 0;
14877
+ }
14878
+ const dir = join(stateRoot, '.dz', 'rounds');
14879
+ const states = [];
14880
+ try {
14881
+ for (const name of readdirSync(dir).filter((entry) => entry.endsWith('.json')).sort()) {
14882
+ const state = readRoundState(join(dir, name));
14883
+ if (state !== null)
14884
+ states.push(state);
14885
+ }
14886
+ }
14887
+ catch { /* no state directory is an honestly empty report */ }
14888
+ const rows = listRounds(states, {
14889
+ now,
14890
+ olderThanMinutes: olderThan,
14891
+ isPidAlive: io.roundPidProbe ?? probePid,
14892
+ isRunAlive: (runId) => roundRunOwnerAlive(stateRoot, runId, now, io.roundRunRegistryReader, io.roundPidProbe ?? probePid),
14893
+ });
14894
+ // AM-5: independent of the `--older-than` filter above (a stuck exec claim is worth flagging at
14895
+ // 10 minutes regardless of the round's own age threshold) — computed over ALL open states, and
14896
+ // additive: when none apply, neither branch below emits anything extra, so the two byte-pinned
14897
+ // zero-rounds lines (NFR-1, see the comment below) stay untouched.
14898
+ const staleExec = states
14899
+ .map((state) => {
14900
+ const minutes = roundExecStaleAgeMinutes(state, now);
14901
+ return minutes === null ? null : { slug: state.slug, round: state.round, minutes };
14902
+ })
14903
+ .filter((warning) => warning !== null);
14904
+ if (json) {
14905
+ emit(rows.length > 0 ? `⚠ ${rows.length} open round(s) older than ${olderThan} min` : 'нет старых открытых кругов', {
14906
+ stateRoot, olderThan, open: rows, ...(staleExec.length === 0 ? {} : { staleExec }),
14907
+ });
14908
+ }
14909
+ else {
14910
+ // FR-3 prints `state root: <dir>` on open unconditionally; here it is printed only when the
14911
+ // root was EXPLICITLY chosen (--state-root / DZ_ROUND_STATE_ROOT). Printing it unconditionally
14912
+ // would change the two default-cwd zero-rounds lines pinned exactly by
14913
+ // round-cli.test.ts ("status reports a fresh open round…" / "…no open rounds"), which NFR-1
14914
+ // requires to stay byte-identical and unmodified.
14915
+ if (stateRootExplicit)
14916
+ write(`state root: ${stateRoot}`);
14917
+ write(states.length === 0
14918
+ ? 'открытых кругов нет'
14919
+ : `открытых кругов: ${states.length}, старше ${olderThan} мин: ${rows.length}`);
14920
+ for (const row of rows) {
14921
+ const live = row.pidAlive === true ? 'alive' : row.pidAlive === false ? 'dead' : 'unknown';
14922
+ write(`${row.state.slug}#${row.state.round} · ${row.ageMinutes} min · pid ${row.state.pid} ${live} · ${row.state.topic}`);
14923
+ }
14924
+ for (const warning of staleExec) {
14925
+ write(`⚠ ${warning.slug}#${warning.round}: владелец завис в exec ${warning.minutes} мин — восстановите вручную (dz round exec вернул lock-busy при возврате владельца)`);
14926
+ }
14927
+ }
14928
+ return 0;
14929
+ }
14930
+ emit('использование: dz round open|exec|close|status');
14931
+ return 2;
14932
+ }
14933
+ /**
14934
+ * ledger-stage-minutes T2: the `ts` of the LAST ledger row (scanning from the end, so a duplicate
14935
+ * or out-of-order runId still finds the truly latest one) that carries the given `runId`. Every
14936
+ * failure mode — the file does not exist yet, a permission error — returns `null` rather than
14937
+ * throwing: this is a BEST-EFFORT observability lookup feeding a non-blocking field (ADR-003), never
14938
+ * a gate the write must pass.
14939
+ *
14940
+ * fix-round-1/AM-n (cross-family review B, MEDIUM): a torn or non-object line — `ledger-corrupt-line`
14941
+ * — is NOT silently skipped past. The original code `continue`d over it and kept scanning further
14942
+ * back, which could return an OLDER valid row for this `runId` while a NEWER one for the same run
14943
+ * sat hidden on the other side of the corrupt line (or was itself the corrupt line). Once the scan
14944
+ * hits a line it cannot parse as a JSON object, it can no longer prove which row is truly LAST for
14945
+ * this run, so it stops and reports `null` (⇒ `minutesSource: 'unavailable'`) rather than risk an
14946
+ * UNDERSTATED delta computed against a stale row.
14947
+ */
14948
+ function findPreviousLedgerRowTs(ledgerPath, runId) {
14949
+ if (runId === '')
14950
+ return null;
14951
+ let body;
14952
+ try {
14953
+ body = readFileSync(ledgerPath, 'utf-8');
14954
+ }
14955
+ catch {
14956
+ return null;
14957
+ }
14958
+ const lines = body.split('\n').filter((l) => l !== '');
14959
+ for (let i = lines.length - 1; i >= 0; i--) {
14960
+ let parsed;
14961
+ try {
14962
+ parsed = JSON.parse(lines[i]);
14963
+ }
14964
+ catch {
14965
+ // ledger-corrupt-line: everything from here to the start of the file is unprovable — a real
14966
+ // match further back cannot be trusted to still be the LAST one, so this is `unavailable`,
14967
+ // never a guess made by skipping past what we could not read.
14968
+ return null;
14969
+ }
14970
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
14971
+ // Same reasoning as the parse failure above: a non-object line is exactly as untrustworthy.
14972
+ return null;
14973
+ }
14974
+ const row = parsed;
14975
+ if (typeof row['runId'] === 'string' && row['runId'].trim() === runId) {
14976
+ return typeof row['ts'] === 'string' && row['ts'].trim() !== '' ? row['ts'] : null;
14977
+ }
14978
+ }
14979
+ return null;
14980
+ }
13747
14981
  function cmdFeatureAdrRecord(options, flags, cwd, write) {
13748
14982
  const json = flags.has('json');
13749
14983
  // `--backfill` is a different verb on the same store: it fills the ledger's null cost fields from
@@ -13780,10 +15014,76 @@ function cmdFeatureAdrRecord(options, flags, cwd, write) {
13780
15014
  const markDir = join(repo, '.dz', 'fa-training', '.backfill-marks');
13781
15015
  const markName = (options.get('mark') ?? '').trim();
13782
15016
  const markPath = markName === '' ? null : join(markDir, markName.replace(/[^\w.-]/g, '_'));
15017
+ // ledger-stage-minutes T2/FR-2: `--run-id` fills the payload's `runId` ONLY WHEN the payload does
15018
+ // not already carry one — the same gap-only stamping discipline `decideRecordWrite` already uses
15019
+ // for `runnerId`. "Absent" is deliberately wider than "missing key": `runId: null`, `runId: ''`
15020
+ // and a non-string `runId` (a number, an object — never a real join key) are ALL gaps too, exactly
15021
+ // the `isRunnerGap` rule one seam over — fixed-round-1/AM-n confirmed this is the INTENDED contract
15022
+ // ("missing when absent or blank"), not a bug: only a genuine non-empty string counts as "the
15023
+ // caller already knew it", so any of those gap shapes are correctly overwritten by the flag. A
15024
+ // malformed --row is left untouched here: decideRecordWrite reports the real JSON parse error,
15025
+ // this merge step must never invent a different one.
15026
+ const isRunIdArgGap = (v) => v === null || v === undefined || typeof v !== 'string' || v.trim() === '';
15027
+ let effectivePayloadRaw = payloadRaw;
15028
+ const explicitRunId = (options.get('run-id') ?? '').trim();
15029
+ if (kind === 'ledger' && explicitRunId !== '') {
15030
+ try {
15031
+ const parsed = JSON.parse(payloadRaw);
15032
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
15033
+ const rowObj = parsed;
15034
+ if (isRunIdArgGap(rowObj['runId'])) {
15035
+ // fix-round-1/AM-n (cross-family review B, MEDIUM): the flag-filled runId now carries its
15036
+ // provenance, the same discipline `resolved-at-write` already applies to the OTHER runId
15037
+ // source (write-time auto-resolution below) — an un-sourced runId looked exactly like one
15038
+ // the caller supplied. A non-empty `runIdSource` the payload already carries (an odd shape,
15039
+ // since `runId` itself was a gap) is left alone rather than overwritten with a guess.
15040
+ const hasRunIdSource = typeof rowObj['runIdSource'] === 'string' && rowObj['runIdSource'].trim() !== '';
15041
+ effectivePayloadRaw = JSON.stringify({
15042
+ ...rowObj,
15043
+ runId: explicitRunId,
15044
+ ...(hasRunIdSource ? {} : { runIdSource: 'cli-flag' }),
15045
+ });
15046
+ }
15047
+ }
15048
+ }
15049
+ catch { /* decideRecordWrite reports the parse error itself */ }
15050
+ }
15051
+ // FR-2/FR-3: find the runId this row will carry (explicit flag, or one the payload already had),
15052
+ // then read the ledger BEST-EFFORT for the last row of that same run and its `ts`. A read failure
15053
+ // (file absent, unreadable, a torn or malformed line) is an honest `previousRowTs: null` — never
15054
+ // a thrown error, because a record write must never fail on an OBSERVABILITY lookup (ADR-003).
15055
+ let runIdForLookup = '';
15056
+ try {
15057
+ const parsed = JSON.parse(effectivePayloadRaw);
15058
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
15059
+ const v = parsed['runId'];
15060
+ if (typeof v === 'string' && v.trim() !== '')
15061
+ runIdForLookup = v.trim();
15062
+ }
15063
+ }
15064
+ catch { /* decideRecordWrite reports the parse error itself */ }
15065
+ // Lead edit after re-review (Codex B): the pipeline's own rows have no runId in the payload — it is
15066
+ // resolved at write time below. Resolve it HERE as well (same resolver, same registry) so the
15067
+ // previous-row lookup and the minutes delta cover the main path, not only explicit ids.
15068
+ let resolvedRunIdPre = null;
15069
+ if (kind === 'ledger' && runIdForLookup === '') {
15070
+ try {
15071
+ const parsed = JSON.parse(effectivePayloadRaw);
15072
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
15073
+ resolvedRunIdPre = resolveLedgerRunId(parsed, listCostLedgerRuns());
15074
+ if (resolvedRunIdPre !== null)
15075
+ runIdForLookup = resolvedRunIdPre.trim();
15076
+ }
15077
+ }
15078
+ catch { /* resolution is an ENRICHMENT; the row is written regardless */ }
15079
+ }
15080
+ const previousRowTs = kind === 'ledger' && runIdForLookup !== '' ? findPreviousLedgerRowTs(target, runIdForLookup) : null;
13783
15081
  const decision = decideRecordWrite({
13784
15082
  kind,
13785
- payloadRaw,
15083
+ payloadRaw: effectivePayloadRaw,
13786
15084
  stage,
15085
+ previousRowTs,
15086
+ effectiveRunId: runIdForLookup !== '' ? runIdForLookup : null,
13787
15087
  stageProducedResult: flags.has('no-result') ? false : true,
13788
15088
  markExists: markPath !== null && existsSync(markPath),
13789
15089
  targetExists: existsSync(target),
@@ -13842,10 +15142,31 @@ function cmdFeatureAdrRecord(options, flags, cwd, write) {
13842
15142
  const parsed = JSON.parse(decision.line);
13843
15143
  if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
13844
15144
  const rowObj = parsed;
13845
- const resolved = resolveLedgerRunId(rowObj, listCostLedgerRuns());
15145
+ // Lead edit after review #3 (Codex B): ONE resolution per write — reuse the id resolved
15146
+ // before the decision (the same one the minutes delta was measured against) instead of
15147
+ // resolving again; two resolutions could disagree if the run registry moved in between.
15148
+ const resolved = resolvedRunIdPre !== null ? resolvedRunIdPre : resolveLedgerRunId(rowObj, listCostLedgerRuns());
13846
15149
  if (resolved !== null) {
13847
15150
  // Marked, because a resolved run id is our inference, not something the pipeline knew.
13848
- lineToWrite = JSON.stringify({ ...rowObj, runId: resolved, runIdSource: 'resolved-at-write' });
15151
+ // Keep the minutes fields LAST (NFR-1 of ledger-stage-minutes): splice runId/runIdSource in
15152
+ // right before `ts` when the decided row already carries the stamped tail.
15153
+ const ordered = {};
15154
+ let spliced = false;
15155
+ for (const [k, v] of Object.entries(rowObj)) {
15156
+ if (k === 'ts' && !spliced) {
15157
+ ordered['runId'] = resolved;
15158
+ ordered['runIdSource'] = 'resolved-at-write';
15159
+ spliced = true;
15160
+ }
15161
+ if (k === 'runId' || k === 'runIdSource')
15162
+ continue;
15163
+ ordered[k] = v;
15164
+ }
15165
+ if (!spliced) {
15166
+ ordered['runId'] = resolved;
15167
+ ordered['runIdSource'] = 'resolved-at-write';
15168
+ }
15169
+ lineToWrite = JSON.stringify(ordered);
13849
15170
  }
13850
15171
  }
13851
15172
  }
@@ -17990,11 +19311,11 @@ export async function runCli(argv, io = {}) {
17990
19311
  case 'auto-canonicalize':
17991
19312
  return await cmdAutoCanonicalize(options, cwd, write);
17992
19313
  case 'publish':
17993
- return cmdPublish(options, flags, cwd, write);
19314
+ return cmdPublish(options, flags, cwd, write, io.publishMirrorRunner);
17994
19315
  case 'release':
17995
19316
  return cmdRelease(options, flags, cwd, write, io.releaseRunner);
17996
19317
  case 'parity':
17997
- return cmdParity(options, flags, write, writeErr);
19318
+ return cmdParity(options, flags, write, writeErr, cwd);
17998
19319
  case 'registry':
17999
19320
  return cmdRegistry(options, cwd, write);
18000
19321
  case 'benchmark':
@@ -18081,6 +19402,8 @@ export async function runCli(argv, io = {}) {
18081
19402
  return cmdJournal(options, flags, cwd, write, io.journalIo);
18082
19403
  case 'feature-adr-record':
18083
19404
  return cmdFeatureAdrRecord(options, flags, cwd, write);
19405
+ case 'round':
19406
+ return await cmdRound(options, optionLists, flags, cwd, write, io);
18084
19407
  case 'runs':
18085
19408
  return cmdRuns(options, flags, cwd, write);
18086
19409
  case 'runs-clean':