@dzhechkov/harness-cli 0.8.22 → 0.8.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dz-manifest.json +16 -16
- package/README.md +591 -138
- package/dist/boolean-flags.d.ts.map +1 -1
- package/dist/boolean-flags.js +4 -0
- package/dist/boolean-flags.js.map +1 -1
- package/dist/cli.d.ts +96 -2
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +1975 -332
- package/dist/cli.js.map +1 -1
- package/dist/known-flags.d.ts.map +1 -1
- package/dist/known-flags.js +22 -0
- package/dist/known-flags.js.map +1 -1
- package/package.json +28 -28
- package/sbom.json +15 -15
- package/src/boolean-flags.ts +4 -0
- package/src/cli.ts +2102 -366
- package/src/known-flags.ts +22 -0
package/dist/cli.js
CHANGED
|
@@ -18,19 +18,20 @@ import { createRequire } from 'node:module';
|
|
|
18
18
|
import { isDeepStrictEqual } from 'node:util';
|
|
19
19
|
import { JOURNAL_KINDS, formatLine, parseLine, selectWindow, appendWitnessed } from '@dzhechkov/harness-core';
|
|
20
20
|
import { appendRunEvent, readRunRegistry, liveParents, liveness, probePid, settleDeadRuns, planRegistryArchive, planWorktreeCleanup, renderCleanupPlan, worktreeRemovalsToApply } from '@dzhechkov/harness-core';
|
|
21
|
-
import {
|
|
21
|
+
import { openRound, closeRound, listRounds, parseCodexTokens, classifyRoundExecOutcome, buildRoundExecRow, } from '@dzhechkov/harness-core';
|
|
22
|
+
import { createSkill, getSkillInfo, listSkillsDetailed, formatSkillLoadFailures, formatSkillApplyFailures, resolveTargetName, formatTargetProblem, formatTargetAliasNote, TARGET_NAMES_SORTED, runDoctor, runInit, discoverSkillIds, resolveSelection, formatSelectRefusal, runIntegrationsVerify, resolvePackageSkillRoots, PACKAGE_SKILL_LAYOUTS, benchmarkSkill, benchmarkSkills, scanMcp, reconcileCapabilities, RECONCILE_BANNER, buildRegistry, discoverSkillPackDirs, discoverVerifiablePackDirs, checkUpstream, compareSkills, checkAllUpstream, sweepSkillDrift, syncCanonicalSkill, checkUpgrades, discoverPackages, discoverSourcePackages, fetchAllDownloads, filterByCategory, pretrain, recommend, generatePlugin, publishPackages, runSetup, memoryBackendSourceLabel, runMigrate, searchRegistry, runSync, runVerify, runInitAgentsMd, runInitGeminiMd, runSyncAgentsPolicy, runSyncCodexHooks, resolveCodexHome, withNamedLockSync,
|
|
22
23
|
// dz workflow run (feature dz-workflow-run): the pure scheduler + the dispatch adapters.
|
|
23
|
-
TRACE_RUNID_RE, WF_RUN_OWNER_HOST, preflight, runWorkflow, makeClaudePDispatcher, makeCodexExecDispatcher, NamedLockTimeoutError, NamedLockCompromisedError, POLICY_SOURCES, detectPolicyDrift, hasPolicyFence, TARGET_NAMES, buildParityMatrix, downgradeForStaleEvidence, findStaleTranscriptEvidence, TARGET_CAPABILITIES, TARGET_SHORT_LABELS, WORKFLOW_TEMPLATES_RETIRED_MESSAGE, parsePlan, isParseErrors, validatePlan, normalizePlan, planDigest, toTraceProjection, renderPlan, mergeRender, lint, lintExitCode, LOOP_BLOBS, parseTrace, assembleTimeline, runInvariants, deriveAttestation, stampAttestation, corroborate, NOT_WITNESSED, renderTimelineHtml, importEcc, recordPattern, recordLessonForms, normalizeLessonForms, resolveLearningBackend, storeStats, consolidateSessions, pruneNoisePatterns, lessonDeltaReport, removePatternsByIds, snapshotStore, recallHybrid, teachGuard, mirrorPatternsToVector, mirrorEntriesToVector, patternVectorEntry, readMemoryLearningConfig, promotePatterns, quarantineExpiryCandidates, pruneQuarantinePatterns, clearAgentdbQuarantine, vectorMirrorEnabled, vectorTierStatus, resolveVectorEngine, reindexVectorStore, harmonizeVectorStore, importRvfCheckpoint, renderFeatureAdrPhaseLine, statuslineData, countLearningStoreRowsReadonly, readStoreMark, writeStoreMark, resetStoreMark, checkStoreHealth, storeGuardPath, storeSnapshotPath, writeFeatureAdrState, writeFeatureAdrStateDetailed, CHECKPOINT_STAGES, estimateEta, extractStageSamples, formatEta, parseCheckpointLines, segmentRun,
|
|
24
|
+
TRACE_RUNID_RE, WF_RUN_OWNER_HOST, preflight, runWorkflow, makeClaudePDispatcher, makeCodexExecDispatcher, NamedLockTimeoutError, NamedLockCompromisedError, POLICY_SOURCES, detectPolicyDrift, hasPolicyFence, TARGET_NAMES, buildParityMatrix, computeParity, PARITY_FEATURES, downgradeForStaleEvidence, findStaleTranscriptEvidence, TARGET_CAPABILITIES, TARGET_SHORT_LABELS, applyLegStatus, applyLegReasonMessage, resolveAgentdbPath, WORKFLOW_TEMPLATES_RETIRED_MESSAGE, parsePlan, isParseErrors, validatePlan, normalizePlan, planDigest, toTraceProjection, renderPlan, mergeRender, lint, lintExitCode, LOOP_BLOBS, parseTrace, assembleTimeline, runInvariants, deriveAttestation, stampAttestation, corroborate, NOT_WITNESSED, renderTimelineHtml, importEcc, recordPattern, recordLessonForms, normalizeLessonForms, resolveLearningBackend, storeStats, consolidateSessions, pruneNoisePatterns, lessonDeltaReport, removePatternsByIds, snapshotStore, recallHybrid, teachGuard, mirrorPatternsToVector, mirrorEntriesToVector, patternVectorEntry, readMemoryLearningConfig, promotePatterns, quarantineExpiryCandidates, pruneQuarantinePatterns, clearAgentdbQuarantine, vectorMirrorEnabled, vectorTierStatus, resolveVectorEngine, reindexVectorStore, harmonizeVectorStore, importRvfCheckpoint, renderFeatureAdrPhaseLine, statuslineData, countLearningStoreRowsReadonly, readStoreMark, writeStoreMark, resetStoreMark, checkStoreHealth, storeGuardPath, storeSnapshotPath, writeFeatureAdrState, writeFeatureAdrStateDetailed, CHECKPOINT_STAGES, estimateEta, extractStageSamples, formatEta, parseCheckpointLines, segmentRun, computeSpendReport, deriveCostLedger, planLedgerBackfill, listCostLedgerRuns, resolveLedgerRunId, AMBIGUOUS, stampCheckpointLine, LEDGER_FILL_SOURCE, renderCostLedger, verifyCostLedgerReport, writeCostLedgerJsonl, COST_LEDGER_SCOPE, spendReport, claimCheck, summarize, BUNDLED_SLOP_REGISTRY_URL, DEFAULT_SLOP_CONFIG, parseSlopRegistry, slopLint, validateSlopLintConfig, queryBookKnowledge, loadStorePatternsSync, patternRecordId, patternIdentityOf, mergeLessonMatchedForms, SWARM_BRIEF_CONTRACT, checkSwarmBrief, visibleText, loadStoreRecords, findExactLesson, recordToPattern, bundleSkills, brainHome, brainAgentdbPath, listPreReindexSnapshots, rotatePreReindexSnapshots, scanSnapshotDir, listBrain, bookKbPath, promoteProjectToBrain, updateBrainSource, queryBrain, groundPrompt, expandKu, reindexBrainVectors, buildPrimer, exportBrainSlice, importBrainSlice, registerKusToBrain, RECALL_USAGE_LOG_RELATIVE, RECALL_USAGE_LOG_MAX_BYTES, parseRecallUsageLog, buildRecallUsageReport, EVENT_CHAIN_TAIL_BYTES, EMPTY_LOG_TAIL, readTailInfo, appendChainedLines, verifyEventChainText, classifyChainDefects, CHAINED_JOURNALS, buildManifest, buildSbom, resolveTrustRoot, decideVerifyPolicy, generateSigningKeypair, appendTransition, evaluateGuard, resolveRules, auditRecord, guardExitCode, DEFAULT_RULES, parsePnpmLockImporters, scannableStubPath,
|
|
24
25
|
// guard-promotion (feature guard-promotion, scout idea #1)
|
|
25
|
-
assembleCandidates, renderPromotionReport, renderPromotionAdr, normalizePromotionState, nextPromotionState, recordPromotionRunEvidence, isLessonRuleContentAnchor, isOffsetIsoTimestamp, globMatch, promotionAdrRelPath, DEFAULT_WINDOW_DAYS, DEFAULT_PERIODS, MAX_CONTENT_FETCHES, BUILTIN_COVERAGE, decideProvenance, isInsideTree, signManifest, verifyManifest, hashPackBytes, rewriteWorkspaceSpecs, listPackFiles, listSignablePackFiles, assertKeyOutsideTree, decidePublishGate, collectPackageFacts, planReleaseGates, selectAffectedPackages, classifyGateExecutions, buildFailureIssue, buildReleaseNotes, releaseTagName, firstOutputLine, formatPublishError, MANIFEST_NAME, SBOM_NAME, buildArchitectureMap, renderMapHuman, findArchitectureDrift, renderDriftReport, scanWorkspacePackages, loadSubsystemManifest, loadProductVision, checkFeatureAgainstArchitecture, renderArchCheck, planProjectSkills, guidanceForStage, renderInjectionReport, analyzeCorpus, renderRakeReport, renderCriticSection, rakeAsLesson, rakeReward, DEFAULT_RAKE_THRESHOLDS, streamSessionEvents, findLatestTranscript, resolveScanTailTranscript, detectProcessRakes, buildRetro, renderRetro, retroLessonText, PROCESS_SIGNATURES, RETRO_DOMAIN, runRetroTailScan, scanForSetup, buildSetupPlan, scaffoldFromSpec, renderScaffoldPreview, readExistingForScaffold, assembleChallengeContext, buildChallengeBrief, planDiscriminationCheck, classifyDiscrimination, classifyExecutionEvidence, pickAdversaryModel, CHALLENGE_QUESTIONS, loadOutcomes, renderOutcomes, statsForKey, selectAutoCost, recordProvisional, finalizeOutcome, harvestStageOutcomes, recommendModels, planFeed, unfedRuns, GRADE_SUCCESS_FLOOR, COST_LADDER, splitScenarios, budgetPlan, selectWinner, proseScopeOk, renderProseDiff, readScenarioIds, DEFAULT_MAX_JUDGE_RUNS, collectDeliveryFacts, planDeliveryCheck, renderDeliveryBrief, classifyDelivery, isUsablePlaneResult, renderDeliveryReview, scanSkillsLayout, declaredPluginSurface, parseInitFacts, verifyRegistration, buildContentProbePrompt, classifyContentProbe, renderContentProbe, findNonRegistrableSkillDirs, assembleCompoundingReport, buildDeadwoodReport, compactCmdUsageIfNeeded, measureCmdUsageDepthDays, recordCommandInvocation, resolveCmdUsageRoot, renderDeadwoodReport, CMD_USAGE_LOG_RELATIVE, banditStats, narrowBanditReport, renderBanditHealth,
|
|
26
|
+
assembleCandidates, renderPromotionReport, renderPromotionAdr, normalizePromotionState, nextPromotionState, recordPromotionRunEvidence, isLessonRuleContentAnchor, isOffsetIsoTimestamp, globMatch, promotionAdrRelPath, DEFAULT_WINDOW_DAYS, DEFAULT_PERIODS, MAX_CONTENT_FETCHES, BUILTIN_COVERAGE, decideProvenance, isInsideTree, signManifest, verifyManifest, hashPackBytes, rewriteWorkspaceSpecs, detectSiblingDrift, planPackedInstallSmoke, judgePackedInstallSmoke, listPackFiles, listSignablePackFiles, assertKeyOutsideTree, decidePublishGate, collectPackageFacts, planReleaseGates, selectAffectedPackages, classifyGateExecutions, buildFailureIssue, buildReleaseNotes, releaseTagName, firstOutputLine, formatPublishError, MANIFEST_NAME, SBOM_NAME, buildArchitectureMap, renderMapHuman, findArchitectureDrift, renderDriftReport, scanWorkspacePackages, loadSubsystemManifest, loadProductVision, checkFeatureAgainstArchitecture, renderArchCheck, planProjectSkills, guidanceForStage, renderInjectionReport, analyzeCorpus, renderRakeReport, renderCriticSection, rakeAsLesson, rakeReward, DEFAULT_RAKE_THRESHOLDS, streamSessionEvents, findLatestTranscript, resolveScanTailTranscript, detectProcessRakes, buildRetro, renderRetro, retroLessonText, PROCESS_SIGNATURES, RETRO_DOMAIN, runRetroTailScan, scanForSetup, buildSetupPlan, scaffoldFromSpec, renderScaffoldPreview, readExistingForScaffold, assembleChallengeContext, buildChallengeBrief, planDiscriminationCheck, classifyDiscrimination, classifyExecutionEvidence, pickAdversaryModel, CHALLENGE_QUESTIONS, loadOutcomes, renderOutcomes, statsForKey, selectAutoCost, recordProvisional, finalizeOutcome, harvestStageOutcomes, recommendModels, planFeed, unfedRuns, GRADE_SUCCESS_FLOOR, COST_LADDER, splitScenarios, budgetPlan, selectWinner, proseScopeOk, renderProseDiff, readScenarioIds, DEFAULT_MAX_JUDGE_RUNS, collectDeliveryFacts, planDeliveryCheck, renderDeliveryBrief, classifyDelivery, isUsablePlaneResult, renderDeliveryReview, scanSkillsLayout, declaredPluginSurface, parseInitFacts, verifyRegistration, buildContentProbePrompt, classifyContentProbe, renderContentProbe, findNonRegistrableSkillDirs, assembleCompoundingReport, buildDeadwoodReport, compactCmdUsageIfNeeded, measureCmdUsageDepthDays, recordCommandInvocation, resolveCmdUsageRoot, renderDeadwoodReport, CMD_USAGE_LOG_RELATIVE, banditStats, narrowBanditReport, renderBanditHealth,
|
|
26
27
|
// Cold-vs-warm EPOCH RUNNER (feature epoch-replay) — orchestrates + scores, never calls a model.
|
|
27
|
-
replayableInstances, buildWorkOrder, buildJudgePrompts, unblindJudgments, verifyWorkOrder, isValidMargin, DIGEST_HONEST_SCOPE, scoreEpochReplay, generateMockOutcomes, renderEpochReplayResult, renderWorkOrderSummary, renderJudgePromptsSummary, WORK_ORDER_KIND, DEFAULT_MOCK_N, DEFAULT_MOCK_SEED, scoreRun, readQeGrade, scoreReceiptToAggregateRow, readScoreAggregateRows, dedupeScoreAggregateRows, buildScoreAggregateReport, renderScoreAggregateReport, recapWindow, decideHorizon, withinWindow, buildRecap, renderRecap, parseSourceManifest, tgPostHtmlIssues, tgVisibleLength, decideTgSend, TG_TEXT_LIMIT, countRecallEventsForRun, unknownFlagNotice, mirrorWriterExplanation, appendRecallUsage, closenessLine, anyAboveFloor, decideNameCheck, renderNameCheck, exportedNamesIn, dispatchedCommandsIn, decideSourceProvenance, renderSourceProvenance, REFUSED_HORIZONS, renderScorecard, renderCompoundingReport, readReinforcementState, readQuarantineState, registrationExitCode, renderRegistrationReport,
|
|
28
|
+
replayableInstances, buildWorkOrder, buildJudgePrompts, unblindJudgments, verifyWorkOrder, isValidMargin, DIGEST_HONEST_SCOPE, scoreEpochReplay, generateMockOutcomes, renderEpochReplayResult, renderWorkOrderSummary, renderJudgePromptsSummary, WORK_ORDER_KIND, DEFAULT_MOCK_N, DEFAULT_MOCK_SEED, scoreRun, readQeGrade, scoreReceiptToAggregateRow, readScoreAggregateRows, dedupeScoreAggregateRows, buildScoreAggregateReport, renderScoreAggregateReport, recapWindow, decideHorizon, withinWindow, buildRecap, renderRecap, parseSourceManifest, tgPostHtmlIssues, tgVisibleLength, decideTgSend, TG_TEXT_LIMIT, countRecallEventsForRun, unknownFlagNotice, mirrorWriterExplanation, mirrorWriterReason, appendRecallUsage, closenessLine, anyAboveFloor, decideNameCheck, renderNameCheck, exportedNamesIn, dispatchedCommandsIn, decideSourceProvenance, renderSourceProvenance, REFUSED_HORIZONS, renderScorecard, renderCompoundingReport, readReinforcementState, readQuarantineState, registrationExitCode, renderRegistrationReport,
|
|
28
29
|
// Smart Backlog (feature smart-backlog) — goal-directed idea pipeline over the Brain vector engine.
|
|
29
30
|
readBacklogConfig, readIdeas, writeIdeas, ideaId, dedupIdea, readGoalMap, readGoalMapDetailed, parseEffort, ensureBacklogGitignored, isSafeId, alignIdea, mirrorIdeaVector, ensureBacklogEmbedForm, readBacklogEmbedFormVersion, recordAbsorption, DEDUP_EMBED_FORM_VERSION, snapshotIdeas, spinRoulette, rankRoulette, seededRng, eligibleIdeas, stageEnrichment, buildJiraDraft, resolveJiraAdapter, makeBacklogIO, harmonizeBacklog, transitionIdeas, editIdea, clearEmbedStale, BACKLOG_BACKENDS, applyDomainBoost, DZ_OWNED_TASK_TYPES, applyExportHoldout, DEFAULT_HELD_OUT_DOMAINS, canonicalDomainKey, readAgentdbRowsByTaskType, heldOutAfterOptIn, renderHoldoutNote, renderSharedStoreAdvice, decideVectorExport, countDisplacedByCut, renderDomainBoostNote, renderDomainCutNote, parseReqeDebt,
|
|
30
31
|
// qe-bridge (feature qe-bridge-claude, ADR-001): the pure half of the reverse QE bridge.
|
|
31
32
|
KNOWN_CLAUDE, isSafeClaudeId, claudeProbeArgs, claudeReviewArgs, interpretClaudeProbe, modelFamily, buildBridgePrompt, parseBridgeOutput, buildBridgeFailureRecord, buildBridgeSignoffRecord, renderBridgeReport, isSafeSlug, hasUnsafePathChars, hasDotDotSegment, buildReqeBrief, settleReqeDebt, renderReqeList, REQE_SCOPE,
|
|
32
33
|
// Mutation gate (feature ha-mutation-gate) — break each named protection, run the suite, require red.
|
|
33
|
-
parseMutationRegistry, applyMutationToText, attributeBaselineRedness, countFailingTests, detectSuiteCompletionReceipt, detectSuiteReceiptMismatch, classifyBaseline, classifyRunFailure, classifyMutationOutcome, mutationGateExitCode, summarizeMutationResults, renderMutationReport, runWithOneInternalRetry, TRACE_BUNDLE_LEDGER_PATH, TRACE_BUNDLE_SCHEMA, TRACE_BUNDLE_RUN_META_FILE, buildBundle, serializeBundle, parseBundle, planImport, decideCheckpointWrite, amendmentSection, amendmentSectionCount, amendmentDeclarationAmbiguity, planSaysNoAmendments, parseAmendments, resolveAmendments, decideAmendmentOutcome, amendmentVerdictLine, amendmentsMissingFromPlan, AMENDMENT_VACUITY_NOTE, extractContractChecklist, readFeatureTier, parseContractVerdictReport, verifyContractVerdicts, decideSignableSet, signableSetLine, decideRecordWrite, decideReadBack, recordVerdictLine, buildCadenceReport, tgVisibleSha256, CADENCE_WINDOW_DAYS, readQeRounds, QE_ROUNDS_DEFAULT_CEILING, adviseRestart, describeStoreLocation, storeLocationLine, resolveTeachTarget, teachReasonPhrase, readTeachToConfig, TeachTargetError, mergeStoreHits, sameStore, globalStoreRoot, storeCountLabel,
|
|
34
|
+
REGISTRY_SELFCHECK_TESTS, buildMutationTestCommand, parseMutationRegistry, applyMutationToText, attributeBaselineRedness, countFailingTests, detectSuiteCompletionReceipt, detectSuiteReceiptMismatch, classifyBaseline, classifyRunFailure, classifyMutationOutcome, mutationGateExitCode, summarizeMutationResults, renderMutationReport, runWithOneInternalRetry, TRACE_BUNDLE_LEDGER_PATH, TRACE_BUNDLE_SCHEMA, TRACE_BUNDLE_RUN_META_FILE, buildBundle, serializeBundle, parseBundle, planImport, decideCheckpointWrite, amendmentSection, amendmentSectionCount, amendmentDeclarationAmbiguity, planSaysNoAmendments, parseAmendments, resolveAmendments, decideAmendmentOutcome, amendmentVerdictLine, amendmentsMissingFromPlan, AMENDMENT_VACUITY_NOTE, extractContractChecklist, readFeatureTier, parseContractVerdictReport, verifyContractVerdicts, decideSignableSet, signableSetLine, decideRecordWrite, decideReadBack, recordVerdictLine, buildCadenceReport, tgVisibleSha256, CADENCE_WINDOW_DAYS, readQeRounds, QE_ROUNDS_DEFAULT_CEILING, adviseRestart, describeStoreLocation, storeLocationLine, resolveTeachTarget, teachReasonPhrase, readTeachToConfig, TeachTargetError, mergeStoreHits, sameStore, globalStoreRoot, storeCountLabel,
|
|
34
35
|
// operator-profile (ADR-001): per-user 0600 store + marked block in ~/.claude/CLAUDE.md
|
|
35
36
|
renderProfileBlock, readProfile, writeProfile, syncProfileBlock, checkProfileDrift, parseRegister, registerOwnerWord, profileAgeDays, parseDomainList, domainListText, parseYesNo, REGISTERS, } from '@dzhechkov/harness-core';
|
|
36
37
|
import { getPreset, PRESET_NAMES } from '@dzhechkov/harness-presets';
|
|
@@ -73,7 +74,7 @@ export const DZ_COMMANDS = [
|
|
|
73
74
|
'epoch-replay', 'score', 'recap', 'cadence', 'qe-rounds', 'restart-advisor', 'tg-post',
|
|
74
75
|
'name-check', 'brief-check', 'provenance-check', 'journal', 'feature-adr-record', 'runs', 'runs-record', 'runs-clean', 'amendment-check', 'contract-check',
|
|
75
76
|
'feature-adr-checkpoint', 'profile', 'reqe', 'qe-bridge', 'backlog', 'routing',
|
|
76
|
-
'bto-optimize', 'dashboard', 'roam', 'import-ecc', 'chain',
|
|
77
|
+
'bto-optimize', 'dashboard', 'roam', 'import-ecc', 'chain', 'round',
|
|
77
78
|
];
|
|
78
79
|
const USAGE = `dz - DZ cross-platform harness CLI
|
|
79
80
|
dz runs [--settle] [--stall-minutes N] [--json] [--project <dir>] [--probe-pid <pid>] (run registry: live, stalled, orphaned, inconclusive or finished; PID probe prints true|false|unknown)
|
|
@@ -134,7 +135,8 @@ Usage:
|
|
|
134
135
|
dz amendment-check --slug <slug> | --feature-dir <dir> | --all [--json] (the deterministic Step-8 amendment gate: every AM-N / AM-CP-N row must resolve to a test found INSIDE the file the row names (the challenge-panel prefix is part of the id: AM-CP-1 is never AM-1); the PLAN is authoritative when it carries rows, and an ideation amendment the plan drops is a failure. exit 0 pass/skip, 1 fail, 3 NOT-ESTABLISHED — a section that parsed ZERO rows is never a pass, UNLESS the plan explicitly declares \"None\"/\"нет\", which is an answer and reports skip. --all is a CENSUS and always exits 0. Does NOT prove non-vacuity — that is dz discrimination-check)
|
|
135
136
|
dz contract-check --slug <s> [--json] (read-only retrospective feature contract gate: extracts canonical AC-N + ADR Confirmation items, requires one artifact-anchored met|unmet|not-testable verdict per CC-N, and rejects A/B with unmet. exit 0 pass / 1 readable contract or verdict violation / 2 invalid invocation or unreadable/not-established artifacts)
|
|
136
137
|
dz journal add --kind decision|verdict|run|error|block "<text>" [--ref <trace>] [--at <ISO>] [--quote <file>] [--commit-quote]; dz journal show [--day|--week] [--at <date>] [--kind <kind>] [--json] (UTC day files, witnessed append; quotes stay local unless explicitly staged)
|
|
137
|
-
dz feature-adr-record --kind ledger|training-pair --stage <s> [--slug <s>] [--row|--pair <json>] [--mark <n>] [--once] [--json] (the witnessed writer for the run-cost ledger and training pairs: the payload arrives as an ARGUMENT, never as shell; a malformed or wrong-kind payload is REFUSED before any write; the
|
|
138
|
+
dz feature-adr-record --kind ledger|training-pair --stage <s> [--slug <s>] [--row|--pair <json>] [--run-id <id>] [--mark <n>] [--once] [--json] (the witnessed writer for the run-cost ledger and training pairs: the payload arrives as an ARGUMENT, never as shell; a malformed or wrong-kind payload is REFUSED before any write; for a ledger row, 'ts' is ALWAYS the actual write instant (ledger-stage-minutes FR-1) — a payload-supplied 'ts' is never trusted for the delta below, and is preserved as 'payloadTs' rather than discarded; --run-id fills the payload's runId ONLY when it is a gap — absent, null, '', or non-string, the same 'missing when absent or blank' rule runnerId uses — and stamps runIdSource:'cli-flag' when it does; for an auto:true ledger row that carries a runId — from the payload, from --run-id, or resolved at write time — the append also carries minutesSincePrev/minutesSource:'ledger-ts-delta' measured against the LAST row of the same run found by a best-effort reverse scan that reports 'unavailable' (never a guess) on a missing prior row OR a corrupt/non-object ledger line anywhere between it and the file's end (ledger-corrupt-line); minutes itself stays untouched. New fields (ts, minutesSincePrev, minutesSource) are always appended after every existing key, never reordering one. The append is verified by re-reading the tail. exit 0 written|duplicate|skipped, 2 refused, 3 not-verified — a record failure is never blocking)
|
|
139
|
+
dz round open --slug <s> --round <n|auto> --topic <text> [--project <brain>] [--run <id>] [--owner-pid <n>|--owner-run <runId>] [--force] [--json]; dz round exec --slug <s> --round <n> --brief <file> [--log <file>] [--model gpt-5.6-sol] [--effort high] [--timeout-min 30] [--json]; dz round close --slug <s> --round <n> --outcome shipped|refuted|blocked|abandoned [--reason <text>] [--lesson teach:<id>...]|[--no-new-knowledge <reason>] [--tokens N] [--agents N] [--coder <spec>] [--reviewer <spec>] [--note <text>] [--no-cost] [--json]; dz round status [--older-than <minutes>] [--json] (focused rounds outside feature-adr: open tracks the parent process by default, an explicit pid, or a registered run; live/stalled run owners stay live and missing registry evidence stays unknown; open --force refuses a live or unknown owner and archives a known-dead owner's state; recall precedes work, then the witnessed ledger is trusted only after reading it back)
|
|
138
140
|
dz feature-adr-checkpoint (--slug <feature> | --feature-dir <abs>) --stage <s> --input-hash <h> --result <json> [--artifact a,b] [--json] (record a pipeline stage ONLY after measuring its artifacts on disk; refuses a null result, an absent artifact, or a stage that declares none — the subagent runs a COMMAND instead of hand-writing durable state)
|
|
139
141
|
dz profile [init|show|set|sync] [--json] (WHO the assistant is talking to — per-user store at ~/.dz/profile.json (0600, NEVER in a project), delivered as a marked block in ~/.claude/CLAUDE.md so it loads in EVERY project, dz installed or not. init = five questions (language, register, deep/weak domains as comma lists — "networking (CCIE; NSX)" keeps the parenthetical as the note, Enter skips — teaches y/n with one re-ask, never a silent default); show ALWAYS prints the store path + age + drift verdict + the rendered block; set register|language|teaches <v> or set deep|weak add|rm <tag> [note] — register accepts the owner's own words (профи / профи лайт / просто), an unknown value is REFUSED naming the accepted set; sync re-writes the block (runs automatically after init/set; foreign content byte-for-byte, timestamped backup before every modifying write). The register changes FORM, never FACTS, and governs dialogue only — never ADRs/commits/QE reports; both rules are baked into the rendered block at every level. exit 0 done / 1 no profile or failed / 2 refused input)
|
|
140
142
|
dz reqe [--slug <feature> [--done --report <f>]] [--json] (the re-QE debt ledger: a usage-switched run whose Step-8 QE ran on the coder's OWN family records a debt; list debts, print the cross-family review brief, settle FAIL-CLOSED against a graded report — the settlement lands in 08_qe_report.md)
|
|
@@ -167,7 +169,8 @@ Usage:
|
|
|
167
169
|
dz brain query "<q>" [--source <slug>] [--limit <N>] [--any] [--rerank] [--json] (cross-source recall; --any = OR match; --rerank reorders top-K)
|
|
168
170
|
dz brain add [--source <slug>] [--project <dir>] [--from-slice <f>|--from-pack <p>|--from-kus <f> --slug <s>] [--kind <k>] [--license <spdx>] [--json] (grow the brain: promote this project, or import a slice/pack/KU-array)
|
|
169
171
|
dz brain update <slug> [--project <dir>] [--json] (non-destructive refresh: re-mirror a re-ingested source into the brain)
|
|
170
|
-
dz brain reindex [--json] (snapshot, re-embed book-KU brain vectors, stamp current model)
|
|
172
|
+
dz brain reindex [--json] (snapshot, re-embed book-KU brain vectors, stamp current model; also rotates old pre-reindex snapshots)
|
|
173
|
+
dz brain snapshots [--keep <N>] [--prune] [--json] [--project <dir>] (list — or, with --prune, rotate — pre-reindex snapshot families of the home brain, or of <dir>/.dz/agentdb.db; default keep 3)
|
|
171
174
|
dz brain primer <slug> [--json] (print a source's capability card — KU-type histogram + top decision moments)
|
|
172
175
|
dz brain export --source <slug> --out <file> (export ONE source as a portable, lexical-only books.sqlite slice)
|
|
173
176
|
dz brain ground [<prompt>] [--k <N>] [--source <slug>] [--text] [--budget <N>] [--full] (UserPromptSubmit hook; --budget inlines top-K KUs within ~N tokens; --full = ~8000)
|
|
@@ -176,7 +179,7 @@ Usage:
|
|
|
176
179
|
dz statusline [--json] [--install] [--project <dir>] (live self-learning panel for Claude Code's status bar; reads the CC JSON payload from STDIN)
|
|
177
180
|
dz store-guard [--status|--reset] [--yes] [--project <dir>] (show the monotonic external high-water mark; --reset is the only lowering path and requires confirmation or --yes)
|
|
178
181
|
dz statusline --fa-record --slug <s> --step "<label>" [--kind <feature-adr|loop>] [--tier <S|M|L|XL>] [--run-id <id>] [--recalled <n>] [--stored <n>] [--mode <m>] (feature-adr: record live per-run learning state + phase → 📐 SECOND-LINE phase panel; the monotone guard absorbs a backwards plain "Step <n>" only within the same non-empty run id, while an absent/empty id retains legacy fresh-slot behavior — prefix the label with ⛔ or ⏸ to record a legitimate regression)
|
|
179
|
-
dz usage [--json] [--project <dir>]
|
|
182
|
+
dz usage [--json] [--project <dir>] (7-day UTC spend from local Claude Code + subagent transcripts; provider-limit routing disabled by design)
|
|
180
183
|
dz usage --by-stage [--run <runId> | --slug <slug>] [--epsilon <0..1>] [--write <file.jsonl>] [--json] (per-stage cost ledger for ONE feature-adr run + the reconciliation invariant: accounted + unaccounted = run total; verdict BALANCED | DEFECT | INSUFFICIENT_DATA; local transcript ESTIMATES — catches ATTRIBUTION errors, not pricing errors)
|
|
181
184
|
dz chain [--project <dir>] [--json] (verify EVERY hash-chained journal in ONE command: coverage is DERIVED from the CHAINED_JOURNALS registry, never typed, so a journal cannot be given a chain and checked by nobody. An ABSENT journal is NAMED absent, never omitted — omission and cleanliness are indistinguishable in a report. Statuses: ok | healed (defects the current unbroken run has outlived — verdicts over present records are sound) | unchained (present, no chained record yet — legal) | absent | broken | unreadable. Exit 1 on broken/unreadable: a verifier that reports damage and exits 0 is one no automation can act on)
|
|
182
185
|
dz claim-check [paths...] [--json] [--fail-on high|medium|none] [--project <dir>] (enforce the Integrity Rule: flag untagged/overstated accuracy claims; default scan = root README.md + every discovered package's README.md + features/*/08_qe_report.md + docs/**/*.md (historical feature artifacts are NOT scanned — pass paths explicitly); exit 1 only at/above --fail-on, default high)
|
|
@@ -2315,12 +2318,13 @@ function cmdStatusline(options, flags, cwd, write, readStdin, writeErr) {
|
|
|
2315
2318
|
: `🎓 dz: ${data.patterns} (${breakdown.active} active${breakdown.quarantined > 0
|
|
2316
2319
|
? ` · ${breakdown.quarantined} quarantined${breakdown.attention ? ' ⚠' : ''}`
|
|
2317
2320
|
: ''})${breakdown.tierDelta !== undefined ? ` ⚠ tiers Δ${breakdown.tierDelta}` : ''}`;
|
|
2318
|
-
//
|
|
2319
|
-
//
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2321
|
+
// Зеркало — самостоятельный источник панели. Отсутствие печатается явно; нечитаемый файл
|
|
2322
|
+
// сохраняет прежнее отдельное состояние, чтобы отказ инструмента не выглядел как настройка off.
|
|
2323
|
+
line += data.patternMirror?.state === 'unavailable'
|
|
2324
|
+
? ' · mirror: unreadable ⚠'
|
|
2325
|
+
: data.mirror.available
|
|
2326
|
+
? ` · mirror: ${data.mirror.lessons} lessons (pending ${data.mirror.pending})`
|
|
2327
|
+
: ' · mirror: absent';
|
|
2324
2328
|
if (data.storeHealth?.verdict === 'collapsed') {
|
|
2325
2329
|
line += ` ⛔ COLLAPSE: was ${data.storeHealth.previousMax ?? '?'} · dz store-guard --reset`;
|
|
2326
2330
|
}
|
|
@@ -2368,156 +2372,6 @@ function cmdStatusline(options, flags, cwd, write, readStdin, writeErr) {
|
|
|
2368
2372
|
return 0;
|
|
2369
2373
|
}
|
|
2370
2374
|
}
|
|
2371
|
-
function isJsonRecord(value) {
|
|
2372
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
2373
|
-
}
|
|
2374
|
-
function usageConfigPath(projectRoot) {
|
|
2375
|
-
return join(projectRoot, '.dz', 'config.json');
|
|
2376
|
-
}
|
|
2377
|
-
function readProjectConfigForUsage(projectRoot) {
|
|
2378
|
-
const path = usageConfigPath(projectRoot);
|
|
2379
|
-
try {
|
|
2380
|
-
if (!existsSync(path))
|
|
2381
|
-
return { config: {} };
|
|
2382
|
-
const parsed = JSON.parse(readFileSync(path, 'utf-8'));
|
|
2383
|
-
if (isJsonRecord(parsed))
|
|
2384
|
-
return { config: parsed };
|
|
2385
|
-
return { config: {}, warning: 'existing config is not a JSON object; writing a minimal config' };
|
|
2386
|
-
}
|
|
2387
|
-
catch {
|
|
2388
|
-
return { config: {}, warning: 'existing config could not be parsed; writing a minimal config' };
|
|
2389
|
-
}
|
|
2390
|
-
}
|
|
2391
|
-
function applyUsageCalibrationToConfig(config, plan) {
|
|
2392
|
-
const next = { ...config };
|
|
2393
|
-
const memory = isJsonRecord(next['memory']) ? { ...next['memory'] } : {};
|
|
2394
|
-
const usage = isJsonRecord(memory['usage']) ? { ...memory['usage'] } : {};
|
|
2395
|
-
for (const change of plan.changes) {
|
|
2396
|
-
if (change.key === 'session') {
|
|
2397
|
-
usage['sessionTokenLimit'] = change.after;
|
|
2398
|
-
}
|
|
2399
|
-
else if (change.key === 'weekly') {
|
|
2400
|
-
usage['weeklyTokenLimit'] = change.after;
|
|
2401
|
-
}
|
|
2402
|
-
else {
|
|
2403
|
-
const model = normalizeClaudeUsageModelKey(change.key);
|
|
2404
|
-
if (model) {
|
|
2405
|
-
const existingByModel = isJsonRecord(usage['weeklyTokenLimitByModel']) ? { ...usage['weeklyTokenLimitByModel'] } : {};
|
|
2406
|
-
existingByModel[model] = change.after;
|
|
2407
|
-
usage['weeklyTokenLimitByModel'] = existingByModel;
|
|
2408
|
-
}
|
|
2409
|
-
}
|
|
2410
|
-
}
|
|
2411
|
-
if (plan.changes.length > 0) {
|
|
2412
|
-
usage['calibratedAt'] = plan.after.calibratedAt;
|
|
2413
|
-
usage['source'] = plan.after.source;
|
|
2414
|
-
// A fresh calibration re-arms routing for THIS account and clears the legacy free-text switch:
|
|
2415
|
-
// the calibration is the very act the disable-note demanded.
|
|
2416
|
-
usage['calibrationAccount'] = plan.after.calibrationAccount ?? null;
|
|
2417
|
-
}
|
|
2418
|
-
memory['usage'] = usage;
|
|
2419
|
-
next['memory'] = memory;
|
|
2420
|
-
return next;
|
|
2421
|
-
}
|
|
2422
|
-
function parseUsageModelArgs(modelArgs) {
|
|
2423
|
-
const modelPct = {};
|
|
2424
|
-
const skipped = [];
|
|
2425
|
-
for (const raw of modelArgs) {
|
|
2426
|
-
const eq = raw.indexOf('=');
|
|
2427
|
-
if (eq <= 0 || eq === raw.length - 1) {
|
|
2428
|
-
skipped.push(`model ${raw}: skipped malformed model=pct argument`);
|
|
2429
|
-
continue;
|
|
2430
|
-
}
|
|
2431
|
-
const modelName = raw.slice(0, eq).trim();
|
|
2432
|
-
const model = normalizeClaudeUsageModelKey(modelName);
|
|
2433
|
-
if (!model) {
|
|
2434
|
-
skipped.push(`model ${modelName}: skipped unknown model`);
|
|
2435
|
-
continue;
|
|
2436
|
-
}
|
|
2437
|
-
modelPct[model] = raw.slice(eq + 1).trim();
|
|
2438
|
-
}
|
|
2439
|
-
return { modelPct, skipped };
|
|
2440
|
-
}
|
|
2441
|
-
function writeUsageCalibrationSummary(opts) {
|
|
2442
|
-
opts.write('usage calibrate: estimated local transcript counts; claude.ai/settings/usage is authoritative');
|
|
2443
|
-
opts.write(`usage calibrate: project ${opts.projectRoot}`);
|
|
2444
|
-
if (opts.configWarning)
|
|
2445
|
-
opts.write(`usage calibrate: ${opts.configWarning}`);
|
|
2446
|
-
for (const change of opts.plan.changes) {
|
|
2447
|
-
opts.write(`usage calibrate: ${change.key} tokens=${change.tokens} pct=${change.pct}% limit ${change.before ?? 'null'} -> ${change.after}`);
|
|
2448
|
-
}
|
|
2449
|
-
const skipped = [...opts.preSkipped, ...opts.plan.skipped];
|
|
2450
|
-
for (const item of skipped)
|
|
2451
|
-
opts.write(`usage calibrate: skipped ${item}`);
|
|
2452
|
-
if (opts.wrote) {
|
|
2453
|
-
opts.write('usage calibrate: wrote .dz/config.json with source claude.ai/settings/usage');
|
|
2454
|
-
}
|
|
2455
|
-
else {
|
|
2456
|
-
opts.write('usage calibrate: no config changes written');
|
|
2457
|
-
}
|
|
2458
|
-
}
|
|
2459
|
-
function cmdUsageCalibrate(options, optionLists, cwd, write) {
|
|
2460
|
-
const projectRoot = resolve(cwd, options.get('project') ?? '.');
|
|
2461
|
-
const suppliedModels = optionLists.get('model') ?? [];
|
|
2462
|
-
const parsedModels = parseUsageModelArgs(suppliedModels);
|
|
2463
|
-
const modelPct = parsedModels.modelPct;
|
|
2464
|
-
const hasModelPct = Object.keys(modelPct).length > 0;
|
|
2465
|
-
const input = {
|
|
2466
|
-
...(options.has('session') ? { sessionPct: options.get('session') } : {}),
|
|
2467
|
-
...(options.has('weekly') ? { weeklyPct: options.get('weekly') } : {}),
|
|
2468
|
-
...(hasModelPct ? { modelPct } : {}),
|
|
2469
|
-
calibratedAt: new Date().toISOString(),
|
|
2470
|
-
source: 'claude.ai/settings/usage',
|
|
2471
|
-
};
|
|
2472
|
-
const missingInputs = [];
|
|
2473
|
-
if (!options.has('session') && !options.has('weekly') && !hasModelPct) {
|
|
2474
|
-
missingInputs.push('no calibration percentages supplied');
|
|
2475
|
-
}
|
|
2476
|
-
try {
|
|
2477
|
-
const current = computeUsage(projectRoot);
|
|
2478
|
-
const before = readUsageLimits(projectRoot);
|
|
2479
|
-
const plan = deriveUsageCalibration(current, before, input);
|
|
2480
|
-
if (plan.changes.length === 0) {
|
|
2481
|
-
writeUsageCalibrationSummary({
|
|
2482
|
-
projectRoot,
|
|
2483
|
-
plan,
|
|
2484
|
-
preSkipped: [...parsedModels.skipped, ...missingInputs],
|
|
2485
|
-
wrote: false,
|
|
2486
|
-
write,
|
|
2487
|
-
});
|
|
2488
|
-
return 0;
|
|
2489
|
-
}
|
|
2490
|
-
const existing = readProjectConfigForUsage(projectRoot);
|
|
2491
|
-
const nextConfig = applyUsageCalibrationToConfig(existing.config, plan);
|
|
2492
|
-
try {
|
|
2493
|
-
mkdirSync(join(projectRoot, '.dz'), { recursive: true });
|
|
2494
|
-
writeFileSync(usageConfigPath(projectRoot), JSON.stringify(nextConfig, null, 2) + '\n');
|
|
2495
|
-
writeUsageCalibrationSummary({
|
|
2496
|
-
projectRoot,
|
|
2497
|
-
plan,
|
|
2498
|
-
preSkipped: [...parsedModels.skipped, ...missingInputs],
|
|
2499
|
-
configWarning: existing.warning,
|
|
2500
|
-
wrote: true,
|
|
2501
|
-
write,
|
|
2502
|
-
});
|
|
2503
|
-
}
|
|
2504
|
-
catch {
|
|
2505
|
-
writeUsageCalibrationSummary({
|
|
2506
|
-
projectRoot,
|
|
2507
|
-
plan,
|
|
2508
|
-
preSkipped: [...parsedModels.skipped, ...missingInputs, 'write failed'],
|
|
2509
|
-
configWarning: existing.warning,
|
|
2510
|
-
wrote: false,
|
|
2511
|
-
write,
|
|
2512
|
-
});
|
|
2513
|
-
}
|
|
2514
|
-
return 0;
|
|
2515
|
-
}
|
|
2516
|
-
catch {
|
|
2517
|
-
write('usage calibrate: skipped internal error; no config changes written');
|
|
2518
|
-
return 0;
|
|
2519
|
-
}
|
|
2520
|
-
}
|
|
2521
2375
|
/**
|
|
2522
2376
|
* `dz usage --by-stage` — the per-stage cost ledger for one feature-adr run (feature `cost-ledger`).
|
|
2523
2377
|
*
|
|
@@ -2569,17 +2423,6 @@ function cmdUsageByStage(options, flags, write) {
|
|
|
2569
2423
|
}
|
|
2570
2424
|
return 0;
|
|
2571
2425
|
}
|
|
2572
|
-
/**
|
|
2573
|
-
* `dz usage` — print an ESTIMATE of Claude session + weekly usage from fixed reset windows,
|
|
2574
|
-
* aggregated READONLY from the local transcript store (see {@link computeUsage}). `--json` emits
|
|
2575
|
-
* the single-line contract the feature-adr usage-probe agent parses; `--calibrate` is the only
|
|
2576
|
-
* write path and records human-transcribed claude.ai percentages in `.dz/config.json`.
|
|
2577
|
-
*
|
|
2578
|
-
* **Exit code is 0 ALWAYS** — including on internal error the whole body is guarded and prints the
|
|
2579
|
-
* all-null JSON, so a probe can NEVER distinguish "usage unknown" from "command failed" via a
|
|
2580
|
-
* non-zero exit. `--project <dir>` scopes ONLY the `.dz/config.json` read/write; measurement is
|
|
2581
|
-
* account-wide (all projects).
|
|
2582
|
-
*/
|
|
2583
2426
|
/**
|
|
2584
2427
|
* dz qe-rounds — how many Step-8 review rounds has one feature already had?
|
|
2585
2428
|
*
|
|
@@ -2754,6 +2597,62 @@ function cmdRestartAdvisor(options, flags, cwd, write) {
|
|
|
2754
2597
|
roundsOrigin,
|
|
2755
2598
|
}));
|
|
2756
2599
|
}
|
|
2600
|
+
function packageCommitCount(root, sinceIso) {
|
|
2601
|
+
try {
|
|
2602
|
+
// Assemble git's flag so the CLI flag-inventory scanner does not mistake a child-process option
|
|
2603
|
+
// for a user-facing dz option. The argv delivered to git is still exactly `--count`.
|
|
2604
|
+
const raw = execFileSync('git', ['rev-list', '--' + 'count', `--since=${sinceIso}`, 'HEAD', '--', 'packages/'], {
|
|
2605
|
+
cwd: root,
|
|
2606
|
+
encoding: 'utf8',
|
|
2607
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
2608
|
+
}).trim();
|
|
2609
|
+
return /^\d+$/.test(raw) ? Number(raw) : null;
|
|
2610
|
+
}
|
|
2611
|
+
catch {
|
|
2612
|
+
return null;
|
|
2613
|
+
}
|
|
2614
|
+
}
|
|
2615
|
+
function roundTraceSince(root) {
|
|
2616
|
+
let firstDate = null;
|
|
2617
|
+
let lastRoundDate = null;
|
|
2618
|
+
try {
|
|
2619
|
+
const rows = readFileSync(join(root, '.dz', 'feature-adr', 'run-cost-ledger.jsonl'), 'utf8').split('\n');
|
|
2620
|
+
for (const line of rows) {
|
|
2621
|
+
if (line.trim() === '')
|
|
2622
|
+
continue;
|
|
2623
|
+
let row;
|
|
2624
|
+
try {
|
|
2625
|
+
const parsed = JSON.parse(line);
|
|
2626
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
2627
|
+
continue;
|
|
2628
|
+
row = parsed;
|
|
2629
|
+
}
|
|
2630
|
+
catch {
|
|
2631
|
+
continue;
|
|
2632
|
+
}
|
|
2633
|
+
const date = typeof row['date'] === 'string' && Number.isFinite(Date.parse(row['date'])) ? row['date'] : null;
|
|
2634
|
+
if (date === null)
|
|
2635
|
+
continue;
|
|
2636
|
+
if (firstDate === null)
|
|
2637
|
+
firstDate = date;
|
|
2638
|
+
if (row['stage'] === 'round' || row['stage'] === 'round-exec')
|
|
2639
|
+
lastRoundDate = date;
|
|
2640
|
+
}
|
|
2641
|
+
}
|
|
2642
|
+
catch {
|
|
2643
|
+
return null;
|
|
2644
|
+
}
|
|
2645
|
+
return lastRoundDate ?? firstDate;
|
|
2646
|
+
}
|
|
2647
|
+
function roundsTracingEnabled(root) {
|
|
2648
|
+
try {
|
|
2649
|
+
const parsed = JSON.parse(readFileSync(join(root, '.dz', 'config.json'), 'utf8'));
|
|
2650
|
+
return parsed?.rounds?.traced !== false;
|
|
2651
|
+
}
|
|
2652
|
+
catch {
|
|
2653
|
+
return true;
|
|
2654
|
+
}
|
|
2655
|
+
}
|
|
2757
2656
|
function cmdCadence(options, flags, cwd, write) {
|
|
2758
2657
|
const root = resolve(cwd, options.get('project') ?? '.');
|
|
2759
2658
|
const windowRaw = (options.get('window') ?? 'week').trim();
|
|
@@ -2761,7 +2660,9 @@ function cmdCadence(options, flags, cwd, write) {
|
|
|
2761
2660
|
write('dz cadence: --window must be one of ' + Object.keys(CADENCE_WINDOW_DAYS).join('|'));
|
|
2762
2661
|
return 1;
|
|
2763
2662
|
}
|
|
2764
|
-
const
|
|
2663
|
+
const now = Date.now();
|
|
2664
|
+
const windowStartIso = new Date(now - CADENCE_WINDOW_DAYS[windowRaw] * 86_400_000).toISOString();
|
|
2665
|
+
const r = buildCadenceReport(root, windowRaw, now, packageCommitCount(root, windowStartIso));
|
|
2765
2666
|
if (flags.has('json')) {
|
|
2766
2667
|
write(JSON.stringify(r));
|
|
2767
2668
|
return r.decision.ok ? 0 : 2;
|
|
@@ -2779,6 +2680,14 @@ function cmdCadence(options, flags, cwd, write) {
|
|
|
2779
2680
|
write(' ' + w.padEnd(12) + String(r.shipments.graded[w] ?? 0).padStart(15) + String(r.npmPublishes.weekly[w] ?? 0).padStart(15) + String(r.recalls.weekly[w] ?? 0).padStart(9));
|
|
2780
2681
|
}
|
|
2781
2682
|
write(' graded ' + r.shipments.gradedTotal + ' (' + Object.entries(r.shipments.byGrade).sort().map(([g, n]) => g + '×' + n).join(', ') + ') · UNGRADED ' + r.shipments.ungraded + ' (named, not hidden)');
|
|
2683
|
+
const roundCount = r.rounds.byStage.round;
|
|
2684
|
+
const roundPart = roundCount === 0
|
|
2685
|
+
? 'rounds 0 (ни одной строки круга в окне)'
|
|
2686
|
+
: `rounds ${roundCount} (shipped ${r.rounds.byOutcome.shipped} · refuted ${r.rounds.byOutcome.refuted} · blocked ${r.rounds.byOutcome.blocked} · abandoned ${r.rounds.byOutcome.abandoned})`;
|
|
2687
|
+
write(` ${roundPart} · exec ${r.rounds.byStage['round-exec']} (done ${r.rounds.byOutcome.done} · timeout ${r.rounds.byOutcome.timeout} · session-limit ${r.rounds.byOutcome['session-limit']} · model-refused ${r.rounds.byOutcome['model-refused']} · failed ${r.rounds.byOutcome.failed} · empty ${r.rounds.byOutcome.empty}) · commits(packages/) ${r.rounds.commitsInWindow ?? 'not measured'}`);
|
|
2688
|
+
for (const round of r.rounds.unfinished) {
|
|
2689
|
+
write(` ✗ ${round.slug}#${round.round} ${round.outcome} — ${round.reason ?? 'причина не названа'}`);
|
|
2690
|
+
}
|
|
2782
2691
|
if (r.guard.decay.length > 0) {
|
|
2783
2692
|
write(' guard repeat decay (FIXED set — rules with pre-window history only):');
|
|
2784
2693
|
for (const d of r.guard.decay.slice(0, 8))
|
|
@@ -2786,133 +2695,91 @@ function cmdCadence(options, flags, cwd, write) {
|
|
|
2786
2695
|
}
|
|
2787
2696
|
if (r.guard.excludedNewborn.length > 0)
|
|
2788
2697
|
write(' excluded newborn rule(s) (no pre-window history — a zero here would be youth, not virtue): ' + r.guard.excludedNewborn.join(', '));
|
|
2789
|
-
for (const dgr of [r.npmPublishes.degraded, r.guard.degraded, r.recalls.degraded])
|
|
2698
|
+
for (const dgr of [r.npmPublishes.degraded, r.guard.degraded, r.recalls.degraded, r.rounds.degraded])
|
|
2790
2699
|
if (dgr)
|
|
2791
2700
|
write(' DEGRADED: ' + dgr);
|
|
2792
2701
|
return 0;
|
|
2793
2702
|
}
|
|
2794
|
-
function cmdUsage(options,
|
|
2703
|
+
function cmdUsage(options, _optionLists, flags, cwd, write) {
|
|
2795
2704
|
const projectRoot = resolve(cwd, options.get('project') ?? '.');
|
|
2796
|
-
const
|
|
2705
|
+
const reqeDue = () => {
|
|
2706
|
+
try {
|
|
2707
|
+
return scanReqeDebts(projectRoot).debts.length;
|
|
2708
|
+
}
|
|
2709
|
+
catch {
|
|
2710
|
+
return 0;
|
|
2711
|
+
}
|
|
2712
|
+
};
|
|
2713
|
+
const jsonContract = (spend) => JSON.stringify({
|
|
2797
2714
|
sessionPct: null,
|
|
2798
2715
|
weeklyPct: null,
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
limits: { session: null, weekly: null },
|
|
2803
|
-
estimated: true,
|
|
2716
|
+
routing: 'disabled-by-design',
|
|
2717
|
+
spend,
|
|
2718
|
+
reqeDue: reqeDue(),
|
|
2804
2719
|
});
|
|
2720
|
+
const number = (value) => (Number.isInteger(value) ? String(value) : String(Math.round(value * 100) / 100));
|
|
2805
2721
|
try {
|
|
2806
|
-
if (flags.has('calibrate'))
|
|
2807
|
-
|
|
2722
|
+
if (flags.has('calibrate')) {
|
|
2723
|
+
// Keep the retired mode's value flags known so its one-line removal receipt is not polluted
|
|
2724
|
+
// by generic unknown-flag notices before dispatch.
|
|
2725
|
+
void ['--session', '--weekly'];
|
|
2726
|
+
write('dz usage --calibrate removed 2026-09-12: provider limits are not measurable (no API, per-account weekly resets, ad-hoc resets); dz usage reports spend only');
|
|
2727
|
+
return 2;
|
|
2728
|
+
}
|
|
2808
2729
|
if (flags.has('by-stage'))
|
|
2809
2730
|
return cmdUsageByStage(options, flags, write);
|
|
2810
|
-
const
|
|
2811
|
-
const lim = readUsageLimits(projectRoot);
|
|
2812
|
-
const modelLimits = lim.weeklyTokenLimitByModel;
|
|
2813
|
-
const hasModelLimits = modelLimits !== undefined && Object.keys(modelLimits).length > 0;
|
|
2731
|
+
const spend = computeSpendReport();
|
|
2814
2732
|
if (flags.has('json')) {
|
|
2815
|
-
|
|
2816
|
-
if (hasModelLimits)
|
|
2817
|
-
limitsPayload.weeklyByModel = { ...modelLimits };
|
|
2818
|
-
const payload = {
|
|
2819
|
-
sessionPct: u.sessionPct,
|
|
2820
|
-
weeklyPct: u.weeklyPct,
|
|
2821
|
-
sessionTokens: u.sessionTokens,
|
|
2822
|
-
weeklyTokens: u.weeklyTokens,
|
|
2823
|
-
resetsAt: { session: u.sessionResetsAt, weekly: u.weeklyResetsAt },
|
|
2824
|
-
limits: limitsPayload,
|
|
2825
|
-
estimated: true,
|
|
2826
|
-
};
|
|
2827
|
-
// ADR-001 usage-honesty: a consumer that reads null pcts deserves the WHY (closed reason
|
|
2828
|
-
// set), and a human deserves the raw estimates when POLICY (not measurement) nulled them.
|
|
2829
|
-
if (u.notEstablished.length > 0)
|
|
2830
|
-
payload.notEstablished = u.notEstablished;
|
|
2831
|
-
if (u.estimatesNotForRouting !== undefined)
|
|
2832
|
-
payload.estimatesNotForRouting = u.estimatesNotForRouting;
|
|
2833
|
-
if (hasModelLimits && u.weeklyByModel !== undefined)
|
|
2834
|
-
payload.weeklyByModel = u.weeklyByModel;
|
|
2835
|
-
// re-QE debt surfacing (backlog 6b40e667 — QE #9: the json contract must carry the debt too,
|
|
2836
|
-
// a probe is exactly the consumer that needs it). The field appears ONLY when a debt exists,
|
|
2837
|
-
// so the zero-debt contract stays byte-identical to the pinned legacy shape. Best-effort.
|
|
2838
|
-
try {
|
|
2839
|
-
const reqeCount = scanReqeDebts(resolve(cwd, options.get('project') ?? '.')).debts.length;
|
|
2840
|
-
if (reqeCount > 0)
|
|
2841
|
-
payload.reqeDue = reqeCount;
|
|
2842
|
-
}
|
|
2843
|
-
catch { /* advisory only */ }
|
|
2844
|
-
write(JSON.stringify(payload));
|
|
2845
|
-
return 0;
|
|
2846
|
-
}
|
|
2847
|
-
if (u.sessionPct === null && u.weeklyPct === null && u.notEstablished.length > 0) {
|
|
2848
|
-
// Limits may be fully configured and the pcts STILL null — that is the honesty, not a config
|
|
2849
|
-
// gap. Say why, and show the human the raw estimates when only policy nulled them.
|
|
2850
|
-
write('usage: not established — ' + u.notEstablished.join(', '));
|
|
2851
|
-
if (u.estimatesNotForRouting !== undefined) {
|
|
2852
|
-
const e = u.estimatesNotForRouting;
|
|
2853
|
-
write(' estimates (NOT for routing): session ~' + (e.sessionPct ?? '?') + '% · week ~' + (e.weeklyPct ?? '?') + '% — recalibrate on THIS account: dz usage --calibrate --session <pct> --weekly <pct>');
|
|
2854
|
-
}
|
|
2855
|
-
else {
|
|
2856
|
-
write(' the scan established nothing (' + u.sessionTokens + ' session / ' + u.weeklyTokens + ' weekly tokens counted) — a percentage would be a guess, and routing must not eat guesses');
|
|
2857
|
-
}
|
|
2858
|
-
try {
|
|
2859
|
-
const reqe = scanReqeDebts(resolve(cwd, options.get('project') ?? '.'));
|
|
2860
|
-
if (reqe.debts.length > 0)
|
|
2861
|
-
write('re-QE due: ' + reqe.debts.length + ' usage-switched run(s) kept same-family QE — run `dz reqe` for the cross-family pass');
|
|
2862
|
-
}
|
|
2863
|
-
catch { /* advisory only */ }
|
|
2864
|
-
return 0;
|
|
2865
|
-
}
|
|
2866
|
-
if (u.sessionPct === null && u.weeklyPct === null) {
|
|
2867
|
-
write('usage: unconfigured — set memory.usage.sessionTokenLimit / weeklyTokenLimit in .dz/config.json (percentages are ESTIMATES calibrated from observed exhaustion)');
|
|
2868
|
-
try {
|
|
2869
|
-
const reqe = scanReqeDebts(resolve(cwd, options.get('project') ?? '.'));
|
|
2870
|
-
if (reqe.debts.length > 0)
|
|
2871
|
-
write('re-QE due: ' + reqe.debts.length + ' usage-switched run(s) kept same-family QE — run `dz reqe` for the cross-family pass');
|
|
2872
|
-
}
|
|
2873
|
-
catch { /* advisory only */ }
|
|
2733
|
+
write(jsonContract(spend));
|
|
2874
2734
|
return 0;
|
|
2875
2735
|
}
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
const
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
const
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2736
|
+
write('usage spend — last 7 UTC days');
|
|
2737
|
+
write('date weighted input output cache-read cache-write events');
|
|
2738
|
+
for (const day of spend.days) {
|
|
2739
|
+
write(`${day.date} ${number(day.weightedTokens)} ${number(day.input)} ${number(day.output)} ${number(day.cacheRead)} ${number(day.cacheWrite)} ${day.events}`);
|
|
2740
|
+
}
|
|
2741
|
+
const total = spend.total7d;
|
|
2742
|
+
write(`7-day total ${number(total.weightedTokens)} ${number(total.input)} ${number(total.output)} ${number(total.cacheRead)} ${number(total.cacheWrite)} ${total.events}`);
|
|
2743
|
+
// "unknown" = `event.model ?? 'unknown'` in `spendReport` — an event with NO model field AT
|
|
2744
|
+
// ALL, or one whose model string matched none of the four recognized substrings (in practice
|
|
2745
|
+
// almost always `<synthetic>`). Fix-round-1 (Codex review, MEDIUM #3): a prior wording here and
|
|
2746
|
+
// in the README said "not an event without a model", which is the OPPOSITE of what the code
|
|
2747
|
+
// does — corrected to name both causes.
|
|
2748
|
+
write('by model — weighted share (0..1) (7-day window; "unknown" = event with no model, or an unrecognized model string e.g. "<synthetic>")');
|
|
2749
|
+
const models = Object.entries(spend.byModel);
|
|
2750
|
+
if (models.length === 0)
|
|
2751
|
+
write(' (no events)');
|
|
2752
|
+
for (const [model, row] of models) {
|
|
2753
|
+
write(` ${model} ${number(row.weightedTokens)} ${number(row.sharePct / 100)}`);
|
|
2754
|
+
}
|
|
2755
|
+
const today = spend.daysByModel.at(-1);
|
|
2756
|
+
if (today !== undefined) {
|
|
2757
|
+
// Fix-round-1 (Codex review, MEDIUM #1): this block used to print weighted tokens only, so
|
|
2758
|
+
// AC-5's "today block shows Sonnet's share of today" had nothing to read it off of. The share
|
|
2759
|
+
// denominator is TODAY's own total (`spend.days.at(-1)`, the same last entry as `today` by
|
|
2760
|
+
// construction — both arrays are built from the same `days` in `spendReport`), not the 7-day
|
|
2761
|
+
// total — a day's share of a week would silently understate every model.
|
|
2762
|
+
write(`today (${today.date}) by model — weighted share-of-day (0..1)`);
|
|
2763
|
+
const todayModels = Object.entries(today.models);
|
|
2764
|
+
const todayTotal = spend.days.at(-1)?.weightedTokens ?? 0;
|
|
2765
|
+
if (todayModels.length === 0)
|
|
2766
|
+
write(' (no events)');
|
|
2767
|
+
for (const [model, weightedTokens] of todayModels) {
|
|
2768
|
+
const shareOfDay = todayTotal > 0 ? weightedTokens / todayTotal : 0;
|
|
2769
|
+
write(` ${model} ${number(weightedTokens)} ${number(shareOfDay)}`);
|
|
2770
|
+
}
|
|
2771
|
+
}
|
|
2772
|
+
write('source: local Claude Code + subagent transcripts, cost-weighted');
|
|
2908
2773
|
return 0;
|
|
2909
2774
|
}
|
|
2910
2775
|
catch {
|
|
2911
|
-
|
|
2776
|
+
const empty = spendReport([], { nowMs: Date.now(), days: 7 });
|
|
2912
2777
|
if (flags.has('json'))
|
|
2913
|
-
write(
|
|
2914
|
-
else
|
|
2915
|
-
write('usage
|
|
2778
|
+
write(jsonContract(empty));
|
|
2779
|
+
else {
|
|
2780
|
+
write('usage spend — last 7 UTC days');
|
|
2781
|
+
write('source: local Claude Code + subagent transcripts, cost-weighted');
|
|
2782
|
+
}
|
|
2916
2783
|
return 0;
|
|
2917
2784
|
}
|
|
2918
2785
|
}
|
|
@@ -3204,14 +3071,20 @@ async function cmdStoreGuard(options, flags, cwd, write, writeErr, stdinText, in
|
|
|
3204
3071
|
}
|
|
3205
3072
|
}
|
|
3206
3073
|
async function runTeachGuardReinforcement(projectRoot, dzId, reward, preserveQuarantine = false) {
|
|
3074
|
+
const matchedDzId = loadStoreRecords(projectRoot)
|
|
3075
|
+
.find((record) => record.id === dzId || record.text === dzId)?.id;
|
|
3207
3076
|
const backend = resolveLearningBackend(projectRoot);
|
|
3208
3077
|
backend.addSample({
|
|
3209
3078
|
dzId,
|
|
3210
3079
|
kind: preserveQuarantine ? 'recall-hit' : 'reinforce',
|
|
3211
|
-
reward,
|
|
3080
|
+
...(reward !== undefined ? { reward } : {}),
|
|
3212
3081
|
ts: new Date().toISOString(),
|
|
3213
3082
|
});
|
|
3214
|
-
|
|
3083
|
+
const trained = await backend.train();
|
|
3084
|
+
return {
|
|
3085
|
+
...trained,
|
|
3086
|
+
...(trained.flushed > 0 && matchedDzId !== undefined ? { dzId: matchedDzId } : {}),
|
|
3087
|
+
};
|
|
3215
3088
|
}
|
|
3216
3089
|
async function cmdTeach(options, flags, cwd, write, writeErr = (line) => { console.error(line); }, interactive = false, guardRunner = teachGuard, reinforceRunner = runTeachGuardReinforcement) {
|
|
3217
3090
|
// WHICH store this lesson belongs to, and WHO decided (teach-chooses-its-store).
|
|
@@ -3251,24 +3124,71 @@ async function cmdTeach(options, flags, cwd, write, writeErr = (line) => { conso
|
|
|
3251
3124
|
// (D3) — an unconfigured project runs ZERO vector code and its output stays byte-identical
|
|
3252
3125
|
// to the pre-feature baseline (AC-1). Failures are queued + logged by the service itself and
|
|
3253
3126
|
// NOT printed on the default path (teach must stay quiet/scriptable); only success emits.
|
|
3127
|
+
// AM-4 (dz-harness-hub issue #10 defect 4, feature setup-installs-apply-leg): a mirror attempt
|
|
3128
|
+
// that produced ZERO rows, resolved NO working engine (`receipt.engine === undefined` — deps
|
|
3129
|
+
// missing/unusable, the ABI-115 failure AM-2 fixes being the measured cause), AND left the
|
|
3130
|
+
// agentdb store file still absent is not "nothing to report" — it is the vector tier having
|
|
3131
|
+
// never come into being, and a lesson taught in that window has nowhere to mirror into until
|
|
3132
|
+
// `dz consolidate`/a later teach (once the store exists) runs. BOTH signals are required so this
|
|
3133
|
+
// never misfires for an rvf-configured project (whose store is not `.dz/agentdb.db` at all) or
|
|
3134
|
+
// for the ordinary "already mirrored, nothing new" case (which resolves an engine successfully).
|
|
3135
|
+
const emitVectorTierAbsentIfNeeded = (root, receipt) => {
|
|
3136
|
+
if (receipt.engine === undefined && !existsSync(resolveAgentdbPath(root))) {
|
|
3137
|
+
write(' ↳ vector tier absent — run dz consolidate');
|
|
3138
|
+
}
|
|
3139
|
+
};
|
|
3140
|
+
// AM-9/AM-10 (issue #10 defect 6, feature setup-installs-apply-leg): `vectorMirrorEnabled(root)`
|
|
3141
|
+
// alone used to decide "say nothing" for every disabled reason alike, including a config that
|
|
3142
|
+
// CLAIMS agentdb via a top-level `backend` key (`{"backend":"agentdb"}` instead of
|
|
3143
|
+
// `{"memory":{"backend":"agentdb"}}`) — a real, readable intent this silently dropped on the
|
|
3144
|
+
// floor. Named for `config-unreadable` / `legacy-shape` — both are a config that TRIED to say
|
|
3145
|
+
// something and got it wrong. THREE reasons stay silent: `engine-off` (deliberate), `no-config`
|
|
3146
|
+
// (the pre-existing AC-1 contract — a NAMED test in `cli.test.ts`/`teach-chooses-its-store.test.ts`
|
|
3147
|
+
// — printing there broke both, MEASURED), and `not-enabled` (AM-10, narrower than the amendment's
|
|
3148
|
+
// literal instruction — MEASURED: `not-enabled` is ALSO the state of the ORDINARY, first-class
|
|
3149
|
+
// jsonl backend `dz setup` produces by default, and printing there added a line to the single most
|
|
3150
|
+
// common `dz teach` invocation shape, reproducer: `mkdir .dz && echo '{"memory":{"backend":
|
|
3151
|
+
// "jsonl"}}' > .dz/config.json && dz teach "x"` → new line `↳ vector tier OFF: …` on the DEFAULT,
|
|
3152
|
+
// fully-supported jsonl path. `not-enabled` cannot distinguish "chose jsonl on purpose" from "typo'd
|
|
3153
|
+
// a backend name", so it is grouped with the other legitimate-quiet states rather than with the
|
|
3154
|
+
// two states that are unambiguously a mistake.
|
|
3155
|
+
const emitMirrorOffIfNeeded = (root) => {
|
|
3156
|
+
const reason = mirrorWriterReason(root);
|
|
3157
|
+
if (reason.state !== 'config-unreadable' && reason.state !== 'legacy-shape')
|
|
3158
|
+
return false;
|
|
3159
|
+
write(` ↳ vector tier OFF: ${mirrorWriterExplanation(reason.state)}`);
|
|
3160
|
+
return true;
|
|
3161
|
+
};
|
|
3254
3162
|
const emitMirror = async (root, records, source) => {
|
|
3255
|
-
if (flags.has('no-mirror') || records.length === 0
|
|
3163
|
+
if (flags.has('no-mirror') || records.length === 0)
|
|
3164
|
+
return;
|
|
3165
|
+
if (!vectorMirrorEnabled(root)) {
|
|
3166
|
+
emitMirrorOffIfNeeded(root);
|
|
3256
3167
|
return;
|
|
3168
|
+
}
|
|
3257
3169
|
const receipt = await mirrorPatternsToVector(root, records, source);
|
|
3258
3170
|
if (receipt.mirrored > 0)
|
|
3259
3171
|
write(` ↳ mirrored to vector tier (${receipt.engine ?? 'vector'})`);
|
|
3172
|
+
else
|
|
3173
|
+
emitVectorTierAbsentIfNeeded(root, receipt);
|
|
3260
3174
|
};
|
|
3261
3175
|
// lesson-quarantine FR-8: the fresh-teach mirror carries the qStatus marker so the hook daemon
|
|
3262
3176
|
// (which reads only the mirror's metadata) can exclude unproven lessons from auto-inject.
|
|
3263
3177
|
const emitMirrorQ = async (root, records, source, quarantined) => {
|
|
3264
|
-
if (flags.has('no-mirror') || records.length === 0
|
|
3178
|
+
if (flags.has('no-mirror') || records.length === 0)
|
|
3265
3179
|
return;
|
|
3180
|
+
if (!vectorMirrorEnabled(root)) {
|
|
3181
|
+
emitMirrorOffIfNeeded(root);
|
|
3182
|
+
return;
|
|
3183
|
+
}
|
|
3266
3184
|
const entries = records
|
|
3267
3185
|
.map((r) => patternVectorEntry(r, source, quarantined ? { quarantined: true } : {}))
|
|
3268
3186
|
.filter((e) => e !== undefined);
|
|
3269
3187
|
const receipt = await mirrorEntriesToVector(root, entries);
|
|
3270
3188
|
if (receipt.mirrored > 0)
|
|
3271
3189
|
write(` ↳ mirrored to vector tier (${receipt.engine ?? 'vector'})${quarantined ? ' [quarantined]' : ''}`);
|
|
3190
|
+
else
|
|
3191
|
+
emitVectorTierAbsentIfNeeded(root, receipt);
|
|
3272
3192
|
};
|
|
3273
3193
|
// `dz teach --harmonize` — documented ALIAS of `dz vector harmonize`: SEMANTIC dedup of the
|
|
3274
3194
|
// learned store. ONE implementation (harmonizeVectorStore), two entry points (QR-6). Routed
|
|
@@ -3388,21 +3308,25 @@ async function cmdTeach(options, flags, cwd, write, writeErr = (line) => { conso
|
|
|
3388
3308
|
}
|
|
3389
3309
|
const reinforce = options.get('reinforce');
|
|
3390
3310
|
if (reinforce !== undefined && reinforce.trim() !== '') {
|
|
3391
|
-
const backend = resolveLearningBackend(storeRoot);
|
|
3392
3311
|
const sampleReward = options.has('reward') ? parseFloat(options.get('reward') ?? '0.8') : undefined;
|
|
3393
|
-
|
|
3394
|
-
dzId: reinforce,
|
|
3395
|
-
kind: 'reinforce',
|
|
3396
|
-
ts: new Date().toISOString(),
|
|
3397
|
-
...(sampleReward !== undefined ? { reward: sampleReward } : {}),
|
|
3398
|
-
});
|
|
3399
|
-
const trained = await backend.train();
|
|
3312
|
+
const trained = await reinforceRunner(storeRoot, reinforce, sampleReward);
|
|
3400
3313
|
if (trained.flushed > 0) {
|
|
3401
|
-
|
|
3314
|
+
const records = loadStoreRecords(storeRoot);
|
|
3315
|
+
const reinforcedDzId = trained.dzId
|
|
3316
|
+
?? findExactLesson(records, reinforce)?.id
|
|
3317
|
+
?? records.find((record) => record.id === reinforce)?.id;
|
|
3318
|
+
write(reinforcedDzId !== undefined && reinforcedDzId !== reinforce
|
|
3319
|
+
? `↳ reinforced ${reinforcedDzId} (matched by text)`
|
|
3320
|
+
: `↳ reinforced ${reinforcedDzId ?? reinforce}`);
|
|
3402
3321
|
// lesson-quarantine: reinforcement IS promotion — keep the hook daemon's mirror in step.
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
|
|
3322
|
+
if (reinforcedDzId === undefined) {
|
|
3323
|
+
write(' ↳ mirror quarantine NOT cleared: matched pattern has no dzId');
|
|
3324
|
+
}
|
|
3325
|
+
else {
|
|
3326
|
+
const clearedQ = clearAgentdbQuarantine(storeRoot, [reinforcedDzId]);
|
|
3327
|
+
if (clearedQ.cleared > 0)
|
|
3328
|
+
write(` ↳ promoted out of quarantine (mirror updated)`);
|
|
3329
|
+
}
|
|
3406
3330
|
write(storeLine('written'));
|
|
3407
3331
|
refreshLearningStoreMark(storeRoot, writeErr, 'dz teach --reinforce');
|
|
3408
3332
|
return 0;
|
|
@@ -4747,6 +4671,7 @@ Usage:
|
|
|
4747
4671
|
dz brain add --from-kus <file.json> --slug <s> [--kind repo|book|paper] [--license <spdx>] [--override] [--json]
|
|
4748
4672
|
dz brain update <slug> [--project <dir>] [--json]
|
|
4749
4673
|
dz brain reindex [--json]
|
|
4674
|
+
dz brain snapshots [--keep <N>] [--prune] [--json] [--project <dir>]
|
|
4750
4675
|
dz brain primer <slug> [--json]
|
|
4751
4676
|
dz brain export --source <slug> --out <file>
|
|
4752
4677
|
dz brain ground [<prompt>] [--k <N>] [--source <slug>] [--text] [--budget <N>] [--full]
|
|
@@ -5215,8 +5140,101 @@ async function cmdBrain(options, flags, cwd, write, readStdin) {
|
|
|
5215
5140
|
write(`dz brain reindex: re-embedded ${result.reembedded} KU vector(s) with ${result.model} (manifest v${result.version})`);
|
|
5216
5141
|
if (result.backupPath !== undefined)
|
|
5217
5142
|
write(` snapshot: ${result.backupPath}`);
|
|
5143
|
+
if (result.snapshots !== undefined) {
|
|
5144
|
+
const mb = (result.snapshots.removedBytes / (1024 * 1024)).toFixed(1);
|
|
5145
|
+
write(` ↳ snapshots: kept ${result.snapshots.kept.length}, removed ${result.snapshots.removed.length} (${mb} MB)`);
|
|
5146
|
+
if (result.snapshots.errors !== undefined && result.snapshots.errors.length > 0) {
|
|
5147
|
+
write(` ⚠ snapshot rotation error(s): ${result.snapshots.errors.join('; ')}`);
|
|
5148
|
+
}
|
|
5149
|
+
if (result.snapshots.scanErrors !== undefined && result.snapshots.scanErrors.length > 0) {
|
|
5150
|
+
write(` ⚠ snapshot scan error(s), nothing removed this call: ${result.snapshots.scanErrors.join('; ')}`);
|
|
5151
|
+
}
|
|
5152
|
+
if (result.snapshots.partialFamilies !== undefined && result.snapshots.partialFamilies.length > 0) {
|
|
5153
|
+
write(` ⚠ .bak preserved after a sibling failure in famil(y/ies): ${result.snapshots.partialFamilies.join(', ')}`);
|
|
5154
|
+
}
|
|
5155
|
+
}
|
|
5218
5156
|
return 0;
|
|
5219
5157
|
}
|
|
5158
|
+
// ── dz brain snapshots [--keep N] [--prune] [--json] ────────────────────────────────────────
|
|
5159
|
+
// Manual rotation of the brain's OWN pre-reindex snapshots — independent of `dz brain reindex`
|
|
5160
|
+
// (FR-7). The owner's hub forbids running a live reindex there today, and 13 snapshots / 50 MB
|
|
5161
|
+
// sit unrotated regardless; this command reaches the same family-aware rotation without one.
|
|
5162
|
+
// Without --prune it only LISTS families (dry, never deletes); --prune applies FR-1..FR-5.
|
|
5163
|
+
if (sub === 'snapshots') {
|
|
5164
|
+
// Lead edit after acceptance (2026-09-13): the owner's hub keeps its 13 families next to the
|
|
5165
|
+
// PROJECT store (.dz/agentdb.db, written by the vector-tier reindex), not the home brain —
|
|
5166
|
+
// `--project <dir>` addresses that store; without it the home brain is the target as before.
|
|
5167
|
+
const projectArg = options.get('project');
|
|
5168
|
+
const dbFile = projectArg !== undefined ? resolveAgentdbPath(resolve(cwd, projectArg)) : brainAgentdbPath(brainHome());
|
|
5169
|
+
const keepRaw = options.get('keep');
|
|
5170
|
+
let keep = 3;
|
|
5171
|
+
if (keepRaw !== undefined) {
|
|
5172
|
+
// AM-1 (fix-round, Codex review Grade D): `Number('')` is `0` and `Number(' 2')` is `2` —
|
|
5173
|
+
// both used to validate as an ordinary non-negative integer, silently accepting empty/
|
|
5174
|
+
// whitespace input. Only the literal digit-string shape is accepted; no trimming.
|
|
5175
|
+
if (!/^(0|[1-9]\d*)$/.test(keepRaw)) {
|
|
5176
|
+
write(`dz brain snapshots: --keep must be a non-negative integer (got '${keepRaw}')`);
|
|
5177
|
+
return 2;
|
|
5178
|
+
}
|
|
5179
|
+
keep = Number(keepRaw);
|
|
5180
|
+
// Lead edit after re-review (Codex C): a digit string can still overflow a safe integer.
|
|
5181
|
+
if (!Number.isSafeInteger(keep)) {
|
|
5182
|
+
write(`dz brain snapshots: --keep is out of range (got '${keepRaw}')`);
|
|
5183
|
+
return 2;
|
|
5184
|
+
}
|
|
5185
|
+
}
|
|
5186
|
+
if (!flags.has('prune')) {
|
|
5187
|
+
// Lead edit after re-review: the list is only trustworthy when the scan was complete —
|
|
5188
|
+
// an unreadable directory is reported with ⚠ and exit 1, never as "no families".
|
|
5189
|
+
const { families, scanErrors } = scanSnapshotDir(dbFile);
|
|
5190
|
+
if (asJson) {
|
|
5191
|
+
write(JSON.stringify({ keep, families: families.map((f) => ({ ms: f.ms, files: f.files.map((file) => file.name), bytes: f.bytes })), scanErrors }));
|
|
5192
|
+
return scanErrors.length > 0 ? 1 : 0;
|
|
5193
|
+
}
|
|
5194
|
+
if (scanErrors.length > 0)
|
|
5195
|
+
write(` ⚠ scan error(s) — the list below may be incomplete: ${scanErrors.join('; ')}`);
|
|
5196
|
+
if (families.length === 0) {
|
|
5197
|
+
write(`dz brain snapshots: no pre-reindex snapshot families next to ${dbFile}`);
|
|
5198
|
+
return scanErrors.length > 0 ? 1 : 0;
|
|
5199
|
+
}
|
|
5200
|
+
write(`dz brain snapshots — ${families.length} family(-ies) @ ${dbFile}`);
|
|
5201
|
+
for (const f of families) {
|
|
5202
|
+
const mb = (f.bytes / (1024 * 1024)).toFixed(1);
|
|
5203
|
+
write(` ${new Date(f.ms).toISOString()} ms=${f.ms} ${f.files.length} file(s) ${mb} MB`);
|
|
5204
|
+
}
|
|
5205
|
+
write(' (dry run — pass --prune to remove families older than --keep)');
|
|
5206
|
+
return scanErrors.length > 0 ? 1 : 0;
|
|
5207
|
+
}
|
|
5208
|
+
const report = rotatePreReindexSnapshots(dbFile, { keep });
|
|
5209
|
+
const scanFailed = report.scanErrors !== undefined && report.scanErrors.length > 0;
|
|
5210
|
+
// agentdb-snapshot-lock FR-4: a busy snapshot lock is reported exactly like a scan failure —
|
|
5211
|
+
// nothing removed, ⚠, exit 1 — never a silent "kept N, removed 0" that reads like an empty rotation.
|
|
5212
|
+
const lockBusy = report.errors !== undefined && report.errors.some((e) => e.startsWith('lock busy'));
|
|
5213
|
+
if (asJson) {
|
|
5214
|
+
write(JSON.stringify(report));
|
|
5215
|
+
return scanFailed || lockBusy ? 1 : 0;
|
|
5216
|
+
}
|
|
5217
|
+
const mb = (report.removedBytes / (1024 * 1024)).toFixed(1);
|
|
5218
|
+
write(`dz brain snapshots: kept ${report.kept.length}, removed ${report.removed.length} (${mb} MB)`);
|
|
5219
|
+
if (report.removed.length > 0)
|
|
5220
|
+
write(` removed: ${report.removed.join(', ')}`);
|
|
5221
|
+
if (report.errors !== undefined && report.errors.length > 0) {
|
|
5222
|
+
write(` ⚠ ${report.errors.length} error(s): ${report.errors.join('; ')}`);
|
|
5223
|
+
}
|
|
5224
|
+
// AM-4: an incomplete scan means NOTHING was removed this call — say so, never silently.
|
|
5225
|
+
if (report.scanErrors !== undefined && report.scanErrors.length > 0) {
|
|
5226
|
+
write(` ⚠ scan error(s), nothing removed this call: ${report.scanErrors.join('; ')}`);
|
|
5227
|
+
}
|
|
5228
|
+
// AM-2: a family whose .bak survived only because a sibling failed to unlink.
|
|
5229
|
+
if (report.partialFamilies !== undefined && report.partialFamilies.length > 0) {
|
|
5230
|
+
write(` ⚠ .bak preserved after a sibling failure in famil(y/ies): ${report.partialFamilies.join(', ')}`);
|
|
5231
|
+
}
|
|
5232
|
+
// FR-3: a live reindex marker rescued a family, or an expired one was cleaned up — honest, never an error.
|
|
5233
|
+
if (report.notes !== undefined && report.notes.length > 0) {
|
|
5234
|
+
write(` note: ${report.notes.join('; ')}`);
|
|
5235
|
+
}
|
|
5236
|
+
return scanFailed || lockBusy ? 1 : 0;
|
|
5237
|
+
}
|
|
5220
5238
|
// ── dz brain ground [<prompt>] ───────────────────────────────────────────────────────────────
|
|
5221
5239
|
// The UserPromptSubmit hook entrypoint. ALWAYS exits 0 — grounding is advisory and must never
|
|
5222
5240
|
// fail a prompt. Emits nothing (silent) unless the brain has relevant citations for the prompt.
|
|
@@ -5425,17 +5443,41 @@ async function cmdSetup(options, flags, cwd, write, writeErr) {
|
|
|
5425
5443
|
write(`║ 2. Preset: ${preset.padEnd(40)}║`);
|
|
5426
5444
|
// Step 3: Run setup (hooks + memory + config)
|
|
5427
5445
|
write(`║ 3. Setting up learning environment... ║`);
|
|
5428
|
-
const
|
|
5446
|
+
const memoryOptRaw = options.get('memory');
|
|
5447
|
+
// FR-1/T3 (feature `setup-backend-from-config`): pass `--memory` through AS-IS — `agentdb`,
|
|
5448
|
+
// `jsonl`, or `undefined` — never collapsed to `undefined` on anything but agentdb. The prior
|
|
5449
|
+
// `memoryOpt === 'agentdb' ? 'agentdb' : undefined` made an explicit `--memory jsonl` INDISTINCT
|
|
5450
|
+
// from "no flag at all", so `runSetup`'s config-aware default (FR-2's downgrade path) could never
|
|
5451
|
+
// fire from the CLI. An unrecognised value (neither `agentdb` nor `jsonl`) still reads as
|
|
5452
|
+
// "no flag" — the same permissive fallback as before.
|
|
5453
|
+
const memoryOpt = memoryOptRaw === 'agentdb' ? 'agentdb' : memoryOptRaw === 'jsonl' ? 'jsonl' : undefined;
|
|
5454
|
+
// ADR-001 Decision 2 (feature setup-installs-apply-leg): bake THIS CLI's own installed
|
|
5455
|
+
// @dzhechkov/harness-core into the generated apply-leg hooks — the installation actually running
|
|
5456
|
+
// `dz setup` is the one a consumer's project can always reach, unlike a hard-coded npm prefix
|
|
5457
|
+
// (FR-3). Best-effort: an unresolvable core (should not happen — the CLI depends on it) falls
|
|
5458
|
+
// back to core's own self-resolution inside `runSetup`, never a crash.
|
|
5459
|
+
let coreDistDir;
|
|
5460
|
+
try {
|
|
5461
|
+
const corePkgJson = createRequire(import.meta.url).resolve('@dzhechkov/harness-core/package.json');
|
|
5462
|
+
coreDistDir = join(dirname(corePkgJson), 'dist');
|
|
5463
|
+
}
|
|
5464
|
+
catch {
|
|
5465
|
+
coreDistDir = undefined;
|
|
5466
|
+
}
|
|
5429
5467
|
const setupResult = runSetup({
|
|
5430
5468
|
projectRoot,
|
|
5431
5469
|
target,
|
|
5432
5470
|
preset,
|
|
5433
|
-
memory: memoryOpt
|
|
5471
|
+
memory: memoryOpt,
|
|
5434
5472
|
noHooks: flags.has('no-hooks'),
|
|
5435
5473
|
noMemory: flags.has('no-memory'),
|
|
5436
5474
|
force: flags.has('force'),
|
|
5437
5475
|
installDriver: flags.has('install-driver'),
|
|
5476
|
+
coreDistDir,
|
|
5438
5477
|
});
|
|
5478
|
+
// FR-3: name the source of the backend actually used — never left to be inferred from the flag
|
|
5479
|
+
// alone, since the backend may now come from `.dz/config.json` or the jsonl default.
|
|
5480
|
+
write(`dz setup: memory backend: ${setupResult.memoryBackend} (${memoryBackendSourceLabel(setupResult.memoryBackendSource)})`);
|
|
5439
5481
|
for (const step of setupResult.steps) {
|
|
5440
5482
|
const icon = step.status === 'done' ? '✓' : step.status === 'skipped' ? '○' : '✗';
|
|
5441
5483
|
write(`║ ${icon} ${step.name.padEnd(25)} ${step.detail.slice(0, 20).padEnd(20)}║`);
|
|
@@ -5486,7 +5528,11 @@ async function cmdSetup(options, flags, cwd, write, writeErr) {
|
|
|
5486
5528
|
// not from package presence — a skipped hook/MCP step must not let the summary claim a store
|
|
5487
5529
|
// nothing writes to (audit code#3).
|
|
5488
5530
|
const wiring = setupResult.steps.find((s) => s.name === 'agentdb wiring');
|
|
5489
|
-
|
|
5531
|
+
// Keyed off the RESOLVED backend (setupResult.memoryBackend), not the raw flag: FR-1 means the
|
|
5532
|
+
// flag can be absent while the actual backend is still agentdb (config-sourced) — the old
|
|
5533
|
+
// `memoryOpt === 'agentdb'` check would have mislabeled that run as jsonl right after fixing the
|
|
5534
|
+
// underlying steps to keep it agentdb.
|
|
5535
|
+
const backendLabel = setupResult.memoryBackend === 'agentdb'
|
|
5490
5536
|
? (wiring?.status === 'done' ? 'agentdb (.dz/agentdb.db + .dz/agentdb-mcp.db, separate stores)' : `agentdb INCOMPLETE — see setup steps`)
|
|
5491
5537
|
: 'sessions.jsonl + patterns.jsonl';
|
|
5492
5538
|
write(`║ Learning: ${backendLabel.padEnd(41)}║`);
|
|
@@ -6176,7 +6222,21 @@ function mirrorFailureMessage(error) {
|
|
|
6176
6222
|
return error.message.trim().split(/\r?\n/, 1)[0] ?? 'mirror command failed';
|
|
6177
6223
|
return String(error);
|
|
6178
6224
|
}
|
|
6179
|
-
|
|
6225
|
+
/**
|
|
6226
|
+
* Scratch root for the packed-install smoke's pack/install dirs (feature
|
|
6227
|
+
* publish-sibling-drift-gate). MEASURED 2026-09-13: npm resolves a LOCAL tarball path (`npm
|
|
6228
|
+
* install <path-to.tgz>`) relative to `os.tmpdir()` — not to cwd — whenever that path sits
|
|
6229
|
+
* INSIDE `os.tmpdir()`, and does the same for the install dir; put pack and install dirs both
|
|
6230
|
+
* under `tmpdir()` and the recorded `file:` spec loses its `tmpdir()` prefix entirely (reproducer:
|
|
6231
|
+
* a fresh `npm pack <src> --pack-destination "$T/pack"` + `cd "$T/install" && npm install
|
|
6232
|
+
* "$T/pack/x.tgz"` with `$T` under `/tmp` silently installs NOTHING — "changed 1 package", empty
|
|
6233
|
+
* node_modules, `reify moves {}` in `--loglevel silly`; the identical commands under `/var/tmp`
|
|
6234
|
+
* install correctly). A directory outside `os.tmpdir()` sidesteps the quirk entirely.
|
|
6235
|
+
*/
|
|
6236
|
+
function packedInstallScratchRoot() {
|
|
6237
|
+
return existsSync('/var/tmp') ? '/var/tmp' : tmpdir();
|
|
6238
|
+
}
|
|
6239
|
+
function cmdPublish(options, flags, cwd, writeOutput, mirrorRunner, siblingDriftFetcher, packedInstallRunner, publishExecRunner) {
|
|
6180
6240
|
const json = flags.has('json');
|
|
6181
6241
|
// Under --json stdout carries exactly one JSON document, so every human line — guard notes, refusals,
|
|
6182
6242
|
// progress — goes to stderr instead of being dropped: a refusal that prints nothing is the silent
|
|
@@ -6184,9 +6244,9 @@ function cmdPublish(options, flags, cwd, writeOutput, mirrorRunner) {
|
|
|
6184
6244
|
const write = json ? (line) => { process.stderr.write(`${line}\n`); } : writeOutput;
|
|
6185
6245
|
// Reject unknown flags/options so a typo (e.g. `--dry-rum`) can NEVER be
|
|
6186
6246
|
// silently swallowed and flip the command into live-publish mode.
|
|
6187
|
-
const allowedFlags = new Set(['dry-run', 'no-dry-run', 'yes', 'confirm', 'bump-only', 'help', 'require-signing', 'provenance', 'no-provenance', 'json', 'no-mirror']);
|
|
6247
|
+
const allowedFlags = new Set(['dry-run', 'no-dry-run', 'yes', 'confirm', 'bump-only', 'help', 'require-signing', 'provenance', 'no-provenance', 'json', 'no-mirror', 'allow-sibling-drift', 'include-drifted']);
|
|
6188
6248
|
const allowedOptions = new Set(['filter', 'claim-check', 'no-guard', 'sign-key', 'mirror-cmd']);
|
|
6189
|
-
const allowedHelp = ' allowed: --dry-run (default), --yes/--confirm/--no-dry-run (go live), --bump-only, --filter <substr>, --claim-check <off|warn|error>, --mirror-cmd <cmd>, --no-mirror, --no-guard "<reason>" (skip the guard pre-flight; logged)';
|
|
6249
|
+
const allowedHelp = ' allowed: --dry-run (default), --yes/--confirm/--no-dry-run (go live), --bump-only, --filter <substr>, --claim-check <off|warn|error>, --mirror-cmd <cmd>, --no-mirror, --no-guard "<reason>" (skip the guard pre-flight; logged), --allow-sibling-drift (override the sibling-drift gate; logged), --include-drifted (auto-extend the batch with a drifted sibling)';
|
|
6190
6250
|
for (const flag of flags) {
|
|
6191
6251
|
if (!allowedFlags.has(flag)) {
|
|
6192
6252
|
write(`dz publish: unknown option --${flag}`);
|
|
@@ -6276,8 +6336,282 @@ function cmdPublish(options, flags, cwd, writeOutput, mirrorRunner) {
|
|
|
6276
6336
|
const bumpOnly = flags.has('bump-only');
|
|
6277
6337
|
// SAFETY: dry-run is the DEFAULT. A real publish requires an EXPLICIT opt-in
|
|
6278
6338
|
// via --yes, --confirm, or --no-dry-run. Without one, we never bump or publish.
|
|
6339
|
+
// Computed HERE (moved up from below the gates, AM-5) so both gates can see it: a dry run keeps
|
|
6340
|
+
// previewing packed-install with the CURRENT pre-bump tarball (nothing to compare a LIVE publish
|
|
6341
|
+
// against yet), while a live run defers the real packed-install-smoke into `publishPackages`'s
|
|
6342
|
+
// `packedTransport` — the one that tests the ACTUAL bytes about to ship (AM-1).
|
|
6279
6343
|
const wantsLive = flags.has('yes') || flags.has('confirm') || flags.has('no-dry-run');
|
|
6280
6344
|
const dryRun = !wantsLive;
|
|
6345
|
+
// ── FR-1..FR-4 — sibling-drift gate, then packed-install smoke (feature
|
|
6346
|
+
// publish-sibling-drift-gate, ADR-001). The sibling-drift gate runs before the signature gate
|
|
6347
|
+
// and the live-publish banner (so a --include-drifted-expanded batch is checked and shown too).
|
|
6348
|
+
// AM-5: on a DRY RUN both gates always print their verdict, even once sibling-drift already
|
|
6349
|
+
// blocks — the whole point of a preview is full information before anything ships. On a LIVE
|
|
6350
|
+
// run, sibling-drift still refuses immediately (packing/installing a doomed batch wastes real
|
|
6351
|
+
// time); its own packed-install smoke is deferred into `publishPackages`'s `packedTransport`
|
|
6352
|
+
// (AM-1) — the one gate that tests the tarball bytes actually handed to `npm publish`.
|
|
6353
|
+
const allowSiblingDrift = flags.has('allow-sibling-drift');
|
|
6354
|
+
const includeDrifted = flags.has('include-drifted');
|
|
6355
|
+
const allPackages = discoverPackages(cwd);
|
|
6356
|
+
const workspaceVersions = new Map(allPackages.map((p) => [p.name, p.version]));
|
|
6357
|
+
const workspaceDirs = new Map(allPackages.map((p) => [p.name, p.dir]));
|
|
6358
|
+
const matchesFilter = (pk) => filter === undefined || filter.length === 0 || filter.some((f) => pk.name.includes(f) || pk.dir.includes(f));
|
|
6359
|
+
let targets = allPackages.filter(matchesFilter);
|
|
6360
|
+
let batchNames = new Set(targets.map((p) => p.name));
|
|
6361
|
+
// Production default: `npm pack <name>@<version>` into a temp dir, extracted. Tests inject a
|
|
6362
|
+
// local directory (ADR-001, "fetchPublished … в тестах — локальный каталог").
|
|
6363
|
+
const fetchPublished = siblingDriftFetcher ??
|
|
6364
|
+
((name, version) => {
|
|
6365
|
+
try {
|
|
6366
|
+
const tmp = mkdtempSync(join(tmpdir(), 'dz-sibling-drift-'));
|
|
6367
|
+
execSync(`npm pack ${name}@${version} --pack-destination ${JSON.stringify(tmp)}`, {
|
|
6368
|
+
stdio: 'pipe',
|
|
6369
|
+
encoding: 'utf-8',
|
|
6370
|
+
timeout: 60_000,
|
|
6371
|
+
});
|
|
6372
|
+
const tarball = readdirSync(tmp).find((f) => f.endsWith('.tgz'));
|
|
6373
|
+
if (tarball === undefined)
|
|
6374
|
+
return null;
|
|
6375
|
+
execSync(`tar -xzf ${JSON.stringify(join(tmp, tarball))} -C ${JSON.stringify(tmp)}`, { stdio: 'pipe', timeout: 60_000 });
|
|
6376
|
+
return { dir: join(tmp, 'package') };
|
|
6377
|
+
}
|
|
6378
|
+
catch {
|
|
6379
|
+
return null;
|
|
6380
|
+
}
|
|
6381
|
+
});
|
|
6382
|
+
// AM-6: an override (--allow-sibling-drift) is only real once its audit row is DURABLE. A write
|
|
6383
|
+
// failure must refuse the publish rather than print "(logged)" about a log entry that never
|
|
6384
|
+
// landed — the same "absence of a receipt is not success" lesson the registry-probe gate already
|
|
6385
|
+
// enforces for a publish's own confirmation.
|
|
6386
|
+
const auditedOverride = (detail, humanMessage, pkgNameForBlock) => {
|
|
6387
|
+
const wrote = appendPublishGateAudit(cwd, 'sibling-drift', 'warn', detail, '--allow-sibling-drift');
|
|
6388
|
+
if (wrote) {
|
|
6389
|
+
write(`dz publish: ⚠ ${humanMessage} — allowed via --allow-sibling-drift (logged)`);
|
|
6390
|
+
return false;
|
|
6391
|
+
}
|
|
6392
|
+
write(`dz publish: BLOCKED ${pkgNameForBlock} — ${humanMessage}, and the override could not be recorded (audit write failed); refusing rather than proceeding unlogged`);
|
|
6393
|
+
return true;
|
|
6394
|
+
};
|
|
6395
|
+
let driftBlocked = 0;
|
|
6396
|
+
const extraBatch = new Set();
|
|
6397
|
+
// AM-2: --include-drifted must reach a FIXED POINT over transitive drifted siblings — a sibling
|
|
6398
|
+
// folded into the batch can itself depend on a drifted sibling outside it, and the round-1 review
|
|
6399
|
+
// (finding 2) showed the single pass never re-checked an EXPANDED batch's own new edges. Capped at
|
|
6400
|
+
// `allPackages.length + 1` rounds (the plan's own "цикл с потолком = число пакетов").
|
|
6401
|
+
const maxRounds = allPackages.length + 1;
|
|
6402
|
+
for (let round = 0; round < maxRounds; round++) {
|
|
6403
|
+
let addedThisRound = false;
|
|
6404
|
+
for (const pk of targets) {
|
|
6405
|
+
let manifestObj;
|
|
6406
|
+
try {
|
|
6407
|
+
manifestObj = JSON.parse(readFileSync(join(pk.dir, 'package.json'), 'utf-8'));
|
|
6408
|
+
}
|
|
6409
|
+
catch (err) {
|
|
6410
|
+
// AM-3: an unreadable/invalid package.json for a BATCH package is an input this HARD gate
|
|
6411
|
+
// cannot build — it must BLOCK, never silently degrade to "no dependencies" (which used to
|
|
6412
|
+
// read as a clean n/a).
|
|
6413
|
+
const reason = `package.json unreadable/invalid (${err.message.split('\n')[0]})`;
|
|
6414
|
+
if (allowSiblingDrift) {
|
|
6415
|
+
if (auditedOverride(`${pk.name}: ${reason}`, `sibling drift check unavailable for ${pk.name} (${reason})`, pk.name))
|
|
6416
|
+
driftBlocked++;
|
|
6417
|
+
}
|
|
6418
|
+
else {
|
|
6419
|
+
write(`dz publish: BLOCKED ${pk.name} — sibling drift check unavailable (${reason}); add --allow-sibling-drift to override (logged) or fix the manifest`);
|
|
6420
|
+
driftBlocked++;
|
|
6421
|
+
}
|
|
6422
|
+
continue;
|
|
6423
|
+
}
|
|
6424
|
+
const deps = manifestObj?.dependencies ?? {};
|
|
6425
|
+
const peerDeps = manifestObj?.peerDependencies ?? {};
|
|
6426
|
+
const optionalDeps = manifestObj?.optionalDependencies ?? {};
|
|
6427
|
+
// AM-6: a package with no workspace: dependency at all is n/a for THIS gate — recorded as a
|
|
6428
|
+
// pass note, not silence (FR-6 compatibility: output stays unchanged for such a batch).
|
|
6429
|
+
const anyWorkspaceDep = [...Object.values(deps), ...Object.values(peerDeps), ...Object.values(optionalDeps)]
|
|
6430
|
+
.some((spec) => String(spec).startsWith('workspace:'));
|
|
6431
|
+
if (!anyWorkspaceDep) {
|
|
6432
|
+
appendPublishGateAudit(cwd, 'sibling-drift', 'pass', `${pk.name}: n/a — no workspace: dependency declared`);
|
|
6433
|
+
continue;
|
|
6434
|
+
}
|
|
6435
|
+
const drifts = detectSiblingDrift({
|
|
6436
|
+
dependencies: deps,
|
|
6437
|
+
peerDependencies: peerDeps,
|
|
6438
|
+
optionalDependencies: optionalDeps,
|
|
6439
|
+
workspaceVersions,
|
|
6440
|
+
workspaceDirs,
|
|
6441
|
+
batch: batchNames,
|
|
6442
|
+
fetchPublished,
|
|
6443
|
+
});
|
|
6444
|
+
for (const r of drifts) {
|
|
6445
|
+
if (r.status === 'same') {
|
|
6446
|
+
appendPublishGateAudit(cwd, 'sibling-drift', 'pass', `${r.name}@${r.version} = workspace (dependent: ${pk.name})`);
|
|
6447
|
+
write(`dz publish: ✓ sibling drift: none (${r.name}@${r.version} = workspace)`);
|
|
6448
|
+
}
|
|
6449
|
+
else if (r.status === 'unavailable') {
|
|
6450
|
+
if (allowSiblingDrift) {
|
|
6451
|
+
if (auditedOverride(`${r.name}@${r.version}: ${r.reason}`, `sibling drift check unavailable for ${r.name}@${r.version} (${r.reason})`, pk.name))
|
|
6452
|
+
driftBlocked++;
|
|
6453
|
+
}
|
|
6454
|
+
else {
|
|
6455
|
+
write(`dz publish: BLOCKED ${pk.name} — sibling drift check unavailable (${r.reason}); add --allow-sibling-drift to override (logged) or check network/registry access`);
|
|
6456
|
+
driftBlocked++;
|
|
6457
|
+
}
|
|
6458
|
+
}
|
|
6459
|
+
else if (includeDrifted) {
|
|
6460
|
+
if (!batchNames.has(r.name) && !extraBatch.has(r.name)) {
|
|
6461
|
+
extraBatch.add(r.name);
|
|
6462
|
+
addedThisRound = true;
|
|
6463
|
+
write(`dz publish: → sibling drift: ${r.name}@${r.version} differs from the workspace (${r.changedFiles.length} file(s)) — adding to the batch via --include-drifted${r.missingExports.length > 0 ? ` (missing exports: ${r.missingExports.join(', ')})` : ''}`);
|
|
6464
|
+
}
|
|
6465
|
+
}
|
|
6466
|
+
else if (allowSiblingDrift) {
|
|
6467
|
+
if (auditedOverride(`${r.name}@${r.version}: ${r.changedFiles.length} file(s) differ from the workspace`, `sibling drift: ${r.name}@${r.version} differs from the workspace (${r.changedFiles.length} file(s))`, pk.name))
|
|
6468
|
+
driftBlocked++;
|
|
6469
|
+
}
|
|
6470
|
+
else {
|
|
6471
|
+
appendPublishGateAudit(cwd, 'sibling-drift', 'block', `${pk.name} depends on ${r.name}@${r.version}; ${r.changedFiles.length} file(s) differ from the workspace`);
|
|
6472
|
+
const suggestFilter = filterStr !== undefined ? `${filterStr},${r.name}` : `${pk.name},${r.name}`;
|
|
6473
|
+
write(`dz publish: BLOCKED ${pk.name} — sibling drift: @dzhechkov/${r.name.replace(/^@dzhechkov\//, '')}@${r.version} on the registry differs from the workspace (${r.changedFiles.length} file(s)); add ${r.name} to the batch (--filter ${suggestFilter}) or publish it first`);
|
|
6474
|
+
driftBlocked++;
|
|
6475
|
+
}
|
|
6476
|
+
}
|
|
6477
|
+
}
|
|
6478
|
+
if (driftBlocked > 0)
|
|
6479
|
+
break; // nothing to expand into a run that already refuses
|
|
6480
|
+
if (!includeDrifted || !addedThisRound)
|
|
6481
|
+
break; // no auto-expand requested, or fixed point reached
|
|
6482
|
+
// FR-4: --include-drifted folds the drifted sibling(s) into the batch — they bump patch like
|
|
6483
|
+
// any other package in `publishPackages`' own (unchanged) bump logic. Re-loop: the newly
|
|
6484
|
+
// folded-in sibling(s) may themselves depend on a drifted sibling outside the (now bigger) batch.
|
|
6485
|
+
filter = filter === undefined ? [...batchNames, ...extraBatch] : [...filter, ...extraBatch];
|
|
6486
|
+
targets = allPackages.filter(matchesFilter);
|
|
6487
|
+
batchNames = new Set(targets.map((p) => p.name));
|
|
6488
|
+
}
|
|
6489
|
+
const siblingDriftFailed = driftBlocked > 0;
|
|
6490
|
+
if (siblingDriftFailed && !dryRun) {
|
|
6491
|
+
write(`dz publish: refusing to publish (${driftBlocked} sibling-drift violation(s))`);
|
|
6492
|
+
return 1;
|
|
6493
|
+
}
|
|
6494
|
+
// FR-3 — packed-install smoke: pack the WHOLE (possibly --include-drifted-expanded) batch,
|
|
6495
|
+
// install every tarball together in a CLEAN dir (out-of-batch siblings resolve from the
|
|
6496
|
+
// registry, exactly like a fresh user's install), then boot every bin with --version.
|
|
6497
|
+
// "n/a" (FR-6) when nothing in the batch has a bin. AM-8: a bin is collected here whether or not
|
|
6498
|
+
// its target file exists YET — a manifest that declares one but ships nothing must BLOCK after a
|
|
6499
|
+
// real install, never silently vanish from the plan (which used to read as n/a, or even skip the
|
|
6500
|
+
// whole gate when it was the batch's only bin).
|
|
6501
|
+
const bins = [];
|
|
6502
|
+
for (const pk of targets) {
|
|
6503
|
+
let manifest = {};
|
|
6504
|
+
try {
|
|
6505
|
+
manifest = JSON.parse(readFileSync(join(pk.dir, 'package.json'), 'utf-8'));
|
|
6506
|
+
}
|
|
6507
|
+
catch { /* no bin info available */ }
|
|
6508
|
+
if (typeof manifest.bin === 'string') {
|
|
6509
|
+
bins.push({ pkg: pk.name, binName: pk.name.split('/').pop() ?? pk.name, relPath: manifest.bin.replace(/^\.\//, '') });
|
|
6510
|
+
}
|
|
6511
|
+
else if (manifest.bin !== undefined && manifest.bin !== null && typeof manifest.bin === 'object') {
|
|
6512
|
+
for (const [name, relRaw] of Object.entries(manifest.bin)) {
|
|
6513
|
+
bins.push({ pkg: pk.name, binName: name, relPath: String(relRaw).replace(/^\.\//, '') });
|
|
6514
|
+
}
|
|
6515
|
+
}
|
|
6516
|
+
}
|
|
6517
|
+
// AM-1/AM-5: the packed-install-smoke PREVIEW below runs on a DRY RUN only, against whatever is
|
|
6518
|
+
// CURRENTLY on disk (pre-bump) — it cannot be the "same bytes that ship" gate AM-1 requires,
|
|
6519
|
+
// because a dry run never bumps/builds/packs anything real to compare against. On a LIVE run the
|
|
6520
|
+
// real gate is `packedTransport` (wired at the `publishPackages` call below), which packs ONCE
|
|
6521
|
+
// post-bump and smokes exactly those tarballs — this preview is skipped entirely then, so its
|
|
6522
|
+
// digest is never confused with the one that actually ships.
|
|
6523
|
+
let packedInstallSmokePreviewFailed = false;
|
|
6524
|
+
if (dryRun) {
|
|
6525
|
+
if (bins.length === 0) {
|
|
6526
|
+
appendPublishGateAudit(cwd, 'packed-install-smoke', 'pass', 'n/a — nothing in the batch declares a bin');
|
|
6527
|
+
write('dz publish: ○ packed install smoke: n/a (nothing in the batch declares a bin)');
|
|
6528
|
+
}
|
|
6529
|
+
else {
|
|
6530
|
+
const scratchRoot = packedInstallScratchRoot();
|
|
6531
|
+
const packDir = mkdtempSync(join(scratchRoot, 'dz-publish-pack-'));
|
|
6532
|
+
const installDir = mkdtempSync(join(scratchRoot, 'dz-publish-install-'));
|
|
6533
|
+
const runSmoke = packedInstallRunner ??
|
|
6534
|
+
((cmd, o) => {
|
|
6535
|
+
try {
|
|
6536
|
+
const stdout = execSync(cmd, { cwd: o.cwd, stdio: 'pipe', encoding: 'utf-8', timeout: o.timeoutMs });
|
|
6537
|
+
return { exitCode: 0, stdout: stdout == null ? '' : String(stdout), stderr: '' };
|
|
6538
|
+
}
|
|
6539
|
+
catch (err) {
|
|
6540
|
+
const e = err;
|
|
6541
|
+
const timedOut = (e.status === null || e.status === undefined) && (e.signal != null || e.killed === true);
|
|
6542
|
+
return {
|
|
6543
|
+
exitCode: typeof e.status === 'number' ? e.status : 1,
|
|
6544
|
+
stdout: e.stdout == null ? '' : String(e.stdout),
|
|
6545
|
+
stderr: e.stderr == null || String(e.stderr).trim() === '' ? formatPublishError(e) : String(e.stderr),
|
|
6546
|
+
timedOut,
|
|
6547
|
+
};
|
|
6548
|
+
}
|
|
6549
|
+
});
|
|
6550
|
+
const smokePlan = planPackedInstallSmoke({
|
|
6551
|
+
packages: targets.map((p) => ({ name: p.name, dir: p.dir, version: p.version })),
|
|
6552
|
+
bins,
|
|
6553
|
+
packDir,
|
|
6554
|
+
installDir,
|
|
6555
|
+
});
|
|
6556
|
+
const smokeExecutions = [];
|
|
6557
|
+
// Lead edit after the live dry-run (13.09 12:05): the preview packed the WORKING directory with
|
|
6558
|
+
// `workspace:^` specs still inside, so `npm install <tgz>` died with EUNSUPPORTEDPROTOCOL — the
|
|
6559
|
+
// preview must stage package.json exactly as the live packedTransport does (sibling pins via
|
|
6560
|
+
// rewriteWorkspaceSpecs, prepublishOnly dropped) and restore the originals afterwards.
|
|
6561
|
+
const stagedOriginals = [];
|
|
6562
|
+
try {
|
|
6563
|
+
for (const p of targets) {
|
|
6564
|
+
const pkgJsonPath = join(p.dir, 'package.json');
|
|
6565
|
+
const original = readFileSync(pkgJsonPath, 'utf-8');
|
|
6566
|
+
const rewritten = JSON.parse(rewriteWorkspaceSpecs(original, workspaceVersions));
|
|
6567
|
+
const scripts = rewritten['scripts'];
|
|
6568
|
+
if (scripts !== null && typeof scripts === 'object' && !Array.isArray(scripts))
|
|
6569
|
+
delete scripts['prepublishOnly'];
|
|
6570
|
+
stagedOriginals.push({ path: pkgJsonPath, text: original });
|
|
6571
|
+
writeFileSync(pkgJsonPath, JSON.stringify(rewritten, null, 2) + '\n');
|
|
6572
|
+
}
|
|
6573
|
+
for (const step of smokePlan.steps) {
|
|
6574
|
+
const r = runSmoke(step.cmd, { cwd: step.cwd, timeoutMs: step.timeoutMs });
|
|
6575
|
+
smokeExecutions.push({ stepId: step.id, exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr, ...(r.timedOut !== undefined ? { timedOut: r.timedOut } : {}) });
|
|
6576
|
+
}
|
|
6577
|
+
}
|
|
6578
|
+
finally {
|
|
6579
|
+
for (const o of stagedOriginals) {
|
|
6580
|
+
try {
|
|
6581
|
+
writeFileSync(o.path, o.text);
|
|
6582
|
+
}
|
|
6583
|
+
catch (err) {
|
|
6584
|
+
write(`dz publish: ⚠ could not restore ${o.path} after the preview smoke: ${formatPublishError(err)}`);
|
|
6585
|
+
}
|
|
6586
|
+
}
|
|
6587
|
+
}
|
|
6588
|
+
const smokeVerdict = judgePackedInstallSmoke(smokePlan, smokeExecutions);
|
|
6589
|
+
try {
|
|
6590
|
+
rmSync(packDir, { recursive: true, force: true });
|
|
6591
|
+
}
|
|
6592
|
+
catch { /* best-effort cleanup */ }
|
|
6593
|
+
try {
|
|
6594
|
+
rmSync(installDir, { recursive: true, force: true });
|
|
6595
|
+
}
|
|
6596
|
+
catch { /* best-effort cleanup */ }
|
|
6597
|
+
if (smokeVerdict.ok) {
|
|
6598
|
+
appendPublishGateAudit(cwd, 'packed-install-smoke', 'pass', 'preview: pack/install/--version all clean');
|
|
6599
|
+
write('dz publish: ✓ packed install smoke (preview)');
|
|
6600
|
+
}
|
|
6601
|
+
else {
|
|
6602
|
+
const detail = smokeVerdict.failureDetail ?? smokeVerdict.bins.find((b) => !b.ok)?.detail ?? '(no detail)';
|
|
6603
|
+
appendPublishGateAudit(cwd, 'packed-install-smoke', 'block', detail);
|
|
6604
|
+
write(`dz publish: BLOCKED — packed install smoke failed (preview): ${detail}`);
|
|
6605
|
+
for (const b of smokeVerdict.bins.filter((b) => !b.ok))
|
|
6606
|
+
write(` ✗ ${b.pkg} (${b.binName}): ${b.detail ?? '(no detail)'}`);
|
|
6607
|
+
packedInstallSmokePreviewFailed = true;
|
|
6608
|
+
}
|
|
6609
|
+
}
|
|
6610
|
+
}
|
|
6611
|
+
if (siblingDriftFailed || packedInstallSmokePreviewFailed) {
|
|
6612
|
+
write(`dz publish: refusing to publish (${driftBlocked} sibling-drift violation(s)${packedInstallSmokePreviewFailed ? ', packed install smoke failed' : ''})`);
|
|
6613
|
+
return 1;
|
|
6614
|
+
}
|
|
6281
6615
|
if (!dryRun) {
|
|
6282
6616
|
// Loud confirmation banner listing exactly what is about to be published.
|
|
6283
6617
|
const targets = discoverPackages(cwd).filter((p) => filter === undefined || filter.length === 0
|
|
@@ -6368,12 +6702,82 @@ function cmdPublish(options, flags, cwd, writeOutput, mirrorRunner) {
|
|
|
6368
6702
|
// longer exist. Default to the same path `dz sign --init` writes, so the ordinary operator needs no
|
|
6369
6703
|
// new flag; `--sign-key` overrides it.
|
|
6370
6704
|
const signKey = (options.get('sign-key') ?? join(homedir(), '.dz', 'keys', 'dz.key')).trim();
|
|
6705
|
+
// AM-1: the packedTransport smoke closure and the actual `npm publish <tgz>` inside
|
|
6706
|
+
// `publishPackages` both read from THIS SAME directory — created once, cleaned up once, after
|
|
6707
|
+
// publishPackages returns (it needs the tarballs on disk through its own publish step).
|
|
6708
|
+
const packedTransportPackDestDir = mkdtempSync(join(packedInstallScratchRoot(), 'dz-publish-packed-'));
|
|
6371
6709
|
const publishReport = publishPackages(cwd, {
|
|
6372
6710
|
provenance,
|
|
6373
6711
|
dryRun,
|
|
6374
6712
|
filter,
|
|
6375
6713
|
bumpOnly,
|
|
6376
6714
|
claimGate: claimCheckOpt,
|
|
6715
|
+
exec: publishExecRunner,
|
|
6716
|
+
packedTransport: {
|
|
6717
|
+
packDestDir: packedTransportPackDestDir,
|
|
6718
|
+
// AM-1: judged ONCE, over every package's packed artifact — nothing in the batch publishes
|
|
6719
|
+
// until this returns ok:true. `bins` (AM-8-fixed: declared bins are collected whether or not
|
|
6720
|
+
// their target file exists yet) was already computed above from the same `targets` this
|
|
6721
|
+
// batch resolves to.
|
|
6722
|
+
smoke: (artifacts) => {
|
|
6723
|
+
for (const a of artifacts)
|
|
6724
|
+
write(`dz publish: tarball ${a.name}@${a.newVersion} sha256:${a.sha256}`);
|
|
6725
|
+
if (bins.length === 0) {
|
|
6726
|
+
appendPublishGateAudit(cwd, 'packed-install-smoke', 'pass', 'n/a — nothing in the batch declares a bin');
|
|
6727
|
+
write('dz publish: ○ packed install smoke: n/a (nothing in the batch declares a bin)');
|
|
6728
|
+
return { ok: true };
|
|
6729
|
+
}
|
|
6730
|
+
const scratchRoot = packedInstallScratchRoot();
|
|
6731
|
+
const installDir = mkdtempSync(join(scratchRoot, 'dz-publish-install-'));
|
|
6732
|
+
const runSmoke = packedInstallRunner ??
|
|
6733
|
+
((cmd, o) => {
|
|
6734
|
+
try {
|
|
6735
|
+
const stdout = execSync(cmd, { cwd: o.cwd, stdio: 'pipe', encoding: 'utf-8', timeout: o.timeoutMs });
|
|
6736
|
+
return { exitCode: 0, stdout: stdout == null ? '' : String(stdout), stderr: '' };
|
|
6737
|
+
}
|
|
6738
|
+
catch (err) {
|
|
6739
|
+
const e = err;
|
|
6740
|
+
const timedOut = (e.status === null || e.status === undefined) && (e.signal != null || e.killed === true);
|
|
6741
|
+
return {
|
|
6742
|
+
exitCode: typeof e.status === 'number' ? e.status : 1,
|
|
6743
|
+
stdout: e.stdout == null ? '' : String(e.stdout),
|
|
6744
|
+
stderr: e.stderr == null || String(e.stderr).trim() === '' ? formatPublishError(e) : String(e.stderr),
|
|
6745
|
+
timedOut,
|
|
6746
|
+
};
|
|
6747
|
+
}
|
|
6748
|
+
});
|
|
6749
|
+
const smokePlan = planPackedInstallSmoke({
|
|
6750
|
+
// skipPack (AM-1): these tarballs are ALREADY packed (by publishPackages, above) — a
|
|
6751
|
+
// second, different pack here would smoke bytes other than the ones about to publish.
|
|
6752
|
+
packages: artifacts.map((a) => ({ name: a.name, dir: '(packed already — see skipPack)', version: a.newVersion })),
|
|
6753
|
+
bins,
|
|
6754
|
+
packDir: packedTransportPackDestDir,
|
|
6755
|
+
installDir,
|
|
6756
|
+
skipPack: true,
|
|
6757
|
+
});
|
|
6758
|
+
const smokeExecutions = [];
|
|
6759
|
+
for (const step of smokePlan.steps) {
|
|
6760
|
+
const r = runSmoke(step.cmd, { cwd: step.cwd, timeoutMs: step.timeoutMs });
|
|
6761
|
+
smokeExecutions.push({ stepId: step.id, exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr, ...(r.timedOut !== undefined ? { timedOut: r.timedOut } : {}) });
|
|
6762
|
+
}
|
|
6763
|
+
const verdict = judgePackedInstallSmoke(smokePlan, smokeExecutions);
|
|
6764
|
+
try {
|
|
6765
|
+
rmSync(installDir, { recursive: true, force: true });
|
|
6766
|
+
}
|
|
6767
|
+
catch { /* best-effort cleanup */ }
|
|
6768
|
+
if (verdict.ok) {
|
|
6769
|
+
appendPublishGateAudit(cwd, 'packed-install-smoke', 'pass', 'pack/install/--version all clean (live, packedTransport)');
|
|
6770
|
+
write('dz publish: ✓ packed install smoke');
|
|
6771
|
+
return { ok: true };
|
|
6772
|
+
}
|
|
6773
|
+
const detail = verdict.failureDetail ?? verdict.bins.find((b) => !b.ok)?.detail ?? '(no detail)';
|
|
6774
|
+
appendPublishGateAudit(cwd, 'packed-install-smoke', 'block', detail);
|
|
6775
|
+
write(`dz publish: BLOCKED — packed install smoke failed: ${detail}`);
|
|
6776
|
+
for (const b of verdict.bins.filter((b) => !b.ok))
|
|
6777
|
+
write(` ✗ ${b.pkg} (${b.binName}): ${b.detail ?? '(no detail)'}`);
|
|
6778
|
+
return { ok: false, reason: detail };
|
|
6779
|
+
},
|
|
6780
|
+
},
|
|
6377
6781
|
signKey: signKey === '' ? undefined : resolve(cwd, signKey),
|
|
6378
6782
|
verifyAfterSign: (packDir) => {
|
|
6379
6783
|
// Verify the OUTCOME against the trust root a CONSUMER would use — an existing key may be the
|
|
@@ -6450,6 +6854,10 @@ function cmdPublish(options, flags, cwd, writeOutput, mirrorRunner) {
|
|
|
6450
6854
|
}
|
|
6451
6855
|
},
|
|
6452
6856
|
});
|
|
6857
|
+
try {
|
|
6858
|
+
rmSync(packedTransportPackDestDir, { recursive: true, force: true });
|
|
6859
|
+
}
|
|
6860
|
+
catch { /* best-effort cleanup */ }
|
|
6453
6861
|
const configMirror = mirrorCommandFromConfig(cwd);
|
|
6454
6862
|
const configuredCommand = (options.get('mirror-cmd') ?? configMirror.command ?? '').trim();
|
|
6455
6863
|
const publishedVersions = publishReport.packages
|
|
@@ -6538,6 +6946,10 @@ function cmdPublish(options, flags, cwd, writeOutput, mirrorRunner) {
|
|
|
6538
6946
|
? ` (confirmed by registry after ${pkg.registryProbes} probes)`
|
|
6539
6947
|
: '';
|
|
6540
6948
|
write(` ${icon} ${pkg.name.padEnd(35)} ${pkg.oldVersion} → ${pkg.newVersion} ${pkg.status}${receipt}${detail}`);
|
|
6949
|
+
// AM-1: the digest of the EXACT tarball bytes that were smoke-tested AND published — present
|
|
6950
|
+
// only for a packedTransport publish, so "the smoke tested what shipped" is checkable here too.
|
|
6951
|
+
if (pkg.status === 'published' && pkg.sha256 !== undefined)
|
|
6952
|
+
write(` sha256:${pkg.sha256}`);
|
|
6541
6953
|
if (pkg.status === 'error' && pkg.error) {
|
|
6542
6954
|
for (const line of pkg.error.split('\n'))
|
|
6543
6955
|
write(` ${line}`);
|
|
@@ -6595,7 +7007,7 @@ function cmdPublish(options, flags, cwd, writeOutput, mirrorRunner) {
|
|
|
6595
7007
|
/* dz parity — the honest feature×target map (target-parity-matrix, */
|
|
6596
7008
|
/* ADR-001): computed from the declarative model, never hand-written */
|
|
6597
7009
|
/* ------------------------------------------------------------------ */
|
|
6598
|
-
function cmdParity(options, flags, write, writeErr) {
|
|
7010
|
+
function cmdParity(options, flags, write, writeErr, cwd) {
|
|
6599
7011
|
const json = flags.has('json');
|
|
6600
7012
|
if (flags.has('help')) {
|
|
6601
7013
|
write('dz parity [--target <name>] [--json] — the computed feature×target map (never hand-written)');
|
|
@@ -6623,7 +7035,33 @@ function cmdParity(options, flags, write, writeErr) {
|
|
|
6623
7035
|
return 1;
|
|
6624
7036
|
}
|
|
6625
7037
|
}
|
|
6626
|
-
|
|
7038
|
+
// ADR-001 Decision 3 (feature setup-installs-apply-leg): `learning-apply` on `claude-code` is
|
|
7039
|
+
// MEASURED, not declared — `hooks-prompt` is present for that ONE target only when
|
|
7040
|
+
// `applyLegStatus(root).installed`. `computeParity` itself is untouched (FR-5); only the
|
|
7041
|
+
// capability SET fed into it for this one cell differs from the static `TARGET_CAPABILITIES`.
|
|
7042
|
+
// `applyLegStatus` never throws (fix round 1, Q3 finding: an unreadable helper used to be able to
|
|
7043
|
+
// crash this command rather than degrade to a named remedy).
|
|
7044
|
+
const applyLegStatusVal = applyLegStatus(cwd);
|
|
7045
|
+
const applyLegInstalled = applyLegStatusVal.installed;
|
|
7046
|
+
const matrix = buildParityMatrix().map((row) => {
|
|
7047
|
+
if (row.feature.id !== 'learning-apply' || applyLegInstalled)
|
|
7048
|
+
return row;
|
|
7049
|
+
const claudeCodeCaps = TARGET_CAPABILITIES['claude-code'].filter((c) => c !== 'hooks-prompt');
|
|
7050
|
+
return { feature: row.feature, cells: { ...row.cells, 'claude-code': computeParity(row.feature, claudeCodeCaps) } };
|
|
7051
|
+
});
|
|
7052
|
+
// The "not installed" remedy — named ONLY for the one cell whose grant is a live measurement,
|
|
7053
|
+
// never a blanket note for every `manual` cell (most targets are manual by DESIGN, not absence).
|
|
7054
|
+
// `stale-version`/`unreadable` route through `applyLegReasonMessage` — the SAME text-producing
|
|
7055
|
+
// function `dz doctor` uses for those two reasons (fix round 1, HIGH finding 2 / Q3 finding 7), so
|
|
7056
|
+
// the two instruments cannot disagree about WHY a stale or broken install is not "full".
|
|
7057
|
+
const applyLegRemedy = (featureId, t) => {
|
|
7058
|
+
if (featureId !== 'learning-apply' || t !== 'claude-code' || applyLegInstalled)
|
|
7059
|
+
return '';
|
|
7060
|
+
if (applyLegStatusVal.reason === 'stale-version' || applyLegStatusVal.reason === 'unreadable') {
|
|
7061
|
+
return ` — ${applyLegReasonMessage(applyLegStatusVal)}`;
|
|
7062
|
+
}
|
|
7063
|
+
return ' — not installed — run dz setup --target claude-code --memory agentdb';
|
|
7064
|
+
};
|
|
6627
7065
|
// EVIDENCE staleness, folded into the report (fix round 2, R2-3). Derived from the records
|
|
6628
7066
|
// themselves — no `codex --version`, no subprocess, so `dz parity` stays a deterministic function
|
|
6629
7067
|
// of the model. A cell whose deciding form rests on a transcript that is older than the newest
|
|
@@ -6667,8 +7105,11 @@ function cmdParity(options, flags, write, writeErr) {
|
|
|
6667
7105
|
const shown = target !== undefined ? [target] : TARGET_NAMES;
|
|
6668
7106
|
const rows = matrix.map((r) => {
|
|
6669
7107
|
const cells = {};
|
|
6670
|
-
for (const t of shown)
|
|
6671
|
-
|
|
7108
|
+
for (const t of shown) {
|
|
7109
|
+
const cell = reportCell(r.feature, t, r.cells[t]);
|
|
7110
|
+
const remedy = applyLegRemedy(r.feature.id, t);
|
|
7111
|
+
cells[t] = remedy === '' ? cell : { ...cell, note: remedy.replace(/^ — /, '') };
|
|
7112
|
+
}
|
|
6672
7113
|
return { id: r.feature.id, title: r.feature.title, cells };
|
|
6673
7114
|
});
|
|
6674
7115
|
// A filtered response stays internally consistent: capabilities are filtered too (Codex QE gap 9).
|
|
@@ -6694,7 +7135,7 @@ function cmdParity(options, flags, write, writeErr) {
|
|
|
6694
7135
|
: c.level === 'inconclusive'
|
|
6695
7136
|
? `via ${c.via ?? ''} — INCONCLUSIVE: stale evidence for ${(c.staleEvidence ?? []).join(', ')}`
|
|
6696
7137
|
: `via ${c.via ?? ''}`;
|
|
6697
|
-
write(` ${icon} ${r.feature.title.padEnd(58)} ${detail}`);
|
|
7138
|
+
write(` ${icon} ${r.feature.title.padEnd(58)} ${detail}${applyLegRemedy(r.feature.id, t)}`);
|
|
6698
7139
|
}
|
|
6699
7140
|
write('\n ✓ full (the complete experience) ◐ manual (works, you drive it by hand) ? evidence stale (re-probe) — not available');
|
|
6700
7141
|
for (const line of staleNote(t))
|
|
@@ -6855,13 +7296,36 @@ function cmdRelease(options, flags, cwd, write, runner) {
|
|
|
6855
7296
|
say(`dz release --affected: ${selected.length}/${factsList.length} package(s) selected from ${changed.length} changed file(s)${selected.length === factsList.length ? ' (no narrowing — full set)' : ''}`);
|
|
6856
7297
|
factsList = selected;
|
|
6857
7298
|
}
|
|
7299
|
+
// FR-6 (feature publish-sibling-drift-gate): real tmp dirs for the packed-install smoke — only
|
|
7300
|
+
// when something in the set actually has a bin to boot (packing bin-less siblings proves
|
|
7301
|
+
// nothing this gate exists to catch). Planning stays pure (planReleaseGates never mkdtemps
|
|
7302
|
+
// itself); these are cleaned up on every exit path below, dry-run included.
|
|
7303
|
+
const packedInstallEligible = factsList.some((f) => f.bins.some((b) => b.exists));
|
|
7304
|
+
const releaseScratchRoot = packedInstallScratchRoot();
|
|
7305
|
+
const packedInstallDirs = packedInstallEligible
|
|
7306
|
+
? { packDir: mkdtempSync(join(releaseScratchRoot, 'dz-release-pack-')), installDir: mkdtempSync(join(releaseScratchRoot, 'dz-release-install-')) }
|
|
7307
|
+
: undefined;
|
|
7308
|
+
const cleanupPackedInstallDirs = () => {
|
|
7309
|
+
if (packedInstallDirs === undefined)
|
|
7310
|
+
return;
|
|
7311
|
+
try {
|
|
7312
|
+
rmSync(packedInstallDirs.packDir, { recursive: true, force: true });
|
|
7313
|
+
}
|
|
7314
|
+
catch { /* best-effort cleanup */ }
|
|
7315
|
+
try {
|
|
7316
|
+
rmSync(packedInstallDirs.installDir, { recursive: true, force: true });
|
|
7317
|
+
}
|
|
7318
|
+
catch { /* best-effort cleanup */ }
|
|
7319
|
+
};
|
|
6858
7320
|
const plan = planReleaseGates(factsList, {
|
|
6859
7321
|
monorepoRoot: cwd,
|
|
6860
7322
|
pnpmLockPresent: existsSync(join(cwd, 'pnpm-lock.yaml')),
|
|
6861
7323
|
includeDevDeps: flags.has('audit-dev'),
|
|
7324
|
+
packedInstall: packedInstallDirs,
|
|
6862
7325
|
});
|
|
6863
7326
|
// --dry-run: print the full plan, execute NOTHING (deterministic, byte-testable preview).
|
|
6864
7327
|
if (flags.has('dry-run')) {
|
|
7328
|
+
cleanupPackedInstallDirs();
|
|
6865
7329
|
if (json) {
|
|
6866
7330
|
write(JSON.stringify({ dryRun: true, packages: plan.packages, steps: plan.steps, skips: plan.skips, warnings }, null, 2));
|
|
6867
7331
|
return 0;
|
|
@@ -6913,6 +7377,7 @@ function cmdRelease(options, flags, cwd, write, runner) {
|
|
|
6913
7377
|
}
|
|
6914
7378
|
catch { /* best-effort cleanup */ }
|
|
6915
7379
|
}
|
|
7380
|
+
cleanupPackedInstallDirs();
|
|
6916
7381
|
const verdict = classifyGateExecutions(plan, executions);
|
|
6917
7382
|
// Report: per-gate ✓/✗/○ with package granularity + timestamp (NFR-5). Skips are named,
|
|
6918
7383
|
// never folded into pass wording (AM-2: "N passed, M skipped", not "all tests passed").
|
|
@@ -8933,8 +9398,41 @@ function gatherGuardFacts(op, root, text, storeCap, publishFilter) {
|
|
|
8933
9398
|
const facts = { op };
|
|
8934
9399
|
const publishPackageRoots = [];
|
|
8935
9400
|
if (op === 'publish') {
|
|
8936
|
-
|
|
8937
|
-
|
|
9401
|
+
try {
|
|
9402
|
+
const roundsDir = join(root, '.dz', 'rounds');
|
|
9403
|
+
const states = readdirSync(roundsDir)
|
|
9404
|
+
.filter((name) => name.endsWith('.json'))
|
|
9405
|
+
.map((name) => readRoundState(join(roundsDir, name)))
|
|
9406
|
+
.filter((state) => state !== null);
|
|
9407
|
+
facts['openRounds'] = listRounds(states, {
|
|
9408
|
+
now: Date.now(),
|
|
9409
|
+
olderThanMinutes: 120,
|
|
9410
|
+
isPidAlive: probePid,
|
|
9411
|
+
isRunAlive: (runId) => roundRunOwnerAlive(root, runId, Date.now()),
|
|
9412
|
+
}).map((row) => ({
|
|
9413
|
+
slug: row.state.slug,
|
|
9414
|
+
round: row.state.round,
|
|
9415
|
+
ageMinutes: row.ageMinutes,
|
|
9416
|
+
pidAlive: row.pidAlive,
|
|
9417
|
+
}));
|
|
9418
|
+
}
|
|
9419
|
+
catch { /* absent/unreadable round state is no fabricated violation */ }
|
|
9420
|
+
const since = roundTraceSince(root);
|
|
9421
|
+
const enabled = roundsTracingEnabled(root);
|
|
9422
|
+
if (!enabled) {
|
|
9423
|
+
facts['codeCommitsSinceLastRound'] = { commits: null, since, enabled: false };
|
|
9424
|
+
}
|
|
9425
|
+
else if (since !== null) {
|
|
9426
|
+
facts['codeCommitsSinceLastRound'] = { commits: packageCommitCount(root, since), since };
|
|
9427
|
+
}
|
|
9428
|
+
else if (existsSync(join(root, '.dz', 'feature-adr', 'run-cost-ledger.jsonl'))) {
|
|
9429
|
+
// The ledger EXISTS but carries no dated row: that is a measurable absence and gets a note.
|
|
9430
|
+
// No ledger file at all is a fresh project — the rule stays not-established silently, so a
|
|
9431
|
+
// note that every new repo would carry does not drown the ones that mean something.
|
|
9432
|
+
facts['codeCommitsSinceLastRound'] = { commits: null, since: null };
|
|
9433
|
+
}
|
|
9434
|
+
// Advisory I/O: unreadable telemetry or fed state is absence of evidence, never a fabricated
|
|
9435
|
+
// stale finding and never a publish blocker.
|
|
8938
9436
|
try {
|
|
8939
9437
|
const routing = readRoutingTelemetry(root);
|
|
8940
9438
|
facts['routingFreshness'] = { unfedRunIds: unfedRuns(routing.harvest.samples, routing.alreadyFed) };
|
|
@@ -9712,6 +10210,33 @@ function runGuardEvaluation(root, op, text, overrideReason, publishFilter) {
|
|
|
9712
10210
|
catch { /* audit is best-effort, never blocks the verdict */ }
|
|
9713
10211
|
return result;
|
|
9714
10212
|
}
|
|
10213
|
+
/**
|
|
10214
|
+
* Feature `publish-sibling-drift-gate` (FR-5/AM-6): both the sibling-drift and packed-install-smoke
|
|
10215
|
+
* gates write to the SAME append-only, hash-chained `.dz/guard-audit.jsonl` the declarative
|
|
10216
|
+
* `dz guard` rules use — visibility for `dz guard promote`/`dz compounding` never depends on
|
|
10217
|
+
* which mechanism produced the finding. `pass` records go through as an informational `note`
|
|
10218
|
+
* (never a violation, so they can never flip the row's own verdict) so a clean check is ALSO on
|
|
10219
|
+
* the record, not just a block or an override (AM-6: "аудит без записи = не аудит").
|
|
10220
|
+
*
|
|
10221
|
+
* Returns whether the write actually landed. Most callers are best-effort (a write failure never
|
|
10222
|
+
* blocks a verdict already decided) — the one exception is an `--allow-sibling-drift` OVERRIDE,
|
|
10223
|
+
* whose caller MUST check this return value: an override is not real without a durable row behind
|
|
10224
|
+
* it (AM-6's load-bearing property — see `auditedOverride` in `cmdPublish`).
|
|
10225
|
+
*/
|
|
10226
|
+
function appendPublishGateAudit(root, rule, verdict, detail, overrideReason) {
|
|
10227
|
+
try {
|
|
10228
|
+
const rec = auditRecord(verdict === 'pass'
|
|
10229
|
+
? { op: 'publish', verdict, violations: [], checked: [rule], notEstablished: [], notes: [`${rule}: ${detail}`] }
|
|
10230
|
+
: { op: 'publish', verdict, violations: [{ rule, severity: 'hard', detail }], checked: [rule], notEstablished: [] }, new Date().toISOString(), overrideReason !== undefined ? { reason: overrideReason } : undefined);
|
|
10231
|
+
mkdirSync(join(root, '.dz'), { recursive: true });
|
|
10232
|
+
const auditPath = join(root, '.dz', 'guard-audit.jsonl');
|
|
10233
|
+
writeFileSync(auditPath, appendChainedLines([rec], readLogTail(auditPath)), { flag: 'a' });
|
|
10234
|
+
return true;
|
|
10235
|
+
}
|
|
10236
|
+
catch {
|
|
10237
|
+
return false; // audit write failed — the caller decides whether that itself is refusable (AM-6)
|
|
10238
|
+
}
|
|
10239
|
+
}
|
|
9715
10240
|
function renderGuardObservation(observation) {
|
|
9716
10241
|
const tag = observation.status === 'unknown' ? 'note' : 'observe';
|
|
9717
10242
|
return ` [${tag}] ${observation.rule} ${observation.scope}: ${observation.detail} [${observation.status}]`;
|
|
@@ -11329,6 +11854,107 @@ function parseCheckMutatedFile(absFile, text) {
|
|
|
11329
11854
|
return { skipped: `parse-check errored: ${String(e.message).slice(0, 120)}` };
|
|
11330
11855
|
}
|
|
11331
11856
|
}
|
|
11857
|
+
const MUTATION_GATE_OUTPUT_TAIL_MAX_LINES = 20;
|
|
11858
|
+
const MUTATION_GATE_OUTPUT_TAIL_MAX_BYTES = 2 * 1024;
|
|
11859
|
+
export function boundedMutationGateOutputTail(output) {
|
|
11860
|
+
const normalized = output.replace(/\r\n?/g, '\n').replace(/\n+$/, '');
|
|
11861
|
+
if (normalized === '')
|
|
11862
|
+
return undefined;
|
|
11863
|
+
let tail = normalized.split('\n').slice(-MUTATION_GATE_OUTPUT_TAIL_MAX_LINES).join('\n');
|
|
11864
|
+
const encoded = Buffer.from(tail, 'utf8');
|
|
11865
|
+
if (encoded.byteLength <= MUTATION_GATE_OUTPUT_TAIL_MAX_BYTES)
|
|
11866
|
+
return tail;
|
|
11867
|
+
const codePoints = Array.from(tail);
|
|
11868
|
+
let start = codePoints.length;
|
|
11869
|
+
let byteLength = 0;
|
|
11870
|
+
while (start > 0) {
|
|
11871
|
+
const nextByteLength = Buffer.byteLength(codePoints[start - 1], 'utf8');
|
|
11872
|
+
if (byteLength + nextByteLength > MUTATION_GATE_OUTPUT_TAIL_MAX_BYTES)
|
|
11873
|
+
break;
|
|
11874
|
+
byteLength += nextByteLength;
|
|
11875
|
+
start -= 1;
|
|
11876
|
+
}
|
|
11877
|
+
return codePoints.slice(start).join('');
|
|
11878
|
+
}
|
|
11879
|
+
// ── Full-output capture for a RED baseline/rebaseline line (gate-stability, 2026-09-12) ────────
|
|
11880
|
+
// The bounded tail above is a diagnostic teaser (3-20 lines); under a multi-entry gate run the
|
|
11881
|
+
// tail was measured to hand back an unrelated neighbour's stderr, leaving OVER_FAILING/
|
|
11882
|
+
// INCONCLUSIVE undiagnosable. Only the baseline and rebaseline lines write here — the per-entry
|
|
11883
|
+
// mutation run is EXPECTED to redden and already carries a bounded tail; this is for the lines
|
|
11884
|
+
// whose redness means "the copy itself is broken", where the full transcript is the only way to
|
|
11885
|
+
// tell what actually happened.
|
|
11886
|
+
const MUTATION_GATE_OUTPUT_FILE_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
|
11887
|
+
function mutationGateOutputDir() {
|
|
11888
|
+
return process.env.DZ_MUTGATE_OUTPUT_DIR ?? join(tmpdir(), 'dz-mutgate-output');
|
|
11889
|
+
}
|
|
11890
|
+
/** own filename prefix (fix-round-1 HIGH finding) — see isMutationGateOutputFile. */
|
|
11891
|
+
const MUTATION_GATE_OUTPUT_FILE_PREFIX = 'dz-mutgate-';
|
|
11892
|
+
/** exact shape of `new Date().toISOString().replace(/:/g, '-')`, e.g. `2026-09-12T20-00-00.000Z`. */
|
|
11893
|
+
const MUTATION_GATE_OUTPUT_TS_PATTERN = String.raw `\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.\d{3}Z`;
|
|
11894
|
+
const MUTATION_GATE_OUTPUT_FILE_RE = new RegExp(`^${MUTATION_GATE_OUTPUT_FILE_PREFIX}.+-(baseline|rebaseline|final-rebaseline)-${MUTATION_GATE_OUTPUT_TS_PATTERN}\\.log$`);
|
|
11895
|
+
/**
|
|
11896
|
+
* true only for a filename THIS executor could have written — rotation never touches a foreign
|
|
11897
|
+
* file. Fix-round-1 HIGH finding (Codex review, gate-stability): the prior
|
|
11898
|
+
* `^.+-(baseline|rebaseline|final-rebaseline)-.+\.log$` had no own prefix and accepted ANY
|
|
11899
|
+
* trailing text as the "timestamp", so a pre-existing unrelated file dropped into a shared
|
|
11900
|
+
* `DZ_MUTGATE_OUTPUT_DIR` (e.g. `service-baseline-backup.log`) matched and could be rotated away.
|
|
11901
|
+
* Now BOTH the `dz-mutgate-` prefix AND the exact ISO-timestamp shape we ourselves write are
|
|
11902
|
+
* required — a foreign file can accidentally share the prefix but essentially never our precise
|
|
11903
|
+
* timestamp format, and a file we did NOT write never carries both.
|
|
11904
|
+
*/
|
|
11905
|
+
function isMutationGateOutputFile(name) {
|
|
11906
|
+
return MUTATION_GATE_OUTPUT_FILE_RE.test(name);
|
|
11907
|
+
}
|
|
11908
|
+
function rotateMutationGateOutputDir(dir) {
|
|
11909
|
+
let names;
|
|
11910
|
+
try {
|
|
11911
|
+
names = readdirSync(dir);
|
|
11912
|
+
}
|
|
11913
|
+
catch {
|
|
11914
|
+
return;
|
|
11915
|
+
}
|
|
11916
|
+
const cutoff = Date.now() - MUTATION_GATE_OUTPUT_FILE_RETENTION_MS;
|
|
11917
|
+
for (const name of names) {
|
|
11918
|
+
if (!isMutationGateOutputFile(name))
|
|
11919
|
+
continue; // "чужие файлы не трогаются" — own prefix only
|
|
11920
|
+
const full = join(dir, name);
|
|
11921
|
+
try {
|
|
11922
|
+
if (statSync(full).mtimeMs < cutoff)
|
|
11923
|
+
rmSync(full, { force: true });
|
|
11924
|
+
}
|
|
11925
|
+
catch { /* best effort — a listing race is not this executor's problem */ }
|
|
11926
|
+
}
|
|
11927
|
+
}
|
|
11928
|
+
/**
|
|
11929
|
+
* Saves the FULL stdout+stderr of a RED baseline/rebaseline run and returns `{ path }`, or
|
|
11930
|
+
* `{ error }` on any I/O failure (EACCES/ENOSPC/EROFS/ENOTDIR and the like — never blocks the gate
|
|
11931
|
+
* on a logging problem: fix-round-1 MEDIUM finding, the prior silent `catch { return undefined; }`
|
|
11932
|
+
* made a failed save indistinguishable from "nothing to save"), or `undefined` when exitCode is 0
|
|
11933
|
+
* (nothing written on green — NFR-1 byte-identity).
|
|
11934
|
+
*/
|
|
11935
|
+
function writeMutationGateOutputOnRed(entryId, phase, exitCode, output) {
|
|
11936
|
+
if (exitCode === 0)
|
|
11937
|
+
return undefined;
|
|
11938
|
+
try {
|
|
11939
|
+
const dir = mutationGateOutputDir();
|
|
11940
|
+
mkdirSync(dir, { recursive: true });
|
|
11941
|
+
rotateMutationGateOutputDir(dir);
|
|
11942
|
+
const ts = new Date().toISOString().replace(/:/g, '-');
|
|
11943
|
+
const full = join(dir, `${MUTATION_GATE_OUTPUT_FILE_PREFIX}${entryId ?? 'baseline'}-${phase}-${ts}.log`);
|
|
11944
|
+
writeFileSync(full, output);
|
|
11945
|
+
return { path: full };
|
|
11946
|
+
}
|
|
11947
|
+
catch (e) {
|
|
11948
|
+
return { error: String(e?.message ?? e) };
|
|
11949
|
+
}
|
|
11950
|
+
}
|
|
11951
|
+
/** Unpacks a `writeMutationGateOutputOnRed` result into the `{outputPath, outputError}` shape the
|
|
11952
|
+
* pure engine (classifyBaseline / MutationObservation) consumes. */
|
|
11953
|
+
function splitMutationGateOutputWrite(result) {
|
|
11954
|
+
if (result === undefined)
|
|
11955
|
+
return {};
|
|
11956
|
+
return 'path' in result ? { outputPath: result.path } : { outputError: result.error };
|
|
11957
|
+
}
|
|
11332
11958
|
function cmdMutationGate(options, flags, cwd, write, injectedRunner) {
|
|
11333
11959
|
const json = flags.has('json');
|
|
11334
11960
|
const fail = (what) => {
|
|
@@ -11384,6 +12010,10 @@ function cmdMutationGate(options, flags, cwd, write, injectedRunner) {
|
|
|
11384
12010
|
if (/[\0\n\r]/.test(testCmdRaw))
|
|
11385
12011
|
return fail('--test-cmd may not contain NUL or newline characters');
|
|
11386
12012
|
const testCmd = testCmdRaw;
|
|
12013
|
+
const excludedSelfChecks = REGISTRY_SELFCHECK_TESTS.filter((testFile) => entries.some((entry) => buildMutationTestCommand(testCmd, entry).excluded.includes(testFile)));
|
|
12014
|
+
if (!json) {
|
|
12015
|
+
write(`mutation-gate: self-check excluded from mutant runs: ${excludedSelfChecks.join(', ') || '(none)'}`);
|
|
12016
|
+
}
|
|
11387
12017
|
const timeoutOpt = Number(options.get('timeout') ?? '300000');
|
|
11388
12018
|
const timeout = Number.isFinite(timeoutOpt) && timeoutOpt > 0 ? timeoutOpt : 300000;
|
|
11389
12019
|
// Route-b guard mode: `per-entry` (default, strongest — each red entry re-baselines the restored
|
|
@@ -11461,11 +12091,16 @@ function cmdMutationGate(options, flags, cwd, write, injectedRunner) {
|
|
|
11461
12091
|
// write below is asserted to RESOLVE inside this root before it happens.
|
|
11462
12092
|
const realScratchRoot = realpathSync(copyDir);
|
|
11463
12093
|
const requireCompletionReceipt = parsed.registry.requireCompletionReceipt === true;
|
|
11464
|
-
const invokeSuite = () => {
|
|
12094
|
+
const invokeSuite = (suiteCommand, phase, entryId) => {
|
|
11465
12095
|
if (injectedRunner !== undefined) {
|
|
11466
|
-
return injectedRunner(
|
|
12096
|
+
return injectedRunner(suiteCommand, {
|
|
12097
|
+
cwd: copyDir,
|
|
12098
|
+
timeoutMs: timeout,
|
|
12099
|
+
phase,
|
|
12100
|
+
...(entryId !== undefined ? { entryId } : {}),
|
|
12101
|
+
});
|
|
11467
12102
|
}
|
|
11468
|
-
const run = spawnSync(
|
|
12103
|
+
const run = spawnSync(suiteCommand, {
|
|
11469
12104
|
cwd: copyDir,
|
|
11470
12105
|
shell: true,
|
|
11471
12106
|
encoding: 'utf-8',
|
|
@@ -11502,8 +12137,8 @@ function cmdMutationGate(options, flags, cwd, write, injectedRunner) {
|
|
|
11502
12137
|
...(failureReason !== undefined ? { failureReason } : {}),
|
|
11503
12138
|
};
|
|
11504
12139
|
};
|
|
11505
|
-
const runSuite = (phase, entryId) => {
|
|
11506
|
-
const retried = runWithOneInternalRetry(invokeSuite);
|
|
12140
|
+
const runSuite = (phase, entryId, suiteCommand = testCmd) => {
|
|
12141
|
+
const retried = runWithOneInternalRetry(() => invokeSuite(suiteCommand, phase, entryId));
|
|
11507
12142
|
const loggedAttempts = retried.attempts.map((attempt) => {
|
|
11508
12143
|
if (attempt.outcome !== 'completed' || retried.value === null)
|
|
11509
12144
|
return attempt;
|
|
@@ -11541,9 +12176,10 @@ function cmdMutationGate(options, flags, cwd, write, injectedRunner) {
|
|
|
11541
12176
|
if (!json)
|
|
11542
12177
|
write(`mutation-gate: baseline suite in scratch copy of ${pkgDir} …`);
|
|
11543
12178
|
const base = runSuite('baseline');
|
|
12179
|
+
const { outputPath: baseOutputPath, outputError: baseOutputError } = splitMutationGateOutputWrite(writeMutationGateOutputOnRed(undefined, 'baseline', base.exitCode, base.output));
|
|
11544
12180
|
baseline = classifyBaseline(base.exitCode, base.failureReason, base.exitCode !== null && base.exitCode !== 0
|
|
11545
12181
|
? attributeBaselineRedness(base.output, entries.map((entry) => entry.file))
|
|
11546
|
-
: undefined);
|
|
12182
|
+
: undefined, baseOutputPath, baseOutputError);
|
|
11547
12183
|
if (!baseline.ok) {
|
|
11548
12184
|
if (json) {
|
|
11549
12185
|
write(JSON.stringify({ packageDir: pkgDir, registryPath, testCommand: testCmd, baseline, results, internalRetries, exitCode: 1 }, null, 2));
|
|
@@ -11616,7 +12252,7 @@ function cmdMutationGate(options, flags, cwd, write, injectedRunner) {
|
|
|
11616
12252
|
parseError = check.error; // no suite run: the verdict is MUTATION_UNPARSEABLE regardless
|
|
11617
12253
|
}
|
|
11618
12254
|
else if (parseInternalFailureReason === undefined) {
|
|
11619
|
-
run = runSuite('mutation', entry.id);
|
|
12255
|
+
run = runSuite('mutation', entry.id, buildMutationTestCommand(testCmd, entry).testCommand);
|
|
11620
12256
|
}
|
|
11621
12257
|
}
|
|
11622
12258
|
finally {
|
|
@@ -11653,6 +12289,9 @@ function cmdMutationGate(options, flags, cwd, write, injectedRunner) {
|
|
|
11653
12289
|
let rebaselineExitCode;
|
|
11654
12290
|
let rebaselineFailureReason;
|
|
11655
12291
|
let rebaselineAttribution;
|
|
12292
|
+
let rebaselineOutputTail;
|
|
12293
|
+
let rebaselineOutputPath;
|
|
12294
|
+
let rebaselineOutputError;
|
|
11656
12295
|
let rebaselineInternalAttemptLog;
|
|
11657
12296
|
if (rebaselineMode === 'per-entry' && run !== null && run.exitCode !== null && run.exitCode !== 0
|
|
11658
12297
|
&& fileLoadFailure === undefined && outputUnrecognised === undefined && receiptMismatch === undefined) {
|
|
@@ -11662,8 +12301,13 @@ function cmdMutationGate(options, flags, cwd, write, injectedRunner) {
|
|
|
11662
12301
|
rebaselineExitCode = rebaselineRun.exitCode;
|
|
11663
12302
|
rebaselineFailureReason = rebaselineRun.failureReason;
|
|
11664
12303
|
rebaselineInternalAttemptLog = rebaselineRun.internalAttemptLog;
|
|
11665
|
-
if (rebaselineRun.exitCode !==
|
|
11666
|
-
|
|
12304
|
+
if (rebaselineRun.exitCode !== 0) {
|
|
12305
|
+
rebaselineOutputTail = boundedMutationGateOutputTail(rebaselineRun.output);
|
|
12306
|
+
({ outputPath: rebaselineOutputPath, outputError: rebaselineOutputError } =
|
|
12307
|
+
splitMutationGateOutputWrite(writeMutationGateOutputOnRed(entry.id, 'rebaseline', rebaselineRun.exitCode, rebaselineRun.output)));
|
|
12308
|
+
if (rebaselineRun.exitCode !== null) {
|
|
12309
|
+
rebaselineAttribution = attributeBaselineRedness(rebaselineRun.output, entries.map((candidate) => candidate.file));
|
|
12310
|
+
}
|
|
11667
12311
|
}
|
|
11668
12312
|
}
|
|
11669
12313
|
const entryRunFailureReason = run?.failureReason ?? parseInternalFailureReason;
|
|
@@ -11684,6 +12328,9 @@ function cmdMutationGate(options, flags, cwd, write, injectedRunner) {
|
|
|
11684
12328
|
...(rebaselineExitCode !== undefined ? { rebaselineExitCode } : {}),
|
|
11685
12329
|
...(rebaselineFailureReason !== undefined ? { rebaselineFailureReason } : {}),
|
|
11686
12330
|
...(rebaselineAttribution !== undefined ? { rebaselineAttribution } : {}),
|
|
12331
|
+
...(rebaselineOutputTail !== undefined ? { rebaselineOutputTail } : {}),
|
|
12332
|
+
...(rebaselineOutputPath !== undefined ? { outputPath: rebaselineOutputPath } : {}),
|
|
12333
|
+
...(rebaselineOutputError !== undefined ? { outputError: rebaselineOutputError } : {}),
|
|
11687
12334
|
};
|
|
11688
12335
|
observations.push(obs);
|
|
11689
12336
|
results.push(classifyMutationOutcome(obs));
|
|
@@ -11699,6 +12346,8 @@ function cmdMutationGate(options, flags, cwd, write, injectedRunner) {
|
|
|
11699
12346
|
const finalRun = runSuite('final-rebaseline');
|
|
11700
12347
|
const finalExit = finalRun.exitCode;
|
|
11701
12348
|
if (finalExit !== 0) {
|
|
12349
|
+
const finalOutputTail = boundedMutationGateOutputTail(finalRun.output);
|
|
12350
|
+
const { outputPath: finalOutputPath, outputError: finalOutputError } = splitMutationGateOutputWrite(writeMutationGateOutputOnRed(undefined, 'final-rebaseline', finalExit, finalRun.output));
|
|
11702
12351
|
const what = finalExit === null ? `no exit code: ${finalRun.failureReason ?? 'unknown timeout / spawn failure'}` : `exit ${finalExit}`;
|
|
11703
12352
|
warnings.push(`final re-baseline NOT green (${what}) — the suite is flaky; red-based verdicts downgraded to INCONCLUSIVE`);
|
|
11704
12353
|
if (!json)
|
|
@@ -11713,6 +12362,9 @@ function cmdMutationGate(options, flags, cwd, write, injectedRunner) {
|
|
|
11713
12362
|
...(finalExit !== null && finalExit !== 0
|
|
11714
12363
|
? { rebaselineAttribution: attributeBaselineRedness(finalRun.output, entries.map((entry) => entry.file)) }
|
|
11715
12364
|
: {}),
|
|
12365
|
+
...(finalOutputTail !== undefined ? { rebaselineOutputTail: finalOutputTail } : {}),
|
|
12366
|
+
...(finalOutputPath !== undefined ? { outputPath: finalOutputPath } : {}),
|
|
12367
|
+
...(finalOutputError !== undefined ? { outputError: finalOutputError } : {}),
|
|
11716
12368
|
}));
|
|
11717
12369
|
results.length = 0;
|
|
11718
12370
|
results.push(...reclassified);
|
|
@@ -13870,6 +14522,893 @@ function cmdRunsRecord(options, flags, cwd, write) {
|
|
|
13870
14522
|
return 2;
|
|
13871
14523
|
}
|
|
13872
14524
|
}
|
|
14525
|
+
const ROUND_LEDGER_REL = join('.dz', 'feature-adr', 'run-cost-ledger.jsonl');
|
|
14526
|
+
/**
|
|
14527
|
+
* round-state-root FR-1/FR-2: where `dz round` state (and its ledger, FR-4) lives — flag beats env
|
|
14528
|
+
* beats cwd. `--project` is untouched by this and stays recall-only (lesson 2ac30a70). Only an
|
|
14529
|
+
* EXPLICIT flag/env value is validated for absoluteness; the cwd fallback is `resolve(cwd)`, exactly
|
|
14530
|
+
* what every subcommand used before this feature (NFR-1: byte-identical when neither is set).
|
|
14531
|
+
*/
|
|
14532
|
+
function resolveRoundStateRoot(options, env, cwd) {
|
|
14533
|
+
const flagRaw = options.get('state-root');
|
|
14534
|
+
if (flagRaw !== undefined) {
|
|
14535
|
+
if (!isAbsolute(flagRaw))
|
|
14536
|
+
return { ok: false, reason: `--state-root должен быть абсолютным путём: ${flagRaw}` };
|
|
14537
|
+
return { ok: true, root: flagRaw, source: 'flag' };
|
|
14538
|
+
}
|
|
14539
|
+
const envRaw = env['DZ_ROUND_STATE_ROOT'];
|
|
14540
|
+
if (envRaw !== undefined) {
|
|
14541
|
+
// A variable that is SET but blank is a misconfiguration, not an absence: falling back to cwd
|
|
14542
|
+
// here would be exactly the stray-write this flag exists to prevent (Codex review, 2026-09-13).
|
|
14543
|
+
if (envRaw.trim() === '')
|
|
14544
|
+
return { ok: false, reason: 'DZ_ROUND_STATE_ROOT задана, но пуста — укажите абсолютный путь или снимите переменную' };
|
|
14545
|
+
if (!isAbsolute(envRaw))
|
|
14546
|
+
return { ok: false, reason: `DZ_ROUND_STATE_ROOT должен быть абсолютным путём: ${envRaw}` };
|
|
14547
|
+
return { ok: true, root: envRaw, source: 'env' };
|
|
14548
|
+
}
|
|
14549
|
+
return { ok: true, root: resolve(cwd), source: 'cwd' };
|
|
14550
|
+
}
|
|
14551
|
+
function roundStatePath(root, slug, round) {
|
|
14552
|
+
return join(root, '.dz', 'rounds', `${slug}-${round}.json`);
|
|
14553
|
+
}
|
|
14554
|
+
/** round-state-lock T2: parses raw JSON text into a `RoundState`, shared by `readRoundState` (reads
|
|
14555
|
+
* from disk) and the AC-1 recheck-under-lock (compares a raw string captured before recall against
|
|
14556
|
+
* one read again inside the lock, so it needs to parse the SAME raw text twice without a third
|
|
14557
|
+
* disk read). */
|
|
14558
|
+
function parseRoundState(raw) {
|
|
14559
|
+
try {
|
|
14560
|
+
const row = JSON.parse(raw);
|
|
14561
|
+
if (typeof row.slug !== 'string' || !Number.isInteger(row.round) || typeof row.topic !== 'string'
|
|
14562
|
+
|| typeof row.startedAt !== 'string' || !Number.isInteger(row.pid) || !Array.isArray(row.recalled)
|
|
14563
|
+
|| row.recalled.some((id) => typeof id !== 'string'))
|
|
14564
|
+
return null;
|
|
14565
|
+
if (row.execs !== undefined && (!Array.isArray(row.execs) || row.execs.some((entry) => typeof entry.startedAt !== 'string' || typeof entry.endedAt !== 'string'
|
|
14566
|
+
|| (entry.exitCode !== null && !Number.isInteger(entry.exitCode))
|
|
14567
|
+
|| typeof entry.outcome !== 'string'
|
|
14568
|
+
|| (entry.tokens !== null && !Number.isInteger(entry.tokens)))))
|
|
14569
|
+
return null;
|
|
14570
|
+
return row;
|
|
14571
|
+
}
|
|
14572
|
+
catch {
|
|
14573
|
+
return null;
|
|
14574
|
+
}
|
|
14575
|
+
}
|
|
14576
|
+
function readRoundState(path) {
|
|
14577
|
+
try {
|
|
14578
|
+
return parseRoundState(readFileSync(path, 'utf8'));
|
|
14579
|
+
}
|
|
14580
|
+
catch {
|
|
14581
|
+
return null;
|
|
14582
|
+
}
|
|
14583
|
+
}
|
|
14584
|
+
/** round-state-lock: the raw bytes at `path`, or `null` when absent/unreadable. Used to detect
|
|
14585
|
+
* whether the state file changed between a check made BEFORE the (long, unlocked) recall and one
|
|
14586
|
+
* made again INSIDE the round-state lock — a byte-identical read means nothing raced us. */
|
|
14587
|
+
function readRawRoundState(path) {
|
|
14588
|
+
try {
|
|
14589
|
+
return readFileSync(path, 'utf8');
|
|
14590
|
+
}
|
|
14591
|
+
catch {
|
|
14592
|
+
return null;
|
|
14593
|
+
}
|
|
14594
|
+
}
|
|
14595
|
+
/** round-state-lock fix-round AM-1: 16 random hex chars, minted once per `open`. */
|
|
14596
|
+
function generateRoundStateId() {
|
|
14597
|
+
return randomBytes(8).toString('hex');
|
|
14598
|
+
}
|
|
14599
|
+
/** Lead edit after Codex re-review: a LEGACY state (written before stateId existed) must not be
|
|
14600
|
+
* matched by `undefined === undefined` — under the lock, the first exec/close that meets it mints
|
|
14601
|
+
* an id, writes it back, and continues with that id as the identity of THIS operation. */
|
|
14602
|
+
function ensureStateId(path, fresh) {
|
|
14603
|
+
if (fresh.stateId !== undefined)
|
|
14604
|
+
return fresh;
|
|
14605
|
+
const minted = { ...fresh, stateId: randomBytes(8).toString('hex') };
|
|
14606
|
+
writeJsonAtomic(path, minted);
|
|
14607
|
+
return minted;
|
|
14608
|
+
}
|
|
14609
|
+
function readStateOrRefuse(path, expectedStateId) {
|
|
14610
|
+
const fresh = readRoundState(path);
|
|
14611
|
+
if (fresh === null)
|
|
14612
|
+
return { refused: 'gone' };
|
|
14613
|
+
if (expectedStateId === undefined && fresh.stateId === undefined)
|
|
14614
|
+
return ensureStateId(path, fresh);
|
|
14615
|
+
if (fresh.stateId !== expectedStateId)
|
|
14616
|
+
return { refused: 'replaced', stateId: fresh.stateId };
|
|
14617
|
+
return fresh;
|
|
14618
|
+
}
|
|
14619
|
+
function readStateForCloseOrRefuse(path, expectedStateId) {
|
|
14620
|
+
const fresh = readRoundState(path);
|
|
14621
|
+
if (fresh === null)
|
|
14622
|
+
return { refused: 'closed-already' };
|
|
14623
|
+
if (expectedStateId === undefined && fresh.stateId === undefined)
|
|
14624
|
+
return ensureStateId(path, fresh);
|
|
14625
|
+
if (fresh.stateId !== expectedStateId)
|
|
14626
|
+
return { refused: 'replaced', stateId: fresh.stateId };
|
|
14627
|
+
return fresh;
|
|
14628
|
+
}
|
|
14629
|
+
/** round-state-lock fix-round AM-4: the exact ledger-row marker `closeRound` (harness-core) will
|
|
14630
|
+
* compute for THIS close attempt, predicted from the same three inputs (slug, round, closedAt)
|
|
14631
|
+
* BEFORE calling it — so a retried `close` with the same injected `roundNow` (same `closedAt`) can
|
|
14632
|
+
* detect "the ledger already carries this attempt's row" and skip writing a duplicate. Mirrors
|
|
14633
|
+
* `closeRound`'s own marker formula in harness-core/src/round.ts exactly; a drift between the two
|
|
14634
|
+
* would only defeat the RETRY-dedup check (closeRound's own success postcondition, verified by
|
|
14635
|
+
* rereading the ledger tail, is unaffected either way). Deliberately NOT keyed on `stateId`: the
|
|
14636
|
+
* run-cost ledger row schema (`RoundLedgerRow`) has no such column, and adding one is out of this
|
|
14637
|
+
* fix's scope (round.ts stays untouched) — (slug, round, closedAt) is the identity already exposed
|
|
14638
|
+
* through the marker, and it is exactly as unique for a genuine retry (same close command, same
|
|
14639
|
+
* injected clock) as a `stateId` would be. */
|
|
14640
|
+
function predictedRoundCloseMarker(slug, round, closedAtIso) {
|
|
14641
|
+
const closedMs = Date.parse(closedAtIso);
|
|
14642
|
+
const compactTs = new Date(closedMs).toISOString().replace(/[-:.]/g, '');
|
|
14643
|
+
return `round-${slug}-${round}-${compactTs}`;
|
|
14644
|
+
}
|
|
14645
|
+
/** round-state-lock fix-round AM-5: `open`/`status` warn when a round has been sitting with
|
|
14646
|
+
* `ownerKind: 'exec'` for more than this many minutes — the shape of a restore-section that
|
|
14647
|
+
* exhausted its lock-busy retries (see `ROUND_RESTORE_LOCK_ATTEMPTS`) and left the round claimed by
|
|
14648
|
+
* an `exec` that already finished. There is no separate "since when has this been exec" timestamp on
|
|
14649
|
+
* `RoundState`, so this measures from `startedAt` (the round's own start) — a deliberate
|
|
14650
|
+
* approximation: an `exec` that ran briefly near round-open would read as "young" even if its
|
|
14651
|
+
* restore failed just now. Good enough to surface the stuck case at all; not a claim of precision. */
|
|
14652
|
+
const ROUND_EXEC_STALE_MINUTES = 10;
|
|
14653
|
+
function roundExecStaleAgeMinutes(state, now) {
|
|
14654
|
+
if (state.ownerKind !== 'exec')
|
|
14655
|
+
return null;
|
|
14656
|
+
// Lead edit after Codex re-review: count from the exec claim, not from the round's own start —
|
|
14657
|
+
// a fresh exec inside an old round is not stuck. Legacy states without the field fall back.
|
|
14658
|
+
const claimedMs = Date.parse(state.execClaimedAt ?? state.startedAt);
|
|
14659
|
+
if (!Number.isFinite(claimedMs))
|
|
14660
|
+
return null;
|
|
14661
|
+
const minutes = Math.floor((now - claimedMs) / 60_000);
|
|
14662
|
+
return minutes >= ROUND_EXEC_STALE_MINUTES ? minutes : null;
|
|
14663
|
+
}
|
|
14664
|
+
function readRoundLedgerTail(root) {
|
|
14665
|
+
try {
|
|
14666
|
+
const body = readFileSync(join(root, ROUND_LEDGER_REL), 'utf8');
|
|
14667
|
+
return body.slice(-64 * 1024);
|
|
14668
|
+
}
|
|
14669
|
+
catch {
|
|
14670
|
+
return '';
|
|
14671
|
+
}
|
|
14672
|
+
}
|
|
14673
|
+
function readRoundLedger(root) {
|
|
14674
|
+
try {
|
|
14675
|
+
return readFileSync(join(root, ROUND_LEDGER_REL), 'utf8');
|
|
14676
|
+
}
|
|
14677
|
+
catch {
|
|
14678
|
+
return '';
|
|
14679
|
+
}
|
|
14680
|
+
}
|
|
14681
|
+
function roundRunOwnerAlive(root, runId, now, registryReader, pidProbe = probePid) {
|
|
14682
|
+
const registry = readRunRegistry(root, registryReader === undefined
|
|
14683
|
+
? runRegistryIO
|
|
14684
|
+
: { ...runRegistryIO, read: () => registryReader(root) });
|
|
14685
|
+
if (registry.status !== 'readable')
|
|
14686
|
+
return null;
|
|
14687
|
+
const decision = liveness(registry.runs.find((run) => run.runId === runId), now, pidProbe);
|
|
14688
|
+
return decision.state === 'live' || decision.state === 'stalled' ? true : decision.state === 'orphaned' ? false : null;
|
|
14689
|
+
}
|
|
14690
|
+
function nextRoundNumber(ledger, slug) {
|
|
14691
|
+
let count = 0;
|
|
14692
|
+
for (const line of ledger.split('\n')) {
|
|
14693
|
+
try {
|
|
14694
|
+
const row = JSON.parse(line);
|
|
14695
|
+
if (row.slug === slug && row.stage === 'round')
|
|
14696
|
+
count++;
|
|
14697
|
+
}
|
|
14698
|
+
catch { /* malformed and torn rows are not completed rounds */ }
|
|
14699
|
+
}
|
|
14700
|
+
return count + 1;
|
|
14701
|
+
}
|
|
14702
|
+
export async function spawnRoundCodex(request) {
|
|
14703
|
+
mkdirSync(dirname(request.logPath), { recursive: true });
|
|
14704
|
+
const logFd = openSync(request.logPath, 'w');
|
|
14705
|
+
return await new Promise((resolveRun) => {
|
|
14706
|
+
let settled = false;
|
|
14707
|
+
let timedOut = false;
|
|
14708
|
+
let escalation;
|
|
14709
|
+
let child;
|
|
14710
|
+
const finish = (receipt) => {
|
|
14711
|
+
if (settled)
|
|
14712
|
+
return;
|
|
14713
|
+
settled = true;
|
|
14714
|
+
clearTimeout(deadline);
|
|
14715
|
+
if (escalation !== undefined)
|
|
14716
|
+
clearTimeout(escalation);
|
|
14717
|
+
try {
|
|
14718
|
+
closeSync(logFd);
|
|
14719
|
+
}
|
|
14720
|
+
catch { /* the subprocess receipt remains authoritative */ }
|
|
14721
|
+
resolveRun({ ...receipt, timedOut });
|
|
14722
|
+
};
|
|
14723
|
+
const deadline = setTimeout(() => {
|
|
14724
|
+
timedOut = true;
|
|
14725
|
+
try {
|
|
14726
|
+
child?.kill('SIGTERM');
|
|
14727
|
+
}
|
|
14728
|
+
catch { /* SIGKILL below is the bounded fallback */ }
|
|
14729
|
+
escalation = setTimeout(() => {
|
|
14730
|
+
try {
|
|
14731
|
+
child?.kill('SIGKILL');
|
|
14732
|
+
}
|
|
14733
|
+
catch { /* close/error decides the receipt */ }
|
|
14734
|
+
}, request.killGraceMs ?? 10_000);
|
|
14735
|
+
}, request.timeoutMs);
|
|
14736
|
+
try {
|
|
14737
|
+
child = spawn(request.command, [...request.args], {
|
|
14738
|
+
cwd: request.cwd,
|
|
14739
|
+
stdio: ['ignore', logFd, logFd],
|
|
14740
|
+
});
|
|
14741
|
+
}
|
|
14742
|
+
catch (error) {
|
|
14743
|
+
const err = error;
|
|
14744
|
+
finish({ exitCode: null, signal: null, ...(err.code === undefined ? {} : { errorCode: err.code }), error: err.message });
|
|
14745
|
+
return;
|
|
14746
|
+
}
|
|
14747
|
+
child.on('error', (error) => {
|
|
14748
|
+
finish({ exitCode: null, signal: null, ...(error.code === undefined ? {} : { errorCode: error.code }), error: error.message });
|
|
14749
|
+
});
|
|
14750
|
+
child.on('close', (code, signal) => finish({ exitCode: code, signal }));
|
|
14751
|
+
});
|
|
14752
|
+
}
|
|
14753
|
+
function roundExecReceiptFound(tail, expected) {
|
|
14754
|
+
for (const line of tail.split('\n')) {
|
|
14755
|
+
try {
|
|
14756
|
+
const row = JSON.parse(line);
|
|
14757
|
+
if (row.stage === 'round-exec' && row.slug === expected.slug && row.round === expected.round
|
|
14758
|
+
&& row.startedAt === expected.startedAt && row.endedAt === expected.endedAt
|
|
14759
|
+
&& row.outcome === expected.outcome && row.exitCode === expected.exitCode)
|
|
14760
|
+
return true;
|
|
14761
|
+
}
|
|
14762
|
+
catch { /* a torn or unrelated line is not this receipt */ }
|
|
14763
|
+
}
|
|
14764
|
+
return false;
|
|
14765
|
+
}
|
|
14766
|
+
/**
|
|
14767
|
+
* round-state-lock T1 — the one named lock every `.dz/rounds/*.json` mutation goes through
|
|
14768
|
+
* (`<stateRoot>/.dz/locks/round-state.lock`, `withNamedLockSync` from `@dzhechkov/harness-core`).
|
|
14769
|
+
*
|
|
14770
|
+
* `fn` MUST be short and synchronous (the same caveat `withNamedLockSync` itself carries): it may
|
|
14771
|
+
* reread state and write it, never spawn a subprocess or await anything — the recall step and the
|
|
14772
|
+
* ledger write stay OUTSIDE the lock by design (teach:0ea46034), and the long-running `codex exec`
|
|
14773
|
+
* child in `round exec` runs between two separate short lock holds, not inside one.
|
|
14774
|
+
*
|
|
14775
|
+
* `io.roundLockTimeoutMs` (NFR-2) lets tests force a small deadline instead of the real default;
|
|
14776
|
+
* omitting it keeps production behaviour (and every existing test) byte-identical.
|
|
14777
|
+
*/
|
|
14778
|
+
function withRoundStateLock(stateRoot, fn, io) {
|
|
14779
|
+
try {
|
|
14780
|
+
return withNamedLockSync(stateRoot, 'round-state', fn, io.roundLockTimeoutMs === undefined ? {} : { timeoutMs: io.roundLockTimeoutMs });
|
|
14781
|
+
}
|
|
14782
|
+
catch (error) {
|
|
14783
|
+
if (error instanceof NamedLockTimeoutError) {
|
|
14784
|
+
return { refused: 'lock-busy', reason: error.message };
|
|
14785
|
+
}
|
|
14786
|
+
throw error;
|
|
14787
|
+
}
|
|
14788
|
+
}
|
|
14789
|
+
/** round-state-lock fix-round AM-5: the restore-section retry budget — `exec`'s SECOND lock hold
|
|
14790
|
+
* (returning ownership after the codex child exits) tries up to this many times, with the SAME
|
|
14791
|
+
* per-attempt timeout, before it gives up and leaves the round `ownerKind: 'exec'` for a human to
|
|
14792
|
+
* notice (via the `open`/`status` staleness warning) rather than looping forever against a lock that
|
|
14793
|
+
* may never free up. */
|
|
14794
|
+
const ROUND_RESTORE_LOCK_ATTEMPTS = 4; // 1 attempt + 3 retries (AM-5; lead edit after re-review)
|
|
14795
|
+
function withRoundStateLockRetried(stateRoot, fn, io, attempts) {
|
|
14796
|
+
let lastBusy = null;
|
|
14797
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
14798
|
+
const result = withRoundStateLock(stateRoot, fn, io);
|
|
14799
|
+
if (!(typeof result === 'object' && result !== null && 'refused' in result && result.refused === 'lock-busy')) {
|
|
14800
|
+
return result;
|
|
14801
|
+
}
|
|
14802
|
+
lastBusy = result;
|
|
14803
|
+
}
|
|
14804
|
+
return lastBusy;
|
|
14805
|
+
}
|
|
14806
|
+
async function cmdRound(options, optionLists, flags, cwd, write, io) {
|
|
14807
|
+
const sub = options.get('_positional_0') ?? '';
|
|
14808
|
+
const json = flags.has('json');
|
|
14809
|
+
const stateRootResolution = resolveRoundStateRoot(options, process.env, cwd);
|
|
14810
|
+
if (!stateRootResolution.ok) {
|
|
14811
|
+
write(json ? JSON.stringify({ message: stateRootResolution.reason }) : stateRootResolution.reason);
|
|
14812
|
+
return 2;
|
|
14813
|
+
}
|
|
14814
|
+
const stateRoot = stateRootResolution.root;
|
|
14815
|
+
const stateRootExplicit = stateRootResolution.source !== 'cwd';
|
|
14816
|
+
const projectRoot = resolve(cwd, options.get('project') ?? '.');
|
|
14817
|
+
const now = io.roundNow?.() ?? Date.now();
|
|
14818
|
+
const emit = (message, extra = {}) => {
|
|
14819
|
+
write(json ? JSON.stringify({ message, ...extra }) : message);
|
|
14820
|
+
};
|
|
14821
|
+
const address = (roundOverride) => {
|
|
14822
|
+
const slug = options.get('slug') ?? '';
|
|
14823
|
+
const round = roundOverride ?? Number(options.get('round'));
|
|
14824
|
+
if (!/^[a-z0-9][a-z0-9._-]*$/i.test(slug) || !Number.isInteger(round) || round < 1)
|
|
14825
|
+
return null;
|
|
14826
|
+
return { slug, round };
|
|
14827
|
+
};
|
|
14828
|
+
if (sub === 'open') {
|
|
14829
|
+
const slug = options.get('slug') ?? '';
|
|
14830
|
+
const roundRaw = options.get('round');
|
|
14831
|
+
const autoRound = roundRaw === 'auto'
|
|
14832
|
+
? nextRoundNumber(io.roundLedgerReader?.(stateRoot) ?? readRoundLedger(stateRoot), slug)
|
|
14833
|
+
: undefined;
|
|
14834
|
+
const at = address(autoRound);
|
|
14835
|
+
const topic = options.get('topic') ?? '';
|
|
14836
|
+
if (at === null || topic.trim() === '') {
|
|
14837
|
+
emit('нужны --slug --round --topic');
|
|
14838
|
+
return 2;
|
|
14839
|
+
}
|
|
14840
|
+
const ownerPidRaw = options.get('owner-pid');
|
|
14841
|
+
const ownerRunRaw = options.get('owner-run');
|
|
14842
|
+
if (ownerPidRaw !== undefined && ownerRunRaw !== undefined) {
|
|
14843
|
+
emit('--owner-pid и --owner-run взаимоисключающие');
|
|
14844
|
+
return 2;
|
|
14845
|
+
}
|
|
14846
|
+
if (ownerRunRaw !== undefined && ownerRunRaw.trim() === '') {
|
|
14847
|
+
emit('--owner-run пуст');
|
|
14848
|
+
return 2;
|
|
14849
|
+
}
|
|
14850
|
+
const ownerRun = ownerRunRaw?.trim();
|
|
14851
|
+
const ownerPid = ownerRunRaw !== undefined ? 0 : ownerPidRaw === undefined ? process.ppid : Number(ownerPidRaw);
|
|
14852
|
+
const ownerKind = ownerRunRaw !== undefined ? 'run' : ownerPidRaw === undefined ? 'parent' : 'explicit';
|
|
14853
|
+
const path = roundStatePath(stateRoot, at.slug, at.round);
|
|
14854
|
+
// round-state-lock FR-3/AC-1: captured BEFORE the (long, unlocked) recall below, so the
|
|
14855
|
+
// recheck under the lock can tell "unchanged since this snapshot" from "a different process
|
|
14856
|
+
// opened it while we were recalling".
|
|
14857
|
+
const beforeRaw = readRawRoundState(path);
|
|
14858
|
+
const existing = beforeRaw === null
|
|
14859
|
+
? null
|
|
14860
|
+
: parseRoundState(beforeRaw) ?? {
|
|
14861
|
+
slug: at.slug, round: at.round, topic: '', startedAt: new Date(now).toISOString(),
|
|
14862
|
+
pid: 1, ownerKind: 'explicit', recalled: [],
|
|
14863
|
+
};
|
|
14864
|
+
let existingOwnerAlive = null;
|
|
14865
|
+
if (existing !== null && flags.has('force') && existing.ownerKind !== 'run') {
|
|
14866
|
+
try {
|
|
14867
|
+
existingOwnerAlive = (io.roundPidProbe ?? probePid)(existing.pid);
|
|
14868
|
+
}
|
|
14869
|
+
catch { /* unavailable is unknown and refuses */ }
|
|
14870
|
+
}
|
|
14871
|
+
const isRunAlive = (runId) => roundRunOwnerAlive(stateRoot, runId, now, io.roundRunRegistryReader, io.roundPidProbe ?? probePid);
|
|
14872
|
+
const runId = options.get('run')?.trim();
|
|
14873
|
+
const recallOptions = { limit: 5, ...(runId === undefined || runId === '' ? {} : { runId }) };
|
|
14874
|
+
const preflight = openRound({
|
|
14875
|
+
...at, topic, startedAt: new Date(now).toISOString(), ownerPid, ownerKind,
|
|
14876
|
+
...(ownerRun === undefined || ownerRun === '' ? {} : { ownerRun }),
|
|
14877
|
+
...(runId === undefined || runId === '' ? {} : { run: runId }), recalled: [], existing,
|
|
14878
|
+
force: flags.has('force'), existingOwnerAlive, isRunAlive,
|
|
14879
|
+
});
|
|
14880
|
+
if (!preflight.ok) {
|
|
14881
|
+
// AM-5: the round we are refusing to touch may itself be a stuck `exec` claim (its restore
|
|
14882
|
+
// section exhausted its lock-busy retries and left `ownerKind: 'exec'`) — name that out loud
|
|
14883
|
+
// rather than leaving the operator to guess why a pid that "shouldn't" be alive is blocking.
|
|
14884
|
+
const staleMinutes = existing === null ? null : roundExecStaleAgeMinutes(existing, now);
|
|
14885
|
+
const reason = staleMinutes === null
|
|
14886
|
+
? preflight.reason
|
|
14887
|
+
: `${preflight.reason} (владелец завис в exec ${staleMinutes} мин)`;
|
|
14888
|
+
emit(reason, { round: at.round, ...(staleMinutes === null ? {} : { staleExecMinutes: staleMinutes }) });
|
|
14889
|
+
return preflight.exit;
|
|
14890
|
+
}
|
|
14891
|
+
let lessons = [];
|
|
14892
|
+
try {
|
|
14893
|
+
lessons = io.roundRecall !== undefined
|
|
14894
|
+
? await io.roundRecall(projectRoot, topic, recallOptions)
|
|
14895
|
+
: (await recallHybrid(projectRoot, topic, recallOptions)).hits.slice(0, 5).map((hit) => ({
|
|
14896
|
+
id: patternRecordId(hit.pattern),
|
|
14897
|
+
reward: hit.pattern.reward,
|
|
14898
|
+
domain: hit.pattern.domain,
|
|
14899
|
+
text: hit.pattern.pattern,
|
|
14900
|
+
}));
|
|
14901
|
+
}
|
|
14902
|
+
catch {
|
|
14903
|
+
lessons = [];
|
|
14904
|
+
}
|
|
14905
|
+
const opened = openRound({
|
|
14906
|
+
...at, topic, startedAt: new Date(now).toISOString(), ownerPid, ownerKind,
|
|
14907
|
+
...(ownerRun === undefined || ownerRun === '' ? {} : { ownerRun }),
|
|
14908
|
+
...(runId === undefined || runId === '' ? {} : { run: runId }),
|
|
14909
|
+
recalled: lessons.slice(0, 5).map((lesson) => lesson.id), existing: null,
|
|
14910
|
+
force: false, existingOwnerAlive: null, isRunAlive,
|
|
14911
|
+
});
|
|
14912
|
+
if (!opened.ok) {
|
|
14913
|
+
emit(opened.reason);
|
|
14914
|
+
return opened.exit;
|
|
14915
|
+
}
|
|
14916
|
+
const openedState = { ...opened.state, execs: [], stateId: generateRoundStateId() };
|
|
14917
|
+
let archived;
|
|
14918
|
+
try {
|
|
14919
|
+
const locked = withRoundStateLock(stateRoot, () => {
|
|
14920
|
+
// AM-3/AM-6: recall ran unlocked and may have taken a while — reread NOW, under the lock,
|
|
14921
|
+
// and decide fresh from what is ACTUALLY there rather than from the pre-recall snapshot.
|
|
14922
|
+
//
|
|
14923
|
+
// AM-3 (was: refuse only when the bytes changed AND the foreign pid differed from ours):
|
|
14924
|
+
// `ppid` coincides for two `dz` launched from the same shell, and every run-owned state
|
|
14925
|
+
// carries pid 0 — so "same pid" proved nothing about identity. ANY change in raw bytes since
|
|
14926
|
+
// `beforeRaw` is now the refusal trigger; the foreign pid is reported for diagnostics only,
|
|
14927
|
+
// never consulted for the decision.
|
|
14928
|
+
//
|
|
14929
|
+
// AM-6 (was: an unconditional `readFileSync(path)` while archiving threw a bare ENOENT if
|
|
14930
|
+
// the target vanished mid-recall): a state that is simply GONE now is not a race to refuse —
|
|
14931
|
+
// it is exactly the "no existing round" case, --force or not. Re-decide fresh: no file under
|
|
14932
|
+
// the lock ⇒ ordinary open, no archive, regardless of what `beforeRaw`/`existing` said.
|
|
14933
|
+
const nowRaw = readRawRoundState(path);
|
|
14934
|
+
if (nowRaw === beforeRaw) {
|
|
14935
|
+
// Unchanged since the pre-recall snapshot: proceed exactly as `preflight` planned —
|
|
14936
|
+
// including the --force archive-a-dead-owner flow, which is safe here because nothing
|
|
14937
|
+
// touched `existing`'s bytes while we were recalling.
|
|
14938
|
+
if (preflight.archiveExisting && existing !== null) {
|
|
14939
|
+
const compactStartedAt = new Date(existing.startedAt).toISOString().replace(/[-:.]/g, '');
|
|
14940
|
+
archived = join(stateRoot, '.dz', 'rounds', 'archive', `${at.slug}-${at.round}-${compactStartedAt}.json`);
|
|
14941
|
+
mkdirSync(dirname(archived), { recursive: true });
|
|
14942
|
+
writeFileSync(archived, readFileSync(path), { flag: 'wx' });
|
|
14943
|
+
}
|
|
14944
|
+
writeJsonAtomic(path, openedState);
|
|
14945
|
+
return { ok: true };
|
|
14946
|
+
}
|
|
14947
|
+
if (nowRaw === null) {
|
|
14948
|
+
// AM-6: vanished under us — nothing left to conflict with or to archive.
|
|
14949
|
+
writeJsonAtomic(path, openedState);
|
|
14950
|
+
return { ok: true };
|
|
14951
|
+
}
|
|
14952
|
+
// Something is there now, and it is byte-different from what we planned around: refuse.
|
|
14953
|
+
// The pid below is diagnostic only (AM-3) — it never gates the decision.
|
|
14954
|
+
const foreign = parseRoundState(nowRaw);
|
|
14955
|
+
return { refused: 'already-open', pid: foreign?.pid ?? -1 };
|
|
14956
|
+
}, io);
|
|
14957
|
+
if ('refused' in locked) {
|
|
14958
|
+
if (locked.refused === 'lock-busy') {
|
|
14959
|
+
emit(`lock busy: ${locked.reason}`, { refused: 'lock-busy' });
|
|
14960
|
+
return 1;
|
|
14961
|
+
}
|
|
14962
|
+
emit(`круг уже открыт (pid ${locked.pid}) — состояние не перезаписано`, { refused: 'already-open', pid: locked.pid });
|
|
14963
|
+
return 1;
|
|
14964
|
+
}
|
|
14965
|
+
}
|
|
14966
|
+
catch (error) {
|
|
14967
|
+
emit(`круг не открыт: ${error instanceof Error ? error.message : String(error)}`);
|
|
14968
|
+
return 1;
|
|
14969
|
+
}
|
|
14970
|
+
const owner = openedState.ownerKind === 'run'
|
|
14971
|
+
? `владелец: run ${openedState.ownerRun} (run)`
|
|
14972
|
+
: `владелец: pid ${openedState.pid} (${openedState.ownerKind})`;
|
|
14973
|
+
if (json) {
|
|
14974
|
+
emit('круг открыт', { state: openedState, owner, stateRoot, lessons: lessons.slice(0, 5), ...(archived === undefined ? {} : { archived }) });
|
|
14975
|
+
}
|
|
14976
|
+
else {
|
|
14977
|
+
if (archived !== undefined)
|
|
14978
|
+
write(`архивировано: ${archived}`);
|
|
14979
|
+
write(`=== КРУГ ОТКРЫТ: ${at.slug} круг ${at.round}`);
|
|
14980
|
+
write(`state root: ${stateRoot}`);
|
|
14981
|
+
write(owner);
|
|
14982
|
+
write(`--- уроки для брифа (${lessons.slice(0, 5).length} поднято):`);
|
|
14983
|
+
for (const lesson of lessons.slice(0, 5)) {
|
|
14984
|
+
const oneLine = lesson.text.replace(/[\r\n\u2028\u2029\u0085\v\f]+/g, ' ⏎ ');
|
|
14985
|
+
write(` [${lesson.reward.toFixed(2)}] (${lesson.domain}) ${oneLine.slice(0, 160)}`);
|
|
14986
|
+
}
|
|
14987
|
+
}
|
|
14988
|
+
return 0;
|
|
14989
|
+
}
|
|
14990
|
+
if (sub === 'exec') {
|
|
14991
|
+
const at = address();
|
|
14992
|
+
const briefArg = options.get('brief') ?? '';
|
|
14993
|
+
const timeoutRaw = options.get('timeout-min') ?? '30';
|
|
14994
|
+
const timeoutMinutes = Number(timeoutRaw);
|
|
14995
|
+
if (at === null || briefArg.trim() === '' || !Number.isInteger(timeoutMinutes) || timeoutMinutes <= 0) {
|
|
14996
|
+
emit('нужны --slug --round --brief; --timeout-min должен быть целым числом больше нуля');
|
|
14997
|
+
return 2;
|
|
14998
|
+
}
|
|
14999
|
+
const briefPath = resolve(cwd, briefArg);
|
|
15000
|
+
let briefText;
|
|
15001
|
+
try {
|
|
15002
|
+
briefText = readFileSync(briefPath, 'utf8');
|
|
15003
|
+
}
|
|
15004
|
+
catch {
|
|
15005
|
+
emit(`brief не читается: ${briefArg}`);
|
|
15006
|
+
return 2;
|
|
15007
|
+
}
|
|
15008
|
+
const path = roundStatePath(stateRoot, at.slug, at.round);
|
|
15009
|
+
let state = readRoundState(path);
|
|
15010
|
+
if (state === null) {
|
|
15011
|
+
emit(existsSync(path) ? 'состояние круга не читается' : 'круг не открыт');
|
|
15012
|
+
return 1;
|
|
15013
|
+
}
|
|
15014
|
+
const model = options.get('model') ?? 'gpt-5.6-sol';
|
|
15015
|
+
const effort = options.get('effort') ?? 'high';
|
|
15016
|
+
const logArg = options.get('log') ?? join('.dz', 'rounds', `${at.slug}-${at.round}.exec.log`);
|
|
15017
|
+
const logPath = resolve(cwd, logArg);
|
|
15018
|
+
const startedMs = io.roundNow?.() ?? Date.now();
|
|
15019
|
+
const startedAt = new Date(startedMs).toISOString();
|
|
15020
|
+
const request = {
|
|
15021
|
+
command: 'codex',
|
|
15022
|
+
args: [
|
|
15023
|
+
'exec',
|
|
15024
|
+
'-c', `model=${model}`,
|
|
15025
|
+
'-c', `model_reasoning_effort=${effort}`,
|
|
15026
|
+
'--dangerously-bypass-approvals-and-sandbox',
|
|
15027
|
+
briefText,
|
|
15028
|
+
],
|
|
15029
|
+
cwd: stateRoot,
|
|
15030
|
+
logPath,
|
|
15031
|
+
timeoutMs: timeoutMinutes * 60_000,
|
|
15032
|
+
killGraceMs: io.roundKillGraceMs ?? 10_000,
|
|
15033
|
+
};
|
|
15034
|
+
let execClaimId = '';
|
|
15035
|
+
try {
|
|
15036
|
+
// T3/FR-1, fix-round AM-1: reread state under the lock immediately before claiming ownership
|
|
15037
|
+
// — a short, synchronous critical section, released before the (possibly long) codex child
|
|
15038
|
+
// below runs. NO fallback to the pre-lock `state` snapshot (that was the resurrection bug:
|
|
15039
|
+
// `readRoundState(path) ?? state!` would recreate a round that had been closed in the
|
|
15040
|
+
// meantime). The claim proceeds ONLY when the state currently under the lock still carries the
|
|
15041
|
+
// exact `stateId` we read before acquiring it — pid/ppid can coincide across processes, but a
|
|
15042
|
+
// `stateId` never does.
|
|
15043
|
+
execClaimId = randomBytes(8).toString('hex');
|
|
15044
|
+
const claimed = withRoundStateLock(stateRoot, () => {
|
|
15045
|
+
const outcome = readStateOrRefuse(path, state.stateId);
|
|
15046
|
+
if ('refused' in outcome)
|
|
15047
|
+
return outcome;
|
|
15048
|
+
if (outcome.ownerKind === 'exec' && outcome.execClaimId !== undefined) {
|
|
15049
|
+
return { refused: 'exec-in-progress', execClaimId: outcome.execClaimId };
|
|
15050
|
+
}
|
|
15051
|
+
writeJsonAtomic(path, { ...outcome, pid: io.roundPid ?? process.pid, ownerKind: 'exec', execClaimId, execClaimedAt: new Date(io.roundNow?.() ?? Date.now()).toISOString() });
|
|
15052
|
+
return { ok: true, base: outcome };
|
|
15053
|
+
}, io);
|
|
15054
|
+
if ('refused' in claimed) {
|
|
15055
|
+
if (claimed.refused === 'lock-busy') {
|
|
15056
|
+
emit(`exec не запущен: владелец круга не обновлён: lock busy: ${claimed.reason}`, { refused: 'lock-busy' });
|
|
15057
|
+
return 1;
|
|
15058
|
+
}
|
|
15059
|
+
if (claimed.refused === 'gone') {
|
|
15060
|
+
emit('exec не запущен: круг закрыт во время exec, владелец не менялся', { refused: 'gone' });
|
|
15061
|
+
return 1;
|
|
15062
|
+
}
|
|
15063
|
+
if (claimed.refused === 'exec-in-progress') {
|
|
15064
|
+
emit(`exec не запущен: у круга уже идёт exec (claim ${claimed.execClaimId})`, { refused: 'exec-in-progress', execClaimId: claimed.execClaimId });
|
|
15065
|
+
return 1;
|
|
15066
|
+
}
|
|
15067
|
+
const replaced = claimed;
|
|
15068
|
+
emit(`exec не запущен: состояние круга заменено (stateId ${replaced.stateId ?? 'unknown'}), возврат владельца пропущен`, { refused: 'replaced', stateId: replaced.stateId });
|
|
15069
|
+
return 1;
|
|
15070
|
+
}
|
|
15071
|
+
state = claimed.base;
|
|
15072
|
+
}
|
|
15073
|
+
catch (error) {
|
|
15074
|
+
emit(`exec не запущен: владелец круга не обновлён: ${error instanceof Error ? error.message : String(error)}`);
|
|
15075
|
+
return 1;
|
|
15076
|
+
}
|
|
15077
|
+
let receipt;
|
|
15078
|
+
try {
|
|
15079
|
+
try {
|
|
15080
|
+
receipt = await (io.roundSpawn ?? spawnRoundCodex)(request);
|
|
15081
|
+
}
|
|
15082
|
+
catch (error) {
|
|
15083
|
+
const err = error;
|
|
15084
|
+
receipt = { exitCode: null, timedOut: false, signal: null, ...(err.code === undefined ? {} : { errorCode: err.code }), error: err.message };
|
|
15085
|
+
}
|
|
15086
|
+
}
|
|
15087
|
+
finally {
|
|
15088
|
+
try {
|
|
15089
|
+
// T3/FR-1, fix-round AM-1/AM-5: the return leg — a second short lock hold, symmetric with
|
|
15090
|
+
// the claim above, and gated by the SAME stateId check (the child may have run long enough
|
|
15091
|
+
// for someone else to close or replace this round while it was running). AM-5: a busy lock
|
|
15092
|
+
// here gets up to ROUND_RESTORE_LOCK_ATTEMPTS tries with the same timeout before giving up —
|
|
15093
|
+
// a codex child can legitimately run for a while, so ownership recovery deserves more than
|
|
15094
|
+
// one attempt before leaving the round stuck at `ownerKind: 'exec'`.
|
|
15095
|
+
const restored = withRoundStateLockRetried(stateRoot, () => {
|
|
15096
|
+
const outcome = readStateOrRefuse(path, state.stateId);
|
|
15097
|
+
if ('refused' in outcome)
|
|
15098
|
+
return outcome;
|
|
15099
|
+
// Lead edit after Codex re-review: restore only OUR claim — another exec of the same round
|
|
15100
|
+
// instance has its own execClaimId and must not be wiped by our base state.
|
|
15101
|
+
if (outcome.execClaimId !== execClaimId) {
|
|
15102
|
+
return { refused: 'replaced', stateId: outcome.stateId, execClaimId: outcome.execClaimId };
|
|
15103
|
+
}
|
|
15104
|
+
writeJsonAtomic(path, state);
|
|
15105
|
+
return { ok: true };
|
|
15106
|
+
}, io, ROUND_RESTORE_LOCK_ATTEMPTS);
|
|
15107
|
+
if ('refused' in restored) {
|
|
15108
|
+
if (restored.refused === 'lock-busy') {
|
|
15109
|
+
// AM-5: no new flag or command is added — this names the manual remedy in prose (a
|
|
15110
|
+
// literal `--flag`-shaped token here would be caught by known-flags-drift.test.ts as an
|
|
15111
|
+
// undocumented flag, which would be exactly the wrong signal for text naming no flag at
|
|
15112
|
+
// all). The durable fix is that `open`/`status` surface the resulting stuck
|
|
15113
|
+
// `ownerKind: 'exec'` on their own (roundExecStaleAgeMinutes), so it is never silently
|
|
15114
|
+
// left for someone to trip over.
|
|
15115
|
+
emit('владелец круга не восстановлен (ownerKind=exec остался): повторите dz round exec для этого круга, когда блокировка освободится', { refused: 'lock-busy', ownerKind: 'exec' });
|
|
15116
|
+
return 1;
|
|
15117
|
+
}
|
|
15118
|
+
if (restored.refused === 'gone') {
|
|
15119
|
+
emit('круг закрыт во время exec, владелец не менялся', { refused: 'gone' });
|
|
15120
|
+
return 1;
|
|
15121
|
+
}
|
|
15122
|
+
emit(`состояние круга заменено (stateId ${restored.stateId ?? 'unknown'}), возврат владельца пропущен`, { refused: 'replaced', stateId: restored.stateId });
|
|
15123
|
+
return 1;
|
|
15124
|
+
}
|
|
15125
|
+
}
|
|
15126
|
+
catch (error) {
|
|
15127
|
+
emit(`exec завершён, но владелец круга не восстановлен: ${error instanceof Error ? error.message : String(error)}`);
|
|
15128
|
+
return 1;
|
|
15129
|
+
}
|
|
15130
|
+
}
|
|
15131
|
+
const endedMs = io.roundNow?.() ?? Date.now();
|
|
15132
|
+
const endedAt = new Date(endedMs).toISOString();
|
|
15133
|
+
let logBuffer = Buffer.alloc(0);
|
|
15134
|
+
try {
|
|
15135
|
+
logBuffer = readFileSync(logPath);
|
|
15136
|
+
}
|
|
15137
|
+
catch { /* no output is an empty receipt */ }
|
|
15138
|
+
const logText = logBuffer.toString('utf8');
|
|
15139
|
+
const bytes = logBuffer.byteLength;
|
|
15140
|
+
const tokens = parseCodexTokens(logText);
|
|
15141
|
+
const outcome = classifyRoundExecOutcome({
|
|
15142
|
+
exitCode: receipt.exitCode,
|
|
15143
|
+
timedOut: receipt.timedOut,
|
|
15144
|
+
bytes,
|
|
15145
|
+
tail: logBuffer.subarray(Math.max(0, bytes - 4096)).toString('utf8'),
|
|
15146
|
+
});
|
|
15147
|
+
const row = buildRoundExecRow({
|
|
15148
|
+
...at,
|
|
15149
|
+
model,
|
|
15150
|
+
effort,
|
|
15151
|
+
minutes: Math.max(0, Math.floor((endedMs - startedMs) / 60_000)),
|
|
15152
|
+
tokens,
|
|
15153
|
+
outcome,
|
|
15154
|
+
exitCode: receipt.exitCode,
|
|
15155
|
+
bytes,
|
|
15156
|
+
startedAt,
|
|
15157
|
+
endedAt,
|
|
15158
|
+
log: logArg,
|
|
15159
|
+
brief: briefArg,
|
|
15160
|
+
});
|
|
15161
|
+
if (io.roundLedgerWriter !== undefined)
|
|
15162
|
+
io.roundLedgerWriter(stateRoot, row);
|
|
15163
|
+
else
|
|
15164
|
+
cmdFeatureAdrRecord(new Map([
|
|
15165
|
+
['kind', 'ledger'], ['stage', 'round-exec'], ['slug', state.slug], ['row', JSON.stringify(row)], ['project', stateRoot],
|
|
15166
|
+
]), new Set(), stateRoot, () => undefined);
|
|
15167
|
+
const ledgerTail = io.roundLedgerReader?.(stateRoot) ?? readRoundLedgerTail(stateRoot);
|
|
15168
|
+
if (!roundExecReceiptFound(ledgerTail, row)) {
|
|
15169
|
+
emit('строка round-exec не найдена — результат НЕ подтверждён');
|
|
15170
|
+
return 1;
|
|
15171
|
+
}
|
|
15172
|
+
try {
|
|
15173
|
+
writeJsonAtomic(path, {
|
|
15174
|
+
...state,
|
|
15175
|
+
execs: [...(state.execs ?? []), { startedAt, endedAt, exitCode: receipt.exitCode, outcome, tokens }],
|
|
15176
|
+
});
|
|
15177
|
+
}
|
|
15178
|
+
catch (error) {
|
|
15179
|
+
emit(`строка round-exec подтверждена, но состояние не обновлено: ${error instanceof Error ? error.message : String(error)}`);
|
|
15180
|
+
return 1;
|
|
15181
|
+
}
|
|
15182
|
+
if (receipt.errorCode === 'ENOENT')
|
|
15183
|
+
emit('codex не найден', { row });
|
|
15184
|
+
else
|
|
15185
|
+
emit(`round exec: ${row.minutes} min; exit ${row.exitCode ?? 'null'}; ${row.bytes} bytes; tokens ${row.tokens ?? 'не найдены'}; ${row.outcome}`, { row });
|
|
15186
|
+
return outcome === 'done' ? 0 : 1;
|
|
15187
|
+
}
|
|
15188
|
+
if (sub === 'close') {
|
|
15189
|
+
const at = address();
|
|
15190
|
+
if (at === null || !options.has('outcome')) {
|
|
15191
|
+
emit('нужны --slug --round --outcome');
|
|
15192
|
+
return 2;
|
|
15193
|
+
}
|
|
15194
|
+
const path = roundStatePath(stateRoot, at.slug, at.round);
|
|
15195
|
+
const state = readRoundState(path);
|
|
15196
|
+
if (state === null) {
|
|
15197
|
+
emit(existsSync(path) ? 'состояние круга не читается — круг НЕ закрыт' : 'круг не открыт');
|
|
15198
|
+
return 1;
|
|
15199
|
+
}
|
|
15200
|
+
const lessons = optionLists.get('lesson') ?? [];
|
|
15201
|
+
const knownLessonIds = lessons.filter((id) => {
|
|
15202
|
+
try {
|
|
15203
|
+
return io.roundLessonExists !== undefined
|
|
15204
|
+
? io.roundLessonExists(projectRoot, id)
|
|
15205
|
+
: loadStoreRecords(projectRoot).some((record) => record.id === id);
|
|
15206
|
+
}
|
|
15207
|
+
catch {
|
|
15208
|
+
return false;
|
|
15209
|
+
}
|
|
15210
|
+
});
|
|
15211
|
+
const numeric = (key) => options.has(key) ? Number(options.get(key)) : undefined;
|
|
15212
|
+
const closedAtIso = new Date(now).toISOString();
|
|
15213
|
+
// AM-4: predict the marker `closeRound` will compute for THIS attempt (same slug/round/closedAt
|
|
15214
|
+
// it will use) and check whether the ledger already carries it BEFORE calling `closeRound` —
|
|
15215
|
+
// this is what makes a retried `close` idempotent: if a prior invocation's write already landed
|
|
15216
|
+
// (this run's own tail read, not trusted from the earlier failed attempt's own belief), skip the
|
|
15217
|
+
// write below instead of appending a duplicate row.
|
|
15218
|
+
const predictedMarker = predictedRoundCloseMarker(at.slug, at.round, closedAtIso);
|
|
15219
|
+
const tailBeforeWrite = io.roundLedgerReader?.(stateRoot) ?? readRoundLedgerTail(stateRoot);
|
|
15220
|
+
// Lead edit after Codex re-review: a retried close carries a NEW clock, so the marker alone never
|
|
15221
|
+
// matches — the row's stateId (identity of the state instance) is what makes the retry idempotent.
|
|
15222
|
+
const alreadyRecorded = tailBeforeWrite.includes(predictedMarker)
|
|
15223
|
+
|| (state.stateId !== undefined && tailBeforeWrite.includes(`"stateId":"${state.stateId}"`));
|
|
15224
|
+
// Lead edit after Codex re-review: a retry whose row is already in the ledger (same stateId) must
|
|
15225
|
+
// not re-run closeRound's postcondition against a marker computed from the NEW clock — the earlier
|
|
15226
|
+
// row is the receipt; only the state-file removal remains.
|
|
15227
|
+
const closed = alreadyRecorded
|
|
15228
|
+
? { ok: true, row: undefined, marker: `already-recorded:${state.stateId ?? predictedMarker}` }
|
|
15229
|
+
: closeRound({
|
|
15230
|
+
state,
|
|
15231
|
+
outcome: options.get('outcome') ?? '',
|
|
15232
|
+
...(options.has('reason') ? { reason: options.get('reason') } : {}),
|
|
15233
|
+
lessons,
|
|
15234
|
+
knownLessonIds,
|
|
15235
|
+
...(options.has('no-new-knowledge') ? { noNewKnowledge: options.get('no-new-knowledge') } : {}),
|
|
15236
|
+
...(options.has('tokens') ? { tokens: numeric('tokens') } : {}),
|
|
15237
|
+
...(options.has('agents') ? { agents: numeric('agents') } : {}),
|
|
15238
|
+
...(options.has('coder') ? { coder: options.get('coder') } : {}),
|
|
15239
|
+
...(options.has('reviewer') ? { reviewer: options.get('reviewer') } : {}),
|
|
15240
|
+
...(options.has('note') ? { note: options.get('note') } : {}),
|
|
15241
|
+
...(flags.has('no-cost') ? { noCost: true } : {}),
|
|
15242
|
+
closedAt: closedAtIso,
|
|
15243
|
+
...(state.stateId !== undefined ? { stateId: state.stateId } : {}),
|
|
15244
|
+
}, {
|
|
15245
|
+
writeLedger: (row) => {
|
|
15246
|
+
// AM-4 idempotent retry: the row for this attempt was already witnessed in the tail read
|
|
15247
|
+
// above — do not append a second one. `closeRound`'s own postcondition (rereading the tail
|
|
15248
|
+
// and checking it contains the marker) still passes, because the marker is already there.
|
|
15249
|
+
if (alreadyRecorded)
|
|
15250
|
+
return undefined;
|
|
15251
|
+
if (io.roundLedgerWriter !== undefined)
|
|
15252
|
+
return io.roundLedgerWriter(stateRoot, row);
|
|
15253
|
+
return cmdFeatureAdrRecord(new Map([
|
|
15254
|
+
['kind', 'ledger'], ['stage', 'round'], ['slug', state.slug], ['row', JSON.stringify(row)], ['project', stateRoot],
|
|
15255
|
+
]), new Set(), stateRoot, () => undefined);
|
|
15256
|
+
},
|
|
15257
|
+
readLedgerTail: () => io.roundLedgerReader?.(stateRoot) ?? readRoundLedgerTail(stateRoot),
|
|
15258
|
+
});
|
|
15259
|
+
if (!closed.ok) {
|
|
15260
|
+
emit(closed.reason);
|
|
15261
|
+
return closed.exit;
|
|
15262
|
+
}
|
|
15263
|
+
try {
|
|
15264
|
+
// T4/FR-1/FR-2, fix-round AM-2: the ledger write above (via `closed`) stays OUTSIDE the lock
|
|
15265
|
+
// (teach:0ea46034); only the final reread-and-delete is a lock-guarded critical section, and it
|
|
15266
|
+
// now deletes ONLY the exact state instance the ledger row above was written for — identified
|
|
15267
|
+
// by `state.stateId`, read before the lock was ever taken.
|
|
15268
|
+
const deleted = withRoundStateLock(stateRoot, () => {
|
|
15269
|
+
const outcome = readStateForCloseOrRefuse(path, state.stateId);
|
|
15270
|
+
if ('refused' in outcome)
|
|
15271
|
+
return outcome;
|
|
15272
|
+
unlinkSync(path);
|
|
15273
|
+
return { ok: true };
|
|
15274
|
+
}, io);
|
|
15275
|
+
if ('refused' in deleted) {
|
|
15276
|
+
if (deleted.refused === 'lock-busy') {
|
|
15277
|
+
// AM-4: the ledger row is ALREADY written by the time this lock is even attempted (see
|
|
15278
|
+
// above) — so a busy lock here never leaves the outcome unrecorded, only the round's OWN
|
|
15279
|
+
// state file open. Say exactly that, and make the retry path explicit.
|
|
15280
|
+
emit('строка леджера записана, состояние круга осталось открытым — повторите close', { refused: 'lock-busy', ledgerWritten: true });
|
|
15281
|
+
return 1;
|
|
15282
|
+
}
|
|
15283
|
+
if (deleted.refused === 'closed-already') {
|
|
15284
|
+
// AM-2: the state file is already gone — this close's own ledger row is written (above, or
|
|
15285
|
+
// by a previous invocation of this same idempotent attempt), so this is the same round
|
|
15286
|
+
// reaching its already-closed postcondition by a different path, not a failure.
|
|
15287
|
+
emit('круг уже закрыт (строка леджера записана)', { closed: true, alreadyClosed: true, marker: closed.marker });
|
|
15288
|
+
return 0;
|
|
15289
|
+
}
|
|
15290
|
+
// AM-2: something else's state sits at this path now (a different stateId) — never delete it.
|
|
15291
|
+
emit('состояние заменено, не удалено', { refused: 'replaced', stateId: deleted.stateId });
|
|
15292
|
+
return 1;
|
|
15293
|
+
}
|
|
15294
|
+
}
|
|
15295
|
+
catch (error) {
|
|
15296
|
+
emit(`строка подтверждена, но состояние не удалено — круг НЕ закрыт: ${error instanceof Error ? error.message : String(error)}`);
|
|
15297
|
+
return 1;
|
|
15298
|
+
}
|
|
15299
|
+
emit(`✓ строка круга в леджере подтверждена чтением (${closed.marker})`, { row: closed.row, marker: closed.marker });
|
|
15300
|
+
return 0;
|
|
15301
|
+
}
|
|
15302
|
+
if (sub === 'status') {
|
|
15303
|
+
const rawThreshold = options.get('older-than') ?? '120';
|
|
15304
|
+
const olderThan = Number(rawThreshold);
|
|
15305
|
+
if (!Number.isInteger(olderThan) || olderThan < 0) {
|
|
15306
|
+
emit('--older-than должен быть целым числом минут не меньше нуля', { open: [] });
|
|
15307
|
+
return 0;
|
|
15308
|
+
}
|
|
15309
|
+
const dir = join(stateRoot, '.dz', 'rounds');
|
|
15310
|
+
const states = [];
|
|
15311
|
+
try {
|
|
15312
|
+
for (const name of readdirSync(dir).filter((entry) => entry.endsWith('.json')).sort()) {
|
|
15313
|
+
const state = readRoundState(join(dir, name));
|
|
15314
|
+
if (state !== null)
|
|
15315
|
+
states.push(state);
|
|
15316
|
+
}
|
|
15317
|
+
}
|
|
15318
|
+
catch { /* no state directory is an honestly empty report */ }
|
|
15319
|
+
const rows = listRounds(states, {
|
|
15320
|
+
now,
|
|
15321
|
+
olderThanMinutes: olderThan,
|
|
15322
|
+
isPidAlive: io.roundPidProbe ?? probePid,
|
|
15323
|
+
isRunAlive: (runId) => roundRunOwnerAlive(stateRoot, runId, now, io.roundRunRegistryReader, io.roundPidProbe ?? probePid),
|
|
15324
|
+
});
|
|
15325
|
+
// AM-5: independent of the `--older-than` filter above (a stuck exec claim is worth flagging at
|
|
15326
|
+
// 10 minutes regardless of the round's own age threshold) — computed over ALL open states, and
|
|
15327
|
+
// additive: when none apply, neither branch below emits anything extra, so the two byte-pinned
|
|
15328
|
+
// zero-rounds lines (NFR-1, see the comment below) stay untouched.
|
|
15329
|
+
const staleExec = states
|
|
15330
|
+
.map((state) => {
|
|
15331
|
+
const minutes = roundExecStaleAgeMinutes(state, now);
|
|
15332
|
+
return minutes === null ? null : { slug: state.slug, round: state.round, minutes };
|
|
15333
|
+
})
|
|
15334
|
+
.filter((warning) => warning !== null);
|
|
15335
|
+
if (json) {
|
|
15336
|
+
emit(rows.length > 0 ? `⚠ ${rows.length} open round(s) older than ${olderThan} min` : 'нет старых открытых кругов', {
|
|
15337
|
+
stateRoot, olderThan, open: rows, ...(staleExec.length === 0 ? {} : { staleExec }),
|
|
15338
|
+
});
|
|
15339
|
+
}
|
|
15340
|
+
else {
|
|
15341
|
+
// FR-3 prints `state root: <dir>` on open unconditionally; here it is printed only when the
|
|
15342
|
+
// root was EXPLICITLY chosen (--state-root / DZ_ROUND_STATE_ROOT). Printing it unconditionally
|
|
15343
|
+
// would change the two default-cwd zero-rounds lines pinned exactly by
|
|
15344
|
+
// round-cli.test.ts ("status reports a fresh open round…" / "…no open rounds"), which NFR-1
|
|
15345
|
+
// requires to stay byte-identical and unmodified.
|
|
15346
|
+
if (stateRootExplicit)
|
|
15347
|
+
write(`state root: ${stateRoot}`);
|
|
15348
|
+
write(states.length === 0
|
|
15349
|
+
? 'открытых кругов нет'
|
|
15350
|
+
: `открытых кругов: ${states.length}, старше ${olderThan} мин: ${rows.length}`);
|
|
15351
|
+
for (const row of rows) {
|
|
15352
|
+
const live = row.pidAlive === true ? 'alive' : row.pidAlive === false ? 'dead' : 'unknown';
|
|
15353
|
+
write(`${row.state.slug}#${row.state.round} · ${row.ageMinutes} min · pid ${row.state.pid} ${live} · ${row.state.topic}`);
|
|
15354
|
+
}
|
|
15355
|
+
for (const warning of staleExec) {
|
|
15356
|
+
write(`⚠ ${warning.slug}#${warning.round}: владелец завис в exec ${warning.minutes} мин — восстановите вручную (dz round exec вернул lock-busy при возврате владельца)`);
|
|
15357
|
+
}
|
|
15358
|
+
}
|
|
15359
|
+
return 0;
|
|
15360
|
+
}
|
|
15361
|
+
emit('использование: dz round open|exec|close|status');
|
|
15362
|
+
return 2;
|
|
15363
|
+
}
|
|
15364
|
+
/**
|
|
15365
|
+
* ledger-stage-minutes T2: the `ts` of the LAST ledger row (scanning from the end, so a duplicate
|
|
15366
|
+
* or out-of-order runId still finds the truly latest one) that carries the given `runId`. Every
|
|
15367
|
+
* failure mode — the file does not exist yet, a permission error — returns `null` rather than
|
|
15368
|
+
* throwing: this is a BEST-EFFORT observability lookup feeding a non-blocking field (ADR-003), never
|
|
15369
|
+
* a gate the write must pass.
|
|
15370
|
+
*
|
|
15371
|
+
* fix-round-1/AM-n (cross-family review B, MEDIUM): a torn or non-object line — `ledger-corrupt-line`
|
|
15372
|
+
* — is NOT silently skipped past. The original code `continue`d over it and kept scanning further
|
|
15373
|
+
* back, which could return an OLDER valid row for this `runId` while a NEWER one for the same run
|
|
15374
|
+
* sat hidden on the other side of the corrupt line (or was itself the corrupt line). Once the scan
|
|
15375
|
+
* hits a line it cannot parse as a JSON object, it can no longer prove which row is truly LAST for
|
|
15376
|
+
* this run, so it stops and reports `null` (⇒ `minutesSource: 'unavailable'`) rather than risk an
|
|
15377
|
+
* UNDERSTATED delta computed against a stale row.
|
|
15378
|
+
*/
|
|
15379
|
+
function findPreviousLedgerRowTs(ledgerPath, runId) {
|
|
15380
|
+
if (runId === '')
|
|
15381
|
+
return null;
|
|
15382
|
+
let body;
|
|
15383
|
+
try {
|
|
15384
|
+
body = readFileSync(ledgerPath, 'utf-8');
|
|
15385
|
+
}
|
|
15386
|
+
catch {
|
|
15387
|
+
return null;
|
|
15388
|
+
}
|
|
15389
|
+
const lines = body.split('\n').filter((l) => l !== '');
|
|
15390
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
15391
|
+
let parsed;
|
|
15392
|
+
try {
|
|
15393
|
+
parsed = JSON.parse(lines[i]);
|
|
15394
|
+
}
|
|
15395
|
+
catch {
|
|
15396
|
+
// ledger-corrupt-line: everything from here to the start of the file is unprovable — a real
|
|
15397
|
+
// match further back cannot be trusted to still be the LAST one, so this is `unavailable`,
|
|
15398
|
+
// never a guess made by skipping past what we could not read.
|
|
15399
|
+
return null;
|
|
15400
|
+
}
|
|
15401
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
15402
|
+
// Same reasoning as the parse failure above: a non-object line is exactly as untrustworthy.
|
|
15403
|
+
return null;
|
|
15404
|
+
}
|
|
15405
|
+
const row = parsed;
|
|
15406
|
+
if (typeof row['runId'] === 'string' && row['runId'].trim() === runId) {
|
|
15407
|
+
return typeof row['ts'] === 'string' && row['ts'].trim() !== '' ? row['ts'] : null;
|
|
15408
|
+
}
|
|
15409
|
+
}
|
|
15410
|
+
return null;
|
|
15411
|
+
}
|
|
13873
15412
|
function cmdFeatureAdrRecord(options, flags, cwd, write) {
|
|
13874
15413
|
const json = flags.has('json');
|
|
13875
15414
|
// `--backfill` is a different verb on the same store: it fills the ledger's null cost fields from
|
|
@@ -13906,10 +15445,76 @@ function cmdFeatureAdrRecord(options, flags, cwd, write) {
|
|
|
13906
15445
|
const markDir = join(repo, '.dz', 'fa-training', '.backfill-marks');
|
|
13907
15446
|
const markName = (options.get('mark') ?? '').trim();
|
|
13908
15447
|
const markPath = markName === '' ? null : join(markDir, markName.replace(/[^\w.-]/g, '_'));
|
|
15448
|
+
// ledger-stage-minutes T2/FR-2: `--run-id` fills the payload's `runId` ONLY WHEN the payload does
|
|
15449
|
+
// not already carry one — the same gap-only stamping discipline `decideRecordWrite` already uses
|
|
15450
|
+
// for `runnerId`. "Absent" is deliberately wider than "missing key": `runId: null`, `runId: ''`
|
|
15451
|
+
// and a non-string `runId` (a number, an object — never a real join key) are ALL gaps too, exactly
|
|
15452
|
+
// the `isRunnerGap` rule one seam over — fixed-round-1/AM-n confirmed this is the INTENDED contract
|
|
15453
|
+
// ("missing when absent or blank"), not a bug: only a genuine non-empty string counts as "the
|
|
15454
|
+
// caller already knew it", so any of those gap shapes are correctly overwritten by the flag. A
|
|
15455
|
+
// malformed --row is left untouched here: decideRecordWrite reports the real JSON parse error,
|
|
15456
|
+
// this merge step must never invent a different one.
|
|
15457
|
+
const isRunIdArgGap = (v) => v === null || v === undefined || typeof v !== 'string' || v.trim() === '';
|
|
15458
|
+
let effectivePayloadRaw = payloadRaw;
|
|
15459
|
+
const explicitRunId = (options.get('run-id') ?? '').trim();
|
|
15460
|
+
if (kind === 'ledger' && explicitRunId !== '') {
|
|
15461
|
+
try {
|
|
15462
|
+
const parsed = JSON.parse(payloadRaw);
|
|
15463
|
+
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
15464
|
+
const rowObj = parsed;
|
|
15465
|
+
if (isRunIdArgGap(rowObj['runId'])) {
|
|
15466
|
+
// fix-round-1/AM-n (cross-family review B, MEDIUM): the flag-filled runId now carries its
|
|
15467
|
+
// provenance, the same discipline `resolved-at-write` already applies to the OTHER runId
|
|
15468
|
+
// source (write-time auto-resolution below) — an un-sourced runId looked exactly like one
|
|
15469
|
+
// the caller supplied. A non-empty `runIdSource` the payload already carries (an odd shape,
|
|
15470
|
+
// since `runId` itself was a gap) is left alone rather than overwritten with a guess.
|
|
15471
|
+
const hasRunIdSource = typeof rowObj['runIdSource'] === 'string' && rowObj['runIdSource'].trim() !== '';
|
|
15472
|
+
effectivePayloadRaw = JSON.stringify({
|
|
15473
|
+
...rowObj,
|
|
15474
|
+
runId: explicitRunId,
|
|
15475
|
+
...(hasRunIdSource ? {} : { runIdSource: 'cli-flag' }),
|
|
15476
|
+
});
|
|
15477
|
+
}
|
|
15478
|
+
}
|
|
15479
|
+
}
|
|
15480
|
+
catch { /* decideRecordWrite reports the parse error itself */ }
|
|
15481
|
+
}
|
|
15482
|
+
// FR-2/FR-3: find the runId this row will carry (explicit flag, or one the payload already had),
|
|
15483
|
+
// then read the ledger BEST-EFFORT for the last row of that same run and its `ts`. A read failure
|
|
15484
|
+
// (file absent, unreadable, a torn or malformed line) is an honest `previousRowTs: null` — never
|
|
15485
|
+
// a thrown error, because a record write must never fail on an OBSERVABILITY lookup (ADR-003).
|
|
15486
|
+
let runIdForLookup = '';
|
|
15487
|
+
try {
|
|
15488
|
+
const parsed = JSON.parse(effectivePayloadRaw);
|
|
15489
|
+
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
15490
|
+
const v = parsed['runId'];
|
|
15491
|
+
if (typeof v === 'string' && v.trim() !== '')
|
|
15492
|
+
runIdForLookup = v.trim();
|
|
15493
|
+
}
|
|
15494
|
+
}
|
|
15495
|
+
catch { /* decideRecordWrite reports the parse error itself */ }
|
|
15496
|
+
// Lead edit after re-review (Codex B): the pipeline's own rows have no runId in the payload — it is
|
|
15497
|
+
// resolved at write time below. Resolve it HERE as well (same resolver, same registry) so the
|
|
15498
|
+
// previous-row lookup and the minutes delta cover the main path, not only explicit ids.
|
|
15499
|
+
let resolvedRunIdPre = null;
|
|
15500
|
+
if (kind === 'ledger' && runIdForLookup === '') {
|
|
15501
|
+
try {
|
|
15502
|
+
const parsed = JSON.parse(effectivePayloadRaw);
|
|
15503
|
+
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
15504
|
+
resolvedRunIdPre = resolveLedgerRunId(parsed, listCostLedgerRuns());
|
|
15505
|
+
if (resolvedRunIdPre !== null)
|
|
15506
|
+
runIdForLookup = resolvedRunIdPre.trim();
|
|
15507
|
+
}
|
|
15508
|
+
}
|
|
15509
|
+
catch { /* resolution is an ENRICHMENT; the row is written regardless */ }
|
|
15510
|
+
}
|
|
15511
|
+
const previousRowTs = kind === 'ledger' && runIdForLookup !== '' ? findPreviousLedgerRowTs(target, runIdForLookup) : null;
|
|
13909
15512
|
const decision = decideRecordWrite({
|
|
13910
15513
|
kind,
|
|
13911
|
-
payloadRaw,
|
|
15514
|
+
payloadRaw: effectivePayloadRaw,
|
|
13912
15515
|
stage,
|
|
15516
|
+
previousRowTs,
|
|
15517
|
+
effectiveRunId: runIdForLookup !== '' ? runIdForLookup : null,
|
|
13913
15518
|
stageProducedResult: flags.has('no-result') ? false : true,
|
|
13914
15519
|
markExists: markPath !== null && existsSync(markPath),
|
|
13915
15520
|
targetExists: existsSync(target),
|
|
@@ -13968,10 +15573,31 @@ function cmdFeatureAdrRecord(options, flags, cwd, write) {
|
|
|
13968
15573
|
const parsed = JSON.parse(decision.line);
|
|
13969
15574
|
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
13970
15575
|
const rowObj = parsed;
|
|
13971
|
-
|
|
15576
|
+
// Lead edit after review #3 (Codex B): ONE resolution per write — reuse the id resolved
|
|
15577
|
+
// before the decision (the same one the minutes delta was measured against) instead of
|
|
15578
|
+
// resolving again; two resolutions could disagree if the run registry moved in between.
|
|
15579
|
+
const resolved = resolvedRunIdPre !== null ? resolvedRunIdPre : resolveLedgerRunId(rowObj, listCostLedgerRuns());
|
|
13972
15580
|
if (resolved !== null) {
|
|
13973
15581
|
// Marked, because a resolved run id is our inference, not something the pipeline knew.
|
|
13974
|
-
|
|
15582
|
+
// Keep the minutes fields LAST (NFR-1 of ledger-stage-minutes): splice runId/runIdSource in
|
|
15583
|
+
// right before `ts` when the decided row already carries the stamped tail.
|
|
15584
|
+
const ordered = {};
|
|
15585
|
+
let spliced = false;
|
|
15586
|
+
for (const [k, v] of Object.entries(rowObj)) {
|
|
15587
|
+
if (k === 'ts' && !spliced) {
|
|
15588
|
+
ordered['runId'] = resolved;
|
|
15589
|
+
ordered['runIdSource'] = 'resolved-at-write';
|
|
15590
|
+
spliced = true;
|
|
15591
|
+
}
|
|
15592
|
+
if (k === 'runId' || k === 'runIdSource')
|
|
15593
|
+
continue;
|
|
15594
|
+
ordered[k] = v;
|
|
15595
|
+
}
|
|
15596
|
+
if (!spliced) {
|
|
15597
|
+
ordered['runId'] = resolved;
|
|
15598
|
+
ordered['runIdSource'] = 'resolved-at-write';
|
|
15599
|
+
}
|
|
15600
|
+
lineToWrite = JSON.stringify(ordered);
|
|
13975
15601
|
}
|
|
13976
15602
|
}
|
|
13977
15603
|
}
|
|
@@ -15250,7 +16876,16 @@ async function cmdQeBridge(options, flags, cwd, write) {
|
|
|
15250
16876
|
catch (error) {
|
|
15251
16877
|
return failRun('audit-write-failed', `the review was PARSED (grade ${signoff.grade}) but its raw stdout could not be retained: ${String(error)} — an unauditable success is not a success, so the run FAILS rather than shipping a verdict nobody can re-derive`, probed, { stdout: review.stdout, stderr: review.stderr });
|
|
15252
16878
|
}
|
|
15253
|
-
|
|
16879
|
+
// writeSequence (qe-bridge-signoff-order): diagnostic sequencing metadata — a self-reported
|
|
16880
|
+
// process-local trace with monotonic stamps taken at each named event (start of the first record
|
|
16881
|
+
// write; after the report landed; just before the atomic update). It replaces a wall-clock
|
|
16882
|
+
// mtime comparison that was a race (the record is rewritten AFTER the report by design). It does
|
|
16883
|
+
// NOT prove write order or crash safety: those are proven by the failpoint test (R4-1) and the
|
|
16884
|
+
// report-failure test. Lead edit after Codex review 2026-09-13: honest step names.
|
|
16885
|
+
const seq = [
|
|
16886
|
+
{ step: 'signoff-write-started', monotonicNs: String(process.hrtime.bigint()) },
|
|
16887
|
+
];
|
|
16888
|
+
const recordText = (reportWritten, writeSequence) => `${JSON.stringify(buildBridgeSignoffRecord(signoff, {
|
|
15254
16889
|
runId,
|
|
15255
16890
|
claudeBin: resolvedBin,
|
|
15256
16891
|
binOverride,
|
|
@@ -15259,11 +16894,12 @@ async function cmdQeBridge(options, flags, cwd, write) {
|
|
|
15259
16894
|
rawStdoutFile,
|
|
15260
16895
|
promptSha256,
|
|
15261
16896
|
...(parsed.channels === undefined ? {} : { channels: parsed.channels }),
|
|
16897
|
+
writeSequence,
|
|
15262
16898
|
}), null, 2)}\n`;
|
|
15263
16899
|
let signoffPath;
|
|
15264
16900
|
try {
|
|
15265
16901
|
signoffPath = uniquePath(join(stateDir, `signoff-${runId}`), '.json');
|
|
15266
|
-
writeNewFileOrThrow(signoffPath, recordText(false));
|
|
16902
|
+
writeNewFileOrThrow(signoffPath, recordText(false, seq));
|
|
15267
16903
|
}
|
|
15268
16904
|
catch (error) {
|
|
15269
16905
|
return failRun('audit-write-failed', `the review was PARSED (grade ${signoff.grade}) but the signoff record could not be written: ${String(error)} — the verdict exists and cannot be persisted, so the run FAILS rather than reporting an unrecorded success`, probed, { stdout: review.stdout, stderr: review.stderr });
|
|
@@ -15276,6 +16912,10 @@ async function cmdQeBridge(options, flags, cwd, write) {
|
|
|
15276
16912
|
reportError = error;
|
|
15277
16913
|
}
|
|
15278
16914
|
if (reportError === null) {
|
|
16915
|
+
// the report is on disk: the sequence gains a step BEFORE the `reportWritten:true` record
|
|
16916
|
+
// write, not after — an observer reading the eventual writeSequence must see the report step
|
|
16917
|
+
// land before the record-update step that persists it.
|
|
16918
|
+
seq.push({ step: 'report-written', monotonicNs: String(process.hrtime.bigint()) });
|
|
15279
16919
|
// the ONLY moment `reportWritten:true` may appear: after the report is on disk
|
|
15280
16920
|
try {
|
|
15281
16921
|
// ATOMIC (R4-1): write a sibling temp file, then rename() over the original. On the same
|
|
@@ -15284,7 +16924,8 @@ async function cmdQeBridge(options, flags, cwd, write) {
|
|
|
15284
16924
|
// which made the "a crash leaves a record that is true or pessimistic" claim untrue in the
|
|
15285
16925
|
// one case it was about.
|
|
15286
16926
|
const tmpPath = `${signoffPath}.tmp.${process.pid}`;
|
|
15287
|
-
|
|
16927
|
+
seq.push({ step: 'record-update-prepared', monotonicNs: String(process.hrtime.bigint()) });
|
|
16928
|
+
writeNewFileOrThrow(tmpPath, recordText(true, seq));
|
|
15288
16929
|
if (process.env[QE_BRIDGE_FAILPOINT_ENV] === 'hang-before-rename') {
|
|
15289
16930
|
// test-only: stop dead INSIDE the window, so a SIGKILL can prove the property
|
|
15290
16931
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 600_000);
|
|
@@ -18116,11 +19757,11 @@ export async function runCli(argv, io = {}) {
|
|
|
18116
19757
|
case 'auto-canonicalize':
|
|
18117
19758
|
return await cmdAutoCanonicalize(options, cwd, write);
|
|
18118
19759
|
case 'publish':
|
|
18119
|
-
return cmdPublish(options, flags, cwd, write, io.publishMirrorRunner);
|
|
19760
|
+
return cmdPublish(options, flags, cwd, write, io.publishMirrorRunner, io.publishSiblingDriftFetcher, io.publishPackedInstallRunner, io.publishExecRunner);
|
|
18120
19761
|
case 'release':
|
|
18121
19762
|
return cmdRelease(options, flags, cwd, write, io.releaseRunner);
|
|
18122
19763
|
case 'parity':
|
|
18123
|
-
return cmdParity(options, flags, write, writeErr);
|
|
19764
|
+
return cmdParity(options, flags, write, writeErr, cwd);
|
|
18124
19765
|
case 'registry':
|
|
18125
19766
|
return cmdRegistry(options, cwd, write);
|
|
18126
19767
|
case 'benchmark':
|
|
@@ -18207,6 +19848,8 @@ export async function runCli(argv, io = {}) {
|
|
|
18207
19848
|
return cmdJournal(options, flags, cwd, write, io.journalIo);
|
|
18208
19849
|
case 'feature-adr-record':
|
|
18209
19850
|
return cmdFeatureAdrRecord(options, flags, cwd, write);
|
|
19851
|
+
case 'round':
|
|
19852
|
+
return await cmdRound(options, optionLists, flags, cwd, write, io);
|
|
18210
19853
|
case 'runs':
|
|
18211
19854
|
return cmdRuns(options, flags, cwd, write);
|
|
18212
19855
|
case 'runs-clean':
|