@dzhechkov/harness-cli 0.8.9 → 0.8.15

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
@@ -10,33 +10,57 @@ import { request as httpsRequest } from 'node:https';
10
10
  import { KNOWN_CLI_FLAGS } from './known-flags.js';
11
11
  import { isBooleanFlag } from './boolean-flags.js';
12
12
  import { resolveInstallSpec } from './install-spec.js';
13
+ import { dispatchedCommands, documentedCommands } from './command-inventory.js';
13
14
  import { execFile, execFileSync, execSync, spawn, spawnSync } from 'node:child_process';
14
15
  import { createHash, randomBytes } from 'node:crypto';
15
- import { homedir, tmpdir } from 'node:os';
16
+ import { homedir, hostname, tmpdir } from 'node:os';
16
17
  import { createRequire } from 'node:module';
17
18
  import { isDeepStrictEqual } from 'node:util';
18
- import { createSkill, getSkillInfo, listSkillsDetailed, formatSkillLoadFailures, formatSkillApplyFailures, resolveTargetName, formatTargetProblem, formatTargetAliasNote, TARGET_NAMES_SORTED, runDoctor, runInit, discoverSkillIds, 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,
19
+ 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,
19
20
  // dz workflow run (feature dz-workflow-run): the pure scheduler + the dispatch adapters.
20
- 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, statuslineData, writeFeatureAdrState, 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, loadStoreRecords, 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, buildManifest, buildSbom, resolveTrustRoot, decideVerifyPolicy, generateSigningKeypair, evaluateGuard, resolveRules, auditRecord, guardExitCode, DEFAULT_RULES, parsePnpmLockImporters, scannableStubPath,
21
+ 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, 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,
21
22
  // guard-promotion (feature guard-promotion, scout idea #1)
22
- assembleCandidates, renderPromotionReport, renderPromotionAdr, normalizePromotionState, nextPromotionState, recordPromotionRunEvidence, isLessonRuleContentAnchor, isOffsetIsoTimestamp, globMatch, promotionAdrRelPath, DEFAULT_WINDOW_DAYS, DEFAULT_PERIODS, MAX_CONTENT_FETCHES, BUILTIN_COVERAGE, decideProvenance, isInsideTree, signManifest, verifyManifest, 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, detectProcessRakes, buildRetro, renderRetro, retroLessonText, PROCESS_SIGNATURES, RETRO_DOMAIN, 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,
23
+ assembleCandidates, renderPromotionReport, renderPromotionAdr, normalizePromotionState, nextPromotionState, recordPromotionRunEvidence, isLessonRuleContentAnchor, isOffsetIsoTimestamp, globMatch, promotionAdrRelPath, DEFAULT_WINDOW_DAYS, DEFAULT_PERIODS, MAX_CONTENT_FETCHES, BUILTIN_COVERAGE, decideProvenance, isInsideTree, signManifest, verifyManifest, 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,
23
24
  // Cold-vs-warm EPOCH RUNNER (feature epoch-replay) — orchestrates + scores, never calls a model.
24
- 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, 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,
25
+ 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,
25
26
  // Smart Backlog (feature smart-backlog) — goal-directed idea pipeline over the Brain vector engine.
26
27
  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,
27
28
  // qe-bridge (feature qe-bridge-claude, ADR-001): the pure half of the reverse QE bridge.
28
29
  KNOWN_CLAUDE, isSafeClaudeId, claudeProbeArgs, claudeReviewArgs, interpretClaudeProbe, modelFamily, buildBridgePrompt, parseBridgeOutput, buildBridgeFailureRecord, buildBridgeSignoffRecord, renderBridgeReport, isSafeSlug, hasUnsafePathChars, hasDotDotSegment, buildReqeBrief, settleReqeDebt, renderReqeList, REQE_SCOPE,
29
30
  // Mutation gate (feature ha-mutation-gate) — break each named protection, run the suite, require red.
30
- parseMutationRegistry, applyMutationToText, countFailingTests, detectSuiteCompletionReceipt, detectSuiteReceiptMismatch, classifyBaseline, classifyRunFailure, classifyMutationOutcome, mutationGateExitCode, summarizeMutationResults, renderMutationReport, TRACE_BUNDLE_LEDGER_PATH, TRACE_BUNDLE_SCHEMA, TRACE_BUNDLE_RUN_META_FILE, buildBundle, serializeBundle, parseBundle, planImport, decideCheckpointWrite, amendmentSection, planSaysNoAmendments, parseAmendments, resolveAmendments, decideAmendmentOutcome, amendmentVerdictLine, amendmentsMissingFromPlan, AMENDMENT_VACUITY_NOTE, extractContractChecklist, 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,
31
+ 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, 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,
31
32
  // operator-profile (ADR-001): per-user 0600 store + marked block in ~/.claude/CLAUDE.md
32
33
  renderProfileBlock, readProfile, writeProfile, syncProfileBlock, checkProfileDrift, parseRegister, registerOwnerWord, profileAgeDays, parseDomainList, domainListText, parseYesNo, REGISTERS, } from '@dzhechkov/harness-core';
33
34
  import { getPreset, PRESET_NAMES } from '@dzhechkov/harness-presets';
34
35
  import { scanGitHub, analyzeRepo, generateReport, deepAnalyze, scanAllSources, ScoutMemory } from '@dzhechkov/scout';
36
+ /**
37
+ * Область, по которой гейт дрейфа собирает факты. ПОЛНАЯ, а не только корни установки.
38
+ *
39
+ * ПОЧЕМУ. Под областью `installs` сравниваются лишь корни установки (`.claude/skills`,
40
+ * `.agents/skills` и далее). Навык, чьи копии лежат в РАЗНЫХ корнях — канон в `packages/`, живая
41
+ * копия в `.claude/skills` — имеет там ОДНУ копию, а одну копию не с чем сравнивать: она
42
+ * отбрасывается как не дублированная. То есть главный класс расхождения был для гейта невидим.
43
+ *
44
+ * ИЗМЕРЕНО 2026-09-03: `brutal-honesty-review` разошёлся ровно так (канон в skills-qe, копия в
45
+ * бандле p-replicator, живая в .claude/skills), и гейт не мог увидеть это В ПРИНЦИПЕ. Я тогда
46
+ * написал в отчёте «гейт разблокирован» — он никогда не был на этом заблокирован.
47
+ *
48
+ * Узкая область давала 19 дублирующихся навыков, полная даёт 211. Безопасность расширения
49
+ * проверена ДО правки: с полной областью и списком исключений дрейфа сегодня НОЛЬ.
50
+ */
51
+ const DRIFT_SWEEP_SCOPE = 'all';
52
+ /**
53
+ * Базовая дата правила `backlog-covers-features`. Каталоги фич, заведённые ДО неё, правило не
54
+ * трогает: они появились раньше самого правила. ИЗМЕРЕНО 2026-09-03 — без базы правило даёт 236
55
+ * нарушений из 336 каталогов, и проверка, изобретающая полсотни нарушений в первый день, учит
56
+ * людей себя игнорировать. Дата = день, когда правило принято владельцем.
57
+ */
58
+ const BACKLOG_COVERAGE_BASELINE = '2026-09-03';
35
59
  /** Literal command inventory, pinned against the main dispatch switch by a layer-1 test. */
36
60
  export const DZ_COMMANDS = [
37
61
  'init', 'verify', 'sync', 'update', 'list', 'create-skill', 'info', 'scout',
38
62
  'workflow', 'workflow-lint', 'workflow-trace', 'migrate', 'doctor', 'install',
39
- 'bundle', 'teach', 'consolidate', 'recall', 'vector', 'brain', 'statusline',
63
+ 'bundle', 'teach', 'consolidate', 'recall', 'vector', 'brain', 'statusline', 'store-guard',
40
64
  'usage', 'claim-check', 'lint', 'sign', 'sbom', 'guard', 'verify-pack', 'setup',
41
65
  'pretrain', 'compose', 'diff', 'recommend', 'upgrade', 'auto-canonicalize',
42
66
  'publish', 'release', 'parity', 'registry', 'benchmark', 'mcp-scan',
@@ -45,9 +69,9 @@ export const DZ_COMMANDS = [
45
69
  'retro', 'feature-adr-setup', 'challenge', 'discrimination-check',
46
70
  'mutation-gate', 'delivery-check', 'skills-verify', 'compounding', 'deadwood',
47
71
  'epoch-replay', 'score', 'recap', 'cadence', 'qe-rounds', 'restart-advisor', 'tg-post',
48
- 'name-check', 'provenance-check', 'feature-adr-record', 'amendment-check', 'contract-check',
72
+ 'name-check', 'brief-check', 'provenance-check', 'feature-adr-record', 'amendment-check', 'contract-check',
49
73
  'feature-adr-checkpoint', 'profile', 'reqe', 'qe-bridge', 'backlog', 'routing',
50
- 'bto-optimize', 'dashboard', 'roam', 'import-ecc',
74
+ 'bto-optimize', 'dashboard', 'roam', 'import-ecc', 'chain',
51
75
  ];
52
76
  const USAGE = `dz - DZ cross-platform harness CLI
53
77
 
@@ -88,27 +112,29 @@ Usage:
88
112
  dz epoch-replay --judge <filled-work-order.json> [--out <file>] (blind judge prompts from the filled plans)
89
113
  dz epoch-replay --score <judgments.json> --work-order <file> [--slice <name>] [--json] (un-blind against the pre-registered assignment → SUPPORTED only when the two 95% Wilson CIs are DISJOINT, else FALSIFIED / INCONCLUSIVE)
90
114
  dz score --slug <feature> [--project <dir>] [--json] (process scorecard for ONE feature-adr run, from its artifacts: ADR confirmation, discrimination, cross-model QE grade, live verification, README-first, learning loop, amendments — descriptive-only, a low score exits 0)
115
+ dz score --all [--project <dir>] [--json] (sweep features/*/.fa-state/score-*.json into the append-only chained scorecards aggregate — descriptive-only, always exits 0)
91
116
  dz recap [--day|--week|--month] [--at <ISO date>] [--project <dir>] [--json] (what was done over a window, from records only: deliveries with the grade an independent review STATED — a report naming two grades is reported ambiguous, never guessed — registry publishes, gate verdicts, knowledge reuse. --quarter/--half-year/--year are RECOGNISED and REFUSED with the real span in days: there is one complete quarter and the longest record is 174 days. Every section carries its own data-start date, and "the source was not read" never prints as zero. Contaminated measures — commit count, lines, tokens, learning-event volume, inventory counts, lesson count — are not computed, and the report says so. exit 0 reported / 2 refused)
92
117
  dz cadence [--window day|week|month|quarter|halfyear|year] [--json] (the WHAT-SHIPPED aggregator: graded-shipment cadence by ISO week + npm-publish cadence (recap cache) + guard repeat decay on the FIXED rule set + recall reuse; a window deeper than 2× the record is REFUSED with the depth named (ADR: a cadence from one point is scale forgery); exit 0 report / 2 refused-window / 1 usage)
93
118
  dz qe-rounds (--slug <feature> | --feature-dir <abs>) [--ceiling <n>] [--project <dir>] [--json] (how many Step-8 review rounds has this feature ALREADY had? Reads what dz qe-bridge already wrote — signoff-<runId>.json and failed-*.json under features/<slug>/.fa-state/qe-bridge — and writes nothing itself, so it can answer for runs already past. A round is a runId, not a file; an attempt with no verdict is counted SEPARATELY and never merged; an unreadable record is NAMED and the count is declared a LOWER BOUND. ONE directory, never a union across checkouts. exit 0 under the ceiling / 1 at-or-over — owner decides, the command does not judge whether the rounds were warranted / 2 NOT ESTABLISHED, which is never "zero rounds")
94
119
  dz restart-advisor --slug <s> [--threshold C|D] [--rounds N] [--json] (read-only advisory decision over features/<slug>/.fa-state/checkpoints.jsonl and .dz/fa-training/<slug>/qe.jsonl. Defaults: threshold D, rounds 2 — both origins are printed. Equal sources corroborate; conflicts, torn/unreadable evidence, gaps, and unsafe paths are NOT ESTABLISHED. RECOMMENDATION ONLY: autoAction=false; never invokes feature-adr, deletes a stage, or writes advisor state. exit 0 established recommendation/no-recommendation / 2 NOT ESTABLISHED or invalid input / 1 unexpected runtime failure)
95
120
  dz tg-post --draft <file.html> [--manifest <sources.json>] [--channel <@name|id>] [--send --yes] [--night] [--preview] [--json] (the sender for an APPROVED channel post, per the accepted genai-tweets-channel ADRs: HTML mode only — never MarkdownV2; link preview OFF by default (x.com previews in Telegram are broken); the 00:00-06:00 MSK quiet window refuses without an explicit --night. DEFAULT IS A DRY-RUN: it validates the draft (tag balance, allowed tags, bare &/<, the 4096 visible-character limit with the overshoot counted) and runs the provenance gate over --manifest IN-PROCESS — a draft with no manifest is refused as unchecked, and anything but ALLOWED refuses. A real send needs --send --yes, stating ADR-004's manual-publishing decision out loud each time. The token comes from TELEGRAM_BOT_TOKEN or telegram.tokenFile in .dz/config.json and is never printed. exit 0 sent or clean dry-run / 1 refused or Telegram error / 2 usage)
96
121
  dz name-check [--command <n>] [--module <basename>] [--export <a,b>] [--project <dir>] [--json] (is this name free, BEFORE a line of code? Scans workspace SOURCE — never dist, because a stale build answers 'free' confidently. Checks a dz command name against the dispatcher AND the help block, a module basename against every package's src/, and exported identifiers against every declaration in the workspace. exit 0 all free / 1 at least one taken, naming where / 2 nothing asked or the scan did not run — an empty sweep is never a clean bill. Honest limit, printed on the passing path: it reads declarations, so a re-export under a different name stays the build's job)
122
+ dz brief-check <file> [--json] (does a swarm brief declare OUTPUT_DIR, UNITS and ASSEMBLY_UNIT? parsed as DATA, refused by name; verifies the brief DECLARED the contract, not that the agent follows it. exit 0 ok / 1 refused / 2 unreadable)
97
123
  dz provenance-check --manifest <sources.json> [--project <dir>] [--json] (nothing goes out citing a source that may not leave this machine. Checks PROVENANCE, not words: every claim names its source, and only a KNOWN kind that resolves safely is cleared. Repo paths go through 'git -C <root> check-ignore' over the RESOLVED path — a symlink into an ignored directory is REFUSED (git classifies the string and never dereferences, MEASURED), and the verdict does not change with your working directory. Store records must be named in the git-TRACKED provenance-public.json, so declaring one public is a reviewable commit rather than a field inside an ignored store. An undeclared kind is refused, never inferred from the path's shape. exit 0 allowed / 1 blocked / 3 NOT ESTABLISHED — an empty manifest, an unreadable one, or an oracle that did not run is never a pass. It proves what was CITED: it cannot see a paraphrase with no citation, nor confidential text pasted by hand into an allowed file)
98
124
  dz project-skills [--project <dir>] [--json] [--stages-json] (polymorphic feature-adr: resolve architecture/project-skills.json — fixed roles product-vision/critic/brand/impl-bar plus an open extra[] — into per-stage guidance. READ-ONLY. --project names the root explicitly, so it works from any cwd; without it the manifest is read from the current repo. No manifest ⇒ a byte-identical generic run)
99
125
  dz discrimination-check --slug <slug> [--base <ref>] [--json] (does the ADR's named test actually DISCRIMINATE? Re-runs it on a worktree at the pre-feature commit, where it MUST go red. A test that passes with the feature removed proves nothing; dz amendment-check proves the test exists, this proves it bites)
100
126
  dz guard [check|promote|init] [--json] [--force] (HARD/SOFT repo rules — readme-first, lockfile-in-sync, claim tagging — run automatically as a pre-flight inside dz publish. HARD blocks, SOFT warns)
101
127
  dz architecture [--check --slug <s> --desc <text>] [--project <dir>] [--revise] (the live product map + vision: --check is the soft Step-0 сверка of a new feature against them, reporting {signal,confidence} rather than blocking)
102
128
  dz sbom [--pack <name>] [--out <file>] (CycloneDX software bill of materials for the workspace, or for one pack with --pack)
103
- dz amendment-check --slug <slug> | --feature-dir <dir> | --all [--json] (the deterministic Step-8 amendment gate: every AM-N row must resolve to a test found INSIDE the file the row names; the PLAN is authoritative when it carries rows, and an ideation amendment the plan drops is a failure. exit 0 pass/skip, 1 fail, 3 NOT-ESTABLISHED — a section that parsed ZERO rows is never a pass. --all is a CENSUS and always exits 0. Does NOT prove non-vacuity — that is dz discrimination-check)
129
+ 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)
104
130
  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)
105
131
  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)
106
132
  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)
107
133
  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)
108
134
  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)
109
- dz qe-bridge --family claude --slug <feature> [--coder-family codex|claude] [--model <id>] [--files a,b] [--out <f>] [--timeout <s>] [--allow-same-family] [--json] (the REVERSE QE bridge: run an INDEPENDENT Claude reviewer over a feature's Step-8 artifacts from ANY host — a Codex session included, plain shell, no Claude agent plane needed — and land a PARSED signoff. The reviewer runs ISOLATED: an EMPTY temp cwd plus --safe-mode --strict-mcp-config --tools '' --no-session-persistence, so no CLAUDE.md/skills/plugins/hooks/MCP load, and the verdict is read from the --output-format json RESULT ENVELOPE — text a session customization printed onto the same stdout can never become a signoff. Probes the model before trusting it; sends SCOPED extracts with a loud 200k-char ceiling (never silent truncation); the grade must AGREE across three LAST-anchored channels (terminal marker line, fenced qe-bridge-signoff JSON, the report's own GRADE line) AND the marker must be the FINAL content — empty, gradeless, self-contradicting or miscounted output is one of 18 NAMED failures with an audit record under features/<slug>/.fa-state/qe-bridge/ (runId, resolved executable + binOverride, prompt sha256, channel offsets, requestedOut, reportWritten, retained raw stdout; 0600 files in a 0700 dir), never a clean review. A --coder-family that contradicts the recorded reqe debt is refused. Writes features/<slug>/08b_reqe_report.md, which dz reqe --done settles unchanged. DISCLOSURE: the extracts you scope are sent to the Claude runtime; the bridge cannot classify secrets. DZ_QE_BRIDGE_CLAUDE_BIN is a TEST SEAM, not a flag. exit 0 signoff parsed (ANY grade — it reports, it does not gate) / 1 named failure / 2 usage)
135
+ dz qe-bridge --family claude --slug <feature> [--coder-family codex|claude] [--model <id>] [--files a,b] [--out <f>] [--timeout <s>] [--allow-same-family] [--json] (the REVERSE QE bridge: run an INDEPENDENT Claude reviewer over a feature's Step-8 artifacts from ANY host — a Codex session included, plain shell, no Claude agent plane needed — and land a PARSED signoff. The reviewer runs ISOLATED: an EMPTY temp cwd plus --safe-mode --strict-mcp-config --tools '' --no-session-persistence, so no CLAUDE.md/skills/plugins/hooks/MCP load, and the verdict is read from the --output-format json RESULT ENVELOPE — text a session customization printed onto the same stdout can never become a signoff. Probes the model before trusting it; sends SCOPED extracts with a loud 200k-char ceiling (never silent truncation); the grade must AGREE across three LAST-anchored channels (terminal marker line, fenced qe-bridge-signoff JSON, the report's own GRADE line) AND the marker must be the FINAL content — empty, gradeless, self-contradicting or miscounted output is one of 17 NAMED failures with an audit record under features/<slug>/.fa-state/qe-bridge/ (runId, resolved executable + binOverride, prompt sha256, channel offsets, requestedOut, reportWritten, retained raw stdout; 0600 files in a 0700 dir), never a clean review. A --coder-family that contradicts the recorded reqe debt is refused. Writes features/<slug>/08b_reqe_report.md, which dz reqe --done settles unchanged. DISCLOSURE: the extracts you scope are sent to the Claude runtime; the bridge cannot classify secrets. DZ_QE_BRIDGE_CLAUDE_BIN is a TEST SEAM, not a flag. exit 0 signoff parsed (ANY grade — it reports, it does not gate) / 1 named failure / 2 usage)
110
136
  dz mutation-gate [--package <dir>] [--registry <file>] [--test-cmd "<cmd>"] [--only <id[,id]>] [--timeout <ms>] [--rebaseline per-entry|final] [--keep-scratch] [--json] (prove each NAMED protection has a test that DISCRIMINATES: copy the package to a scratch dir, verify the baseline suite is green, apply each registry mutation, run the suite, REQUIRE red, restore. The red must be BEHAVIOURAL: a mutation that no longer parses is MUTATION_UNPARSEABLE; a red run whose OWN output reports a test FILE failing to load (node --test file-level not-ok with exitCode, vitest Failed Suites) is MUTATION_LOAD_FATAL — the signal comes from the same run as the failing count, never from a separate isolated import; red output whose shape matches no known runner is INCONCLUSIVE (a runner-coverage gap, loud, never PROVEN); a count far above the entry's bound is OVER_FAILING; a restored tree that does not reproduce green makes the entry INCONCLUSIVE (flaky). Mutation writes are realpath-contained to the scratch copy: a symlink escape or a node_modules/ target is refused (exit 2), the real tree is never written. A mutation that does not apply, a green suite, or an inconclusive run is a FAILURE — never a skip. exit 0 all proven / 1 gate failed / 2 setup error)
111
- dz backlog add "<idea>" [--effort 1-5] [--proposal <text>] [--dry-run] [--project <dir>] [--json] (capture an idea: semantic dedup against existing ideas via the Brain vector engine (DUPLICATE>=0.92 merges, RELATED links, NEW creates) + GoalMap alignment; --dry-run classifies without writing)
137
+ dz backlog add "<idea>" [--effort 1-5] [--proposal <text>] [--dry-run] [--allow-cold-start] [--project <dir>] [--json] (capture an idea: semantic dedup against existing ideas via the Brain vector engine (DUPLICATE>=0.92 merges, RELATED links, NEW creates) + GoalMap alignment; --dry-run classifies without writing)
112
138
  dz backlog list [--status <s>] [--goal <id>] [--project <dir>] [--json] (list captured ideas, filterable by status/goal)
113
139
  dz backlog show <id> [--project <dir>] [--json] (full record for one idea)
114
140
  dz backlog goals [--validate] [--project <dir>] [--json] (list/validate the compass at .dz/backlog/goals.json)
@@ -122,7 +148,7 @@ Usage:
122
148
  dz backlog jira <id> [--project <dir>] [--json] (draft a Jira issue via the configurable adapter (backlog.jira.adapter: jira-mcp|copilot-mcp|none); none writes an auditable jira-outbox/<id>.json stub)
123
149
  dz backlog harmonize [--apply] [--threshold <0-1>] [--project <dir>] [--json] (batch semantic dedup of the backlog ideas; --dry-run default, --apply snapshots first)
124
150
  dz setup --target <name> [--preset <name>] [--select id,id,...] [--skills-dir <dir>] [--project <dir>] [--memory agentdb] [--no-memory] [--no-hooks] [--no-verify] [--install-driver] [--force] [--enrich] (--target codex ALSO installs + LIVE-verifies the codex hooks; an unverified hook exits non-zero WITHOUT aborting the rest of setup)
125
- dz teach "<pattern>" [--class-form "<template with :slot>"] [--reward <0-1>] [--domain <name>] [--type rule|success-pattern|lesson-learned] [--project <dir>] [--no-mirror] (class form is optional; rejection never blocks the specific write; --project pins the learned store to <dir>/.dz)
151
+ dz teach "<pattern>" [--class-form "<template with :slot>"] [--reward <0-1>] [--domain <name>] [--type rule|success-pattern|lesson-learned] [--project <dir>] [--no-mirror] [--allow-cold-start] (class form is optional; rejection never blocks the specific write; --project pins the learned store to <dir>/.dz)
126
152
  dz teach --from-json <file> [--project <dir>] [--no-mirror] (bulk-import a 'dz recall --all --json' export — share a learned store across machines)
127
153
  dz consolidate [--sessions-dir <dir>] [--project <dir>] [--no-mirror] [--prune-noise [--apply]] [--prune-quarantine [--apply]] (both prunes: DRY-RUN by default; --apply snapshots then deletes; prune-quarantine = expired unproven lessons ONLY, never coupled to noise)
128
154
  dz recall "<query>" [--limit <N>] [--domain <name>] [--semantic | --no-semantic] [--books [--book <slug>]] [--project <dir>] | dz recall --all [--json] | dz recall --usage [--json] | dz recall --forget <dzId>[,<dzId>] [--apply] | dz recall --promote <dzId>[,<dzId>] [--apply] (--domain <name> BOOSTS lessons of that domain without dropping foreign ones — a shared store keeps its cross-domain transfers; forget/promote: dry-run default; forget snapshots before removing; promote lifts lesson-quarantine)
@@ -142,9 +168,11 @@ Usage:
142
168
  dz brain expand <kuId> [--source <slug>] [--json] (full-content lookup for a citation kuId; --json emits the full KU object)
143
169
  dz brain init [--project <dir>] [--k <N>] (wire the grounding hook into .claude/settings.json — opt-in)
144
170
  dz statusline [--json] [--install] [--project <dir>] (live self-learning panel for Claude Code's status bar; reads the CC JSON payload from STDIN)
145
- dz statusline --fa-record --slug <s> --step "<label>" [--kind <feature-adr|loop>] [--recalled <n>] [--stored <n>] [--mode <m>] (feature-adr: record live per-run learning state 📐 panel segment)
171
+ 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)
172
+ dz statusline --fa-record --slug <s> --step "<label>" [--kind <feature-adr|loop>] [--tier <S|M|L|XL>] [--recalled <n>] [--stored <n>] [--mode <m>] (feature-adr: record live per-run learning state + phase → 📐 SECOND-LINE phase panel; a plain "Step <n>" label that goes BACKWARDS against a slot younger than 90 min is absorbed as a stale duplicate — prefix the label with ⛔ or ⏸ to record a legitimate regression)
146
173
  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)
147
174
  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)
175
+ 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)
148
176
  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)
149
177
  dz lint [paths...] [--json] [--config <file>] [--registry <file>] [--project <dir>] (advisory EN/RU prose-style lint; findings exit 0, incomplete input/policy exits 1, usage exits 2)
150
178
  dz pretrain [--project <dir>]
@@ -169,6 +197,10 @@ Usage:
169
197
  dz dashboard
170
198
  dz roam [--apply] [--slug <slug>]
171
199
  dz import-ecc [--local-path <dir>] [--select id,id,...] [--limit N] [--output <dir>] [--force]
200
+ dz retro [transcript-path] [--json] [--threshold N] [--no-teach] [--project <dir>] [--install-hook] (per-session retrospective + co-learning: mines the session transcript for recurring PROCESS rakes, drills you, and teaches the same lesson to the store)
201
+ dz feature-adr-setup [--plan] [--from-spec <spec.json>] [--guards [--loc-cap <n>]] [--gates [--target <name>]] [--apply] [--json] (scaffold the project-awareness files feature-adr reads — vision / map / testing / project-skills — plus the deterministic project guards and the portable delivery gates; --guards and --gates work STANDALONE or with --from-spec, and --apply writes for all three; without --apply everything is a preview)
202
+ dz mr-rakes [--json] [--candidate N] [--confirmed N] [--teach] [--gen-critic <path> [--apply]] (experimental: mine the review corpus — features' QE reports + REVIEW files — for RECURRING mistakes and close them into self-learning)
203
+ dz bto-optimize --split | --plan | --select | --scope-check | --diff [--json] (experimental: deterministic tune/holdout split, budget plan and holdout-no-regress winner selection behind the /bto-optimize skill)
172
204
  dz help
173
205
 
174
206
  Global: --version | -v [--json] (prints this CLI's own semver on one line, exit 0; "unknown" + exit 1 when unresolvable)
@@ -266,6 +298,35 @@ function discoverSkillsDirs(cwd, explicitSkillsDir) {
266
298
  async function installSkills(opts) {
267
299
  const { target, projectRoot, cwd, explicitSkillsDir, select, force, enrich } = opts;
268
300
  const skillsDirs = discoverSkillsDirs(cwd, explicitSkillsDir);
301
+ // PREFLIGHT (backlog 9d15b9b6, PR-A) — resolve the REQUEST once, before anything is written.
302
+ //
303
+ // Two defects lived in asking each root independently instead of resolving the request: a skill
304
+ // present in two roots was installed TWICE and counted twice (the field report's `2 skill(s)` was
305
+ // one skill installed twice), and a skill present in NO root produced a warning and exit 0 —
306
+ // `0 skill(s)` reading as success. Both are gone once the decision happens here.
307
+ //
308
+ // Placement is load-bearing: an exit 1 that arrives after hooks and memory are written leaves a
309
+ // half-configured project, which is worse than either clean outcome. This runs before the loop
310
+ // below and before every target adapter.
311
+ //
312
+ // Dependency closure is deliberately NOT resolved here — that is PR-B. This preflight fixes the
313
+ // count and the exit contract, and gives that work a base it can trust.
314
+ if (select !== undefined) {
315
+ const roots = skillsDirs.map((dir) => ({ dir, ids: discoverSkillIds(dir) }));
316
+ const resolution = resolveSelection(select, roots);
317
+ for (const shadow of resolution.shadowed) {
318
+ opts.writeErr?.(`dz: skill '${shadow.id}' is offered by ${shadow.alsoIn.length + 1} roots; ` +
319
+ `installing from ${shadow.chosen} (earlier root wins). Also present in: ${shadow.alsoIn.join(', ')}`);
320
+ }
321
+ const refusal = formatSelectRefusal(resolution, roots);
322
+ if (refusal !== null) {
323
+ return {
324
+ selectRefusal: refusal,
325
+ results: [], dirsSearched: skillsDirs.length, written: 0, skipped: 0,
326
+ missing: [...resolution.missing], failures: [], applyFailures: [], integrations: [],
327
+ };
328
+ }
329
+ }
269
330
  // agents-md and gemini are FLATTENING single-file targets: each must aggregate
270
331
  // every selected skill from ALL discovered dirs into ONE root file (AGENTS.md /
271
332
  // GEMINI.md) in a single merge. A per-dir runInit loop (like the tree targets
@@ -398,6 +459,7 @@ async function cmdInit(options, flags, cwd, write, writeErr) {
398
459
  cwd,
399
460
  explicitSkillsDir,
400
461
  select,
462
+ writeErr,
401
463
  force: flags.has('force'),
402
464
  enrich: flags.has('enrich'),
403
465
  noHooks: flags.has('no-hooks'),
@@ -405,6 +467,12 @@ async function cmdInit(options, flags, cwd, write, writeErr) {
405
467
  noVerify: flags.has('no-verify'),
406
468
  ...(options.get('allow-integrations') !== undefined ? { allowIntegrations: options.get('allow-integrations') } : {}),
407
469
  });
470
+ // PR-A: an explicit --select that named a skill no root provides is a REFUSAL, not a warning.
471
+ // Printed and returned here, before any target adapter runs — nothing has been written yet.
472
+ if (r.selectRefusal !== undefined) {
473
+ writeErr(r.selectRefusal);
474
+ return 1;
475
+ }
408
476
  // Codex keeps its established user-registry writer, but its result is normalized into the same
409
477
  // two-outcome contract before JSON/human rendering. A write without a live ready observation is
410
478
  // a refusal with applied=true, never a second success channel.
@@ -711,7 +779,7 @@ async function cmdScout(options, flags, cwd, write) {
711
779
  write('Scanning 9 sources (GitHub + npm + HN + MCP Registry + Glama + OSSInsight + Smithery + Semantic Scholar + arXiv)...\n');
712
780
  try {
713
781
  const scanTopics = topicsArg ? topicsArg.split(',').map((t) => t.trim()) : undefined;
714
- const { results: repos, totalBySource } = await scanAllSources({
782
+ const { results: repos, totalBySource, statusBySource } = await scanAllSources({
715
783
  token,
716
784
  topics: scanTopics,
717
785
  since,
@@ -723,10 +791,17 @@ async function cmdScout(options, flags, cwd, write) {
723
791
  .map(([src, count]) => `${src}: ${count}`)
724
792
  .join(', ');
725
793
  write(`Sources: ${sourceLines}`);
726
- // Memory: diff with previous scan
794
+ // Memory: diff with previous scan.
795
+ //
796
+ // СОСТОЯНИЕ ИСТОЧНИКОВ ПЕРЕДАЁТСЯ ОБЯЗАТЕЛЬНО. Без него разность не выводит исчезновений
797
+ // вообще — и это правильно: источник, ответивший кодом ошибки, раньше делал ВСЕ свои записи
798
+ // «пропавшими» на экране, то есть отчёт печатал факт о нашей сети как факт о мире.
727
799
  if (showDiff || memory.size > 0) {
728
- const diff = memory.diff(repos);
729
- if (diff.newRepos.length > 0 || diff.goneRepos.length > 0 || diff.changedScore.length > 0) {
800
+ const health = {};
801
+ for (const [source, status] of Object.entries(statusBySource))
802
+ health[source] = status.health;
803
+ const diff = memory.diff(repos, health);
804
+ if (diff.newRepos.length > 0 || diff.goneRepos.length > 0 || diff.changedScore.length > 0 || diff.goneOmittedReason !== undefined) {
730
805
  write(memory.diffMarkdown(diff));
731
806
  }
732
807
  else if (memory.size > 0) {
@@ -1553,6 +1628,47 @@ function cmdBundle(options, flags, cwd, write) {
1553
1628
  }
1554
1629
  return 0;
1555
1630
  }
1631
+ /**
1632
+ * Команда npm для установки пакета В ЦЕЛЕВОЙ КАТАЛОГ, а не куда решит npm.
1633
+ *
1634
+ * ЗАЧЕМ `--prefix`. Без него npm при отсутствии `package.json` в текущем каталоге поднимается по
1635
+ * дереву до первого найденного и мутирует ЕГО — а `dz` потом ищет пакет в
1636
+ * `<цель>/node_modules` и не находит. Место установки и место проверки были двумя независимыми
1637
+ * предположениями, и совпадали они только по удаче.
1638
+ *
1639
+ * ИЗМЕРЕНО 2026-09-03 (полевой случай владельца): установка в каталог без `package.json`
1640
+ * записала в `/home`, где лежит ЧУЖОЙ проект; ручной откат вернул `package.json`, а запись
1641
+ * `extraneous` в `/home/package-lock.json` пережила откат.
1642
+ *
1643
+ * ПОЧЕМУ НЕ ОТКАЗ (ADR-001, вариант A отвергнут). Отказ запретил бы законный сценарий: проект
1644
+ * внутри монорепо, намеренно не имеющий своего `package.json` и опирающийся на родительский
1645
+ * воркспейс. `--prefix` согласует установку с проверкой ПО ПОСТРОЕНИЮ и сценарий сохраняет.
1646
+ *
1647
+ * ЧИСТАЯ: ни файловой системы, ни запуска npm — проверяется без обоих. Путь экранируется, потому
1648
+ * что каталоги с пробелом в имени встречаются в наших же тестах.
1649
+ */
1650
+ export function buildInstallArgs(npmSpec, projectRoot) {
1651
+ return ['install', npmSpec, '--prefix', projectRoot, '--save-dev', '--no-fund', '--no-audit'];
1652
+ }
1653
+ /**
1654
+ * Та же команда СТРОКОЙ — только для показа человеку и для тестового шва.
1655
+ *
1656
+ * НЕ ДЛЯ ИСПОЛНЕНИЯ, и это не стилистическая оговорка. `JSON.stringify` НЕ является экранированием
1657
+ * для оболочки: внутри двойных кавычек оболочка по-прежнему выполняет `$(...)` и обратные кавычки.
1658
+ * ИЗМЕРЕНО 2026-09-03 — `execSync('echo ' + JSON.stringify('pkg$(touch ФАЙЛ)'))` создал файл.
1659
+ * Прежняя редакция этого комментария утверждала «путь экранируется»; это было неверно, и находку
1660
+ * предъявило кросс-семейное ревью (gpt-5.6-sol), а я подтвердил её пробой.
1661
+ *
1662
+ * Боевой путь исполняется через `execFileSync` массивом аргументов — оболочки в цепочке нет вовсе,
1663
+ * поэтому подставлять некуда. Это структурное лечение, а не более хитрое экранирование.
1664
+ */
1665
+ export function buildInstallCommand(npmSpec, projectRoot) {
1666
+ // ЗНАЧЕНИЯ в кавычках, ФЛАГИ без — та же форма, что печаталась до этой фичи, чтобы читатель
1667
+ // (и закреплённые тесты) видели знакомую строку. Кавычки здесь — ЧИТАЕМОСТЬ, а не безопасность:
1668
+ // безопасность даёт отсутствие оболочки на боевом пути.
1669
+ return `npm install ${JSON.stringify(npmSpec)} --prefix ${JSON.stringify(projectRoot)}`
1670
+ + ' --save-dev --no-fund --no-audit';
1671
+ }
1556
1672
  async function cmdInstall(options, flags, cwd, write, writeErr, installRunner) {
1557
1673
  const pkg = options.get('_positional_0');
1558
1674
  if (!pkg) {
@@ -1611,17 +1727,38 @@ async function cmdInstall(options, flags, cwd, write, writeErr, installRunner) {
1611
1727
  if (targetResolution.via === 'alias')
1612
1728
  writeErr(formatTargetAliasNote('dz install', targetOpt, target));
1613
1729
  const projectRoot = resolve(cwd, options.get('project') ?? '.');
1730
+ // ПРЕДПОСЫЛКА НАЗЫВАЕТСЯ ДО ПОБОЧНОГО ЭФФЕКТА, А НЕ ПОСЛЕ (ADR-001, FR-2).
1731
+ //
1732
+ // Проверка стоит ЗДЕСЬ, до развилки installRunner/execSync, и это не стилистика. Поставить её
1733
+ // внутрь ветки execSync значило бы оставить боевой путь непокрытым при зелёных тестах — ровно
1734
+ // то состояние, из которого фича и родилась.
1735
+ const hasOwnManifest = existsSync(join(projectRoot, 'package.json'));
1736
+ if (!hasOwnManifest) {
1737
+ write(`dz install: ${projectRoot} — не npm-проект (нет своего package.json).`);
1738
+ write(` Ставлю ЛОКАЛЬНО в него: npm получит --prefix, package.json и node_modules появятся здесь.`);
1739
+ write(` Без --prefix npm поднялся бы по дереву и записал в ЧУЖОЙ проект выше — измерено 2026-09-03.`);
1740
+ }
1614
1741
  // Step 1: npm install the package (installRunner is the CliIo test seam — unset in production)
1615
1742
  write(`Installing ${specResolution.npmSpec}${specResolution.kind === 'name' ? '' : ` (${specResolution.kind} → node_modules/${specResolution.dirName})`}...`);
1616
- const installCmd = `npm install ${JSON.stringify(specResolution.npmSpec)} --save-dev --no-fund --no-audit`;
1743
+ const installCmd = buildInstallCommand(specResolution.npmSpec, projectRoot);
1617
1744
  try {
1618
1745
  if (installRunner)
1619
1746
  installRunner(installCmd, projectRoot);
1747
+ // БЕЗ ОБОЛОЧКИ. execFileSync с массивом аргументов не запускает shell, поэтому имя пакета или
1748
+ // путь с `$(...)` подставить нечему. Строка выше — для показа и для тестового шва, не для
1749
+ // исполнения (см. докстринг buildInstallCommand).
1620
1750
  else
1621
- execSync(installCmd, { cwd: projectRoot, stdio: 'pipe', encoding: 'utf-8' });
1751
+ execFileSync('npm', [...buildInstallArgs(specResolution.npmSpec, projectRoot)], { cwd: projectRoot, stdio: 'pipe', encoding: 'utf-8' });
1622
1752
  }
1623
1753
  catch (err) {
1624
1754
  write(`dz install: npm install failed — ${err instanceof Error ? err.message : String(err)}`);
1755
+ // НЕАТОМАРНЫЙ ОТКАЗ НАЗЫВАЕТСЯ ВСЛУХ (ADR-001, FR-4). Названо кросс-семейной проверкой
1756
+ // 2026-09-03: npm мог успеть изменить package.json, файл замков и node_modules и упасть уже
1757
+ // после этого. Отката у нас нет — и молчать об этом хуже, чем не откатывать: пользователь
1758
+ // считает каталог нетронутым. Полевой случай: ручной откат вернул package.json, а запись
1759
+ // extraneous в файле замков пережила его.
1760
+ write(` npm мог успеть изменить файлы ДО падения — проверьте ${join(projectRoot, 'package.json')},`);
1761
+ write(` ${join(projectRoot, 'package-lock.json')} и ${join(projectRoot, 'node_modules')}: отката dz не делает.`);
1625
1762
  return 1;
1626
1763
  }
1627
1764
  // Step 2: Find SKILL.md files in the installed package — under the RESOLVED dir name, never the
@@ -1872,7 +2009,7 @@ function cmdStatuslineInstall(options, cwd, write) {
1872
2009
  * `--kind <feature-adr|loop>` identifies the producer, defaults to `feature-adr`, and rejects any
1873
2010
  * other value rather than silently weakening panel arbitration.
1874
2011
  */
1875
- function cmdStatuslineFaRecord(options, cwd, write) {
2012
+ function cmdStatuslineFaRecord(options, cwd, write, writeErr) {
1876
2013
  const slug = (options.get('slug') ?? '').trim();
1877
2014
  const step = (options.get('step') ?? '').trim();
1878
2015
  // `--recalled auto` derives the count from the recall-usage log for `--run <id>`, replacing the
@@ -1932,18 +2069,40 @@ function cmdStatuslineFaRecord(options, cwd, write) {
1932
2069
  write(' Example: dz statusline --fa-record --slug add-user-auth --step "Step 0" --kind feature-adr --recalled 5 --stored 2');
1933
2070
  return 1;
1934
2071
  }
2072
+ // fa-phase-statusline (acid A1): --tier drives done/total on the phase line — an invalid tier is
2073
+ // REJECTED before anything is written (nothing slotted, nothing ledgered), never silently dropped.
2074
+ const tierRaw = options.get('tier');
2075
+ const tier = tierRaw?.trim();
2076
+ if (tier !== undefined && tier !== 'S' && tier !== 'M' && tier !== 'L' && tier !== 'XL') {
2077
+ write(`dz statusline --fa-record: --tier must be S, M, L or XL (got "${tierRaw}")`);
2078
+ write(' Example: dz statusline --fa-record --slug add-user-auth --step "Step 7 Code" --tier M');
2079
+ return 1;
2080
+ }
1935
2081
  const mode = options.get('mode');
1936
2082
  const projectRoot = resolve(cwd, options.get('project') ?? '.');
1937
- const state = writeFeatureAdrState(projectRoot, {
2083
+ const outcome = writeFeatureAdrStateDetailed(projectRoot, {
1938
2084
  kind: kindRaw, slug, step, recalled, stored,
1939
2085
  ...(reinforced > 0 ? { reinforced } : {}),
1940
2086
  ...(mode !== undefined && mode.trim() !== '' ? { mode: mode.trim() } : {}),
2087
+ ...(tier !== undefined ? { tier } : {}),
1941
2088
  });
2089
+ const state = outcome.state;
1942
2090
  if (state === undefined) {
2091
+ // [AM-5] A REFUSED slot write is LOUD. A held `fa-phase-slot` lock or an unwritable `.dz` used
2092
+ // to return a bare `undefined`, and a caller reading silence as success is exactly the
2093
+ // "absence of a receipt is not success" class. The reason goes to stderr — diagnosis, not
2094
+ // data — and the EXIT CODE is unchanged for a refusal, because the panel must never break the
2095
+ // pipeline that is only reporting to it.
2096
+ if (outcome.refused !== undefined) {
2097
+ writeErr(`fa-record: slot write refused (${slug}): ${outcome.refused}`);
2098
+ return 0;
2099
+ }
1943
2100
  write(`dz statusline --fa-record: could not write learning state under ${projectRoot}/.dz/feature-adr/`);
1944
2101
  return 1;
1945
2102
  }
1946
- write(`dz statusline: recorded /feature-adr learning state for "${slug}" (${step}) 🎓 ${state.pool} pool · ↑${state.recalled} used · +${state.stored} new · ↻${state.reinforced ?? 0} reinforced`);
2103
+ // state.step, not the flag: the monotonic guard may have kept a LATER step against a stale
2104
+ // duplicate record (fa-phase-statusline P1) — print what actually stands in the slot.
2105
+ write(`dz statusline: recorded /feature-adr learning state for "${slug}" (${state.step}) — 🎓 ${state.pool} pool · ↑${state.recalled} used · +${state.stored} new · ↻${state.reinforced ?? 0} reinforced`);
1947
2106
  return 0;
1948
2107
  }
1949
2108
  function statuslineEtaStage(step) {
@@ -2098,16 +2257,21 @@ function statuslineEta(projectRoot, state, nowMs) {
2098
2257
  * least a minimal `dz` even on total failure.
2099
2258
  *
2100
2259
  * Flags: `--install` wires it into settings.json; `--fa-record` records a live `/feature-adr`
2101
- * learning state (WRITES — see {@link cmdStatuslineFaRecord}); `--json` prints the raw data object;
2102
- * default prints the status line (with a 📐 pipeline segment prepended when a fresh run is in flight).
2260
+ * learning state (WRITES — see {@link cmdStatuslineFaRecord}); `--json` prints the raw data object
2261
+ * (plus `featureAdrLine`, the rendered phase line, when a fresh /feature-adr run is in flight);
2262
+ * default prints the status line, with the 📐 phase panel as its OWN SECOND LINE (format B —
2263
+ * fa-phase-statusline ADR-001 D1; Claude Code renders every stdout line of a statusline command).
2264
+ * The ETA fragment main shipped for that panel rides the SECOND line with it (fa-phase-statusline ADR-001 D1) — the panel
2265
+ * moved, the estimate was not dropped.
2103
2266
  */
2104
- function cmdStatusline(options, flags, cwd, write, readStdin) {
2267
+ function cmdStatusline(options, flags, cwd, write, readStdin, writeErr) {
2105
2268
  if (flags.has('install'))
2106
2269
  return cmdStatuslineInstall(options, cwd, write);
2107
2270
  if (flags.has('fa-record'))
2108
- return cmdStatuslineFaRecord(options, cwd, write);
2271
+ return cmdStatuslineFaRecord(options, cwd, write, writeErr);
2109
2272
  try {
2110
2273
  const projectRoot = statuslineProjectRoot(readStdin(), options, cwd);
2274
+ warnLearningStoreRead(projectRoot, writeErr, 'dz statusline');
2111
2275
  const data = statuslineData(projectRoot);
2112
2276
  const fa = data.featureAdr;
2113
2277
  let eta;
@@ -2124,27 +2288,56 @@ function cmdStatusline(options, flags, cwd, write, readStdin) {
2124
2288
  etaFragment = undefined;
2125
2289
  }
2126
2290
  }
2291
+ // fa-phase-statusline (ADR-001 D1): the phase line renders from the slot ALONE — a pure
2292
+ // function over data.featureAdr, computed once here for both the plain and --json surfaces.
2293
+ const phaseLine = fa !== undefined ? renderFeatureAdrPhaseLine(fa) : undefined;
2127
2294
  if (flags.has('json')) {
2128
- write(JSON.stringify({ ...data, ...(eta !== undefined ? { eta } : {}) }));
2295
+ write(JSON.stringify({
2296
+ ...data,
2297
+ ...(eta !== undefined ? { eta } : {}),
2298
+ ...(phaseLine !== undefined ? { featureAdrLine: phaseLine } : {}),
2299
+ }));
2129
2300
  return 0;
2130
2301
  }
2131
- let line = `🎓 dz: ${data.patterns} patterns${data.usedPatterns !== undefined ? ` · ${data.usedPatterns} used` : ''} · 🧠 ${data.brainSources} sources`;
2302
+ const breakdown = data.patternBreakdown;
2303
+ let line = breakdown === undefined
2304
+ ? `🎓 dz: ${data.patterns} patterns`
2305
+ : `🎓 dz: ${data.patterns} (${breakdown.active} актив${breakdown.quarantined > 0
2306
+ ? ` · ${breakdown.quarantined} карантин${breakdown.attention ? ' ⚠' : ''}`
2307
+ : ''})${breakdown.tierDelta !== undefined ? ` ⚠ тиры Δ${breakdown.tierDelta}` : ''}`;
2308
+ if (data.storeHealth?.verdict === 'collapsed') {
2309
+ line += ` ⛔ ОБВАЛ: было ${data.storeHealth.previousMax ?? '?'} · dz store-guard --reset`;
2310
+ }
2311
+ else if (data.storeHealth?.verdict === 'cold-start-over-existing') {
2312
+ line += ` ⛔ СТОР ПУСТ: было ${data.storeHealth.previousMax ?? '?'} · восстановить из снимков ${data.storeHealth.snapshotPath ?? ''}`.trimEnd();
2313
+ }
2314
+ else if (data.storeHealth?.verdict === 'unreadable') {
2315
+ line += ` ⛔ СТОР НЕЧИТАЕМ${data.storeHealth.unreadableFiles !== undefined && data.storeHealth.unreadableFiles.length > 0
2316
+ ? `: ${data.storeHealth.unreadableFiles.join(', ')}` : ''}`;
2317
+ }
2318
+ else if (data.storeHealth?.verdict === 'source-changed') {
2319
+ line += ' ⚠ смена источника хранения';
2320
+ }
2321
+ line += `${data.usedPatterns !== undefined ? ` · ${data.usedPatterns} used` : ''} · 🧠 ${data.brainSources} sources`;
2132
2322
  const branch = statuslineGitBranch(projectRoot);
2133
2323
  if (branch !== undefined)
2134
2324
  line += ` · ⎇ ${branch}`;
2135
2325
  if (data.consolidatedAgeH !== undefined)
2136
2326
  line += ` · ⟳ ${data.consolidatedAgeH}h`;
2137
- // Live /feature-adr run in flight → PREPEND the pipeline learning segment to the base dz line.
2138
- if (fa !== undefined) {
2139
- // The producer marker reached data and arbitration in a previous round but not this label, so the bar asserted a pipeline that was not running.
2140
- if (fa.kind === 'loop') {
2141
- line = `🔁 loop ${fa.step} · ${line}`;
2142
- }
2143
- else {
2144
- line = `📐 feature-adr ${fa.step} · ${etaFragment !== undefined ? `${etaFragment} · ` : ''}🎓 ${fa.pool} pool · ↑${fa.recalled} used · +${fa.stored} new · ↻${fa.reinforced ?? 0} reinforced · ${line}`;
2145
- }
2327
+ // Live loop run in flight → PREPEND its segment to the base dz line (unchanged). A live
2328
+ // /feature-adr run no longer glues into line 1: its 📐 segment IS the second line (format B)
2329
+ // Claude Code renders every stdout line of a statusline command (fa-phase-statusline ADR-001 D1).
2330
+ if (fa !== undefined && fa.kind === 'loop') {
2331
+ line = `🔁 loop ${fa.step} · ${line}`;
2146
2332
  }
2147
2333
  write(line);
2334
+ // fa-phase-statusline ADR-001 D1: the phase panel moved to line 2 and TOOK main's ETA fragment with it. The move is
2335
+ // the point of format B (ADR-001 D1); dropping the estimate would have been a silent
2336
+ // regression of a feature `main` shipped while this branch was stranded, so it rides here
2337
+ // instead. A phase line that renders (fresh, non-terminal, non-loop slot) is the only gate.
2338
+ if (phaseLine !== undefined) {
2339
+ write(`${phaseLine}${etaFragment !== undefined ? ` · ${etaFragment}` : ''}`);
2340
+ }
2148
2341
  return 0;
2149
2342
  }
2150
2343
  catch {
@@ -2728,6 +2921,259 @@ function learningStoreLine(storeRoot, projectOption, target, verb) {
2728
2921
  const reason = teachReasonPhrase(target.reason);
2729
2922
  return storeLocationLine(describeStoreLocation(storeRoot, projectOption, target.store === 'global' ? 'global' : undefined), verb) + (reason ? ' [' + reason + ']' : '');
2730
2923
  }
2924
+ function inspectLearningStore(projectRoot) {
2925
+ const mark = readStoreMark(projectRoot);
2926
+ const rows = countLearningStoreRowsReadonly(projectRoot);
2927
+ return { mark, rows, health: checkStoreHealth({ projectRoot, ...rows, mark }) };
2928
+ }
2929
+ function shellQuote(value) {
2930
+ return `'${value.replace(/'/g, `'\\''`)}'`;
2931
+ }
2932
+ function storeGuardResetCommand(projectRoot) {
2933
+ return `dz store-guard --reset --project ${shellQuote(projectRoot)}`;
2934
+ }
2935
+ function lexicalSourceLines(rows) {
2936
+ return [
2937
+ ` lexical selected: ${rows.lexicalSourcePath} (${rows.lexicalSource}, ${rows.lexicalRows} rows)`,
2938
+ ...(rows.lexicalIgnoredSourcePath === undefined ? [] : [
2939
+ ` lexical ignored: ${rows.lexicalIgnoredSourcePath} (${rows.lexicalIgnoredRows} rows)`,
2940
+ ]),
2941
+ ];
2942
+ }
2943
+ function storeGuardRecoveryLines(projectRoot, mark, health, rows) {
2944
+ const snapshots = storeSnapshotPath(projectRoot);
2945
+ const markPath = storeGuardPath(projectRoot);
2946
+ let lexicalSnapshot;
2947
+ let vectorSnapshot = false;
2948
+ try {
2949
+ const names = existsSync(snapshots) ? readdirSync(snapshots) : [];
2950
+ const hasSqlite = names.some((name) => /^lexical\..+\.sqlite$/.test(name));
2951
+ const hasJsonl = names.some((name) => /^lexical\..+\.jsonl$/.test(name));
2952
+ const preferred = mark?.lexicalSource === 'sqlite' || mark?.lexicalSource === 'jsonl'
2953
+ ? mark.lexicalSource
2954
+ : rows?.lexicalSource;
2955
+ if (preferred === 'jsonl' && hasJsonl)
2956
+ lexicalSnapshot = 'jsonl';
2957
+ else if (preferred === 'sqlite' && hasSqlite)
2958
+ lexicalSnapshot = 'sqlite';
2959
+ else if (hasSqlite)
2960
+ lexicalSnapshot = 'sqlite';
2961
+ else if (hasJsonl)
2962
+ lexicalSnapshot = 'jsonl';
2963
+ vectorSnapshot = names.some((name) => /^vector\..+\.sqlite$/.test(name));
2964
+ }
2965
+ catch {
2966
+ lexicalSnapshot = undefined;
2967
+ vectorSnapshot = false;
2968
+ }
2969
+ const lines = [
2970
+ `dz store guard: REFUSED — ${health.reason}`,
2971
+ ` mark: ${markPath}`,
2972
+ ...(mark === undefined ? [] : [` recorded rows: lexical=${mark.lexicalMax} (${mark.lexicalSource}), vector=${mark.vectorMax}`]),
2973
+ ...(rows === undefined ? [] : lexicalSourceLines(rows)),
2974
+ ];
2975
+ if (lexicalSnapshot !== undefined && vectorSnapshot) {
2976
+ const lexicalDestination = lexicalSnapshot === 'sqlite'
2977
+ ? join(projectRoot, '.dz', 'memory', 'patterns.sqlite')
2978
+ : join(projectRoot, '.dz', 'patterns.jsonl');
2979
+ lines.push(` snapshots: ${snapshots}/`, ` restore: mkdir -p ${shellQuote(dirname(lexicalDestination))} && cp ${shellQuote(join(snapshots, `lexical.<timestamp>.${lexicalSnapshot}`))} ${shellQuote(lexicalDestination)} && cp ${shellQuote(join(snapshots, 'vector.<timestamp>.sqlite'))} ${shellQuote(join(projectRoot, '.dz', 'agentdb.db'))}`);
2980
+ }
2981
+ else {
2982
+ lines.push(` snapshots: none found in ${snapshots}/`, ` create one manually: scripts/dz-store-snapshot.sh --project ${shellQuote(projectRoot)}`);
2983
+ }
2984
+ lines.push(` accept current counts: ${storeGuardResetCommand(projectRoot)}`, ' continue intentionally: set DZ_ALLOW_COLD_START=1 or pass --allow-cold-start');
2985
+ return lines;
2986
+ }
2987
+ function observedRows(rows) {
2988
+ return typeof rows.lexicalRows === 'number' && typeof rows.vectorRows === 'number'
2989
+ ? { lexicalRows: rows.lexicalRows, vectorRows: rows.vectorRows, lexicalSource: rows.lexicalSource }
2990
+ : undefined;
2991
+ }
2992
+ /** Mark maintenance is diagnostic: the store operation already completed and must keep its exit code. */
2993
+ function refreshLearningStoreMark(projectRoot, writeErr, command, options = {}) {
2994
+ try {
2995
+ const rows = countLearningStoreRowsReadonly(projectRoot);
2996
+ const counts = observedRows(rows);
2997
+ if (counts === undefined) {
2998
+ writeErr(`⚠ dz store guard: ${options.reader ? 'reader observation' : 'store operation'} completed but the external mark was not updated — a store tier is unreadable`);
2999
+ return;
3000
+ }
3001
+ writeStoreMark(projectRoot, {
3002
+ ...counts,
3003
+ observedAt: new Date().toISOString(),
3004
+ command,
3005
+ }, options.reader ? { timeoutMs: 0 } : {});
3006
+ }
3007
+ catch (error) {
3008
+ if (options.reader && error instanceof NamedLockTimeoutError)
3009
+ return;
3010
+ writeErr(`⚠ dz store guard: ${options.reader ? 'reader observation' : 'store operation'} completed but the external mark could not be updated — ${error instanceof Error ? error.message : String(error)}`);
3011
+ }
3012
+ }
3013
+ function storeGuardResetReminder(projectRoot, writeErr, command) {
3014
+ try {
3015
+ const rows = countLearningStoreRowsReadonly(projectRoot);
3016
+ const mark = readStoreMark(projectRoot);
3017
+ writeErr(`⚠ DZ STORE GUARD — ${command}: store now lexical=${rows.lexicalRows}, vector=${rows.vectorRows}; maximum remains lexical=${mark?.lexicalMax ?? 'none'}, vector=${mark?.vectorMax ?? 'none'}; reconcile explicitly: ${storeGuardResetCommand(projectRoot)}`);
3018
+ for (const line of lexicalSourceLines(rows))
3019
+ writeErr(line);
3020
+ }
3021
+ catch (error) {
3022
+ writeErr(`⚠ DZ STORE GUARD — ${command}: store changed; inspect it and reconcile explicitly with ${storeGuardResetCommand(projectRoot)} (${error instanceof Error ? error.message : String(error)})`);
3023
+ }
3024
+ }
3025
+ /** Fail closed for store writers, except for an explicit per-process/per-command override. */
3026
+ function allowLearningStoreWrite(projectRoot, flags, writeErr, command) {
3027
+ let inspection;
3028
+ try {
3029
+ inspection = inspectLearningStore(projectRoot);
3030
+ }
3031
+ catch (error) {
3032
+ const health = {
3033
+ verdict: 'unreadable',
3034
+ reason: `cannot read the external mark (${error instanceof Error ? error.message : String(error)})`,
3035
+ };
3036
+ for (const line of storeGuardRecoveryLines(projectRoot, undefined, health))
3037
+ writeErr(line);
3038
+ return false;
3039
+ }
3040
+ if (inspection.health.verdict === 'no-mark' || inspection.health.verdict === 'ok') {
3041
+ // The successful write path records the resulting counts. Refreshing here as
3042
+ // well would emit the same telemetry failure twice when the external mark is
3043
+ // unavailable, and would receipt a source transition before the command's
3044
+ // own row had landed.
3045
+ return true;
3046
+ }
3047
+ if (inspection.health.verdict === 'source-changed') {
3048
+ const counts = observedRows(inspection.rows);
3049
+ if (counts === undefined)
3050
+ return false;
3051
+ writeErr(`⚠ DZ STORE GUARD WARNING — SOURCE CHANGED: ${inspection.health.reason}; ${storeGuardResetCommand(projectRoot)}`);
3052
+ for (const line of lexicalSourceLines(inspection.rows))
3053
+ writeErr(line);
3054
+ try {
3055
+ // Consume the single migration allowance BEFORE the store write. If the
3056
+ // following command fails, the safe result is a consumed allowance that
3057
+ // requires an explicit reset, never a silently reusable permission.
3058
+ writeStoreMark(projectRoot, { ...counts, observedAt: new Date().toISOString(), command }, {
3059
+ expectedPreviousLexicalSource: inspection.mark?.lexicalSource ?? 'unknown',
3060
+ });
3061
+ return true;
3062
+ }
3063
+ catch (error) {
3064
+ writeErr(`dz store guard: REFUSED — source-change allowance could not be recorded: ${error instanceof Error ? error.message : String(error)}`);
3065
+ return false;
3066
+ }
3067
+ }
3068
+ const allowed = process.env.DZ_ALLOW_COLD_START === '1' || flags.has('allow-cold-start');
3069
+ if (allowed) {
3070
+ const label = inspection.health.verdict.replaceAll('-', ' ').toUpperCase();
3071
+ writeErr(`⚠ DZ STORE GUARD WARNING — ${label}: ${inspection.health.reason}; explicit cold-start override accepted`);
3072
+ return true;
3073
+ }
3074
+ for (const line of storeGuardRecoveryLines(projectRoot, inspection.mark, inspection.health, inspection.rows))
3075
+ writeErr(line);
3076
+ return false;
3077
+ }
3078
+ /** Readers warn on damage and bootstrap/refresh a healthy non-empty store mark. */
3079
+ function warnLearningStoreRead(projectRoot, writeErr, command) {
3080
+ try {
3081
+ const { health, rows, mark } = inspectLearningStore(projectRoot);
3082
+ if (health.verdict === 'collapsed' || health.verdict === 'cold-start-over-existing' || health.verdict === 'unreadable'
3083
+ || health.verdict === 'source-changed') {
3084
+ writeErr(`⚠ DZ STORE GUARD WARNING — ${health.verdict.replaceAll('-', ' ').toUpperCase()}: ${health.reason}`);
3085
+ for (const line of lexicalSourceLines(rows))
3086
+ writeErr(line);
3087
+ return;
3088
+ }
3089
+ const counts = observedRows(rows);
3090
+ if (counts !== undefined && counts.lexicalRows + counts.vectorRows > 0
3091
+ && (mark === undefined || mark.lexicalLast !== counts.lexicalRows || mark.vectorLast !== counts.vectorRows
3092
+ || mark.lexicalSource !== counts.lexicalSource
3093
+ || mark.lexicalMax < counts.lexicalRows || mark.vectorMax < counts.vectorRows)) {
3094
+ refreshLearningStoreMark(projectRoot, writeErr, command, { reader: true });
3095
+ }
3096
+ }
3097
+ catch (error) {
3098
+ writeErr(`⚠ DZ STORE GUARD WARNING — external mark unreadable: ${error instanceof Error ? error.message : String(error)}`);
3099
+ }
3100
+ }
3101
+ async function cmdStoreGuard(options, flags, cwd, write, writeErr, stdinText, interactive) {
3102
+ const projectRoot = resolve(cwd, options.get('project') ?? '.');
3103
+ const path = storeGuardPath(projectRoot);
3104
+ const reset = flags.has('reset');
3105
+ const status = flags.has('status') || options.has('status');
3106
+ if (reset && status) {
3107
+ writeErr('dz store-guard: --status and --reset are mutually exclusive');
3108
+ return 2;
3109
+ }
3110
+ if (reset) {
3111
+ const rows = countLearningStoreRowsReadonly(projectRoot);
3112
+ const counts = observedRows(rows);
3113
+ if (counts === undefined) {
3114
+ writeErr(`dz store-guard: REFUSED — cannot reset from an unreadable store; mark: ${path}`);
3115
+ return 1;
3116
+ }
3117
+ try {
3118
+ const previous = readStoreMark(projectRoot);
3119
+ const beforeLexical = previous?.lexicalMax ?? counts.lexicalRows;
3120
+ const beforeVector = previous?.vectorMax ?? counts.vectorRows;
3121
+ writeErr('⚠ dz store-guard --reset: manual operator decision required; this lowers the recorded high-water evidence');
3122
+ writeErr(` old maximum: lexical=${beforeLexical}, vector=${beforeVector}`);
3123
+ writeErr(` new observed: lexical=${counts.lexicalRows} (${counts.lexicalSource}), vector=${counts.vectorRows}`);
3124
+ for (const line of lexicalSourceLines(rows))
3125
+ writeErr(line);
3126
+ let answer = stdinText?.trim().split(/\r?\n/, 1)[0]?.trim().toLowerCase() ?? '';
3127
+ if (!flags.has('yes') && answer === '' && interactive && process.stdin.isTTY) {
3128
+ const { createInterface } = await import('node:readline/promises');
3129
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
3130
+ try {
3131
+ answer = (await rl.question(' type yes to continue: ')).trim().toLowerCase();
3132
+ }
3133
+ finally {
3134
+ rl.close();
3135
+ }
3136
+ }
3137
+ if (!flags.has('yes') && !['y', 'yes', 'да'].includes(answer)) {
3138
+ writeErr(`dz store-guard: REFUSED — reset was not confirmed; re-run with --yes or answer yes`);
3139
+ return 1;
3140
+ }
3141
+ const current = countLearningStoreRowsReadonly(projectRoot);
3142
+ const currentCounts = observedRows(current);
3143
+ if (currentCounts === undefined || !isDeepStrictEqual(currentCounts, counts)) {
3144
+ writeErr('dz store-guard: REFUSED — store counts changed after confirmation; inspect and confirm again');
3145
+ return 1;
3146
+ }
3147
+ const mark = resetStoreMark(projectRoot, {
3148
+ ...counts,
3149
+ observedAt: new Date().toISOString(),
3150
+ command: 'dz store-guard --reset',
3151
+ });
3152
+ write(`dz store-guard: RESET — accepted lexical=${mark.lexicalMax}, vector=${mark.vectorMax}`);
3153
+ write(` mark: ${path}`);
3154
+ write(` receipt: ${mark.resetAt?.at} — ${mark.resetAt?.reason}`);
3155
+ return 0;
3156
+ }
3157
+ catch (error) {
3158
+ writeErr(`dz store-guard: reset failed — ${error instanceof Error ? error.message : String(error)}; mark: ${path}`);
3159
+ return 1;
3160
+ }
3161
+ }
3162
+ try {
3163
+ const inspection = inspectLearningStore(projectRoot);
3164
+ write(`dz store-guard: ${inspection.health.verdict.toUpperCase()} — ${inspection.health.reason}`);
3165
+ write(` mark: ${path}`);
3166
+ write(` current rows: lexical=${inspection.rows.lexicalRows}, vector=${inspection.rows.vectorRows}`);
3167
+ for (const line of lexicalSourceLines(inspection.rows))
3168
+ write(line);
3169
+ write(` recorded: ${inspection.mark === undefined ? 'none' : JSON.stringify(inspection.mark)}`);
3170
+ return 0;
3171
+ }
3172
+ catch (error) {
3173
+ writeErr(`dz store-guard: mark unreadable — ${error instanceof Error ? error.message : String(error)}; mark: ${path}`);
3174
+ return 1;
3175
+ }
3176
+ }
2731
3177
  async function runTeachGuardReinforcement(projectRoot, dzId, reward) {
2732
3178
  const backend = resolveLearningBackend(projectRoot);
2733
3179
  backend.addSample({ dzId, kind: 'reinforce', reward, ts: new Date().toISOString() });
@@ -2755,6 +3201,13 @@ async function cmdTeach(options, flags, cwd, write, writeErr = (line) => { conso
2755
3201
  // repo's own store holds 361 records written under that behaviour, and every other user's store
2756
3202
  // is the same. Only an explicit choice moves it.
2757
3203
  const { storeRoot, target: teachTarget } = resolved;
3204
+ const teachWillWrite = flags.has('harmonize')
3205
+ ? false
3206
+ : options.has('from-json')
3207
+ || (options.get('reinforce') ?? '').trim() !== ''
3208
+ || (options.get('_positional_0') ?? '').trim() !== '';
3209
+ if (teachWillWrite && !allowLearningStoreWrite(storeRoot, flags, writeErr, 'dz teach'))
3210
+ return 1;
2758
3211
  // The verb is per OUTCOME, not per command: a harmonize dry-run and a failed --reinforce READ
2759
3212
  // the store and change nothing, so saying "written" there is a false claim about what happened
2760
3213
  // (cross-family QE round 2, 2026-08-27).
@@ -2793,10 +3246,11 @@ async function cmdTeach(options, flags, cwd, write, writeErr = (line) => { conso
2793
3246
  // worse defect than the invisibility it was closing (measured live, cross-family QE round 2).
2794
3247
  if (!flags.has('json'))
2795
3248
  write(storeLine(flags.has('apply') ? 'written' : 'read'));
2796
- return runHarmonize(storeRoot, options, flags, write, {
3249
+ const code = await runHarmonize(storeRoot, options, flags, write, writeErr, {
2797
3250
  store: join(storeRoot, '.dz'),
2798
3251
  storeChosenBy: teachTarget.reason,
2799
3252
  });
3253
+ return code;
2800
3254
  }
2801
3255
  // Bulk import: `dz teach --from-json <file>` ingests a `dz recall --all --json`
2802
3256
  // export (an array of learned patterns) — the portable way to SHARE a learned
@@ -2894,6 +3348,8 @@ async function cmdTeach(options, flags, cwd, write, writeErr = (line) => { conso
2894
3348
  const report = await harmonizeVectorStore(storeRoot, {});
2895
3349
  write(` ℹ ${imported} imported — ${report.clusters.length} near-duplicate cluster(s): review with dz vector harmonize (dry-run); merge with dz vector harmonize --apply after backup`);
2896
3350
  }
3351
+ if (imported > 0)
3352
+ refreshLearningStoreMark(storeRoot, writeErr, 'dz teach --from-json');
2897
3353
  return 0;
2898
3354
  }
2899
3355
  const reinforce = options.get('reinforce');
@@ -2914,6 +3370,7 @@ async function cmdTeach(options, flags, cwd, write, writeErr = (line) => { conso
2914
3370
  if (clearedQ.cleared > 0)
2915
3371
  write(` ↳ promoted out of quarantine (mirror updated)`);
2916
3372
  write(storeLine('written'));
3373
+ refreshLearningStoreMark(storeRoot, writeErr, 'dz teach --reinforce');
2917
3374
  return 0;
2918
3375
  }
2919
3376
  // HIGH-fix: a no-match must NOT auto-teach the raw argument — callers pass dzIds or truncated
@@ -2961,6 +3418,7 @@ async function cmdTeach(options, flags, cwd, write, writeErr = (line) => { conso
2961
3418
  const clearedQ = clearAgentdbQuarantine(storeRoot, [verdict.dzId]);
2962
3419
  if (clearedQ.cleared > 0)
2963
3420
  write(' ↳ promoted out of quarantine (mirror updated)');
3421
+ refreshLearningStoreMark(storeRoot, writeErr, 'dz teach --guard');
2964
3422
  return 0;
2965
3423
  }
2966
3424
  write(`dz teach --guard: reinforce of ${verdict.dzId} did not flush (backend off or write failure) — teaching the lesson normally instead`);
@@ -3077,9 +3535,13 @@ async function cmdTeach(options, flags, cwd, write, writeErr = (line) => { conso
3077
3535
  }
3078
3536
  // The lexical write above is durable — the vector mirror is strictly best-effort (I-3).
3079
3537
  await emitMirrorQ(storeRoot, recordsToMirror, 'dz-teach', quarantineOn);
3538
+ if (stored.records.length > 0) {
3539
+ write(` ID: ${stored.records.map((record) => patternRecordId(record)).join(', ')}`);
3540
+ refreshLearningStoreMark(storeRoot, writeErr, 'dz teach');
3541
+ }
3080
3542
  return commandFailed ? 1 : 0;
3081
3543
  }
3082
- async function cmdConsolidate(options, flags, cwd, write) {
3544
+ async function cmdConsolidate(options, flags, cwd, write, writeErr) {
3083
3545
  const projectRoot = resolve(cwd, options.get('project') ?? '.');
3084
3546
  const sessionsDirOpt = options.get('sessions-dir');
3085
3547
  const pruneNoise = flags.has('prune-noise');
@@ -3103,6 +3565,8 @@ async function cmdConsolidate(options, flags, cwd, write) {
3103
3565
  write(`dz consolidate --prune-quarantine: removed ${res.removed} expired quarantined lesson(s)`);
3104
3566
  if (res.snapshot !== undefined)
3105
3567
  write(` snapshot: ${res.snapshot}`);
3568
+ refreshLearningStoreMark(projectRoot, writeErr, 'dz consolidate --prune-quarantine --apply');
3569
+ storeGuardResetReminder(projectRoot, writeErr, 'dz consolidate --prune-quarantine --apply');
3106
3570
  return 0;
3107
3571
  }
3108
3572
  // --prune-noise: RETRO-PRUNE legacy noise (tool telemetry + system-wrapper "responses") from
@@ -3212,6 +3676,10 @@ async function cmdConsolidate(options, flags, cwd, write) {
3212
3676
  }
3213
3677
  }
3214
3678
  catch { /* best-effort — the ranking is advisory, never fails the consolidate */ }
3679
+ refreshLearningStoreMark(projectRoot, writeErr, 'dz consolidate');
3680
+ if (pruneNoise && applyPrune) {
3681
+ storeGuardResetReminder(projectRoot, writeErr, 'dz consolidate --prune-noise --apply');
3682
+ }
3215
3683
  return 0;
3216
3684
  }
3217
3685
  function recallUsagePatternRefs(projectRoot) {
@@ -3322,7 +3790,7 @@ function cmdRecallUsage(options, flags, projectRoot, write) {
3322
3790
  * DRY-RUN BY DEFAULT, and it snapshots before deleting: `.dz/` is git-ignored and unversioned, so a
3323
3791
  * mistaken id is unrecoverable. Use `dz recall --all --json` to find ids.
3324
3792
  */
3325
- async function cmdRecallForget(options, flags, projectRoot, write) {
3793
+ async function cmdRecallForget(options, flags, projectRoot, write, writeErr) {
3326
3794
  const raw = options.get('forget') ?? '';
3327
3795
  const ids = new Set(raw.split(',').map((s) => s.trim()).filter((s) => s !== ''));
3328
3796
  if (ids.size === 0) {
@@ -3348,7 +3816,7 @@ async function cmdRecallForget(options, flags, projectRoot, write) {
3348
3816
  write(`dz recall --forget: DRY RUN — ${found.length} record(s) would be removed. Re-run with --apply.`);
3349
3817
  return 0;
3350
3818
  }
3351
- const dest = join(projectRoot, '.dz', `patterns-pre-forget-${Date.now()}.json`);
3819
+ const dest = join(storeSnapshotPath(projectRoot), `forget-${Date.now()}.json`);
3352
3820
  const snap = snapshotStore(projectRoot, dest);
3353
3821
  if (snap.error !== undefined) {
3354
3822
  write(`dz recall --forget: snapshot failed (${snap.error}) — nothing removed; the store is not versioned`);
@@ -3360,6 +3828,8 @@ async function cmdRecallForget(options, flags, projectRoot, write) {
3360
3828
  if (result.error !== undefined)
3361
3829
  write(` ⚠ ${result.error}`);
3362
3830
  write(' the vector mirror still holds them — run `dz vector reindex` to resync');
3831
+ refreshLearningStoreMark(projectRoot, writeErr, 'dz recall --forget --apply');
3832
+ storeGuardResetReminder(projectRoot, writeErr, 'dz recall --forget --apply');
3363
3833
  return 0;
3364
3834
  }
3365
3835
  /**
@@ -3439,12 +3909,17 @@ async function cmdRecallPromote(options, flags, projectRoot, write) {
3439
3909
  }
3440
3910
  async function cmdRecall(options, flags, cwd, write, writeErr, classMatcher) {
3441
3911
  const projectRoot = resolve(cwd, options.get('project') ?? '.');
3912
+ warnLearningStoreRead(projectRoot, writeErr, 'dz recall');
3913
+ const globalRootForGuard = globalStoreRoot();
3914
+ if (!sameStore(projectRoot, globalRootForGuard) && existsSync(join(globalRootForGuard, '.dz', 'memory'))) {
3915
+ warnLearningStoreRead(globalRootForGuard, writeErr, 'dz recall');
3916
+ }
3442
3917
  const asJson = flags.has('json');
3443
3918
  const all = flags.has('all');
3444
3919
  if (flags.has('usage'))
3445
3920
  return cmdRecallUsage(options, flags, projectRoot, write);
3446
3921
  if (options.has('forget'))
3447
- return cmdRecallForget(options, flags, projectRoot, write);
3922
+ return cmdRecallForget(options, flags, projectRoot, write, writeErr);
3448
3923
  if (options.has('promote'))
3449
3924
  return cmdRecallPromote(options, flags, projectRoot, write);
3450
3925
  // --all: dump the entire learned store (backend-agnostic, via loadStorePatternsSync).
@@ -4009,7 +4484,7 @@ function renderHarmonize(report, write) {
4009
4484
  * and `dz teach --harmonize` — ONE implementation, no bespoke second path (QR-6). Guards (AC-6):
4010
4485
  * `--apply` + `--dry-run` together is rejected; `--threshold` must be in `(0, 1]`; no flag ⇒ dry-run.
4011
4486
  */
4012
- async function runHarmonize(projectRoot, options, flags, write,
4487
+ async function runHarmonize(projectRoot, options, flags, write, writeErr,
4013
4488
  /**
4014
4489
  * Where this harmonize is pointed and what chose it. Under `--json` the human store line is
4015
4490
  * suppressed to keep stdout ONE document, so the destination has to travel INSIDE that document
@@ -4032,6 +4507,10 @@ storeAnnotation) {
4032
4507
  }
4033
4508
  }
4034
4509
  const report = await harmonizeVectorStore(projectRoot, { apply, ...(threshold !== undefined ? { threshold } : {}) });
4510
+ if (apply && report.error === undefined) {
4511
+ refreshLearningStoreMark(projectRoot, writeErr, 'dz vector harmonize --apply');
4512
+ storeGuardResetReminder(projectRoot, writeErr, 'dz vector harmonize --apply');
4513
+ }
4035
4514
  if (flags.has('json')) {
4036
4515
  write(JSON.stringify(storeAnnotation !== undefined ? { ...report, ...storeAnnotation } : report));
4037
4516
  return report.error !== undefined ? 1 : 0;
@@ -4039,7 +4518,7 @@ storeAnnotation) {
4039
4518
  renderHarmonize(report, write);
4040
4519
  return report.error !== undefined ? 1 : 0;
4041
4520
  }
4042
- async function cmdVector(options, flags, cwd, write) {
4521
+ async function cmdVector(options, flags, cwd, write, writeErr) {
4043
4522
  const projectRoot = resolve(cwd, options.get('project') ?? '.');
4044
4523
  const sub = options.get('_positional_0');
4045
4524
  if (sub === 'status') {
@@ -4093,6 +4572,10 @@ async function cmdVector(options, flags, cwd, write) {
4093
4572
  if (sub === 'reindex') {
4094
4573
  const report = await reindexVectorStore(projectRoot);
4095
4574
  if (flags.has('json')) {
4575
+ if (report.error === undefined) {
4576
+ refreshLearningStoreMark(projectRoot, writeErr, 'dz vector reindex');
4577
+ storeGuardResetReminder(projectRoot, writeErr, 'dz vector reindex');
4578
+ }
4096
4579
  write(JSON.stringify(report));
4097
4580
  return report.error !== undefined ? 1 : 0;
4098
4581
  }
@@ -4112,6 +4595,8 @@ async function cmdVector(options, flags, cwd, write) {
4112
4595
  if (report.staleTaskTypes.includes('book-knowledge'))
4113
4596
  write(' run \`dz brain reindex\` to rebuild the brain\'s book vectors');
4114
4597
  }
4598
+ refreshLearningStoreMark(projectRoot, writeErr, 'dz vector reindex');
4599
+ storeGuardResetReminder(projectRoot, writeErr, 'dz vector reindex');
4115
4600
  return 0;
4116
4601
  }
4117
4602
  if (sub === 'export') {
@@ -4163,7 +4648,7 @@ async function cmdVector(options, flags, cwd, write) {
4163
4648
  // harmonize (alias: dz teach --harmonize) — SEMANTIC dedup of the learned store, NON-DESTRUCTIVE:
4164
4649
  // dry-run by default (previews clusters, writes nothing); --apply drops after a restorable backup.
4165
4650
  if (sub === 'harmonize') {
4166
- return runHarmonize(projectRoot, options, flags, write);
4651
+ return runHarmonize(projectRoot, options, flags, write, writeErr);
4167
4652
  }
4168
4653
  // import <file.rvf> — the missing HALF of the RVF cycle: UPSERT-BY-dzId, never overwrites.
4169
4654
  if (sub === 'import') {
@@ -4174,6 +4659,8 @@ async function cmdVector(options, flags, cwd, write) {
4174
4659
  }
4175
4660
  const report = await importRvfCheckpoint(projectRoot, resolve(cwd, src), {});
4176
4661
  if (flags.has('json')) {
4662
+ if (report.error === undefined)
4663
+ refreshLearningStoreMark(projectRoot, writeErr, 'dz vector import');
4177
4664
  write(JSON.stringify(report));
4178
4665
  return report.error !== undefined ? 1 : 0;
4179
4666
  }
@@ -4186,6 +4673,7 @@ async function cmdVector(options, flags, cwd, write) {
4186
4673
  if (report.skippedOrphans > 0) {
4187
4674
  write(' ↳ orphan vectors have no local pattern — import the text first: dz teach --from-json <recall-export.json>, then re-run dz vector import');
4188
4675
  }
4676
+ refreshLearningStoreMark(projectRoot, writeErr, 'dz vector import');
4189
4677
  return 0;
4190
4678
  }
4191
4679
  write('dz vector — semantic (vector) tier of the learned-pattern store');
@@ -4860,6 +5348,28 @@ async function cmdSetup(options, flags, cwd, write, writeErr) {
4860
5348
  writeErr(formatTargetAliasNote('dz setup', targetOpt, target));
4861
5349
  const projectRoot = resolve(cwd, options.get('project') ?? '.');
4862
5350
  const presetName = options.get('preset');
5351
+ // PREFLIGHT BEFORE THE FIRST WRITE (backlog 9d15b9b6, PR-A). Step 3 configures the learning
5352
+ // environment and step 4 installs skills, so refusing at step 4 would leave a project that has
5353
+ // memory and hooks but not the skills the operator asked for — a half-configured state worse than
5354
+ // either clean outcome. The request is therefore resolved HERE, before the banner's first step.
5355
+ //
5356
+ // Only an EXPLICIT --select is refused. A preset names skills the package itself ships, so a gap
5357
+ // there is our packaging defect, not the operator's typo, and it is reported by the existing
5358
+ // missing-list rather than by refusing the whole run.
5359
+ const setupSelectRaw = options.get('select');
5360
+ if (setupSelectRaw !== undefined) {
5361
+ const requested = setupSelectRaw.split(',').map((x) => x.trim()).filter((x) => x.length > 0);
5362
+ const roots = discoverSkillsDirs(cwd, options.get('skills-dir')).map((dir) => ({ dir, ids: discoverSkillIds(dir) }));
5363
+ const resolution = resolveSelection(requested, roots);
5364
+ for (const shadow of resolution.shadowed) {
5365
+ writeErr(`dz: skill '${shadow.id}' is offered by ${shadow.alsoIn.length + 1} roots; installing from ${shadow.chosen} (earlier root wins). Also present in: ${shadow.alsoIn.join(', ')}`);
5366
+ }
5367
+ const refusal = formatSelectRefusal(resolution, roots);
5368
+ if (refusal !== null) {
5369
+ writeErr(refusal);
5370
+ return 1;
5371
+ }
5372
+ }
4863
5373
  write(`\n╔══════════════════════════════════════════════════════╗`);
4864
5374
  write(`║ DZ SETUP — Full Environment ║`);
4865
5375
  write(`╠══════════════════════════════════════════════════════╣`);
@@ -5001,7 +5511,7 @@ function cmdPretrain(options, cwd, write) {
5001
5511
  write(`╚══════════════════════════════════════════════════════════════╝`);
5002
5512
  return 0;
5003
5513
  }
5004
- function cmdRecommend(options, cwd, write) {
5514
+ function cmdRecommend(options, flags, cwd, write) {
5005
5515
  const task = options.get('_positional_0');
5006
5516
  if (!task) {
5007
5517
  write('dz recommend: task description required');
@@ -5010,15 +5520,40 @@ function cmdRecommend(options, cwd, write) {
5010
5520
  }
5011
5521
  const registry = buildRegistry(cwd);
5012
5522
  const report = recommend(task, registry, cwd);
5523
+ if (flags.has('json')) {
5524
+ write(JSON.stringify(report, null, 2));
5525
+ return 0;
5526
+ }
5013
5527
  write(`\n╔══════════════════════════════════════════════════════════════╗`);
5014
5528
  write(`║ DZ RECOMMEND — Task Advisor ║`);
5015
5529
  write(`╠══════════════════════════════════════════════════════════════╣`);
5016
5530
  write(`║ Task: ${report.task.slice(0, 52).padEnd(52)}║`);
5017
- const topicSuffix = report.pretrainFallback ? ' (via pretrain)' : '';
5018
- write(`║ Topics: ${(report.topics.join(', ') + topicSuffix).slice(0, 50).padEnd(50)}║`);
5531
+ if (report.topicSource === 'task') {
5532
+ write(`║ Topics: ${report.topics.join(', ').slice(0, 50).padEnd(50)}║`);
5533
+ }
5534
+ else if (report.topicSource === 'project-stack') {
5535
+ write(`║ Topics: ${'not matched in the question'.padEnd(50)}║`);
5536
+ }
5537
+ else {
5538
+ write(`║ Topics: ${'not recognized — no recommendations'.padEnd(50)}║`);
5539
+ }
5019
5540
  write(`╠══════════════════════════════════════════════════════════════╣`);
5541
+ if (report.topicSource === 'project-stack') {
5542
+ write(`⚠ Тема запроса не распознана — подбор ниже сделан по СТЕКУ ПРОЕКТА, не по вашему вопросу.`);
5543
+ write(` (topic not recognized — recommendations reflect the project stack, not the question)`);
5544
+ write(`PROJECT-STACK SUGGESTIONS`);
5545
+ }
5546
+ else if (report.topicSource === 'none') {
5547
+ write(`Тема запроса не распознана; рекомендаций нет.`);
5548
+ write(`Переформулируйте задачу или используйте dz registry search <слово> / /skill-advisor.`);
5549
+ write(`╚══════════════════════════════════════════════════════════════╝`);
5550
+ return 0;
5551
+ }
5552
+ const stackDerived = report.topicSource === 'project-stack';
5020
5553
  if (report.presets.length > 0) {
5021
- write(`║ RECOMMENDED PRESETS ║`);
5554
+ write(stackDerived
5555
+ ? `║ PROJECT-STACK PRESETS ║`
5556
+ : `║ RECOMMENDED PRESETS ║`);
5022
5557
  for (const p of report.presets) {
5023
5558
  const matched = p.matchedSkills.length > 0 ? ` (${p.matchedSkills.slice(0, 3).join(', ')})` : '';
5024
5559
  write(`║ ${p.name.padEnd(15)} ${String(p.skills).padStart(2)} skills coverage: ${String(p.coverage).padStart(2)} topics${matched.padEnd(15)}║`);
@@ -5026,7 +5561,9 @@ function cmdRecommend(options, cwd, write) {
5026
5561
  write(`║${''.padEnd(62)}║`);
5027
5562
  }
5028
5563
  if (report.skills.length > 0) {
5029
- write(`║ RECOMMENDED SKILLS (top ${Math.min(report.skills.length, 8)})${' '.repeat(35)}║`);
5564
+ write(stackDerived
5565
+ ? `║ PROJECT-STACK SKILLS (top ${Math.min(report.skills.length, 8)})${' '.repeat(35)}║`
5566
+ : `║ RECOMMENDED SKILLS (top ${Math.min(report.skills.length, 8)})${' '.repeat(35)}║`);
5030
5567
  for (const s of report.skills.slice(0, 8)) {
5031
5568
  const desc = s.description.length > 35 ? s.description.slice(0, 32) + '...' : s.description;
5032
5569
  write(`║ ${s.id.padEnd(24)} ${desc.padEnd(36)}║`);
@@ -5034,7 +5571,9 @@ function cmdRecommend(options, cwd, write) {
5034
5571
  write(`║${''.padEnd(62)}║`);
5035
5572
  }
5036
5573
  if (report.toolkits.length > 0) {
5037
- write(`║ FULL PIPELINE (npx toolkits) ║`);
5574
+ write(stackDerived
5575
+ ? `║ PROJECT-STACK PIPELINE (npx toolkits) ║`
5576
+ : `║ FULL PIPELINE (npx toolkits) ║`);
5038
5577
  for (const tk of report.toolkits) {
5039
5578
  const desc = tk.description.length > 44 ? tk.description.slice(0, 41) + '...' : tk.description;
5040
5579
  write(`║ ${tk.name.padEnd(16)} ${desc.padEnd(44)}║`);
@@ -5044,13 +5583,17 @@ function cmdRecommend(options, cwd, write) {
5044
5583
  write(`║${''.padEnd(62)}║`);
5045
5584
  }
5046
5585
  write(`╠══════════════════════════════════════════════════════════════╣`);
5047
- write(`║ STEP-BY-STEP PLAN ║`);
5586
+ write(stackDerived
5587
+ ? `║ PROJECT-STACK PLAN ║`
5588
+ : `║ STEP-BY-STEP PLAN ║`);
5048
5589
  for (const step of report.plan) {
5049
5590
  const line = step.length > 60 ? step.slice(0, 57) + '...' : step;
5050
5591
  write(`║ ${line.padEnd(58)}║`);
5051
5592
  }
5052
5593
  write(`╠══════════════════════════════════════════════════════════════╣`);
5053
- write(`║ QUICK INSTALL ║`);
5594
+ write(stackDerived
5595
+ ? `║ PROJECT-STACK QUICK INSTALL ║`
5596
+ : `║ QUICK INSTALL ║`);
5054
5597
  const cmd = report.installCommand.length > 58 ? report.installCommand.slice(0, 55) + '...' : report.installCommand;
5055
5598
  write(`║ ${cmd.padEnd(58)}║`);
5056
5599
  write(`╚══════════════════════════════════════════════════════════════╝`);
@@ -5586,6 +6129,18 @@ function cmdPublish(options, flags, cwd, write) {
5586
6129
  return 1;
5587
6130
  }
5588
6131
  }
6132
+ const filterStr = options.get('filter');
6133
+ // SAFETY: trim + drop empty segments (mirrors --select at the top of cmdInit).
6134
+ // Parse before the guard pre-flight so its packed-secret scan uses the SAME scoped package set
6135
+ // that publishPackages receives below; an empty resulting list remains an explicit error.
6136
+ let filter;
6137
+ if (filterStr !== undefined) {
6138
+ filter = filterStr.split(',').map((s) => s.trim()).filter((s) => s.length > 0);
6139
+ if (filter.length === 0) {
6140
+ write('dz publish: --filter requires a non-empty comma-separated list of package-name substrings');
6141
+ return 1;
6142
+ }
6143
+ }
5589
6144
  // dz guard pre-flight (ADR-002 option A): publish is the most dangerous, least-reversible self-mutation, so
5590
6145
  // it ALWAYS runs the declarative guard first. A HARD violation refuses the publish; `--no-guard "<reason>"`
5591
6146
  // is the logged escape hatch (the override lands in .dz/guard-audit.jsonl — visible, never silent).
@@ -5600,7 +6155,7 @@ function cmdPublish(options, flags, cwd, write) {
5600
6155
  write('dz publish: --no-guard requires a reason (it is logged): --no-guard "hotfix, guard re-run after"');
5601
6156
  return 1;
5602
6157
  }
5603
- const guardResult = runGuardEvaluation(guardRoot, 'publish', undefined, noGuard);
6158
+ const guardResult = runGuardEvaluation(guardRoot, 'publish', undefined, noGuard, filter);
5604
6159
  if (guardResult.verdict === 'block' && noGuard === undefined) {
5605
6160
  write('dz publish: ✗ BLOCKED by dz guard (HARD invariant violated):');
5606
6161
  for (const v of guardResult.violations.filter((x) => x.severity === 'hard'))
@@ -5645,21 +6200,6 @@ function cmdPublish(options, flags, cwd, write) {
5645
6200
  }
5646
6201
  const claimCheckOpt = claimCheckRaw ?? 'warn';
5647
6202
  const bumpOnly = flags.has('bump-only');
5648
- const filterStr = options.get('filter');
5649
- // SAFETY: trim + drop empty segments (mirrors --select at the top of cmdInit).
5650
- // Without this, `--filter ""` (e.g. an unset shell var) or a stray comma yields
5651
- // [''] / ['', 'core'], and publishPackages matches with name.includes(''), which
5652
- // is true for EVERY package — silently turning a scoped publish into a
5653
- // whole-monorepo publish. An empty resulting list is an explicit error, never
5654
- // "match all".
5655
- let filter;
5656
- if (filterStr !== undefined) {
5657
- filter = filterStr.split(',').map((s) => s.trim()).filter((s) => s.length > 0);
5658
- if (filter.length === 0) {
5659
- write('dz publish: --filter requires a non-empty comma-separated list of package-name substrings');
5660
- return 1;
5661
- }
5662
- }
5663
6203
  // SAFETY: dry-run is the DEFAULT. A real publish requires an EXPLICIT opt-in
5664
6204
  // via --yes, --confirm, or --no-dry-run. Without one, we never bump or publish.
5665
6205
  const wantsLive = flags.has('yes') || flags.has('confirm') || flags.has('no-dry-run');
@@ -5852,6 +6392,14 @@ function cmdPublish(options, flags, cwd, write) {
5852
6392
  if (pkg.claimCheck && pkg.claimCheck.findings > 0 && pkg.status !== 'error') {
5853
6393
  write(` ⚠ claim-check: ${pkg.claimCheck.findings} finding(s) (${pkg.claimCheck.high} high) in README.md`);
5854
6394
  }
6395
+ // A dry run stops before build/sign/pack, so it says NOTHING about the gates below that line.
6396
+ // Printing what it did not check is what keeps a clean preview from reading as a clean publish
6397
+ // (measured 2026-09-02: a clean dry run preceded a RED real gate).
6398
+ if (pkg.notVerified && pkg.notVerified.length > 0) {
6399
+ write(` ⓘ холостой прогон НЕ проверял (${pkg.notVerified.length}):`);
6400
+ for (const item of pkg.notVerified)
6401
+ write(` · ${item}`);
6402
+ }
5855
6403
  }
5856
6404
  return report.errors > 0 ? 1 : 0;
5857
6405
  }
@@ -6811,6 +7359,81 @@ function looksBinaryText(text) {
6811
7359
  * per-file findings (each enriched with its `file`), and applies the exit-code contract.
6812
7360
  * `--json` ALWAYS emits valid JSON `{ok, findings, scanned}`, even on the failure path.
6813
7361
  */
7362
+ /**
7363
+ * `dz chain` — verify EVERY hash-chained journal in one command (W0-chain, backlog bc4ee35c).
7364
+ *
7365
+ * The machinery to verify a chain has worked for weeks. What was missing is the ABILITY TO ASK:
7366
+ * verification lived inside two consumers, each carrying its own hardcoded list of which files are
7367
+ * chained, so a journal could be given a chain and still be checked by nobody. Coverage here is
7368
+ * DERIVED from CHAINED_JOURNALS, never typed — adding a journal to the registry adds it to this
7369
+ * report by construction.
7370
+ *
7371
+ * An ABSENT journal is reported as `absent`, not omitted. Omission and cleanliness are
7372
+ * indistinguishable in a report, and that indistinguishability is how the original blind spot
7373
+ * survived; the same reason `broken` exits NON-ZERO rather than merely printing — a verifier that
7374
+ * reports damage and exits 0 is one no automation can act on, and this verb exists to run unattended.
7375
+ *
7376
+ * A journal that exists but carries NO chained records is `unchained`, which is legal (a log may
7377
+ * predate the chain) and therefore does not fail the command. Calling it a defect would train the
7378
+ * reader to ignore the output — the failure mode already measured once on the doctor's own line.
7379
+ */
7380
+ function cmdChain(options, flags, cwd, write) {
7381
+ const root = options.get('project') ?? cwd;
7382
+ const journals = CHAINED_JOURNALS.map((journal) => {
7383
+ const path = join(root, journal.rel);
7384
+ if (!existsSync(path)) {
7385
+ return { rel: journal.rel, decides: journal.decides, status: 'absent', chained: 0, defects: 0, detail: 'file not present' };
7386
+ }
7387
+ let text = '';
7388
+ try {
7389
+ text = readFileSync(path, 'utf-8');
7390
+ }
7391
+ catch {
7392
+ // Unreadable is NOT clean. It is the one outcome that must never be quietly folded into
7393
+ // "nothing to report": we did not look, so we know nothing.
7394
+ return { rel: journal.rel, decides: journal.decides, status: 'unreadable', chained: 0, defects: 0, detail: 'file could not be read' };
7395
+ }
7396
+ const v = verifyEventChainText(text);
7397
+ if (v.chained === 0) {
7398
+ return { rel: journal.rel, decides: journal.decides, status: 'unchained', chained: 0, defects: 0, detail: 'present, but no record carries a chain (legal — the log predates chaining)' };
7399
+ }
7400
+ const total = text.split('\n').filter((l) => l.trim() !== '').length;
7401
+ const age = classifyChainDefects(v, total);
7402
+ if (v.ok) {
7403
+ return { rel: journal.rel, decides: journal.decides, status: 'ok', chained: v.chained, defects: 0, detail: `${v.chained} chained record(s), ${v.resets} recorded restart(s)` };
7404
+ }
7405
+ // A break the current unbroken run has already outlived does not make TODAY's records unsound.
7406
+ // Reporting both alike is what made the doctor's equivalent line permanently red for four weeks.
7407
+ const historical = age.inRun.length === 0 && age.runRecords > 0;
7408
+ return {
7409
+ rel: journal.rel,
7410
+ decides: journal.decides,
7411
+ status: historical ? 'healed' : 'broken',
7412
+ chained: v.chained,
7413
+ defects: v.defects.length,
7414
+ detail: historical
7415
+ ? `${v.defects.length} defect(s), all BEFORE the current run — the last ${age.runRecords} record(s) are unbroken, so verdicts over those are sound`
7416
+ : `${v.defects.length} defect(s) with NO sound records after them: verdicts computed from this log are unsafe`,
7417
+ };
7418
+ });
7419
+ const failed = journals.filter((j) => j.status === 'broken' || j.status === 'unreadable');
7420
+ const ok = failed.length === 0;
7421
+ if (flags.has('json')) {
7422
+ write(JSON.stringify({ ok, root, journals }, null, 2));
7423
+ return ok ? 0 : 1;
7424
+ }
7425
+ write(`dz chain — ${journals.length} registered journal(s) under ${root}`);
7426
+ write('');
7427
+ const MARK = { ok: '\u2713', healed: '\u2713', unchained: '\u00b7', absent: '\u00b7', broken: '\u2717', unreadable: '\u2717' };
7428
+ for (const j of journals) {
7429
+ write(` ${MARK[j.status] ?? '?'} ${j.rel} — ${j.status}`);
7430
+ write(` ${j.detail}`);
7431
+ write(` decides: ${j.decides}`);
7432
+ }
7433
+ write('');
7434
+ write(ok ? ' all registered journals are sound for present verdicts' : ` ${failed.length} journal(s) UNSAFE — see above`);
7435
+ return ok ? 0 : 1;
7436
+ }
6814
7437
  function cmdClaimCheck(options, _optionLists, flags, cwd, write) {
6815
7438
  // Reject (never silently coerce) an invalid --fail-on.
6816
7439
  const failOnRaw = options.get('fail-on') ?? 'high';
@@ -7499,7 +8122,19 @@ function cmdAgentsSync(options, flags, cwd, write, writeErr) {
7499
8122
  writeErr(`dz agents-sync: DRIFT — ${drifted.length} stale/missing section(s); ${effect}`);
7500
8123
  for (const finding of drifted)
7501
8124
  writeErr(` ${finding.id}: ${finding.file} (${finding.status})`);
7502
- if (drifted.some((finding) => finding.id === 'dz:policies')) {
8125
+ const unregistered = drifted.filter((finding) => finding.status === 'unregistered-section');
8126
+ if (unregistered.length > 0) {
8127
+ // ПОДСКАЗКА, ВЕДУЩАЯ НЕ ТУДА, ХУЖЕ ОТСУТСТВУЮЩЕЙ. Повторный `dz agents-sync` эту находку
8128
+ // НЕ лечит: реестр POLICY_SOURCES ведётся руками, и секция, не вписанная в него, не
8129
+ // попадёт в проекцию сколько ни синхронизируй. Раньше здесь печаталась общая подсказка —
8130
+ // читатель прогнал бы её и снова увидел ту же ошибку.
8131
+ writeErr('→ heal with: объяви секцию в POLICY_SOURCES (packages/@dzhechkov/harness-core/src/agents-policy.ts):');
8132
+ for (const finding of unregistered) {
8133
+ writeErr(` { id: '${finding.id}', file: '${finding.file}', heading: '…', why: '…', operativeClause: '…' }`);
8134
+ }
8135
+ writeErr(' затем: dz agents-sync');
8136
+ }
8137
+ else if (drifted.some((finding) => finding.id === 'dz:policies')) {
7503
8138
  writeErr('→ heal with: repair duplicate/unmatched dz:policies markers, then run dz agents-sync');
7504
8139
  }
7505
8140
  else {
@@ -7584,7 +8219,9 @@ const DEFAULT_STORE_CAP = 5000;
7584
8219
  * the rule reports nothing for them — a pre-flight must never become a filesystem sweep.
7585
8220
  */
7586
8221
  const MAX_STUB_SCAN_FILES = 400;
7587
- /** Read the optional `.dz/guard.json` `{ rules?: [...], storeCap?: number, stubWaivers?: [...] }`. Missing/broken ⇒ defaults. */
8222
+ /** Maximum packed-file size read by the publish secret scan. */
8223
+ const SECRET_SCAN_MAX_BYTES = 512 * 1024;
8224
+ /** Read the optional `.dz/guard.json` — `{ rules?, storeCap?, stubWaivers?, secretWaivers? }`. Missing/broken ⇒ defaults. */
7588
8225
  function loadGuardConfig(root) {
7589
8226
  const p = join(root, '.dz', 'guard.json');
7590
8227
  if (!existsSync(p))
@@ -7621,6 +8258,54 @@ function gatherReadmeCounts(root) {
7621
8258
  // (target repo without sitedoc — missing-evidence contract). But a file that EXISTS and no longer
7622
8259
  // matches its anchored pattern emits a MISMATCH pair (a: -1) — silent non-extraction is the exact
7623
8260
  // disease this contour cures (AM-1 applies to the guard path too, not only the CI test).
8261
+ // ЗНАЧКИ ПРОТИВ ДЕРЕВА, а не только README против README (бэклог 9cb30764).
8262
+ //
8263
+ // Прежде правило сверяло только числа МЕЖДУ документами: два согласованных документа могли
8264
+ // хором утверждать одно и то же неверное число, и правило молчало. Значок «пакетов: 52» стоял
8265
+ // при 55 публикуемых на диске — ИЗМЕРЕНО 2026-09-03. Меню обещает 32 блюда, официант
8266
+ // перечисляет 30, кухня готовит 38, и никто в ресторане не знает правду.
8267
+ //
8268
+ // Сверяются только числа, ВЫЧИСЛИМЫЕ ИЗ ДЕРЕВА. Значок «опубликовано в npm» сюда НЕ входит и
8269
+ // это сказано вслух: его источник — реестр, а не рабочая копия, и пара, которая делает вид, что
8270
+ // проверила его, была бы хуже отсутствующей.
8271
+ const badge = (s, name) => {
8272
+ const m = s.match(new RegExp(`img\\.shields\\.io/badge/${name}-(\\d+)`));
8273
+ return m && m[1] ? Number(m[1]) : null;
8274
+ };
8275
+ const publishablePackages = (() => {
8276
+ const dir = join(root, 'packages', '@dzhechkov');
8277
+ if (!existsSync(dir))
8278
+ return null;
8279
+ let n = 0;
8280
+ for (const name of readdirSync(dir)) {
8281
+ const pj = join(dir, name, 'package.json');
8282
+ if (!existsSync(pj))
8283
+ continue;
8284
+ try {
8285
+ const j = JSON.parse(readFileSync(pj, 'utf8'));
8286
+ if (j.private !== true)
8287
+ n += 1;
8288
+ }
8289
+ catch { /* нечитаемый манифест — не считается ни в одну сторону */ }
8290
+ }
8291
+ return n;
8292
+ })();
8293
+ const pkgBadge = badge(rootMd, 'packages');
8294
+ if (pkgBadge !== null && publishablePackages !== null) {
8295
+ pairs.push({ label: 'packages (root badge vs publishable package.json on disk)', a: pkgBadge, b: publishablePackages });
8296
+ }
8297
+ const presetBadge = badge(rootMd, 'presets');
8298
+ if (presetBadge !== null) {
8299
+ pairs.push({ label: 'presets (root badge vs PRESET_NAMES in the build)', a: presetBadge, b: PRESET_NAMES.length });
8300
+ }
8301
+ const targetBadge = badge(rootMd, 'targets');
8302
+ if (targetBadge !== null) {
8303
+ pairs.push({ label: 'targets (root badge vs TARGET_NAMES in the build)', a: targetBadge, b: TARGET_NAMES.length });
8304
+ }
8305
+ const cmdBadge = badge(rootMd, 'CLI%20commands');
8306
+ if (cmdBadge !== null && cliAll !== null) {
8307
+ pairs.push({ label: 'commands (root badge vs cli All Commands)', a: cmdBadge, b: cliAll });
8308
+ }
7624
8309
  const sitePair = (rel, re, label) => {
7625
8310
  if (cliAll === null || !existsSync(join(root, rel)))
7626
8311
  return;
@@ -8056,8 +8741,9 @@ function gatherVolumeShadowFacts(root, packages) {
8056
8741
  };
8057
8742
  }
8058
8743
  /** Gather the facts one op needs. All I/O is best-effort — a missing signal skips its rule, never crashes. */
8059
- function gatherGuardFacts(op, root, text, storeCap) {
8744
+ function gatherGuardFacts(op, root, text, storeCap, publishFilter) {
8060
8745
  const facts = { op };
8746
+ const publishPackageRoots = [];
8061
8747
  if (op === 'publish') {
8062
8748
  // Advisory I/O: unreadable telemetry or fed state is absence of evidence, never a fabricated
8063
8749
  // stale finding and never a publish blocker.
@@ -8204,6 +8890,11 @@ function gatherGuardFacts(op, root, text, storeCap) {
8204
8890
  for (const m of manifests)
8205
8891
  if (m.name && typeof m.version === 'string')
8206
8892
  versionByName.set(m.name, m.version);
8893
+ publishPackageRoots.push(...located
8894
+ .filter(({ dir, m }) => m.private !== true && (publishFilter === undefined
8895
+ || publishFilter.length === 0
8896
+ || publishFilter.some((filter) => (m.name ?? '').includes(filter) || dir.includes(filter))))
8897
+ .map(({ dir }) => dir));
8207
8898
  const pnpmWorkspace = existsSync(join(root, 'pnpm-workspace.yaml'));
8208
8899
  const packages = [];
8209
8900
  for (const m of manifests) {
@@ -8218,6 +8909,95 @@ function gatherGuardFacts(op, root, text, storeCap) {
8218
8909
  packages.push({ name: m.name ?? '(unnamed)', deps });
8219
8910
  }
8220
8911
  facts['packages'] = packages;
8912
+ // sibling-dep-protocol: сырые спеки, БЕЗ подстановки версии. Подставленная версия выглядела бы
8913
+ // как обычный диапазон, и правило потеряло бы ровно то, что проверяет.
8914
+ const siblingDeps = [];
8915
+ for (const m of manifests) {
8916
+ if (m.private === true)
8917
+ continue;
8918
+ const fields = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
8919
+ for (const field of fields) {
8920
+ const table = m[field];
8921
+ if (typeof table !== 'object' || table === null)
8922
+ continue;
8923
+ for (const [dep, spec] of Object.entries(table)) {
8924
+ if (!dep.startsWith('@dzhechkov/') || typeof spec !== 'string')
8925
+ continue;
8926
+ siblingDeps.push({ name: m.name ?? '(unnamed)', field, dep, spec });
8927
+ }
8928
+ }
8929
+ }
8930
+ facts['siblingDeps'] = siblingDeps;
8931
+ // plugin-manifest-audit: каждый `.claude-plugin/plugin.json` в дереве. Обход ограничен по
8932
+ // глубине и не заходит в node_modules/dist — чужие манифесты не наши, и краснеть на них
8933
+ // значило бы отчитываться о том, чего мы не публикуем.
8934
+ const pluginManifests = [];
8935
+ const walkPlugins = (dir, depth) => {
8936
+ if (depth > 4)
8937
+ return;
8938
+ let entries;
8939
+ try {
8940
+ entries = readdirSync(dir, { withFileTypes: true });
8941
+ }
8942
+ catch {
8943
+ return;
8944
+ }
8945
+ for (const e of entries) {
8946
+ // `out/` — СГЕНЕРИРОВАННОЕ публичное зеркало: те же манифесты, скопированные. Дефект в нём
8947
+ // есть дефект генератора, и он уже сообщается по источнику; вторая копия только удвоила бы
8948
+ // одну и ту же находку.
8949
+ if (!e.isDirectory() || e.name === 'node_modules' || e.name === 'dist' || e.name === 'out')
8950
+ continue;
8951
+ const full = join(dir, e.name);
8952
+ if (e.name === '.claude-plugin') {
8953
+ const manifest = join(full, 'plugin.json');
8954
+ if (!existsSync(manifest))
8955
+ continue;
8956
+ try {
8957
+ const j = JSON.parse(readFileSync(manifest, 'utf8'));
8958
+ const pick = (k) => (typeof j[k] === 'string' ? j[k] : undefined);
8959
+ // Состав навыков — ОБЕ стороны инвентаризации, и ТОЛЬКО для коробки ОДНОГО ПАКЕТА.
8960
+ //
8961
+ // Корневая витрина сюда НЕ входит, и это не упрощение: её состав собирается из всего
8962
+ // монорепозитория через реестр, «что лежит на складе» для неё — не обход одного дерева,
8963
+ // а весь реестр, и ровно это уже проверяет `marketplace-parity` регенерацией. Первая
8964
+ // редакция этой проверки обошла корень с ограничением глубины и выдала 27 ЛОЖНЫХ
8965
+ // «объявлено, но не найдено» — навыки лежали глубже границы обхода (ИЗМЕРЕНО 2026-09-04,
8966
+ // поймано до коммита прогоном стража на этом же дереве).
8967
+ //
8968
+ // Сравниваются ПУТИ, как их объявил манифест, а не имена: два навыка с одинаковым
8969
+ // именем в разных подкаталогах — законная вещь, и сведение к имени их бы склеило.
8970
+ const boxRoot = dirname(full);
8971
+ const isRepoRoot = resolve(boxRoot) === resolve(root);
8972
+ const declaredSkills = Array.isArray(j['skills']) && !isRepoRoot
8973
+ ? j['skills'].filter((x) => typeof x === 'string')
8974
+ .map((rel) => rel.replace(/^\.\//, '').replace(/\/+$/, '')).filter(Boolean)
8975
+ : undefined;
8976
+ const skillsOnDisk = declaredSkills === undefined ? undefined : findSkillDirs(boxRoot);
8977
+ pluginManifests.push({
8978
+ path: relative(root, manifest),
8979
+ ...(pick('name') !== undefined ? { name: pick('name') } : {}),
8980
+ ...(pick('version') !== undefined ? { version: pick('version') } : {}),
8981
+ ...(pick('description') !== undefined ? { description: pick('description') } : {}),
8982
+ ...(declaredSkills !== undefined ? { declaredSkills } : {}),
8983
+ ...(skillsOnDisk !== undefined ? { skillsOnDisk } : {}),
8984
+ });
8985
+ }
8986
+ catch (error) {
8987
+ pluginManifests.push({
8988
+ path: relative(root, manifest),
8989
+ parseError: (error instanceof Error ? error.message : String(error)).replace(/\s+/g, ' ').slice(0, 200),
8990
+ });
8991
+ }
8992
+ continue;
8993
+ }
8994
+ if (e.name.startsWith('.'))
8995
+ continue;
8996
+ walkPlugins(full, depth + 1);
8997
+ }
8998
+ };
8999
+ walkPlugins(root, 0);
9000
+ facts['pluginManifests'] = pluginManifests;
8221
9001
  facts['volume'] = gatherVolumeShadowFacts(root, located.map(({ dir, m }) => ({
8222
9002
  dir,
8223
9003
  name: m.name ?? dir,
@@ -8252,9 +9032,57 @@ function gatherGuardFacts(op, root, text, storeCap) {
8252
9032
  }
8253
9033
  catch { /* unreadable tree — the rule reports nothing rather than inventing a violation */ }
8254
9034
  try {
8255
- facts['drift'] = sweepSkillDrift(root, { scope: 'installs', allowlist: readDriftAllowlist(root) }).drifted.map((d) => d.name);
9035
+ facts['drift'] = sweepSkillDrift(root, { scope: DRIFT_SWEEP_SCOPE, allowlist: readDriftAllowlist(root) }).drifted.map((d) => d.name);
8256
9036
  }
8257
9037
  catch { /* skip */ }
9038
+ // backlog-covers-features: каталоги фич, дата их ПОЯВЛЕНИЯ В ИСТОРИИ (не mtime — его двигает
9039
+ // любой посторонний процесс), оговорки из README фичи и тексты записей бэклога. Базовая дата
9040
+ // делает правило зелёным на приходе: 336 существующих каталогов заведены до правила.
9041
+ // Нечитаемое дерево ⇒ факт НЕ выставляется ⇒ правило молчит, а не выдумывает вердикт.
9042
+ try {
9043
+ const featDir = join(root, 'features');
9044
+ if (existsSync(featDir)) {
9045
+ const slugs = readdirSync(featDir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
9046
+ const features = slugs.map((slug) => {
9047
+ let createdIso = '';
9048
+ try {
9049
+ createdIso = execSync(`git log --diff-filter=A --format=%aI -1 -- ${JSON.stringify('features/' + slug)}`, { cwd: root, encoding: 'utf-8' }).trim().split('\n').filter(Boolean).pop() ?? '';
9050
+ }
9051
+ catch { /* нет в истории — функция засчитает как новый, это верный дефолт */ }
9052
+ let waiver;
9053
+ for (const f of ['README.md', '07_code_changes/change_manifest.md']) {
9054
+ try {
9055
+ const m = readFileSync(join(featDir, slug, f), 'utf-8')
9056
+ .match(/^\s*Backlog:\s*не заведено\s*[—-]\s*(.+)$/m);
9057
+ if (m && m[1] && m[1].trim() !== '') {
9058
+ waiver = m[1].trim();
9059
+ break;
9060
+ }
9061
+ }
9062
+ catch { /* нет файла — не оговорка */ }
9063
+ }
9064
+ return { slug, createdIso, waiver };
9065
+ });
9066
+ const backlogTexts = [];
9067
+ try {
9068
+ for (const line of readFileSync(join(root, '.dz', 'backlog', 'ideas.jsonl'), 'utf-8').split('\n')) {
9069
+ if (line.trim() === '')
9070
+ continue;
9071
+ try {
9072
+ const o = JSON.parse(line);
9073
+ if (typeof o.text === 'string')
9074
+ backlogTexts.push(o.text);
9075
+ }
9076
+ catch { /* рваная строка */ }
9077
+ }
9078
+ }
9079
+ catch { /* стора нет */ }
9080
+ if (backlogTexts.length > 0) {
9081
+ facts['featureBacklog'] = { baseline: BACKLOG_COVERAGE_BASELINE, features, backlogTexts };
9082
+ }
9083
+ }
9084
+ }
9085
+ catch { /* нечитаемо — правило молчит */ }
8258
9086
  facts['counts'] = gatherReadmeCounts(root);
8259
9087
  // readme-first: from the WORKING-TREE diff (publishes happen pre-commit here), per package: does the
8260
9088
  // change set contain its package.json (the version-bump signal) without its README.md?
@@ -8449,16 +9277,61 @@ function gatherGuardFacts(op, root, text, storeCap) {
8449
9277
  catch { /* not a git repo — every template rule stays silent (fail-open) */ }
8450
9278
  // no-stubs config waivers: `.dz/guard.json` `stubWaivers: [{path, reason}]` — path-keyed, reason
8451
9279
  // MANDATORY (the feature-adr-setup --guards shape; the pure checker refuses a reasonless entry).
8452
- const stubWaivers = loadGuardConfig(root).stubWaivers;
9280
+ const guardConfig = loadGuardConfig(root);
9281
+ const stubWaivers = guardConfig.stubWaivers;
8453
9282
  if (Array.isArray(stubWaivers))
8454
9283
  facts['stubWaivers'] = stubWaivers;
9284
+ const secretWaivers = guardConfig.secretWaivers;
9285
+ if (Array.isArray(secretWaivers))
9286
+ facts['secretWaivers'] = secretWaivers;
8455
9287
  }
8456
9288
  if (op === 'consolidate') {
8457
9289
  try {
8458
- facts['drift'] = sweepSkillDrift(root, { scope: 'installs', allowlist: readDriftAllowlist(root) }).drifted.map((d) => d.name);
9290
+ facts['drift'] = sweepSkillDrift(root, { scope: DRIFT_SWEEP_SCOPE, allowlist: readDriftAllowlist(root) }).drifted.map((d) => d.name);
8459
9291
  }
8460
9292
  catch { /* skip */ }
8461
9293
  }
9294
+ if (op === 'publish') {
9295
+ const secretTargets = [];
9296
+ let skipped = 0;
9297
+ for (const dir of publishPackageRoots) {
9298
+ const packageRoot = join(root, dir);
9299
+ let packed;
9300
+ try {
9301
+ packed = listPackFiles(packageRoot);
9302
+ }
9303
+ catch {
9304
+ skipped++;
9305
+ continue;
9306
+ }
9307
+ for (const rel of packed) {
9308
+ const absolute = join(packageRoot, rel);
9309
+ try {
9310
+ const stat = lstatSync(absolute);
9311
+ if (!stat.isFile() || stat.size > SECRET_SCAN_MAX_BYTES) {
9312
+ skipped++;
9313
+ continue;
9314
+ }
9315
+ const content = readFileSync(absolute);
9316
+ if (content.subarray(0, 8 * 1024).includes(0)) {
9317
+ skipped++;
9318
+ continue;
9319
+ }
9320
+ secretTargets.push({
9321
+ label: relative(root, absolute).split(sep).join('/'),
9322
+ text: content.toString('utf8'),
9323
+ });
9324
+ }
9325
+ catch {
9326
+ skipped++;
9327
+ }
9328
+ }
9329
+ }
9330
+ if (secretTargets.length > 0)
9331
+ facts['secretTargets'] = secretTargets;
9332
+ if (skipped > 0)
9333
+ facts['secretScan'] = { skipped };
9334
+ }
8462
9335
  if (op === 'teach' || op === 'consolidate') {
8463
9336
  if (op === 'teach' && text)
8464
9337
  facts['secretTargets'] = [{ label: 'lesson', text }];
@@ -8478,13 +9351,13 @@ function gatherGuardFacts(op, root, text, storeCap) {
8478
9351
  * shared by `dz guard check` and the `dz publish` pre-flight (ADR-002 option A) so they can never disagree.
8479
9352
  * `overrideReason` (when the caller forces through a block) is logged, never silent.
8480
9353
  */
8481
- function runGuardEvaluation(root, op, text, overrideReason) {
9354
+ function runGuardEvaluation(root, op, text, overrideReason, publishFilter) {
8482
9355
  const cfg = loadGuardConfig(root);
8483
9356
  // Number.isFinite, not just > 0: a config `storeCap: 1e400` parses to Infinity, passes `> 0`, and would
8484
9357
  // silently DISABLE the cap (count <= Infinity always). Non-finite ⇒ fall back to the default.
8485
9358
  const storeCap = typeof cfg.storeCap === 'number' && Number.isFinite(cfg.storeCap) && cfg.storeCap > 0 ? cfg.storeCap : DEFAULT_STORE_CAP;
8486
9359
  const rules = resolveRules(Array.isArray(cfg.rules) ? cfg.rules : undefined);
8487
- const facts = gatherGuardFacts(op, root, text, storeCap);
9360
+ const facts = gatherGuardFacts(op, root, text, storeCap, publishFilter);
8488
9361
  const result = evaluateGuard(facts, rules);
8489
9362
  // audit (append-only). ts is real time here (a CLI, not the sandboxed workflow).
8490
9363
  try {
@@ -9320,17 +10193,57 @@ async function cmdMrRakes(options, flags, cwd, write) {
9320
10193
  * --threshold N drill threshold (default 2 — anti-noise: a first-seen rake accrues, never drills)
9321
10194
  * --no-teach drill only; do NOT write the store (skip the agent side)
9322
10195
  * --project <dir> pin the teach ledger
9323
- * --install-hook print the opt-in SessionEnd hook to add (non-destructive)
10196
+ * --install-hook print the opt-in hook set to add (non-destructive): Stop scan-tail +
10197
+ * PreCompact/SessionEnd full retro (feature narrated-error-must-be-taught)
10198
+ * --scan-tail per-turn Stop-hook mode: incremental admission-debt scan, O(new bytes) —
10199
+ * writes/clears .dz/retro-pending.json; no ledger, no teach, no git subprocess
10200
+ * --transcript <p> the transcript --scan-tail must read. Without it the Stop hook's own stdin
10201
+ * payload (`transcript_path`) is used; with neither, the scan REFUSES
10202
+ * (NOT-ESTABLISHED) rather than guessing the newest file on disk
9324
10203
  */
9325
- async function cmdRetro(options, flags, cwd, write) {
10204
+ async function cmdRetro(options, flags, cwd, write, readStdin = () => '') {
10205
+ if (flags.has('scan-tail')) {
10206
+ // Per-turn primary detector (ADR-001 D2). Cost budget IS the design: no `git rev-parse`
10207
+ // subprocess (hooks run with cwd = project root; CLAUDE_PROJECT_DIR pins it), no store open,
10208
+ // O(new bytes) via the persisted offset. Always exit 0 — a Stop hook must never fail a turn.
10209
+ const root = process.env['CLAUDE_PROJECT_DIR'] ?? cwd;
10210
+ // WHICH transcript. Round 3, P1-3: this used to fall back to `findLatestTranscript(root)`, so with
10211
+ // several sessions and their subagents alive at once it scanned whichever file had the newest
10212
+ // mtime — routinely another session's, advancing that session's offset and never seeing this
10213
+ // turn's admission. The Stop hook hands the exact path on stdin; with no path from any source the
10214
+ // answer is a stated refusal, never a guess. Still exit 0: a Stop hook must never fail a turn.
10215
+ const picked = resolveScanTailTranscript({
10216
+ flag: options.get('transcript'),
10217
+ positional: options.get('_positional_0'),
10218
+ stdin: readStdin(),
10219
+ });
10220
+ if (picked.path === null) {
10221
+ const reason = picked.reason ?? 'no transcript path';
10222
+ if (flags.has('json'))
10223
+ write(JSON.stringify({ status: 'not-established', source: 'none', reason, scannedBytes: 0, offset: 0 }));
10224
+ else
10225
+ write(`retro scan-tail: NOT-ESTABLISHED — ${reason}. Wire the hook as \`dz retro --scan-tail\` (Claude Code pipes the payload on stdin) or pass \`--transcript <path>\`.`);
10226
+ return 0;
10227
+ }
10228
+ const outcome = runRetroTailScan(join(root, '.dz'), picked.path);
10229
+ if (flags.has('json'))
10230
+ write(JSON.stringify({ ...outcome, source: picked.source }));
10231
+ else if (outcome.status === 'pending')
10232
+ write(`retro scan-tail: unpaid admission — .dz/retro-pending.json armed («${(outcome.snippet ?? '').slice(0, 60)}…»)`);
10233
+ return 0;
10234
+ }
9326
10235
  let repoRoot = cwd;
9327
10236
  try {
9328
10237
  repoRoot = execSync('git rev-parse --show-toplevel', { cwd, encoding: 'utf-8' }).trim() || cwd;
9329
10238
  }
9330
10239
  catch { /* not git */ }
9331
10240
  if (flags.has('install-hook')) {
9332
- write('Add this opt-in SessionEnd hook to .claude/settings.json (runs a retro when a session ends):');
9333
- write(JSON.stringify({ hooks: { SessionEnd: [{ hooks: [{ type: 'command', command: 'dz retro' }] }] } }, null, 2));
10241
+ write('Add these opt-in hooks to .claude/settings.json (per-turn debt scan + retro at compaction AND session end — PreCompact covers the crash/disconnect sessions SessionEnd never sees):');
10242
+ write(JSON.stringify({ hooks: {
10243
+ Stop: [{ hooks: [{ type: 'command', command: 'dz retro --scan-tail', timeout: 10000, continueOnError: true }] }],
10244
+ PreCompact: [{ hooks: [{ type: 'command', command: 'dz retro', timeout: 60000, continueOnError: true }] }],
10245
+ SessionEnd: [{ hooks: [{ type: 'command', command: 'dz retro', timeout: 60000, continueOnError: true }] }],
10246
+ } }, null, 2));
9334
10247
  return 0;
9335
10248
  }
9336
10249
  const transcript = options.get('_positional_0') ?? findLatestTranscript(repoRoot);
@@ -9593,6 +10506,50 @@ function cmdChallenge(options, flags, cwd, write) {
9593
10506
  * decide (dz's rule — a false gate kills trust). Exit code is 0 on a clean run regardless of verdict; 2 only on
9594
10507
  * a usage/setup error, so a caller distinguishes "gate ran" from "gate could not run".
9595
10508
  */
10509
+ /**
10510
+ * Есть ли в этом каталоге НЕЗАКОММИЧЕННЫЕ правки. Это и отличает «фича ещё в рабочем дереве»
10511
+ * (тогда `HEAD` — законная предфичевая база) от «фича уже закоммичена» (тогда `HEAD` её содержит).
10512
+ *
10513
+ * Не удалось спросить git — возвращается null, и вызывающий обязан считать положение НЕ
10514
+ * УСТАНОВЛЕННЫМ, а не выбрать удобный ответ.
10515
+ */
10516
+ /**
10517
+ * Каталоги с `SKILL.md` внутри коробки ОДНОГО пакета — то, что РЕАЛЬНО лежит на складе.
10518
+ * Возвращаются ПУТИ относительно коробки, как их объявляет манифест, а не имена.
10519
+ */
10520
+ function findSkillDirs(boxRoot) {
10521
+ const found = new Set();
10522
+ const walk = (dir, depth) => {
10523
+ if (depth > 4)
10524
+ return;
10525
+ let entries;
10526
+ try {
10527
+ entries = readdirSync(dir, { withFileTypes: true });
10528
+ }
10529
+ catch {
10530
+ return;
10531
+ }
10532
+ for (const e of entries) {
10533
+ if (!e.isDirectory() || e.name === 'node_modules' || e.name === 'dist' || e.name.startsWith('.'))
10534
+ continue;
10535
+ const full = join(dir, e.name);
10536
+ if (existsSync(join(full, 'SKILL.md')))
10537
+ found.add(relative(boxRoot, full));
10538
+ walk(full, depth + 1);
10539
+ }
10540
+ };
10541
+ walk(boxRoot, 0);
10542
+ return [...found];
10543
+ }
10544
+ function hasUncommittedChangesIn(repoRoot, dir) {
10545
+ try {
10546
+ const out = execSync(`git status --porcelain -- ${JSON.stringify(dir)}`, { cwd: repoRoot, encoding: 'utf-8' });
10547
+ return out.split('\n').some((line) => line.trim() !== '');
10548
+ }
10549
+ catch {
10550
+ return null;
10551
+ }
10552
+ }
9596
10553
  function cmdDiscriminationCheck(options, flags, cwd, write) {
9597
10554
  let repoRoot = cwd;
9598
10555
  try {
@@ -9606,13 +10563,124 @@ function cmdDiscriminationCheck(options, flags, cwd, write) {
9606
10563
  }
9607
10564
  const nameFilter = options.get('name');
9608
10565
  const propertyTests = testArg.split(',').map((s) => s.trim()).filter(Boolean).map((file) => nameFilter !== undefined && nameFilter.trim() !== '' ? { file, name: nameFilter.trim() } : { file });
9609
- const baseRef = options.get('base') ?? 'HEAD';
10566
+ /**
10567
+ * УМОЛЧАНИЕ БАЗЫ РАЗЛИЧАЕТ ДВА РАЗНЫХ ПОЛОЖЕНИЯ, потому что они и правда разные.
10568
+ *
10569
+ * В штатном ходе конвейера правка Шага 7 ЕЩЁ НЕ ЗАКОММИЧЕНА, и тогда `HEAD` — настоящая
10570
+ * предфичевая база; так конвейер и зовёт гейт (`--base HEAD`, причина записана в его тексте).
10571
+ *
10572
+ * Но если фича УЖЕ ЗАКОММИЧЕНА, `HEAD` её содержит, и гейт сравнивает фичу С САМОЙ СОБОЙ.
10573
+ * ИЗМЕРЕНО 2026-09-04: такой прогон дал уверенное `NON_DISCRIMINATING` с советом «усилить тест»
10574
+ * на тестах, которые дискриминируют; тот же прогон с верной базой дал `DISCRIMINATES_VIA_ERROR`.
10575
+ * Находка выглядела как утверждение О ТЕСТАХ, и автор пошёл бы чинить исправное.
10576
+ *
10577
+ * Различить эти положения можно ДЕТЕРМИНИРОВАННО: есть ли незакоммиченные правки в пакете, чьи
10578
+ * тесты названы. Есть — `HEAD` законен и используется как раньше. Нет — база не установлена, и
10579
+ * гейт ОТКАЗЫВАЕТСЯ (exit 3), вместо того чтобы измерять относительно самого себя.
10580
+ */
10581
+ const explicitBase = options.get('base');
9610
10582
  const runnerOpt = options.get('runner');
9611
10583
  // R11: a hung runner is a loud non-answer, never a pass. Same default + parse shape as mutation-gate.
9612
10584
  const timeoutOpt = Number(options.get('timeout') ?? '300000');
9613
10585
  const timeoutMs = Number.isFinite(timeoutOpt) && timeoutOpt > 0 ? timeoutOpt : 300000;
9614
- const plan = planDiscriminationCheck(runnerOpt !== undefined ? { baseRef, propertyTests, runner: runnerOpt } : { baseRef, propertyTests });
10586
+ // Runner honesty (feature instrument-honesty, ADR-001): the runner is selected from the TARGET
10587
+ // package's own scripts.test, never from a global default. The package dir is the nearest
10588
+ // ancestor of the FIRST named test that carries a package.json — walked here, at the seam,
10589
+ // because the pure half deliberately takes the script text as data and never touches the fs.
10590
+ let packageTestScript = null;
10591
+ let packageDevDependencies = [];
10592
+ let packageDir = repoRoot;
10593
+ {
10594
+ const firstTest = propertyTests[0]?.file;
10595
+ // QE-1 (instrument-honesty, HIGH): this walk runs on the RAW --test argument, BEFORE the
10596
+ // engine's sanitation — a `../` traversal made it read an arbitrary package.json OUTSIDE the
10597
+ // repo and echo its scripts.test verbatim into the JSON output (MEASURED with a planted
10598
+ // marker file). Containment first: a start point outside the repo root never gets walked,
10599
+ // the script stays null, and the engine's own path sanitation then refuses the test path.
10600
+ const walkStart = firstTest !== undefined ? resolve(cwd, dirname(firstTest)) : undefined;
10601
+ if (firstTest !== undefined && walkStart !== undefined
10602
+ && (walkStart === resolve(repoRoot) || walkStart.startsWith(resolve(repoRoot) + sep))) {
10603
+ let probe = walkStart;
10604
+ // walk up to the repo root looking for package.json (bounded by the fs root either way)
10605
+ for (;;) {
10606
+ if (existsSync(join(probe, 'package.json'))) {
10607
+ packageDir = probe;
10608
+ break;
10609
+ }
10610
+ const parent = dirname(probe);
10611
+ if (parent === probe || probe === repoRoot)
10612
+ break;
10613
+ probe = parent;
10614
+ }
10615
+ try {
10616
+ const pkg = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf-8'));
10617
+ packageTestScript = typeof pkg.scripts?.test === 'string' ? pkg.scripts.test : null;
10618
+ packageDevDependencies = Object.keys(pkg.devDependencies ?? {});
10619
+ }
10620
+ catch { /* unreadable package.json → selection falls through to the honest REFUSE */ }
10621
+ }
10622
+ }
10623
+ // The pure half's path sanitation expects a REPO-RELATIVE package dir ('.'-rooted), not an
10624
+ // absolute one — an absolute path is refused as unsafe-package-dir by design.
10625
+ const packageDirRel = relative(repoRoot, packageDir) || '.';
10626
+ // Умолчание базы решается ЗДЕСЬ, потому что только здесь известен пакет, чьи тесты названы.
10627
+ let baseRef;
10628
+ if (explicitBase !== undefined && explicitBase.trim() !== '') {
10629
+ baseRef = explicitBase.trim();
10630
+ }
10631
+ else {
10632
+ const dirty = hasUncommittedChangesIn(repoRoot, packageDirRel);
10633
+ if (dirty !== true) {
10634
+ write('dz discrimination-check: NOT-ESTABLISHED — предфичевая база не установлена.');
10635
+ write(dirty === null
10636
+ ? ` Не удалось спросить git о состоянии ${packageDirRel}; выбирать удобный ответ вместо этого нельзя.`
10637
+ : ` В ${packageDirRel} нет незакоммиченных правок, значит фича УЖЕ в HEAD, и сравнение шло бы с самой собой.`);
10638
+ write(' Гейт сравнивает поведение ДО и ПОСЛЕ фичи; без базы сравнивать не с чем, а HEAD здесь');
10639
+ write(' дал бы уверенное NON_DISCRIMINATING на исправных тестах (ИЗМЕРЕНО 2026-09-04).');
10640
+ write(' Передайте --base <коммит перед фичей>, например `<sha коммита фичи>^`.');
10641
+ return 3;
10642
+ }
10643
+ // Штатный ход конвейера: правка Шага 7 ещё в рабочем дереве, HEAD — настоящая предфичевая база.
10644
+ baseRef = 'HEAD';
10645
+ write(`dz discrimination-check: база не задана; в ${packageDirRel} есть незакоммиченные правки, беру HEAD как предфичевую базу.`);
10646
+ }
10647
+ // Провенанс базы едет в квитанцию вместе с самой базой: «явно указано» — утверждение о действии
10648
+ // человека, и писать его для HEAD, выбранного инструментом, значит подделывать происхождение
10649
+ // доказательства, на которое сошлются позже.
10650
+ const baseRefSupplied = explicitBase !== undefined && explicitBase.trim() !== '';
10651
+ const planInput = runnerOpt !== undefined
10652
+ ? { baseRef, baseRefSupplied, propertyTests, runner: runnerOpt, packageTestScript, packageDevDependencies, packageDir: packageDirRel }
10653
+ : { baseRef, baseRefSupplied, propertyTests, packageTestScript, packageDevDependencies, packageDir: packageDirRel };
10654
+ const plan = planDiscriminationCheck(planInput);
9615
10655
  if (!plan.runnable) {
10656
+ // QE-2 (instrument-honesty, MEDIUM): a runner REFUSE used to be reported through the generic
10657
+ // "no property test to check"/map-a-test framing — the operator-facing surface re-created the
10658
+ // exact "instrument gap misread as test gap" class ADR-001 names as the reason three duplicate
10659
+ // backlog entries existed. The plan's own named reason is the verdict; the generic classify
10660
+ // stays only for the genuinely-empty-target case.
10661
+ const runnerRefusal = typeof plan.reason === 'string' && plan.reason.startsWith('unsupported-runner');
10662
+ if (runnerRefusal) {
10663
+ const refusal = {
10664
+ aggregate: 'CANNOT_ISOLATE',
10665
+ measurementValid: false,
10666
+ primaryAction: plan.primaryAction ?? 'fix-runner-invocation',
10667
+ finding: {
10668
+ severity: 'high',
10669
+ verdict: 'CANNOT_ISOLATE',
10670
+ files: plan.targets.map((t) => t.file),
10671
+ detail: `runner refused: ${plan.reason} — the INSTRUMENT could not run, nothing was measured; `
10672
+ + `declare scripts.test in the target package (or pass --runner) and re-run. `
10673
+ + `This is NOT a statement about the tests.`,
10674
+ },
10675
+ };
10676
+ if (flags.has('json')) {
10677
+ write(JSON.stringify({ plan, results: [], perTest: [], ...refusal }, null, 2));
10678
+ return 0;
10679
+ }
10680
+ write(`discrimination-check: REFUSED (${plan.reason})`);
10681
+ write(` → ${refusal.finding.detail}`);
10682
+ return 0;
10683
+ }
9616
10684
  // No safe target to run → this is the existing "property untested" finding (empty propertyTests classify).
9617
10685
  const result = classifyDiscrimination({ propertyTests: [], results: [] });
9618
10686
  if (flags.has('json')) {
@@ -9717,10 +10785,24 @@ function cmdDiscriminationCheck(options, flags, cwd, write) {
9717
10785
  }
9718
10786
  // t.file + t.name already passed the engine's strict sanitation (no quotes/metacharacters/leading-dash);
9719
10787
  // still quote + `--` so a path can never be read as a runner option or split a word.
10788
+ // Runner honesty (ADR-001): the run executes FROM the target package dir with a
10789
+ // package-relative path — a root-cwd `npx vitest run packages/...` loads the ROOT config
10790
+ // (none) and reds unclassifiably, which is exactly the CANNOT_ISOLATE artifact this
10791
+ // feature removes. The plan's own commands encode the same cd; this body mirrors it.
10792
+ const pkgRel = plan.packageDir === '.' ? '' : plan.packageDir;
10793
+ const fileInPkg = pkgRel !== '' && t.file.startsWith(pkgRel + '/') ? t.file.slice(pkgRel.length + 1) : t.file;
10794
+ const execDirBase = pkgRel === '' ? worktree : join(worktree, pkgRel);
10795
+ const execDirTip = pkgRel === '' ? repoRoot : join(repoRoot, pkgRel);
9720
10796
  const nameArg = t.name ? ` -t '${t.name}'` : '';
9721
- const cmd = `${runner}${nameArg} -- '${t.file}'`;
9722
- const base = runCapturedTest(cmd, worktree, timeoutMs);
9723
- const evidence = classifyExecutionEvidence(base.output, base.exitCode, t.file);
10797
+ // NO `--` before the path: MEASURED 2026-09-02 — `npx vitest run -- 'file'` IGNORES the
10798
+ // filter and runs the whole suite (5269 tests), which is the exact whole-repo artifact
10799
+ // this feature removes (QE ha-intake-archive F5). The path is engine-sanitized (no
10800
+ // leading dash, no metacharacters), so it can never be read as an option.
10801
+ const cmd = `${runner}${nameArg} '${fileInPkg}'`;
10802
+ const base = runCapturedTest(cmd, execDirBase, timeoutMs);
10803
+ // The classifier's targetSeen is a substring probe: the run now prints PACKAGE-relative
10804
+ // paths, so it must be probed with the same form, or every hit reads as target-unseen.
10805
+ const evidence = classifyExecutionEvidence(base.output, base.exitCode, fileInPkg);
9724
10806
  const outcome = discriminationOutcomeOf(base.exitCode, evidence);
9725
10807
  const row = t.name !== undefined
9726
10808
  ? { file: t.file, name: t.name, outcome, evidence }
@@ -9731,8 +10813,8 @@ function cmdDiscriminationCheck(options, flags, cwd, write) {
9731
10813
  // base rows per the matrix; running it is cheap and only ever on an already-broken path.
9732
10814
  // Do NOT "simplify" this to evidenced-error-only — that silently breaks Confirmation 17.
9733
10815
  if (base.exitCode !== null && base.exitCode !== 0 && evidence.failureKind !== 'assertions') {
9734
- const tip = runCapturedTest(cmd, repoRoot, timeoutMs);
9735
- const tipEvidence = classifyExecutionEvidence(tip.output, tip.exitCode, t.file);
10816
+ const tip = runCapturedTest(cmd, execDirTip, timeoutMs);
10817
+ const tipEvidence = classifyExecutionEvidence(tip.output, tip.exitCode, fileInPkg);
9736
10818
  row['tipOutcome'] = discriminationOutcomeOf(tip.exitCode, tipEvidence);
9737
10819
  row['tipEvidence'] = tipEvidence;
9738
10820
  // R15, named honestly: the base run is isolated in a worktree, but the tip runs in the LIVE
@@ -9841,51 +10923,6 @@ function readTipTreeConditions(repoRoot) {
9841
10923
  function nameFor(t, outcome) {
9842
10924
  return t.name !== undefined ? { file: t.file, name: t.name, outcome } : { file: t.file, outcome };
9843
10925
  }
9844
- /**
9845
- * `dz mutation-gate` — the mutation gate (feature ha-mutation-gate, SPEC at
9846
- * features/ha-mutation-gate/SPEC.md). A green test proves the code works; it does NOT prove the
9847
- * test would notice the protection being DELETED. For each entry in a declarative registry this
9848
- * gate applies the entry's exact {find, replace} mutation to a SCRATCH COPY of the package, runs
9849
- * the package's test command there, and REQUIRES a non-zero exit (red). All logic is in
9850
- * harness-core's pure engine (mutation-gate.ts); this is the I/O executor.
9851
- *
9852
- * --package <dir> the package to gate (default: cwd; must contain package.json)
9853
- * --registry <file> registry path (default: <pkg>/test/mutation-registry.json, then
9854
- * <pkg>/mutation-registry.json)
9855
- * --test-cmd '<cmd>' suite command run in the scratch copy (default: registry.testCommand,
9856
- * then `npm test`)
9857
- * --only <id[,id]> run a subset of entries (unknown id = usage error, never a silent skip)
9858
- * --timeout <ms> per-suite-run timeout (default 300000). A timed-out run is INCONCLUSIVE —
9859
- * a FAILURE, never a pass.
9860
- * --rebaseline <m> route-b guard mode: 'per-entry' (default — every red entry re-runs the
9861
- * suite on the restored tree; not green ⇒ that entry is INCONCLUSIVE) or
9862
- * 'final' (one re-run at the end; not green ⇒ every red-based verdict is
9863
- * downgraded). The gate's PROVEN now means the redness was ATTRIBUTABLE.
9864
- * --keep-scratch keep the scratch copy for inspection (default: removed in a finally)
9865
- * --json machine contract {packageDir, registryPath, testCommand, rebaselineMode,
9866
- * baseline, results, summary, warnings, exitCode}
9867
- *
9868
- * The four rules (SPEC §"Four rules") and where each is enforced:
9869
- * 1. does-not-apply = FAILURE → core classifyMutationOutcome (occurrences !== 1 ⇒ NOT_APPLIED);
9870
- * 2. green suite = FAILURE → core (exit 0 ⇒ UNDEFENDED, names the property);
9871
- * 3. never mutate the working tree → HERE: every write targets the scratch copy under tmpdir();
9872
- * the repo tree is opened read-only, and a crashed run leaves at worst a stale tmp dir;
9873
- * 4. the gate's own discrimination proof → harness-cli/test/fixtures/mutation-gate-undefended
9874
- * (the gate MUST fail on it; asserted by test/mutation-gate-cli.test.ts).
9875
- *
9876
- * Exit codes: 0 every entry PROVEN · 1 the gate ran and failed (undefended / not-applied /
9877
- * below-min / unparseable / load-fatal / over-failing / inconclusive entry) · 2 usage or setup
9878
- * error (missing registry, red BASELINE — a red unmutated copy proves nothing and must not be
9879
- * read as a mutation result — or an entry whose file RESOLVES outside the scratch copy: a
9880
- * symlink escape is refused before anything is written, SPEC rule 3).
9881
- */
9882
- /**
9883
- * Route-a guard for `dz mutation-gate`: parse-check a MUTATED file as its own language BEFORE the
9884
- * suite runs. A registry mutation must delete the protection while keeping the file loadable — a
9885
- * file that no longer parses kills the whole suite (or its import chain), and that STRUCTURAL
9886
- * redness says nothing about the named protection. Returns `{error}` when a parser ran and the
9887
- * text does not parse; `{skipped}` (reported loudly, never silently) when no parser is available.
9888
- */
9889
10926
  function parseCheckMutatedFile(absFile, text) {
9890
10927
  const ext = extname(absFile).toLowerCase();
9891
10928
  try {
@@ -9916,18 +10953,36 @@ function parseCheckMutatedFile(absFile, text) {
9916
10953
  }
9917
10954
  }
9918
10955
  if (ext === '.js' || ext === '.cjs' || ext === '.mjs' || ext === '') {
9919
- try {
9920
- // `node --check` on the file IN PLACE, so the nearest package.json decides the module goal.
9921
- execFileSync(process.execPath, ['--check', absFile], { stdio: 'pipe' });
9922
- return {};
9923
- }
9924
- catch (e) {
9925
- const err = e;
9926
- const stderrLines = String(err.stderr ?? '').split('\n').map((l) => l.trim()).filter((l) => l !== '');
9927
- // prefer the actual `SyntaxError: …` line over node's trailing version footer.
9928
- const msg = [...stderrLines].reverse().find((l) => l.includes('Error')) ?? stderrLines.at(-1) ?? 'node --check failed';
9929
- return { error: msg.slice(0, 200) };
10956
+ const checked = runWithOneInternalRetry(() => {
10957
+ try {
10958
+ // `node --check` on the file IN PLACE, so the nearest package.json decides the module goal.
10959
+ execFileSync(process.execPath, ['--check', absFile], { stdio: 'pipe' });
10960
+ return {};
10961
+ }
10962
+ catch (e) {
10963
+ const err = e;
10964
+ // A launched parser that exits non-zero with a SyntaxError is a parse verdict. A child
10965
+ // launch/internal error (EPERM, ENOENT, Node's thrown internal) is runner infrastructure
10966
+ // and must take the bounded retry → INCONCLUSIVE route instead of masquerading as bad JS.
10967
+ if (typeof err.code === 'string' || typeof err.status !== 'number')
10968
+ throw e;
10969
+ const stderrLines = String(err.stderr ?? '').split('\n').map((line) => line.trim()).filter((line) => line !== '');
10970
+ const msg = [...stderrLines].reverse().find((line) => line.includes('Error'))
10971
+ ?? stderrLines.at(-1)
10972
+ ?? err.message
10973
+ ?? 'node --check failed';
10974
+ return { error: msg.slice(0, 200) };
10975
+ }
10976
+ });
10977
+ if (checked.value === null) {
10978
+ return {
10979
+ internalFailureReason: checked.failureReason ?? 'runner-internal-error: persistent after 2/2 attempts',
10980
+ internalAttempts: checked.attempts,
10981
+ };
9930
10982
  }
10983
+ return checked.internalRetries === 1
10984
+ ? { ...checked.value, internalAttempts: checked.attempts }
10985
+ : checked.value;
9931
10986
  }
9932
10987
  return { skipped: `no parser for '${ext}' files — parse-check unavailable` };
9933
10988
  }
@@ -9935,7 +10990,7 @@ function parseCheckMutatedFile(absFile, text) {
9935
10990
  return { skipped: `parse-check errored: ${String(e.message).slice(0, 120)}` };
9936
10991
  }
9937
10992
  }
9938
- function cmdMutationGate(options, flags, cwd, write) {
10993
+ function cmdMutationGate(options, flags, cwd, write, injectedRunner) {
9939
10994
  const json = flags.has('json');
9940
10995
  const fail = (what) => {
9941
10996
  write(json ? JSON.stringify({ error: what, exitCode: 2 }) : `dz mutation-gate: ${what}`);
@@ -10003,6 +11058,7 @@ function cmdMutationGate(options, flags, cwd, write) {
10003
11058
  const results = [];
10004
11059
  const observations = [];
10005
11060
  const warnings = [];
11061
+ const internalRetries = [];
10006
11062
  let baseline;
10007
11063
  try {
10008
11064
  if (gitTop !== null && gitTop !== pkgDir && resolve(pkgDir).startsWith(resolve(gitTop) + sep)) {
@@ -10046,7 +11102,10 @@ function cmdMutationGate(options, flags, cwd, write) {
10046
11102
  // write below is asserted to RESOLVE inside this root before it happens.
10047
11103
  const realScratchRoot = realpathSync(copyDir);
10048
11104
  const requireCompletionReceipt = parsed.registry.requireCompletionReceipt === true;
10049
- const runSuite = () => {
11105
+ const invokeSuite = () => {
11106
+ if (injectedRunner !== undefined) {
11107
+ return injectedRunner(testCmd, { cwd: copyDir, timeoutMs: timeout });
11108
+ }
10050
11109
  const run = spawnSync(testCmd, {
10051
11110
  cwd: copyDir,
10052
11111
  shell: true,
@@ -10058,6 +11117,12 @@ function cmdMutationGate(options, flags, cwd, write) {
10058
11117
  const errorCode = run.error && 'code' in run.error && typeof run.error.code === 'string'
10059
11118
  ? run.error.code
10060
11119
  : undefined;
11120
+ // Node may populate both `error` and a numeric `status` for an internal spawn failure. The
11121
+ // error wins except for the two already-named resource observations: a status alongside
11122
+ // EPERM/Unreachable-code is not a suite verdict and takes the one-retry internal-error path.
11123
+ if (run.error !== undefined && errorCode !== 'ETIMEDOUT' && errorCode !== 'ENOBUFS') {
11124
+ throw run.error;
11125
+ }
10061
11126
  const signal = typeof run.signal === 'string' ? run.signal : undefined;
10062
11127
  let failureReason;
10063
11128
  if (typeof run.status !== 'number') {
@@ -10074,25 +11139,59 @@ function cmdMutationGate(options, flags, cwd, write) {
10074
11139
  }
10075
11140
  return {
10076
11141
  exitCode: typeof run.status === 'number' ? run.status : null,
10077
- // Receipt markers may be on stderr. Preserve both streams even on exit 0; stdout-only
10078
- // collection would silently lose a green-run marker.
10079
11142
  output: `${String(run.stdout ?? '')}\n${String(run.stderr ?? '')}`,
10080
11143
  ...(failureReason !== undefined ? { failureReason } : {}),
10081
11144
  };
10082
11145
  };
11146
+ const runSuite = (phase, entryId) => {
11147
+ const retried = runWithOneInternalRetry(invokeSuite);
11148
+ const loggedAttempts = retried.attempts.map((attempt) => {
11149
+ if (attempt.outcome !== 'completed' || retried.value === null)
11150
+ return attempt;
11151
+ const outcome = retried.value.exitCode === null
11152
+ ? `no exit code (${retried.value.failureReason ?? 'unnamed failure'})`
11153
+ : `exit ${retried.value.exitCode}`;
11154
+ return { ...attempt, detail: `attempt ${attempt.attempt}: completed — ${outcome}` };
11155
+ });
11156
+ if (retried.internalRetries === 1) {
11157
+ const record = entryId === undefined
11158
+ ? { phase, attempts: loggedAttempts }
11159
+ : { phase, entryId, attempts: loggedAttempts };
11160
+ internalRetries.push(record);
11161
+ if (!json)
11162
+ write(`mutation-gate: internal retry — ${loggedAttempts.map((attempt) => attempt.detail).join('; ')}`);
11163
+ }
11164
+ const internalAttemptLog = retried.internalRetries === 1
11165
+ ? loggedAttempts.map((attempt) => attempt.detail).join('; ')
11166
+ : undefined;
11167
+ if (retried.value !== null) {
11168
+ return {
11169
+ ...retried.value,
11170
+ ...(internalAttemptLog !== undefined ? { internalAttemptLog } : {}),
11171
+ };
11172
+ }
11173
+ return {
11174
+ exitCode: null,
11175
+ output: '',
11176
+ failureReason: retried.failureReason ?? 'runner-internal-error: persistent after 2/2 attempts',
11177
+ ...(internalAttemptLog !== undefined ? { internalAttemptLog } : {}),
11178
+ };
11179
+ };
10083
11180
  // Baseline BEFORE any mutation: a red copy proves nothing, and reading it as a mutation
10084
11181
  // result would be this gate shipping the defect class it exists to catch.
10085
11182
  if (!json)
10086
11183
  write(`mutation-gate: baseline suite in scratch copy of ${pkgDir} …`);
10087
- const base = runSuite();
10088
- baseline = classifyBaseline(base.exitCode, base.failureReason);
11184
+ const base = runSuite('baseline');
11185
+ baseline = classifyBaseline(base.exitCode, base.failureReason, base.exitCode !== null && base.exitCode !== 0
11186
+ ? attributeBaselineRedness(base.output, entries.map((entry) => entry.file))
11187
+ : undefined);
10089
11188
  if (!baseline.ok) {
10090
11189
  if (json) {
10091
- write(JSON.stringify({ packageDir: pkgDir, registryPath, testCommand: testCmd, baseline, results: [], exitCode: 2 }, null, 2));
10092
- return 2;
11190
+ write(JSON.stringify({ packageDir: pkgDir, registryPath, testCommand: testCmd, baseline, results: [], internalRetries, exitCode: 1 }, null, 2));
11191
+ return 1;
10093
11192
  }
10094
11193
  write(renderMutationReport([], baseline, pkgDir));
10095
- return 2;
11194
+ return 1;
10096
11195
  }
10097
11196
  for (const entry of entries) {
10098
11197
  const filePath = join(copyDir, entry.file);
@@ -10135,6 +11234,8 @@ function cmdMutationGate(options, flags, cwd, write) {
10135
11234
  write(`mutation-gate: ${entry.id} — mutating ${entry.file}, running suite …`);
10136
11235
  let run = null;
10137
11236
  let parseError;
11237
+ let parseInternalFailureReason;
11238
+ let parseInternalAttemptLog;
10138
11239
  try {
10139
11240
  writeFileSync(filePath, applied.text);
10140
11241
  // Route-a guard: the mutated file must still PARSE — a load failure reddens the whole
@@ -10145,11 +11246,18 @@ function cmdMutationGate(options, flags, cwd, write) {
10145
11246
  if (!json)
10146
11247
  write(`mutation-gate: WARNING ${entry.id}: parse-check skipped — ${check.skipped}`);
10147
11248
  }
11249
+ if (check.internalAttempts !== undefined) {
11250
+ internalRetries.push({ phase: 'parse-check', entryId: entry.id, attempts: check.internalAttempts });
11251
+ parseInternalAttemptLog = check.internalAttempts.map((attempt) => attempt.detail).join('; ');
11252
+ if (!json)
11253
+ write(`mutation-gate: internal retry — ${parseInternalAttemptLog}`);
11254
+ }
11255
+ parseInternalFailureReason = check.internalFailureReason;
10148
11256
  if (check.error !== undefined) {
10149
11257
  parseError = check.error; // no suite run: the verdict is MUTATION_UNPARSEABLE regardless
10150
11258
  }
10151
- else {
10152
- run = runSuite();
11259
+ else if (parseInternalFailureReason === undefined) {
11260
+ run = runSuite('mutation', entry.id);
10153
11261
  }
10154
11262
  }
10155
11263
  finally {
@@ -10185,14 +11293,24 @@ function cmdMutationGate(options, flags, cwd, write) {
10185
11293
  // those verdicts outrank the rebaseline check, so the extra suite run would buy nothing.
10186
11294
  let rebaselineExitCode;
10187
11295
  let rebaselineFailureReason;
11296
+ let rebaselineAttribution;
11297
+ let rebaselineInternalAttemptLog;
10188
11298
  if (rebaselineMode === 'per-entry' && run !== null && run.exitCode !== null && run.exitCode !== 0
10189
11299
  && fileLoadFailure === undefined && outputUnrecognised === undefined && receiptMismatch === undefined) {
10190
11300
  if (!json)
10191
11301
  write(`mutation-gate: ${entry.id} — re-baselining the restored tree …`);
10192
- const rebaselineRun = runSuite();
11302
+ const rebaselineRun = runSuite('rebaseline', entry.id);
10193
11303
  rebaselineExitCode = rebaselineRun.exitCode;
10194
11304
  rebaselineFailureReason = rebaselineRun.failureReason;
11305
+ rebaselineInternalAttemptLog = rebaselineRun.internalAttemptLog;
11306
+ if (rebaselineRun.exitCode !== null && rebaselineRun.exitCode !== 0) {
11307
+ rebaselineAttribution = attributeBaselineRedness(rebaselineRun.output, entries.map((candidate) => candidate.file));
11308
+ }
10195
11309
  }
11310
+ const entryRunFailureReason = run?.failureReason ?? parseInternalFailureReason;
11311
+ const entryInternalAttemptLog = [parseInternalAttemptLog, run?.internalAttemptLog, rebaselineInternalAttemptLog]
11312
+ .filter((log) => log !== undefined)
11313
+ .join('; ');
10196
11314
  const obs = {
10197
11315
  entry,
10198
11316
  occurrences: 1,
@@ -10202,9 +11320,11 @@ function cmdMutationGate(options, flags, cwd, write) {
10202
11320
  ...(fileLoadFailure !== undefined ? { fileLoadFailure } : {}),
10203
11321
  ...(outputUnrecognised !== undefined ? { outputUnrecognised } : {}),
10204
11322
  ...(receiptMismatch !== undefined ? { receiptMismatch } : {}),
10205
- ...(run?.failureReason !== undefined ? { runFailureReason: run.failureReason } : {}),
11323
+ ...(entryRunFailureReason !== undefined ? { runFailureReason: entryRunFailureReason } : {}),
11324
+ ...(entryInternalAttemptLog !== '' ? { internalAttemptLog: entryInternalAttemptLog } : {}),
10206
11325
  ...(rebaselineExitCode !== undefined ? { rebaselineExitCode } : {}),
10207
11326
  ...(rebaselineFailureReason !== undefined ? { rebaselineFailureReason } : {}),
11327
+ ...(rebaselineAttribution !== undefined ? { rebaselineAttribution } : {}),
10208
11328
  };
10209
11329
  observations.push(obs);
10210
11330
  results.push(classifyMutationOutcome(obs));
@@ -10217,7 +11337,7 @@ function cmdMutationGate(options, flags, cwd, write) {
10217
11337
  if (rebaselineMode === 'final') {
10218
11338
  if (!json)
10219
11339
  write('mutation-gate: final re-baseline of the restored tree …');
10220
- const finalRun = runSuite();
11340
+ const finalRun = runSuite('final-rebaseline');
10221
11341
  const finalExit = finalRun.exitCode;
10222
11342
  if (finalExit !== 0) {
10223
11343
  const what = finalExit === null ? `no exit code: ${finalRun.failureReason ?? 'unknown timeout / spawn failure'}` : `exit ${finalExit}`;
@@ -10228,6 +11348,12 @@ function cmdMutationGate(options, flags, cwd, write) {
10228
11348
  ...obs,
10229
11349
  rebaselineExitCode: finalExit,
10230
11350
  ...(finalRun.failureReason !== undefined ? { rebaselineFailureReason: finalRun.failureReason } : {}),
11351
+ ...(finalRun.internalAttemptLog !== undefined
11352
+ ? { internalAttemptLog: [obs.internalAttemptLog, finalRun.internalAttemptLog].filter((log) => log !== undefined).join('; ') }
11353
+ : {}),
11354
+ ...(finalExit !== null && finalExit !== 0
11355
+ ? { rebaselineAttribution: attributeBaselineRedness(finalRun.output, entries.map((entry) => entry.file)) }
11356
+ : {}),
10231
11357
  }));
10232
11358
  results.length = 0;
10233
11359
  results.push(...reclassified);
@@ -10247,7 +11373,7 @@ function cmdMutationGate(options, flags, cwd, write) {
10247
11373
  }
10248
11374
  const exitCode = mutationGateExitCode(results, baseline.ok);
10249
11375
  if (json) {
10250
- write(JSON.stringify({ packageDir: pkgDir, registryPath, testCommand: testCmd, rebaselineMode, baseline, results, summary: summarizeMutationResults(results), warnings, exitCode }, null, 2));
11376
+ write(JSON.stringify({ packageDir: pkgDir, registryPath, testCommand: testCmd, rebaselineMode, baseline, results, summary: summarizeMutationResults(results), warnings, internalRetries, exitCode }, null, 2));
10251
11377
  return exitCode;
10252
11378
  }
10253
11379
  write(renderMutationReport(results, baseline, pkgDir));
@@ -11507,12 +12633,30 @@ function nameCheckScan(repoRoot) {
11507
12633
  // Command names come from the dispatcher AND from the help block: a name that dispatches but
11508
12634
  // is undocumented is still taken, and so is the reverse.
11509
12635
  if (f.name === 'cli.ts') {
11510
- for (const c of dispatchedCommandsIn(text))
11511
- commands.add(c);
11512
- const help = /^\s{2}dz ([a-z][a-z0-9-]*)/gm;
11513
- for (let m = help.exec(text); m !== null; m = help.exec(text))
11514
- if (m[1] !== undefined)
11515
- commands.add(m[1]);
12636
+ // ONE enumeration, every consumer derives (ADR-001, feature command-count-triad). This
12637
+ // scan's question is "is the name TAKEN?", so taken = dispatched ∪ documented, which is
12638
+ // legitimately LARGER than the canonical command count — but it must be the SAME parse
12639
+ // the layer-1 parity test uses, not a second private regex that agrees by coincidence.
12640
+ // The any-indent `dispatchedCommandsIn` fallback stays for a cli.ts WITHOUT a main
12641
+ // `switch (command)` (none in this workspace today): a partial sweep would answer "free"
12642
+ // about a taken name, and that is the one answer this command may never give.
12643
+ let mainSwitchParsed = false;
12644
+ try {
12645
+ for (const c of dispatchedCommands(text))
12646
+ commands.add(c);
12647
+ for (const c of documentedCommands(text))
12648
+ commands.add(c);
12649
+ mainSwitchParsed = true;
12650
+ }
12651
+ catch { /* no main switch here — fall back to the broad regexes below */ }
12652
+ if (!mainSwitchParsed) {
12653
+ for (const c of dispatchedCommandsIn(text))
12654
+ commands.add(c);
12655
+ const help = /^\s{2}dz ([a-z][a-z0-9-]*)/gm;
12656
+ for (let m = help.exec(text); m !== null; m = help.exec(text))
12657
+ if (m[1] !== undefined)
12658
+ commands.add(m[1]);
12659
+ }
11516
12660
  }
11517
12661
  }
11518
12662
  }
@@ -11527,6 +12671,75 @@ function nameCheckScan(repoRoot) {
11527
12671
  return { commands: new Set(), modules: new Map(), exports: new Map(), scanFailed: true };
11528
12672
  return { commands, modules, exports: exportsFound, scanned: { packages, files, exports: exportsFound.size, commands: commands.size } };
11529
12673
  }
12674
+ /**
12675
+ * `dz brief-check <файл>` — проверить бриф роя на контракт вывода (ADR-001 swarm-brief-output-contract).
12676
+ *
12677
+ * Разбирает объявления брифа как ДАННЫЕ и отказывает поимённо: «бриф неверен» не говорит автору,
12678
+ * что чинить, поэтому каждое нарушение называет ключ и причину.
12679
+ *
12680
+ * ЧЕСТНЫЙ ПРЕДЕЛ печатается ВМЕСТЕ С ЗЕЛЁНЫМ ответом: проверено, что бриф ОБЪЯВИЛ каталог и
12681
+ * единицы, а не что агент им последует. Зелёная проверка, читаемая как гарантия поведения, хуже
12682
+ * её отсутствия.
12683
+ */
12684
+ function cmdBriefCheck(options, flags, cwd, write) {
12685
+ const json = flags.has('json');
12686
+ /**
12687
+ * ВЕТКА «ПРОВЕРИТЬ НЕ УДАЛОСЬ» ТОЖЕ ОБЯЗАНА ОТВЕТИТЬ JSON-ом (находка 11).
12688
+ *
12689
+ * `--json` печатал человеческую строку на несуществующем файле, и потребитель, читающий вывод как
12690
+ * JSON, получал ошибку разбора вместо структурного «не проверено». Признак `checked` — тот же
12691
+ * трихотомический вердикт, что и коды выхода, только для машины: не «бриф плох», а «мы про него
12692
+ * ничего не установили».
12693
+ */
12694
+ const unchecked = (reason, detail) => {
12695
+ if (json)
12696
+ write(JSON.stringify({ ok: false, checked: false, reason, detail }));
12697
+ else
12698
+ write(detail);
12699
+ return 2;
12700
+ };
12701
+ // Только позиционный аргумент: `--file` был необъявленным псевдонимом, и страж дрейфа флагов
12702
+ // справедливо на него указал — лишняя поверхность, которой нет в справке.
12703
+ const file = options.get('_positional_0');
12704
+ if (file === undefined || file.trim() === '') {
12705
+ const code = unchecked('no-file', 'dz brief-check: name the brief file — dz brief-check <file> [--json]');
12706
+ if (!json)
12707
+ write(' A brief must declare: ' + SWARM_BRIEF_CONTRACT.map((c) => c.key).join(', '));
12708
+ return code;
12709
+ }
12710
+ let text;
12711
+ try {
12712
+ text = readFileSync(resolve(cwd, file), 'utf-8');
12713
+ }
12714
+ catch {
12715
+ // Нечитаемый файл — НЕ «бриф плох»: мы про него ничего не установили. Отдельный код выхода,
12716
+ // чтобы отказ прибора не смешивался с отказом брифа.
12717
+ return unchecked('unreadable', `dz brief-check: cannot read ${visibleText(file)}`);
12718
+ }
12719
+ const result = checkSwarmBrief(text);
12720
+ if (json) {
12721
+ // `checked: true` — вторая половина того же различителя: потребитель отличает «проверено и
12722
+ // отвергнуто» от «проверить не удалось» полем, а не отсутствием поля.
12723
+ write(JSON.stringify({ ...result, checked: true }));
12724
+ return result.ok ? 0 : 1;
12725
+ }
12726
+ if (result.ok) {
12727
+ // ЗНАЧЕНИЯ ИЗ БРИФА ОБЕЗВРЕЖИВАЮТСЯ И В ЗЕЛЁНОЙ СТРОКЕ (находка 10). Отказ их уже обезвредил,
12728
+ // но подделывается ровно эта строка: управляющая последовательность в имени каталога стирает
12729
+ // предыдущий вывод и печатает поверх него подделку.
12730
+ write(`dz brief-check: OK — dir ${visibleText(result.outputDir ?? '')}, ${result.units.length} unit(s), assembly "${visibleText(result.assemblyUnit ?? '')}"`);
12731
+ write(' LIMIT: this verifies the brief DECLARED the contract, not that the agent will follow it —');
12732
+ write(' only comparing the directory against the unit list on an orchestrator tick can show that.');
12733
+ write(' A filled-in template and an unedited one both pass: the parse cannot tell them apart.');
12734
+ return 0;
12735
+ }
12736
+ write(`dz brief-check: REFUSED — ${result.violations.length} violation(s)`);
12737
+ // Ядро уже обезвредило значения в причинах; повтор на слое печати — не суеверие, а граница:
12738
+ // печатающий слой не обязан знать, кто именно из его источников уже почистил текст.
12739
+ for (const v of result.violations)
12740
+ write(` ${v.rule}: ${visibleText(v.detail)}`);
12741
+ return 1;
12742
+ }
11530
12743
  function cmdNameCheck(options, flags, cwd, write) {
11531
12744
  const repoRoot = resolve(options.get('project') ?? cwd);
11532
12745
  const json = flags.has('json');
@@ -12091,6 +13304,18 @@ function cmdFeatureAdrRecord(options, flags, cwd, write) {
12091
13304
  targetExists: existsSync(target),
12092
13305
  targetHasPair: flags.has('once') && existsSync(target),
12093
13306
  timestamp: new Date().toISOString(),
13307
+ // WHO ran it: `--runner <id>` when the caller knows, otherwise this host. The workflow cannot
13308
+ // supply it — it has no host inside its sandbox — so the identity is resolved here, at the one
13309
+ // seam that runs outside. hostname() can throw on an exotic setup; an unresolvable runner stays
13310
+ // ABSENT rather than becoming the string 'unknown', which would later join as if it were one.
13311
+ runnerId: (options.get('runner') ?? '').trim() !== ''
13312
+ ? (options.get('runner') ?? '').trim()
13313
+ : (() => { try {
13314
+ return hostname();
13315
+ }
13316
+ catch {
13317
+ return null;
13318
+ } })(),
12094
13319
  });
12095
13320
  if (decision.line === null)
12096
13321
  return emit(decision);
@@ -12445,12 +13670,21 @@ function cmdAmendmentCheck(options, flags, cwd, write) {
12445
13670
  // Paths in an amendment row are repo-relative, so they resolve against the repo root — not
12446
13671
  // against the feature directory, and not against wherever the caller happened to stand.
12447
13672
  const resolutions = resolveAmendments(rows, { readFile: (rel) => readOr(resolve(cwd, rel)) });
13673
+ // A document that opens `## Amendments` twice cannot have its first heading answer for the
13674
+ // rest (Codex round 6, P2). Counted per document and taken at its worst — one contradictory
13675
+ // input is enough to make the run inconclusive.
13676
+ const sectionCount = Math.max(ideation === null ? 0 : amendmentSectionCount(ideation), plan === null ? 0 : amendmentSectionCount(plan));
12448
13677
  const decision = decideAmendmentOutcome({
12449
13678
  sectionPresent,
12450
13679
  rows,
12451
13680
  resolutions,
12452
13681
  planSaysNone: plan !== null && planSaysNoAmendments(plan),
12453
13682
  missingFromPlan,
13683
+ sectionCount,
13684
+ // Fail-closed: measured on the document the rows would have come from (the plan when it has
13685
+ // a section, otherwise the ideation report).
13686
+ ambiguity: (plan !== null ? amendmentDeclarationAmbiguity(plan) : null)
13687
+ ?? (ideation !== null ? amendmentDeclarationAmbiguity(ideation) : null),
12454
13688
  });
12455
13689
  return { slug, decision, resolutions };
12456
13690
  };
@@ -13443,6 +14677,143 @@ async function cmdQeBridge(options, flags, cwd, write) {
13443
14677
  cleanupIsolated();
13444
14678
  }
13445
14679
  }
14680
+ function scoreReceiptFiles(root) {
14681
+ const featuresDir = join(root, 'features');
14682
+ let features;
14683
+ try {
14684
+ features = readdirSync(featuresDir, { withFileTypes: true });
14685
+ }
14686
+ catch {
14687
+ return [];
14688
+ }
14689
+ const receipts = [];
14690
+ for (const feature of features) {
14691
+ if (!feature.isDirectory())
14692
+ continue;
14693
+ const stateDir = join(featuresDir, feature.name, '.fa-state');
14694
+ let entries;
14695
+ try {
14696
+ if (lstatSync(stateDir).isSymbolicLink())
14697
+ continue;
14698
+ entries = readdirSync(stateDir, { withFileTypes: true });
14699
+ }
14700
+ catch {
14701
+ continue;
14702
+ }
14703
+ for (const entry of entries) {
14704
+ if (!entry.isFile())
14705
+ continue;
14706
+ const match = /^score-(.+)\.json$/.exec(entry.name);
14707
+ if (match === null || match[1] === undefined || match[1] === '')
14708
+ continue;
14709
+ const path = join(stateDir, entry.name);
14710
+ receipts.push({ path, displayPath: relative(root, path), qeHash: match[1] });
14711
+ }
14712
+ }
14713
+ return receipts.sort((a, b) => a.displayPath < b.displayPath ? -1 : a.displayPath > b.displayPath ? 1 : 0);
14714
+ }
14715
+ function scoreAggregateChainLine(text) {
14716
+ if (text === '') {
14717
+ return { line: 'chain: NOT_PRESENT — no aggregate evidence file was created', verification: null, defectAges: null };
14718
+ }
14719
+ const verification = verifyEventChainText(text);
14720
+ const defectAges = classifyChainDefects(verification, verification.lines);
14721
+ const kinds = new Map();
14722
+ for (const defect of verification.defects)
14723
+ kinds.set(defect.kind, (kinds.get(defect.kind) ?? 0) + 1);
14724
+ const kindText = [...kinds.entries()].map(([kind, count]) => `${kind}: ${count}`).join(' · ');
14725
+ const line = `chain: ${verification.ok ? 'OK' : 'FAILED'} · ${verification.chained} chained · ` +
14726
+ `${verification.resets} recorded restart(s) · before-run defects ${defectAges.beforeRun.length} · ` +
14727
+ `in-run defects ${defectAges.inRun.length} · current run ${defectAges.runRecords} record(s)` +
14728
+ (kindText === '' ? '' : ` · ${kindText}`) +
14729
+ ` — ${verification.scope}`;
14730
+ return { line, verification, defectAges };
14731
+ }
14732
+ function cmdScoreAll(options, flags, cwd, write) {
14733
+ const json = flags.has('json');
14734
+ if (options.has('slug')) {
14735
+ write(json
14736
+ ? JSON.stringify({ error: '--all and --slug are mutually exclusive', exitCode: 1 })
14737
+ : 'dz score: --all and --slug are mutually exclusive');
14738
+ return 1;
14739
+ }
14740
+ const root = resolve(cwd, options.get('project') ?? '.');
14741
+ const receiptFiles = scoreReceiptFiles(root);
14742
+ if (receiptFiles.length === 0) {
14743
+ const report = buildScoreAggregateReport([], [], 0);
14744
+ const chain = scoreAggregateChainLine('');
14745
+ if (json)
14746
+ write(JSON.stringify({ ...report, chain: null, aggregatePath: '.dz/feature-adr/scorecards.jsonl', exitCode: 0 }, null, 2));
14747
+ else {
14748
+ write(renderScoreAggregateReport(report));
14749
+ write(chain.line);
14750
+ }
14751
+ return 0;
14752
+ }
14753
+ const ts = new Date().toISOString();
14754
+ const rows = [];
14755
+ const unreadableReceipts = [];
14756
+ for (const receipt of receiptFiles) {
14757
+ try {
14758
+ rows.push(scoreReceiptToAggregateRow({
14759
+ content: readFileSync(receipt.path, 'utf8'),
14760
+ qeHash: receipt.qeHash,
14761
+ ts,
14762
+ }));
14763
+ }
14764
+ catch {
14765
+ unreadableReceipts.push(receipt.displayPath);
14766
+ }
14767
+ }
14768
+ const storeDir = join(root, '.dz', 'feature-adr');
14769
+ const aggregatePath = join(storeDir, 'scorecards.jsonl');
14770
+ let finalText = '';
14771
+ let finalRows = rows;
14772
+ let appended = 0;
14773
+ let storeError = null;
14774
+ try {
14775
+ const result = withNamedLockSync(storeDir, 'scorecards', () => {
14776
+ let existingText = '';
14777
+ try {
14778
+ existingText = readFileSync(aggregatePath, 'utf8');
14779
+ }
14780
+ catch (error) {
14781
+ if (error.code !== 'ENOENT')
14782
+ throw error;
14783
+ }
14784
+ const fresh = dedupeScoreAggregateRows(rows, readScoreAggregateRows(existingText));
14785
+ const appendText = appendChainedLines(fresh, readTailInfo(existingText));
14786
+ if (appendText !== '')
14787
+ appendFileSync(aggregatePath, appendText, { encoding: 'utf8', mode: 0o600 });
14788
+ const settledText = existingText + appendText;
14789
+ return { text: settledText, rows: readScoreAggregateRows(settledText), appended: fresh.length };
14790
+ });
14791
+ finalText = result.text;
14792
+ finalRows = result.rows;
14793
+ appended = result.appended;
14794
+ }
14795
+ catch (error) {
14796
+ storeError = error instanceof Error ? error.message : String(error);
14797
+ }
14798
+ const report = buildScoreAggregateReport(finalRows, unreadableReceipts, appended);
14799
+ const chain = scoreAggregateChainLine(finalText);
14800
+ if (json) {
14801
+ write(JSON.stringify({
14802
+ ...report,
14803
+ aggregatePath: '.dz/feature-adr/scorecards.jsonl',
14804
+ chain: chain.verification === null ? null : { verification: chain.verification, defectAges: chain.defectAges },
14805
+ storeError,
14806
+ exitCode: 0,
14807
+ }, null, 2));
14808
+ }
14809
+ else {
14810
+ write(renderScoreAggregateReport(report));
14811
+ write(chain.line);
14812
+ if (storeError !== null)
14813
+ write(`store error (nothing was claimed appended): ${storeError}`);
14814
+ }
14815
+ return 0;
14816
+ }
13446
14817
  function cmdScore(options, flags, cwd, write) {
13447
14818
  const json = flags.has('json');
13448
14819
  if (flags.has('help')) {
@@ -13451,13 +14822,14 @@ function cmdScore(options, flags, cwd, write) {
13451
14822
  write(JSON.stringify({ help: usage, exitCode: 0 })); // --json stays ONE document even for help
13452
14823
  else {
13453
14824
  write(usage);
14825
+ write('dz score --all [--project <dir>] [--json] — sweep immutable score receipts into the append-only chained aggregate');
13454
14826
  write(' disciplines: ADR confirmation · discrimination · cross-model QE · live verification · README-first · learning loop · amendments');
13455
14827
  write(' descriptive-only, never a gate: a low score exits 0');
13456
14828
  }
13457
14829
  return 0;
13458
14830
  }
13459
14831
  for (const flag of flags) {
13460
- if (!new Set(['json', 'help']).has(flag)) {
14832
+ if (!new Set(['json', 'help', 'all']).has(flag)) {
13461
14833
  write(json ? JSON.stringify({ error: `unknown option --${flag}`, exitCode: 1 }) : `dz score: unknown option --${flag}\n allowed: --slug <feature>, --project <dir>, --json`);
13462
14834
  return 1;
13463
14835
  }
@@ -13469,6 +14841,8 @@ function cmdScore(options, flags, cwd, write) {
13469
14841
  return 1;
13470
14842
  }
13471
14843
  }
14844
+ if (flags.has('all'))
14845
+ return cmdScoreAll(options, flags, cwd, write);
13472
14846
  const slug = options.get('slug') ?? '';
13473
14847
  // The delivery-check traversal lesson, upgraded to a WHITELIST: `.` slipped the blacklist and
13474
14848
  // silently aggregated the entire features/ tree as one "run" (Codex QE #2).
@@ -14562,7 +15936,7 @@ function cmdDeliveryCheck(options, flags, cwd, write) {
14562
15936
  /* backlog — the Smart Backlog idea pipeline (feature smart-backlog) */
14563
15937
  /* ------------------------------------------------------------------ */
14564
15938
  /** Thin dispatcher — ALL logic lives in harness-core/src/backlog.ts (05 architecture: handlers stay dumb). */
14565
- async function cmdBacklog(options, flags, cwd, write) {
15939
+ async function cmdBacklog(options, flags, cwd, write, writeErr) {
14566
15940
  const projectRoot = resolve(cwd, options.get('project') ?? '.');
14567
15941
  const json = flags.has('json');
14568
15942
  const sub = options.get('_positional_0');
@@ -14580,6 +15954,8 @@ async function cmdBacklog(options, flags, cwd, write) {
14580
15954
  if (eff.adjusted && !json && eff.note !== undefined)
14581
15955
  write(`dz backlog: ${eff.note}`);
14582
15956
  const dryRun = flags.has('dry-run');
15957
+ if (!dryRun && !allowLearningStoreWrite(projectRoot, flags, writeErr, 'dz backlog add'))
15958
+ return 1;
14583
15959
  // Embed-form migration (register-inflation fix): v1 vectors are FULL-TEXT embeds, v2 queries are
14584
15960
  // bounded excerpts — comparing across the forms is a query-vs-row space split. Re-mirror once
14585
15961
  // (idempotent upsert), before the dedup search. Dry-run writes nothing, so it only WARNS.
@@ -14610,6 +15986,7 @@ async function cmdBacklog(options, flags, cwd, write) {
14610
15986
  const ideas = readIdeas(projectRoot);
14611
15987
  const match = ideas.find((i) => i.id === verdict.matchedId);
14612
15988
  let absorbErr;
15989
+ let didWrite = false;
14613
15990
  if (!dryRun && match !== undefined) {
14614
15991
  const snap = snapshotIdeas(projectRoot, join(projectRoot, '.dz', 'backlog', `ideas.pre-merge-${Date.now()}.jsonl`));
14615
15992
  if (snap.error !== undefined)
@@ -14626,7 +16003,10 @@ async function cmdBacklog(options, flags, cwd, write) {
14626
16003
  }).error;
14627
16004
  match.uses += 1;
14628
16005
  writeIdeas(projectRoot, ideas);
16006
+ didWrite = true;
14629
16007
  }
16008
+ if (didWrite)
16009
+ refreshLearningStoreMark(projectRoot, writeErr, 'dz backlog add');
14630
16010
  if (json)
14631
16011
  write(JSON.stringify({ action: 'duplicate', matchedId: verdict.matchedId, cosine: verdict.cosine, ...(verdict.containment !== undefined ? { containment: verdict.containment } : {}), ...(verdict.subsetMatch === true ? { subsetMatch: true } : {}), ...(topMatch !== undefined ? { topMatch } : {}), ...(eff.note !== undefined ? { effortNote: eff.note } : {}), ...(dryRun ? {} : { absorbedLogged: absorbErr === undefined, ...(absorbErr !== undefined ? { absorbedLogError: absorbErr } : {}) }), exitCode: 0 }, null, 2));
14632
16012
  else {
@@ -14688,6 +16068,7 @@ async function cmdBacklog(options, flags, cwd, write) {
14688
16068
  ideas.push(rec);
14689
16069
  writeIdeas(projectRoot, ideas);
14690
16070
  const mirror = await mirrorIdeaVector(projectRoot, rec); // best-effort — never blocks capture
16071
+ refreshLearningStoreMark(projectRoot, writeErr, 'dz backlog add');
14691
16072
  if (json)
14692
16073
  write(JSON.stringify({ action: verdict.action, idea: rec, related: verdict.relatedIds, ...(verdict.demoted !== undefined ? { demoted: verdict.demoted } : {}), ...(topMatch !== undefined ? { topMatch } : {}), ...(eff.note !== undefined ? { effortNote: eff.note } : {}), gitignore: ignore, exitCode: 0 }, null, 2));
14693
16074
  else {
@@ -14887,8 +16268,18 @@ async function cmdBacklog(options, flags, cwd, write) {
14887
16268
  if (commitId !== undefined) {
14888
16269
  // Validated above (safe id + known id) BEFORE any early return.
14889
16270
  const idx = ideas.findIndex((i) => i.id === commitId);
16271
+ // ОТМЕТКА ВРЕМЕНИ И ЖУРНАЛ ставятся ЗДЕСЬ, а не «где-нибудь потом».
16272
+ // Измерено 2026-09-02: `grep -c statusTs cli.ts` давал НОЛЬ — оба пути смены статуса в этом
16273
+ // файле меняли поле и не отмечали, когда. Отсюда 22 терминальные записи без отметки и
16274
+ // невычислимое «сколько идея пробыла в работе».
16275
+ const spinFrom = ideas[idx].status;
16276
+ const spinTs = new Date().toISOString();
14890
16277
  ideas[idx].status = 'in-progress';
16278
+ ideas[idx].statusTs = spinTs;
14891
16279
  writeIdeas(projectRoot, ideas);
16280
+ if (!appendTransition(projectRoot, { id: commitId, from: spinFrom, to: 'in-progress', ts: spinTs, by: 'backlog roulette --commit' })) {
16281
+ write('dz backlog: переход НЕ записан в журнал — наблюдение потеряно (сам статус изменён)');
16282
+ }
14892
16283
  pick = ideas[idx];
14893
16284
  committed = true;
14894
16285
  }
@@ -14963,6 +16354,7 @@ async function cmdBacklog(options, flags, cwd, write) {
14963
16354
  }
14964
16355
  else
14965
16356
  embed = 'stale';
16357
+ refreshLearningStoreMark(projectRoot, writeErr, 'dz backlog edit');
14966
16358
  }
14967
16359
  if (json) {
14968
16360
  write(JSON.stringify({ verb: 'edit', ...report, embed, exitCode: report.ok ? (embed === 'stale' ? 1 : 0) : 1 }, null, 2));
@@ -14997,9 +16389,15 @@ async function cmdBacklog(options, flags, cwd, write) {
14997
16389
  const goalMap = readGoalMap(projectRoot);
14998
16390
  const related = ideas.filter((i) => rec.relatedIds.includes(i.id));
14999
16391
  const staging = stageEnrichment(projectRoot, rec, related, goalMap);
16392
+ const enrichFrom = rec.status;
16393
+ const enrichTs = new Date().toISOString();
15000
16394
  rec.status = 'enriched';
16395
+ rec.statusTs = enrichTs;
15001
16396
  rec.enrichedPath = `features/${staging.slug}`;
15002
16397
  writeIdeas(projectRoot, ideas);
16398
+ if (!appendTransition(projectRoot, { id: rec.id, from: enrichFrom, to: 'enriched', ts: enrichTs, by: 'backlog enrich' })) {
16399
+ write('dz backlog: переход НЕ записан в журнал — наблюдение потеряно (сам статус изменён)');
16400
+ }
15003
16401
  if (json)
15004
16402
  write(JSON.stringify({ slug: staging.slug, scaffoldPath: staging.scaffoldPath, handoff: 'idea2prd-manual', exitCode: 0 }, null, 2));
15005
16403
  else {
@@ -15044,6 +16442,10 @@ async function cmdBacklog(options, flags, cwd, write) {
15044
16442
  else if (form.action === 'deferred' && !json)
15045
16443
  write(`dz backlog: ⚠ embed-form migration deferred (${form.error ?? 'unknown error'})`);
15046
16444
  const report = await harmonizeBacklog(projectRoot, { apply, ...(thr !== undefined ? { threshold: Number(thr) } : {}) });
16445
+ if (apply) {
16446
+ refreshLearningStoreMark(projectRoot, writeErr, 'dz backlog harmonize --apply');
16447
+ storeGuardResetReminder(projectRoot, writeErr, 'dz backlog harmonize --apply');
16448
+ }
15047
16449
  if (json) {
15048
16450
  write(JSON.stringify({ ...report, exitCode: 0 }, null, 2));
15049
16451
  return 0;
@@ -15342,17 +16744,19 @@ function cmdStats(cwd, write) {
15342
16744
  }
15343
16745
  const dirs = readdirSync(baseDir, { withFileTypes: true }).filter((e) => e.isDirectory());
15344
16746
  const packages = dirs.length;
15345
- let totalSkills = 0;
15346
- let skillPacks = 0;
15347
- for (const dir of dirs) {
15348
- if (dir.name.startsWith('skills-')) {
15349
- skillPacks++;
15350
- const skillDir = join(baseDir, dir.name);
15351
- const skillDirs = readdirSync(skillDir, { withFileTypes: true })
15352
- .filter((e) => e.isDirectory() && existsSync(join(skillDir, e.name, 'SKILL.md')));
15353
- totalSkills += skillDirs.length;
15354
- }
15355
- }
16747
+ // Backlog e160aeee. This used to walk the tree ITSELF, and was wrong in two independent ways:
16748
+ // it counted only packages whose NAME starts with `skills-` (health-advisor, p-replicator,
16749
+ // keysarium and trip-planner were therefore invisible), and it knew only ONE of the three skill
16750
+ // layouts. Result: 203 here against 250 from `dz registry` on the same tree — two counters of one
16751
+ // quantity, each unable to refute the other because neither knew the other existed.
16752
+ //
16753
+ // The fix is structural, not arithmetic: there is now ONE enumerator, and both commands ask it.
16754
+ // Pinned by test/stats-registry-parity.test.ts, whose red half is this exact divergence.
16755
+ // The registry already PUBLISHES these totals; recomputing them from `entries` here would be a
16756
+ // third implementation of the same count, which is the very defect being fixed.
16757
+ const registry = buildRegistry(cwd);
16758
+ const totalSkills = registry.totalSkills;
16759
+ const skillPacks = registry.totalPacks;
15356
16760
  const targets = TARGET_NAMES.length;
15357
16761
  const presets = PRESET_NAMES.length;
15358
16762
  write(`dz stats — DZ Harness Hub`);
@@ -15837,7 +17241,28 @@ export async function runCli(argv, io = {}) {
15837
17241
  // let a guard treat "I could not tell you" as "I answered you".
15838
17242
  return version === 'unknown' ? 1 : 0;
15839
17243
  }
15840
- if (command === '' || command === 'help' || flags.has('help')) {
17244
+ // `-h` is the most-typed help flag and is NOT a command: before the unknown-command contract
17245
+ // landed it fell through to the switch and still printed usage; afterwards it would have died
17246
+ // with exit 2 and an empty stdout (measured regression, cross-model QE M1). It belongs beside
17247
+ // `-v` above — an argv-level flag, resolved before command dispatch.
17248
+ if (argv[0] === '-h') {
17249
+ write(USAGE);
17250
+ return 0;
17251
+ }
17252
+ // A bare `--typo` leaves the command empty, so the usage branch reported SUCCESS on a misspelled
17253
+ // FLAG exactly as it used to on a misspelled VERB (cross-model QE M2): `dz --frobnicate` exited 0
17254
+ // with 30 KB of usage. The refusal is deliberately narrowed to the no-command case, because the
17255
+ // warn-don't-refuse decision above is measured and still stands: with a command present, an
17256
+ // unrecognised name may simply be missing from KNOWN_CLI_FLAGS and refusing would break working
17257
+ // invocations. With NO command there is nothing the flag could belong to, so it is a usage error.
17258
+ if (command === '') {
17259
+ const strayNames = unknownFlagNotice([...flags, ...options.keys()].filter((k) => !k.startsWith('_positional_')), KNOWN_CLI_FLAGS).map((n) => n.name);
17260
+ if (strayNames.length > 0) {
17261
+ writeErr(`dz: unknown option --${strayNames[0]} — run 'dz help' for usage`);
17262
+ return 2;
17263
+ }
17264
+ }
17265
+ if (command === '' || command === 'help' || (flags.has('help') && DZ_COMMANDS.includes(command))) {
15841
17266
  write(USAGE);
15842
17267
  return 0;
15843
17268
  }
@@ -15885,17 +17310,21 @@ export async function runCli(argv, io = {}) {
15885
17310
  case 'teach':
15886
17311
  return await cmdTeach(options, flags, cwd, write, writeErr, io.interactive ?? process.stdout.isTTY === true, io.teachGuardRunner ?? teachGuard, io.teachReinforceRunner ?? runTeachGuardReinforcement);
15887
17312
  case 'consolidate':
15888
- return await cmdConsolidate(options, flags, cwd, write);
17313
+ return await cmdConsolidate(options, flags, cwd, write, writeErr);
15889
17314
  case 'recall':
15890
17315
  return await cmdRecall(options, flags, cwd, write, writeErr, io.classMatcher);
15891
17316
  case 'vector':
15892
- return await cmdVector(options, flags, cwd, write);
17317
+ return await cmdVector(options, flags, cwd, write, writeErr);
15893
17318
  case 'brain':
15894
17319
  return await cmdBrain(options, flags, cwd, write, readStdin);
15895
17320
  case 'statusline':
15896
- return cmdStatusline(options, flags, cwd, write, readStdin);
17321
+ return cmdStatusline(options, flags, cwd, write, readStdin, writeErr);
17322
+ case 'store-guard':
17323
+ return await cmdStoreGuard(options, flags, cwd, write, writeErr, io.stdin, io.interactive ?? process.stdin.isTTY === true);
15897
17324
  case 'usage':
15898
17325
  return cmdUsage(options, optionLists, flags, cwd, write);
17326
+ case 'chain':
17327
+ return cmdChain(options, flags, cwd, write);
15899
17328
  case 'claim-check':
15900
17329
  return cmdClaimCheck(options, optionLists, flags, cwd, write);
15901
17330
  case 'lint':
@@ -15917,7 +17346,7 @@ export async function runCli(argv, io = {}) {
15917
17346
  case 'diff':
15918
17347
  return cmdDiff(options, cwd, write);
15919
17348
  case 'recommend':
15920
- return cmdRecommend(options, cwd, write);
17349
+ return cmdRecommend(options, flags, cwd, write);
15921
17350
  case 'upgrade':
15922
17351
  return cmdUpgrade(options, flags, cwd, write, writeErr);
15923
17352
  case 'auto-canonicalize':
@@ -15959,15 +17388,29 @@ export async function runCli(argv, io = {}) {
15959
17388
  case 'mr-rakes':
15960
17389
  return await cmdMrRakes(options, flags, cwd, write);
15961
17390
  case 'retro':
15962
- return await cmdRetro(options, flags, cwd, write);
17391
+ return await cmdRetro(options, flags, cwd, write, readStdin);
15963
17392
  case 'feature-adr-setup':
15964
17393
  return cmdFeatureAdrSetup(options, flags, cwd, write, writeErr);
15965
17394
  case 'challenge':
15966
17395
  return cmdChallenge(options, flags, cwd, write);
15967
17396
  case 'discrimination-check':
15968
17397
  return cmdDiscriminationCheck(options, flags, cwd, write);
15969
- case 'mutation-gate':
15970
- return cmdMutationGate(options, flags, cwd, write);
17398
+ case 'mutation-gate': {
17399
+ try {
17400
+ return cmdMutationGate(options, flags, cwd, write, io.mutationGateRunner);
17401
+ }
17402
+ catch (error) {
17403
+ const raw = error instanceof Error ? error.message : String(error);
17404
+ const head = Array.from(raw.split(/\r?\n/, 1)[0]?.trim() || 'unknown internal error').slice(0, 160).join('');
17405
+ if (flags.has('json')) {
17406
+ write(JSON.stringify({ verdict: 'INCONCLUSIVE', reason: 'runner-internal-error', error: head, exitCode: 1 }));
17407
+ }
17408
+ else {
17409
+ write(`mutation-gate: INTERNAL ERROR (${head}) — verdict INCONCLUSIVE, exit 1`);
17410
+ }
17411
+ return 1;
17412
+ }
17413
+ }
15971
17414
  case 'delivery-check':
15972
17415
  return cmdDeliveryCheck(options, flags, cwd, write);
15973
17416
  case 'skills-verify':
@@ -15992,6 +17435,8 @@ export async function runCli(argv, io = {}) {
15992
17435
  return cmdTgPost(options, flags, cwd, write);
15993
17436
  case 'name-check':
15994
17437
  return cmdNameCheck(options, flags, cwd, write);
17438
+ case 'brief-check':
17439
+ return cmdBriefCheck(options, flags, cwd, write);
15995
17440
  case 'provenance-check':
15996
17441
  return cmdProvenanceCheck(options, flags, cwd, write);
15997
17442
  case 'feature-adr-record':
@@ -16009,7 +17454,7 @@ export async function runCli(argv, io = {}) {
16009
17454
  case 'qe-bridge':
16010
17455
  return await cmdQeBridge(options, flags, cwd, write);
16011
17456
  case 'backlog':
16012
- return await cmdBacklog(options, flags, cwd, write);
17457
+ return await cmdBacklog(options, flags, cwd, write, writeErr);
16013
17458
  case 'routing':
16014
17459
  return cmdRouting(options, flags, cwd, write);
16015
17460
  case 'bto-optimize':
@@ -16021,9 +17466,8 @@ export async function runCli(argv, io = {}) {
16021
17466
  case 'import-ecc':
16022
17467
  return await cmdImportEcc(options, flags, cwd, write);
16023
17468
  default:
16024
- write(`dz: unknown command ${JSON.stringify(command)}`);
16025
- write(USAGE);
16026
- return 1;
17469
+ writeErr(`dz: unknown command ${JSON.stringify(command)} — run 'dz help' for the command list`);
17470
+ return 2;
16027
17471
  }
16028
17472
  }
16029
17473
  catch (error) {