@dzhechkov/harness-cli 0.7.8 → 0.8.1
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 +12 -12
- package/README.md +155 -21
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +704 -73
- package/dist/cli.js.map +1 -1
- package/dist/known-flags.d.ts.map +1 -1
- package/dist/known-flags.js +2 -0
- package/dist/known-flags.js.map +1 -1
- package/package.json +18 -18
- package/sbom.json +16 -12
- package/src/cli.ts +704 -68
- package/src/known-flags.ts +2 -0
package/dist/cli.js
CHANGED
|
@@ -18,7 +18,7 @@ import { createSkill, getSkillInfo, listSkillsDetailed, formatSkillLoadFailures,
|
|
|
18
18
|
// dz workflow run (feature dz-workflow-run): the pure scheduler + the dispatch adapters.
|
|
19
19
|
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, resolveLearningBackend, storeStats, consolidateSessions, pruneNoisePatterns, lessonDeltaReport, removePatternsByIds, snapshotStore, recallHybrid, teachGuard, mirrorPatternsToVector, mirrorEntriesToVector, patternVectorEntry, readMemoryLearningConfig, promotePatterns, quarantineExpiryCandidates, pruneQuarantinePatterns, clearAgentdbQuarantine, vectorMirrorEnabled, vectorTierStatus, resolveVectorEngine, reindexVectorStore, harmonizeVectorStore, importRvfCheckpoint, statuslineData, writeFeatureAdrState, computeUsage, deriveCostLedger, planLedgerBackfill, listCostLedgerRuns, resolveLedgerRunId, AMBIGUOUS, stampCheckpointLine, LEDGER_FILL_SOURCE, renderCostLedger, verifyCostLedgerReport, writeCostLedgerJsonl, COST_LEDGER_SCOPE, deriveUsageCalibration, normalizeClaudeUsageModelKey, readUsageLimits, parseWeeklyResetAnchor, claimCheck, summarize, queryBookKnowledge, loadStorePatternsSync, patternRecordId, loadStoreRecords, recordToPattern, bundleSkills, brainHome, listBrain, bookKbPath, promoteProjectToBrain, updateBrainSource, queryBrain, groundPrompt, expandKu, reindexBrainVectors, buildPrimer, exportBrainSlice, importBrainSlice, registerKusToBrain, RECALL_USAGE_LOG_RELATIVE, RECALL_USAGE_LOG_MAX_BYTES, parseRecallUsageLog, buildRecallUsageReport, EVENT_CHAIN_TAIL_BYTES, EMPTY_LOG_TAIL, readTailInfo, appendChainedLines, verifyEventChainText, buildManifest, buildSbom, resolveTrustRoot, decideVerifyPolicy, generateSigningKeypair, evaluateGuard, resolveRules, auditRecord, guardExitCode, DEFAULT_RULES, parsePnpmLockImporters, scannableStubPath,
|
|
20
20
|
// guard-promotion (feature guard-promotion, scout idea #1)
|
|
21
|
-
assembleCandidates, renderPromotionReport, renderPromotionAdr, normalizePromotionState, nextPromotionState, globMatch, promotionAdrRelPath, DEFAULT_WINDOW_DAYS, DEFAULT_PERIODS, MAX_CONTENT_FETCHES, BUILTIN_COVERAGE, decideProvenance, isInsideTree, signManifest, verifyManifest, listSignablePackFiles, assertKeyOutsideTree, decidePublishGate, collectPackageFacts, planReleaseGates, selectAffectedPackages, classifyGateExecutions, buildFailureIssue, buildReleaseNotes, releaseTagName, firstOutputLine, formatPublishError, MANIFEST_NAME, SBOM_NAME, buildArchitectureMap, renderMapHuman, findArchitectureDrift, renderDriftReport, scanWorkspacePackages, loadSubsystemManifest, loadProductVision, checkFeatureAgainstArchitecture, renderArchCheck, planProjectSkills, guidanceForStage, renderInjectionReport, analyzeCorpus, renderRakeReport, renderCriticSection, rakeAsLesson, rakeReward, DEFAULT_RAKE_THRESHOLDS, streamSessionEvents, findLatestTranscript, detectProcessRakes, buildRetro, renderRetro, retroLessonText, PROCESS_SIGNATURES, RETRO_DOMAIN, scanForSetup, buildSetupPlan, scaffoldFromSpec, renderScaffoldPreview, readExistingForScaffold, assembleChallengeContext, buildChallengeBrief, planDiscriminationCheck, classifyDiscrimination, classifyExecutionEvidence, pickAdversaryModel, CHALLENGE_QUESTIONS, loadOutcomes, renderOutcomes, statsForKey, selectAutoCost, recordProvisional, finalizeOutcome, harvestStageOutcomes, recommendModels, planFeed, 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,
|
|
21
|
+
assembleCandidates, renderPromotionReport, renderPromotionAdr, normalizePromotionState, nextPromotionState, globMatch, promotionAdrRelPath, DEFAULT_WINDOW_DAYS, DEFAULT_PERIODS, MAX_CONTENT_FETCHES, BUILTIN_COVERAGE, decideProvenance, isInsideTree, signManifest, verifyManifest, listSignablePackFiles, assertKeyOutsideTree, decidePublishGate, collectPackageFacts, planReleaseGates, selectAffectedPackages, classifyGateExecutions, buildFailureIssue, buildReleaseNotes, releaseTagName, firstOutputLine, formatPublishError, MANIFEST_NAME, SBOM_NAME, buildArchitectureMap, renderMapHuman, findArchitectureDrift, renderDriftReport, scanWorkspacePackages, loadSubsystemManifest, loadProductVision, checkFeatureAgainstArchitecture, renderArchCheck, planProjectSkills, guidanceForStage, renderInjectionReport, analyzeCorpus, renderRakeReport, renderCriticSection, rakeAsLesson, rakeReward, DEFAULT_RAKE_THRESHOLDS, streamSessionEvents, findLatestTranscript, detectProcessRakes, buildRetro, renderRetro, retroLessonText, PROCESS_SIGNATURES, RETRO_DOMAIN, scanForSetup, buildSetupPlan, scaffoldFromSpec, renderScaffoldPreview, readExistingForScaffold, assembleChallengeContext, buildChallengeBrief, planDiscriminationCheck, classifyDiscrimination, classifyExecutionEvidence, pickAdversaryModel, CHALLENGE_QUESTIONS, loadOutcomes, renderOutcomes, statsForKey, selectAutoCost, recordProvisional, finalizeOutcome, harvestStageOutcomes, recommendModels, planFeed, 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, banditStats, narrowBanditReport, renderBanditHealth,
|
|
22
22
|
// Cold-vs-warm EPOCH RUNNER (feature epoch-replay) — orchestrates + scores, never calls a model.
|
|
23
23
|
replayableInstances, buildWorkOrder, buildJudgePrompts, unblindJudgments, verifyWorkOrder, isValidMargin, DIGEST_HONEST_SCOPE, scoreEpochReplay, generateMockOutcomes, renderEpochReplayResult, renderWorkOrderSummary, renderJudgePromptsSummary, WORK_ORDER_KIND, DEFAULT_MOCK_N, DEFAULT_MOCK_SEED, scoreRun, readQeGrade, recapWindow, decideHorizon, withinWindow, buildRecap, renderRecap, parseSourceManifest, tgPostHtmlIssues, tgVisibleLength, decideTgSend, TG_TEXT_LIMIT, countRecallEventsForRun, unknownFlagNotice, mirrorWriterExplanation, appendRecallUsage, closenessLine, anyAboveFloor, decideNameCheck, renderNameCheck, exportedNamesIn, dispatchedCommandsIn, decideSourceProvenance, renderSourceProvenance, REFUSED_HORIZONS, renderScorecard, renderCompoundingReport, readReinforcementState, readQuarantineState, registrationExitCode, renderRegistrationReport,
|
|
24
24
|
// Smart Backlog (feature smart-backlog) — goal-directed idea pipeline over the Brain vector engine.
|
|
@@ -26,7 +26,9 @@ readBacklogConfig, readIdeas, writeIdeas, ideaId, dedupIdea, readGoalMap, readGo
|
|
|
26
26
|
// qe-bridge (feature qe-bridge-claude, ADR-001): the pure half of the reverse QE bridge.
|
|
27
27
|
KNOWN_CLAUDE, isSafeClaudeId, claudeProbeArgs, claudeReviewArgs, interpretClaudeProbe, modelFamily, buildBridgePrompt, parseBridgeOutput, buildBridgeFailureRecord, buildBridgeSignoffRecord, renderBridgeReport, isSafeSlug, hasUnsafePathChars, hasDotDotSegment, buildReqeBrief, settleReqeDebt, renderReqeList, REQE_SCOPE,
|
|
28
28
|
// Mutation gate (feature ha-mutation-gate) — break each named protection, run the suite, require red.
|
|
29
|
-
parseMutationRegistry, applyMutationToText, countFailingTests, classifyBaseline, classifyRunFailure, classifyMutationOutcome, mutationGateExitCode, summarizeMutationResults, renderMutationReport, TRACE_BUNDLE_LEDGER_PATH, TRACE_BUNDLE_SCHEMA, TRACE_BUNDLE_RUN_META_FILE, buildBundle, serializeBundle, parseBundle, planImport, decideCheckpointWrite, amendmentSection, planSaysNoAmendments, parseAmendments, resolveAmendments, decideAmendmentOutcome, amendmentVerdictLine, amendmentsMissingFromPlan, AMENDMENT_VACUITY_NOTE, decideSignableSet, signableSetLine, decideRecordWrite, decideReadBack, recordVerdictLine, buildCadenceReport, tgVisibleSha256, CADENCE_WINDOW_DAYS,
|
|
29
|
+
parseMutationRegistry, applyMutationToText, countFailingTests, detectSuiteCompletionReceipt, detectSuiteReceiptMismatch, classifyBaseline, classifyRunFailure, classifyMutationOutcome, mutationGateExitCode, summarizeMutationResults, renderMutationReport, TRACE_BUNDLE_LEDGER_PATH, TRACE_BUNDLE_SCHEMA, TRACE_BUNDLE_RUN_META_FILE, buildBundle, serializeBundle, parseBundle, planImport, decideCheckpointWrite, amendmentSection, planSaysNoAmendments, parseAmendments, resolveAmendments, decideAmendmentOutcome, amendmentVerdictLine, amendmentsMissingFromPlan, AMENDMENT_VACUITY_NOTE, decideSignableSet, signableSetLine, decideRecordWrite, decideReadBack, recordVerdictLine, buildCadenceReport, tgVisibleSha256, CADENCE_WINDOW_DAYS, readQeRounds, QE_ROUNDS_DEFAULT_CEILING, describeStoreLocation, storeLocationLine, resolveTeachTarget, teachReasonPhrase, readTeachToConfig, TeachTargetError, mergeStoreHits, sameStore, globalStoreRoot, storeCountLabel,
|
|
30
|
+
// operator-profile (ADR-001): per-user 0600 store + marked block in ~/.claude/CLAUDE.md
|
|
31
|
+
renderProfileBlock, readProfile, writeProfile, syncProfileBlock, checkProfileDrift, parseRegister, registerOwnerWord, profileAgeDays, parseDomainList, domainListText, parseYesNo, REGISTERS, } from '@dzhechkov/harness-core';
|
|
30
32
|
import { getPreset, PRESET_NAMES } from '@dzhechkov/harness-presets';
|
|
31
33
|
import { scanGitHub, analyzeRepo, generateReport, deepAnalyze, scanAllSources, ScoutMemory } from '@dzhechkov/scout';
|
|
32
34
|
const USAGE = `dz - DZ cross-platform harness CLI
|
|
@@ -69,12 +71,19 @@ Usage:
|
|
|
69
71
|
dz score --slug <feature> [--project <dir>] [--json] (process scorecard for ONE feature-adr run, from its artifacts: ADR confirmation, discrimination, cross-model QE grade, live verification, README-first, learning loop, amendments — descriptive-only, a low score exits 0)
|
|
70
72
|
dz recap [--day|--week|--month] [--at <ISO date>] [--project <dir>] [--json] (what was done over a window, from records only: deliveries with the grade an independent review STATED — a report naming two grades is reported ambiguous, never guessed — registry publishes, gate verdicts, knowledge reuse. --quarter/--half-year/--year are RECOGNISED and REFUSED with the real span in days: there is one complete quarter and the longest record is 174 days. Every section carries its own data-start date, and "the source was not read" never prints as zero. Contaminated measures — commit count, lines, tokens, learning-event volume, inventory counts, lesson count — are not computed, and the report says so. exit 0 reported / 2 refused)
|
|
71
73
|
dz cadence [--window day|week|month|quarter|halfyear|year] [--json] (the WHAT-SHIPPED aggregator: graded-shipment cadence by ISO week + npm-publish cadence (recap cache) + guard repeat decay on the FIXED rule set + recall reuse; a window deeper than 2× the record is REFUSED with the depth named (ADR: a cadence from one point is scale forgery); exit 0 report / 2 refused-window / 1 usage)
|
|
74
|
+
dz qe-rounds (--slug <feature> | --feature-dir <abs>) [--ceiling <n>] [--project <dir>] [--json] (how many Step-8 review rounds has this feature ALREADY had? Reads what dz qe-bridge already wrote — signoff-<runId>.json and failed-*.json under features/<slug>/.fa-state/qe-bridge — and writes nothing itself, so it can answer for runs already past. A round is a runId, not a file; an attempt with no verdict is counted SEPARATELY and never merged; an unreadable record is NAMED and the count is declared a LOWER BOUND. ONE directory, never a union across checkouts. exit 0 under the ceiling / 1 at-or-over — owner decides, the command does not judge whether the rounds were warranted / 2 NOT ESTABLISHED, which is never "zero rounds")
|
|
72
75
|
dz tg-post --draft <file.html> [--manifest <sources.json>] [--channel <@name|id>] [--send --yes] [--night] [--preview] [--json] (the sender for an APPROVED channel post, per the accepted genai-tweets-channel ADRs: HTML mode only — never MarkdownV2; link preview OFF by default (x.com previews in Telegram are broken); the 00:00-06:00 MSK quiet window refuses without an explicit --night. DEFAULT IS A DRY-RUN: it validates the draft (tag balance, allowed tags, bare &/<, the 4096 visible-character limit with the overshoot counted) and runs the provenance gate over --manifest IN-PROCESS — a draft with no manifest is refused as unchecked, and anything but ALLOWED refuses. A real send needs --send --yes, stating ADR-004's manual-publishing decision out loud each time. The token comes from TELEGRAM_BOT_TOKEN or telegram.tokenFile in .dz/config.json and is never printed. exit 0 sent or clean dry-run / 1 refused or Telegram error / 2 usage)
|
|
73
76
|
dz name-check [--command <n>] [--module <basename>] [--export <a,b>] [--project <dir>] [--json] (is this name free, BEFORE a line of code? Scans workspace SOURCE — never dist, because a stale build answers 'free' confidently. Checks a dz command name against the dispatcher AND the help block, a module basename against every package's src/, and exported identifiers against every declaration in the workspace. exit 0 all free / 1 at least one taken, naming where / 2 nothing asked or the scan did not run — an empty sweep is never a clean bill. Honest limit, printed on the passing path: it reads declarations, so a re-export under a different name stays the build's job)
|
|
74
77
|
dz provenance-check --manifest <sources.json> [--project <dir>] [--json] (nothing goes out citing a source that may not leave this machine. Checks PROVENANCE, not words: every claim names its source, and only a KNOWN kind that resolves safely is cleared. Repo paths go through 'git -C <root> check-ignore' over the RESOLVED path — a symlink into an ignored directory is REFUSED (git classifies the string and never dereferences, MEASURED), and the verdict does not change with your working directory. Store records must be named in the git-TRACKED provenance-public.json, so declaring one public is a reviewable commit rather than a field inside an ignored store. An undeclared kind is refused, never inferred from the path's shape. exit 0 allowed / 1 blocked / 3 NOT ESTABLISHED — an empty manifest, an unreadable one, or an oracle that did not run is never a pass. It proves what was CITED: it cannot see a paraphrase with no citation, nor confidential text pasted by hand into an allowed file)
|
|
78
|
+
dz project-skills [--project <dir>] [--json] [--stages-json] (polymorphic feature-adr: resolve architecture/project-skills.json — fixed roles product-vision/critic/brand/impl-bar plus an open extra[] — into per-stage guidance. READ-ONLY. --project names the root explicitly, so it works from any cwd; without it the manifest is read from the current repo. No manifest ⇒ a byte-identical generic run)
|
|
79
|
+
dz discrimination-check --slug <slug> [--base <ref>] [--json] (does the ADR's named test actually DISCRIMINATE? Re-runs it on a worktree at the pre-feature commit, where it MUST go red. A test that passes with the feature removed proves nothing; dz amendment-check proves the test exists, this proves it bites)
|
|
80
|
+
dz guard [check|promote|init] [--json] [--force] (HARD/SOFT repo rules — readme-first, lockfile-in-sync, claim tagging — run automatically as a pre-flight inside dz publish. HARD blocks, SOFT warns)
|
|
81
|
+
dz architecture [--check --slug <s> --desc <text>] [--project <dir>] [--revise] (the live product map + vision: --check is the soft Step-0 сверка of a new feature against them, reporting {signal,confidence} rather than blocking)
|
|
82
|
+
dz sbom [--pack <name>] [--out <file>] (CycloneDX software bill of materials for the workspace, or for one pack with --pack)
|
|
75
83
|
dz amendment-check --slug <slug> | --feature-dir <dir> | --all [--json] (the deterministic Step-8 amendment gate: every AM-N row must resolve to a test found INSIDE the file the row names; the PLAN is authoritative when it carries rows, and an ideation amendment the plan drops is a failure. exit 0 pass/skip, 1 fail, 3 NOT-ESTABLISHED — a section that parsed ZERO rows is never a pass. --all is a CENSUS and always exits 0. Does NOT prove non-vacuity — that is dz discrimination-check)
|
|
76
84
|
dz feature-adr-record --kind ledger|training-pair --stage <s> [--slug <s>] [--row|--pair <json>] [--mark <n>] [--once] [--json] (the witnessed writer for the run-cost ledger and training pairs: the payload arrives as an ARGUMENT, never as shell; a malformed or wrong-kind payload is REFUSED before any write; the timestamp is stamped before serialising; the append is verified by re-reading the tail. exit 0 written|duplicate|skipped, 2 refused, 3 not-verified — a record failure is never blocking)
|
|
77
85
|
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)
|
|
86
|
+
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)
|
|
78
87
|
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)
|
|
79
88
|
dz qe-bridge --family claude --slug <feature> [--coder-family codex|claude] [--model <id>] [--files a,b] [--out <f>] [--timeout <s>] [--allow-same-family] [--json] (the REVERSE QE bridge: run an INDEPENDENT Claude reviewer over a feature's Step-8 artifacts from ANY host — a Codex session included, plain shell, no Claude agent plane needed — and land a PARSED signoff. The reviewer runs ISOLATED: an EMPTY temp cwd plus --safe-mode --strict-mcp-config --tools '' --no-session-persistence, so no CLAUDE.md/skills/plugins/hooks/MCP load, and the verdict is read from the --output-format json RESULT ENVELOPE — text a session customization printed onto the same stdout can never become a signoff. Probes the model before trusting it; sends SCOPED extracts with a loud 200k-char ceiling (never silent truncation); the grade must AGREE across three LAST-anchored channels (terminal marker line, fenced qe-bridge-signoff JSON, the report's own GRADE line) AND the marker must be the FINAL content — empty, gradeless, self-contradicting or miscounted output is one of 18 NAMED failures with an audit record under features/<slug>/.fa-state/qe-bridge/ (runId, resolved executable + binOverride, prompt sha256, channel offsets, requestedOut, reportWritten, retained raw stdout; 0600 files in a 0700 dir), never a clean review. A --coder-family that contradicts the recorded reqe debt is refused. Writes features/<slug>/08b_reqe_report.md, which dz reqe --done settles unchanged. DISCLOSURE: the extracts you scope are sent to the Claude runtime; the bridge cannot classify secrets. DZ_QE_BRIDGE_CLAUDE_BIN is a TEST SEAM, not a flag. exit 0 signoff parsed (ANY grade — it reports, it does not gate) / 1 named failure / 2 usage)
|
|
80
89
|
dz mutation-gate [--package <dir>] [--registry <file>] [--test-cmd "<cmd>"] [--only <id[,id]>] [--timeout <ms>] [--rebaseline per-entry|final] [--keep-scratch] [--json] (prove each NAMED protection has a test that DISCRIMINATES: copy the package to a scratch dir, verify the baseline suite is green, apply each registry mutation, run the suite, REQUIRE red, restore. The red must be BEHAVIOURAL: a mutation that no longer parses is MUTATION_UNPARSEABLE; a red run whose OWN output reports a test FILE failing to load (node --test file-level not-ok with exitCode, vitest Failed Suites) is MUTATION_LOAD_FATAL — the signal comes from the same run as the failing count, never from a separate isolated import; red output whose shape matches no known runner is INCONCLUSIVE (a runner-coverage gap, loud, never PROVEN); a count far above the entry's bound is OVER_FAILING; a restored tree that does not reproduce green makes the entry INCONCLUSIVE (flaky). Mutation writes are realpath-contained to the scratch copy: a symlink escape or a node_modules/ target is refused (exit 2), the real tree is never written. A mutation that does not apply, a green suite, or an inconclusive run is a FAILURE — never a skip. exit 0 all proven / 1 gate failed / 2 setup error)
|
|
@@ -2100,6 +2109,75 @@ function cmdUsageByStage(options, flags, write) {
|
|
|
2100
2109
|
* non-zero exit. `--project <dir>` scopes ONLY the `.dz/config.json` read/write; measurement is
|
|
2101
2110
|
* account-wide (all projects).
|
|
2102
2111
|
*/
|
|
2112
|
+
/**
|
|
2113
|
+
* dz qe-rounds — how many Step-8 review rounds has one feature already had?
|
|
2114
|
+
*
|
|
2115
|
+
* The stopping rule ("Max iterations: 3") lived ONLY as a sentence in a prose module, so every
|
|
2116
|
+
* restart of the agent forgot it. MEASURED 2026-08-27: one real slug reached 38 graded rounds.
|
|
2117
|
+
* This makes the rule answerable by a command instead of by memory — and the exit code ASKS the
|
|
2118
|
+
* owner rather than concluding, because whether 38 rounds were warranted is not a thing a counter
|
|
2119
|
+
* can know.
|
|
2120
|
+
*/
|
|
2121
|
+
function cmdQeRounds(options, flags, cwd, write) {
|
|
2122
|
+
const root = resolve(cwd, options.get('project') ?? '.');
|
|
2123
|
+
const slug = (options.get('slug') ?? '').trim();
|
|
2124
|
+
const dirOpt = (options.get('feature-dir') ?? '').trim();
|
|
2125
|
+
if (!slug && !dirOpt) {
|
|
2126
|
+
write('dz qe-rounds: need --slug <feature> or --feature-dir <abs path>');
|
|
2127
|
+
return 2;
|
|
2128
|
+
}
|
|
2129
|
+
// One directory, never a union. MEASURED: the slug `package-story-page-hardening` exists in two
|
|
2130
|
+
// checkouts holding 38 and 7 records; a tool that searched for the slug would report 45 for a run
|
|
2131
|
+
// that had 38, and the output would look identical to a correct one.
|
|
2132
|
+
const featureDir = dirOpt ? resolve(cwd, dirOpt) : join(root, 'features', slug);
|
|
2133
|
+
const ceilingRaw = options.get('ceiling');
|
|
2134
|
+
let ceiling = QE_ROUNDS_DEFAULT_CEILING;
|
|
2135
|
+
if (ceilingRaw !== undefined) {
|
|
2136
|
+
const n = Number(ceilingRaw);
|
|
2137
|
+
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1) {
|
|
2138
|
+
write('dz qe-rounds: --ceiling must be a positive integer, got ' + JSON.stringify(ceilingRaw));
|
|
2139
|
+
return 2;
|
|
2140
|
+
}
|
|
2141
|
+
ceiling = n;
|
|
2142
|
+
}
|
|
2143
|
+
const r = readQeRounds(featureDir, { ceiling });
|
|
2144
|
+
if (flags.has('json')) {
|
|
2145
|
+
write(JSON.stringify(r));
|
|
2146
|
+
return r.status === 'not-established' ? 2 : r.status === 'at-or-over-ceiling' ? 1 : 0;
|
|
2147
|
+
}
|
|
2148
|
+
if (r.status === 'not-established') {
|
|
2149
|
+
write('dz qe-rounds: NOT ESTABLISHED — ' + (r.notEstablishedReason ?? 'no readable records'));
|
|
2150
|
+
write(' This is not "zero rounds". Nothing was measured, so nothing follows about continuing.');
|
|
2151
|
+
if (r.unreadable.length) {
|
|
2152
|
+
write(' unreadable record(s):');
|
|
2153
|
+
for (const u of r.unreadable)
|
|
2154
|
+
write(' - ' + u.file + ' — ' + u.why);
|
|
2155
|
+
}
|
|
2156
|
+
return 2;
|
|
2157
|
+
}
|
|
2158
|
+
write('dz qe-rounds: ' + r.rounds + ' graded round(s), ceiling ' + r.ceiling
|
|
2159
|
+
+ (r.failedAttempts.length ? ', plus ' + r.failedAttempts.length + ' attempt(s) with no verdict' : ''));
|
|
2160
|
+
write(' dir: ' + r.dir);
|
|
2161
|
+
if (r.firstAt && r.lastAt)
|
|
2162
|
+
write(' window: ' + r.firstAt + ' → ' + r.lastAt);
|
|
2163
|
+
if (r.grades.length)
|
|
2164
|
+
write(' grades: ' + r.grades.join(' '));
|
|
2165
|
+
if (r.unreadable.length) {
|
|
2166
|
+
// Named, never silently skipped: a dropped record makes the count quietly too low, and in a
|
|
2167
|
+
// counter whose job is to STOP a loop that fails open.
|
|
2168
|
+
write(' ' + r.unreadable.length + ' unreadable record(s) — the count below is a LOWER BOUND:');
|
|
2169
|
+
for (const u of r.unreadable)
|
|
2170
|
+
write(' - ' + u.file + ' — ' + u.why);
|
|
2171
|
+
}
|
|
2172
|
+
if (r.status === 'at-or-over-ceiling') {
|
|
2173
|
+
write(' STOP: the ceiling is reached. The rule in 08-qe.md says the remaining gaps go to the');
|
|
2174
|
+
write(' owner for a decision. This command does not judge whether the rounds were warranted —');
|
|
2175
|
+
write(' it only makes sure the question gets asked.');
|
|
2176
|
+
return 1;
|
|
2177
|
+
}
|
|
2178
|
+
write(' under the ceiling — another round is within the documented budget');
|
|
2179
|
+
return 0;
|
|
2180
|
+
}
|
|
2103
2181
|
function cmdCadence(options, flags, cwd, write) {
|
|
2104
2182
|
const root = resolve(cwd, options.get('project') ?? '.');
|
|
2105
2183
|
const windowRaw = (options.get('window') ?? 'week').trim();
|
|
@@ -2262,8 +2340,59 @@ function cmdUsage(options, optionLists, flags, cwd, write) {
|
|
|
2262
2340
|
return 0;
|
|
2263
2341
|
}
|
|
2264
2342
|
}
|
|
2265
|
-
|
|
2343
|
+
/**
|
|
2344
|
+
* Which store a learning WRITE belongs to, resolved once and shared.
|
|
2345
|
+
*
|
|
2346
|
+
* `dz retro` reads a recurrence ledger and then calls `cmdTeach` to append to it. Before this
|
|
2347
|
+
* helper existed, a session mode split the two: the ledger was read from the project while the
|
|
2348
|
+
* write went to the home store, so the recurrence count never advanced and the drill threshold
|
|
2349
|
+
* could never be reached (cross-family QE, 2026-08-27). A read and its write must resolve the
|
|
2350
|
+
* same way or the counter they share is a fiction.
|
|
2351
|
+
*/
|
|
2352
|
+
function resolveLearningStore(options, cwd) {
|
|
2266
2353
|
const projectRoot = resolve(cwd, options.get('project') ?? '.');
|
|
2354
|
+
const target = resolveTeachTarget({
|
|
2355
|
+
flag: options.get('to'),
|
|
2356
|
+
env: process.env.DZ_LEARN,
|
|
2357
|
+
config: readTeachToConfig(projectRoot),
|
|
2358
|
+
});
|
|
2359
|
+
return {
|
|
2360
|
+
projectRoot,
|
|
2361
|
+
storeRoot: target.store === 'global' ? globalStoreRoot() : projectRoot,
|
|
2362
|
+
target,
|
|
2363
|
+
};
|
|
2364
|
+
}
|
|
2365
|
+
/** The store line for a learning write, carrying the path AND what chose it. */
|
|
2366
|
+
function learningStoreLine(storeRoot, projectOption, target, verb) {
|
|
2367
|
+
const reason = teachReasonPhrase(target.reason);
|
|
2368
|
+
return storeLocationLine(describeStoreLocation(storeRoot, projectOption, target.store === 'global' ? 'global' : undefined), verb) + (reason ? ' [' + reason + ']' : '');
|
|
2369
|
+
}
|
|
2370
|
+
async function cmdTeach(options, flags, cwd, write) {
|
|
2371
|
+
// WHICH store this lesson belongs to, and WHO decided (teach-chooses-its-store).
|
|
2372
|
+
// `--to` → `DZ_LEARN` → `.dz/config.json` learning.teachTo → project. The owner asked for a
|
|
2373
|
+
// per-session choice; for a CLI every invocation is a fresh process, so the only honest session
|
|
2374
|
+
// is the shell, and the only honest session state is an environment variable.
|
|
2375
|
+
// An unknown value is REFUSED here rather than defaulted: `DZ_LEARN=globl` silently writing to
|
|
2376
|
+
// the project store would be precisely the invisible mislabel this mode exists to prevent.
|
|
2377
|
+
let resolved;
|
|
2378
|
+
try {
|
|
2379
|
+
resolved = resolveLearningStore(options, cwd);
|
|
2380
|
+
}
|
|
2381
|
+
catch (e) {
|
|
2382
|
+
if (e instanceof TeachTargetError) {
|
|
2383
|
+
write('dz teach: ' + e.message);
|
|
2384
|
+
return 1;
|
|
2385
|
+
}
|
|
2386
|
+
throw e;
|
|
2387
|
+
}
|
|
2388
|
+
// The default keeps writing exactly where it wrote before this feature existed — MEASURED: this
|
|
2389
|
+
// repo's own store holds 361 records written under that behaviour, and every other user's store
|
|
2390
|
+
// is the same. Only an explicit choice moves it.
|
|
2391
|
+
const { storeRoot, target: teachTarget } = resolved;
|
|
2392
|
+
// The verb is per OUTCOME, not per command: a harmonize dry-run and a failed --reinforce READ
|
|
2393
|
+
// the store and change nothing, so saying "written" there is a false claim about what happened
|
|
2394
|
+
// (cross-family QE round 2, 2026-08-27).
|
|
2395
|
+
const storeLine = (verb) => learningStoreLine(storeRoot, options.get('project'), teachTarget, verb);
|
|
2267
2396
|
// Vector tier (dz-rvf-vector-bridge FR-1): best-effort mirror AFTER the lexical write is
|
|
2268
2397
|
// durable (I-3). Auto-gated on the agentdb memory backend / an explicit vector-engine config
|
|
2269
2398
|
// (D3) — an unconfigured project runs ZERO vector code and its output stays byte-identical
|
|
@@ -2292,7 +2421,16 @@ async function cmdTeach(options, flags, cwd, write) {
|
|
|
2292
2421
|
// learned store. ONE implementation (harmonizeVectorStore), two entry points (QR-6). Routed
|
|
2293
2422
|
// BEFORE the --from-json / single-teach paths. Dry-run by default; --apply after a backup.
|
|
2294
2423
|
if (flags.has('harmonize')) {
|
|
2295
|
-
|
|
2424
|
+
// `--harmonize --apply` MUTATES this store. A forgotten session mode pointing it at ~/.dz
|
|
2425
|
+
// and saying nothing was the exact hazard this feature exists to close (cross-family QE).
|
|
2426
|
+
// Suppressed under --json: this line ahead of the report made stdout unparseable, which is a
|
|
2427
|
+
// worse defect than the invisibility it was closing (measured live, cross-family QE round 2).
|
|
2428
|
+
if (!flags.has('json'))
|
|
2429
|
+
write(storeLine(flags.has('apply') ? 'written' : 'read'));
|
|
2430
|
+
return runHarmonize(storeRoot, options, flags, write, {
|
|
2431
|
+
store: join(storeRoot, '.dz'),
|
|
2432
|
+
storeChosenBy: teachTarget.reason,
|
|
2433
|
+
});
|
|
2296
2434
|
}
|
|
2297
2435
|
// Bulk import: `dz teach --from-json <file>` ingests a `dz recall --all --json`
|
|
2298
2436
|
// export (an array of learned patterns) — the portable way to SHARE a learned
|
|
@@ -2320,7 +2458,7 @@ async function cmdTeach(options, flags, cwd, write) {
|
|
|
2320
2458
|
write('dz teach --from-json: expected a JSON array (produced by `dz recall --all --json`)');
|
|
2321
2459
|
return 1;
|
|
2322
2460
|
}
|
|
2323
|
-
const existing = new Set(loadStorePatternsSync(
|
|
2461
|
+
const existing = new Set(loadStorePatternsSync(storeRoot).map((p) => p.pattern));
|
|
2324
2462
|
let imported = 0;
|
|
2325
2463
|
let skipped = 0;
|
|
2326
2464
|
const importedRecs = [];
|
|
@@ -2338,7 +2476,7 @@ async function cmdTeach(options, flags, cwd, write) {
|
|
|
2338
2476
|
ts: typeof p.ts === 'string' ? p.ts : new Date().toISOString(),
|
|
2339
2477
|
source: 'dz-teach-import',
|
|
2340
2478
|
};
|
|
2341
|
-
await recordPattern(
|
|
2479
|
+
await recordPattern(storeRoot, rec);
|
|
2342
2480
|
existing.add(p.pattern);
|
|
2343
2481
|
importedRecs.push(rec);
|
|
2344
2482
|
imported += 1;
|
|
@@ -2348,7 +2486,7 @@ async function cmdTeach(options, flags, cwd, write) {
|
|
|
2348
2486
|
// Carrying a brain to a new machine goes through this path, and the mirror gate is the SAME one
|
|
2349
2487
|
// teach uses — so without a config the whole import lands unindexed while `vector status` still
|
|
2350
2488
|
// prints `pending: 0`. Say it here, where the user can act on it (FR-6).
|
|
2351
|
-
if (imported > 0 && !flags.has('no-mirror') && !vectorMirrorEnabled(
|
|
2489
|
+
if (imported > 0 && !flags.has('no-mirror') && !vectorMirrorEnabled(storeRoot)) {
|
|
2352
2490
|
write(` ⚠ the vector mirror writer is OFF — these ${imported} pattern(s) are LEXICAL ONLY`);
|
|
2353
2491
|
write(` enable it in .dz/config.json (memory.backend=agentdb), then run: dz vector reindex`);
|
|
2354
2492
|
}
|
|
@@ -2356,9 +2494,9 @@ async function cmdTeach(options, flags, cwd, write) {
|
|
|
2356
2494
|
// a shared store as silently as a hand-typed teach — and it returned before the
|
|
2357
2495
|
// advice single-teach prints. The same advice, at the same point in the flow: after
|
|
2358
2496
|
// the write, naming the choice, blocking nothing.
|
|
2359
|
-
let resolvedImportRoot =
|
|
2497
|
+
let resolvedImportRoot = storeRoot;
|
|
2360
2498
|
try {
|
|
2361
|
-
resolvedImportRoot = realpathSync(
|
|
2499
|
+
resolvedImportRoot = realpathSync(storeRoot);
|
|
2362
2500
|
}
|
|
2363
2501
|
catch { /* unresolvable is not the brain */ }
|
|
2364
2502
|
const importedMedical = importedRecs.filter((r) => DEFAULT_HELD_OUT_DOMAINS.map(canonicalDomainKey).includes(canonicalDomainKey(r.domain)));
|
|
@@ -2369,18 +2507,19 @@ async function cmdTeach(options, flags, cwd, write) {
|
|
|
2369
2507
|
write(advice);
|
|
2370
2508
|
}
|
|
2371
2509
|
}
|
|
2372
|
-
write(` Backend: memory (@dzhechkov/memory) Total now: ${loadStorePatternsSync(
|
|
2510
|
+
write(` Backend: memory (@dzhechkov/memory) Total now: ${loadStorePatternsSync(storeRoot).length}`);
|
|
2511
|
+
write(storeLine('written'));
|
|
2373
2512
|
// ONE batched mirror call through the same seam as single-teach (QR-6 — no bespoke path).
|
|
2374
|
-
await emitMirror(
|
|
2513
|
+
await emitMirror(storeRoot, importedRecs, 'dz-teach-import');
|
|
2375
2514
|
if (imported > 0) {
|
|
2376
|
-
const report = await harmonizeVectorStore(
|
|
2515
|
+
const report = await harmonizeVectorStore(storeRoot, {});
|
|
2377
2516
|
write(` ℹ ${imported} imported — ${report.clusters.length} near-duplicate cluster(s): review with dz vector harmonize (dry-run); merge with dz vector harmonize --apply after backup`);
|
|
2378
2517
|
}
|
|
2379
2518
|
return 0;
|
|
2380
2519
|
}
|
|
2381
2520
|
const reinforce = options.get('reinforce');
|
|
2382
2521
|
if (reinforce !== undefined && reinforce.trim() !== '') {
|
|
2383
|
-
const backend = resolveLearningBackend(
|
|
2522
|
+
const backend = resolveLearningBackend(storeRoot);
|
|
2384
2523
|
const sampleReward = options.has('reward') ? parseFloat(options.get('reward') ?? '0.8') : undefined;
|
|
2385
2524
|
backend.addSample({
|
|
2386
2525
|
dzId: reinforce,
|
|
@@ -2392,9 +2531,10 @@ async function cmdTeach(options, flags, cwd, write) {
|
|
|
2392
2531
|
if (trained.flushed > 0) {
|
|
2393
2532
|
write(`↳ reinforced ${reinforce}`);
|
|
2394
2533
|
// lesson-quarantine: reinforcement IS promotion — keep the hook daemon's mirror in step.
|
|
2395
|
-
const clearedQ = clearAgentdbQuarantine(
|
|
2534
|
+
const clearedQ = clearAgentdbQuarantine(storeRoot, [reinforce]);
|
|
2396
2535
|
if (clearedQ.cleared > 0)
|
|
2397
2536
|
write(` ↳ promoted out of quarantine (mirror updated)`);
|
|
2537
|
+
write(storeLine('written'));
|
|
2398
2538
|
return 0;
|
|
2399
2539
|
}
|
|
2400
2540
|
// HIGH-fix: a no-match must NOT auto-teach the raw argument — callers pass dzIds or truncated
|
|
@@ -2403,6 +2543,9 @@ async function cmdTeach(options, flags, cwd, write) {
|
|
|
2403
2543
|
write(`dz teach --reinforce: no existing pattern matched ${JSON.stringify(reinforce)} — nothing reinforced`);
|
|
2404
2544
|
write(' If this is a genuinely NEW lesson, teach it explicitly: dz teach "<full lesson text>" --reward <0-1> --domain <area>');
|
|
2405
2545
|
write(' To find the exact pattern to reinforce: dz recall "<terms>" (match by its full text)');
|
|
2546
|
+
// WHICH store was searched — otherwise "no existing pattern matched" reads as "this lesson is
|
|
2547
|
+
// new" when it may simply be sitting in the other store. `read`, because nothing was written.
|
|
2548
|
+
write(storeLine('read'));
|
|
2406
2549
|
return 1;
|
|
2407
2550
|
}
|
|
2408
2551
|
const pattern = options.get('_positional_0');
|
|
@@ -2411,14 +2554,16 @@ async function cmdTeach(options, flags, cwd, write) {
|
|
|
2411
2554
|
write(' Example: dz teach "Used DataLoader to fix N+1 query" --reward 0.9 --domain performance');
|
|
2412
2555
|
write(' Import: dz teach --from-json patterns.json (bulk-import a `dz recall --all --json` export)');
|
|
2413
2556
|
write(' Harmonize: dz teach --harmonize [--apply] (semantic dedup — alias of dz vector harmonize; dry-run default)');
|
|
2557
|
+
write(' Store: dz teach "<lesson>" --to project|global (project = this repo; global = ~/.dz, shared across every project)');
|
|
2558
|
+
write(' session default: export DZ_LEARN=global · project default: .dz/config.json → learning.teachTo');
|
|
2414
2559
|
return 1;
|
|
2415
2560
|
}
|
|
2416
2561
|
const reward = parseFloat(options.get('reward') ?? '0.8');
|
|
2417
2562
|
const domain = options.get('domain') ?? 'general';
|
|
2418
2563
|
if (flags.has('guard')) {
|
|
2419
|
-
const verdict = await teachGuard(
|
|
2564
|
+
const verdict = await teachGuard(storeRoot, pattern, { reward: Math.max(0, Math.min(1, reward)) });
|
|
2420
2565
|
if (verdict.action === 'reinforce') {
|
|
2421
|
-
const backend = resolveLearningBackend(
|
|
2566
|
+
const backend = resolveLearningBackend(storeRoot);
|
|
2422
2567
|
backend.addSample({ dzId: verdict.dzId, kind: 'reinforce', reward: Math.max(0, Math.min(1, reward)), ts: new Date().toISOString() });
|
|
2423
2568
|
const trained = await backend.train();
|
|
2424
2569
|
// HIGH-fix: only claim success when the reinforce actually FLUSHED. With backend 'off'
|
|
@@ -2426,7 +2571,7 @@ async function cmdTeach(options, flags, cwd, write) {
|
|
|
2426
2571
|
// teach below so the lesson is NEVER silently discarded (the exact silent-drop the ADR forbids).
|
|
2427
2572
|
if (trained.flushed > 0) {
|
|
2428
2573
|
write(`↳ reinforced existing pattern ${verdict.dzId} (cos=${verdict.cosine.toFixed(2)}) — not re-added`);
|
|
2429
|
-
const clearedQ = clearAgentdbQuarantine(
|
|
2574
|
+
const clearedQ = clearAgentdbQuarantine(storeRoot, [verdict.dzId]);
|
|
2430
2575
|
if (clearedQ.cleared > 0)
|
|
2431
2576
|
write(' ↳ promoted out of quarantine (mirror updated)');
|
|
2432
2577
|
return 0;
|
|
@@ -2456,20 +2601,28 @@ async function cmdTeach(options, flags, cwd, write) {
|
|
|
2456
2601
|
// folds any legacy .dz/patterns.jsonl into the backend (idempotent) and returns the
|
|
2457
2602
|
// total count. The lossy `npx agentdb add` dual-write was removed in Tier-1 (audit #6).
|
|
2458
2603
|
// lesson-quarantine (opt-in): a fresh lesson is a HYPOTHESIS until it earns promotion.
|
|
2459
|
-
const quarantineOn = readMemoryLearningConfig(
|
|
2460
|
-
const count = await recordPattern(
|
|
2604
|
+
const quarantineOn = readMemoryLearningConfig(storeRoot).quarantine;
|
|
2605
|
+
const count = await recordPattern(storeRoot, entry, quarantineOn ? { quarantine: true } : {});
|
|
2461
2606
|
write(`Learned: "${pattern.slice(0, 60)}${pattern.length > 60 ? '...' : ''}"`);
|
|
2462
2607
|
write(` Domain: ${domain} Reward: ${reward} Backend: memory (@dzhechkov/memory)`);
|
|
2463
2608
|
write(` Total patterns: ${count}`);
|
|
2609
|
+
// WHERE the write landed. MEASURED before this line existed: teach printed the pattern, the
|
|
2610
|
+
// domain, the reward and the backend — and not one word about the path, so a store written to
|
|
2611
|
+
// the wrong directory was indistinguishable from one written to the right one. A user running
|
|
2612
|
+
// this in eight project directories gets eight isolated stores and believes they accumulate.
|
|
2613
|
+
// ...and WHY that store. Without this a user who exported DZ_LEARN three hours ago and forgot
|
|
2614
|
+
// sees a path, cannot tell what chose it, and has no reason to question it. `default` adds
|
|
2615
|
+
// nothing, so the line stays byte-identical for everyone who set nothing.
|
|
2616
|
+
write(storeLine('written'));
|
|
2464
2617
|
// ADVICE, not a gate. Someone putting medical lessons in a shared store owns both
|
|
2465
2618
|
// directories and this binary; refusing would be defending a user against themselves,
|
|
2466
2619
|
// which this design does not attempt. Making the choice INFORMED is the part that is
|
|
2467
2620
|
// ours to do — the write has already happened when this prints.
|
|
2468
2621
|
// RESOLVE before deciding. A symlink named `.health-brain` pointing at a shared project
|
|
2469
2622
|
// silenced this advice in exactly the case it exists for.
|
|
2470
|
-
let resolvedRoot =
|
|
2623
|
+
let resolvedRoot = storeRoot;
|
|
2471
2624
|
try {
|
|
2472
|
-
resolvedRoot = realpathSync(
|
|
2625
|
+
resolvedRoot = realpathSync(storeRoot);
|
|
2473
2626
|
}
|
|
2474
2627
|
catch { /* a path we cannot resolve is not the brain */ }
|
|
2475
2628
|
const sharedAdvice = renderSharedStoreAdvice(domain, resolvedRoot);
|
|
@@ -2479,7 +2632,7 @@ async function cmdTeach(options, flags, cwd, write) {
|
|
|
2479
2632
|
write(' ⚠ quarantined: excluded from auto-inject, damped in recall — promote by confirming it (dz teach --reinforce "<text>") or dz recall --promote <dzId> --apply');
|
|
2480
2633
|
}
|
|
2481
2634
|
// The lexical write above is durable — the vector mirror is strictly best-effort (I-3).
|
|
2482
|
-
await emitMirrorQ(
|
|
2635
|
+
await emitMirrorQ(storeRoot, [entry], 'dz-teach', quarantineOn);
|
|
2483
2636
|
return 0;
|
|
2484
2637
|
}
|
|
2485
2638
|
async function cmdConsolidate(options, flags, cwd, write) {
|
|
@@ -3054,8 +3207,8 @@ async function cmdRecall(options, flags, cwd, write) {
|
|
|
3054
3207
|
// second occurrence. Foreign stdout is routed to stderr for the duration of the engine call — our
|
|
3055
3208
|
// own output is written after it returns.
|
|
3056
3209
|
const result = asJson
|
|
3057
|
-
? await withForeignStdoutOnStderr(() => recallHybrid(projectRoot, query, { limit: fetchLimit, mode }))
|
|
3058
|
-
: await recallHybrid(projectRoot, query, { limit: fetchLimit, mode });
|
|
3210
|
+
? await withForeignStdoutOnStderr(() => recallHybrid(projectRoot, query, { limit: fetchLimit, mode, deferExposures: true, ...(wantedDomain !== undefined ? { domain: wantedDomain } : {}) }))
|
|
3211
|
+
: await recallHybrid(projectRoot, query, { limit: fetchLimit, mode, deferExposures: true, ...(wantedDomain !== undefined ? { domain: wantedDomain } : {}) });
|
|
3059
3212
|
if (mode === 'semantic' && result.vectorEngine === 'none') {
|
|
3060
3213
|
// --semantic is an explicit ask — degrading it silently would be dishonest (FR-3).
|
|
3061
3214
|
const why = result.vectorReason ?? 'no vector engine available — run: dz setup --memory agentdb';
|
|
@@ -3070,7 +3223,33 @@ async function cmdRecall(options, flags, cwd, write) {
|
|
|
3070
3223
|
// tagged with that domain WITHOUT dropping foreign ones — a boost, not a filter, so a
|
|
3071
3224
|
// shared store keeps the cross-domain transfers that make it worth more than two stores.
|
|
3072
3225
|
const boost = wantedDomain !== undefined ? applyDomainBoost(result.hits, wantedDomain) : null;
|
|
3073
|
-
|
|
3226
|
+
// ── The cross-project store ────────────────────────────────────────────────────────────────
|
|
3227
|
+
//
|
|
3228
|
+
// Our own shipped precedent (learning_bridge.py:23): "the compounding objection was answered by
|
|
3229
|
+
// making RECALL read both stores rather than by merging them" — and :880, "two stores are
|
|
3230
|
+
// different stores". Writing needs a choice; reading almost never does, so there is no mode here.
|
|
3231
|
+
//
|
|
3232
|
+
// The global store is a project store rooted at the home directory: same code, same format.
|
|
3233
|
+
// It is read ONLY when it exists AND is a different store — a user recalling FROM their home
|
|
3234
|
+
// directory would otherwise read one file twice and see every hit doubled.
|
|
3235
|
+
//
|
|
3236
|
+
// ABSENT global store ⇒ this whole block is skipped and the output is byte-identical to before.
|
|
3237
|
+
// That is the load-bearing property: every existing user must be unaffected by a feature they
|
|
3238
|
+
// did not ask for.
|
|
3239
|
+
const globalRoot = globalStoreRoot();
|
|
3240
|
+
const readGlobal = !sameStore(projectRoot, globalRoot)
|
|
3241
|
+
&& existsSync(join(globalRoot, '.dz', 'memory'));
|
|
3242
|
+
let globalHits = [];
|
|
3243
|
+
if (readGlobal) {
|
|
3244
|
+
const g = asJson
|
|
3245
|
+
? await withForeignStdoutOnStderr(() => recallHybrid(globalRoot, query, { limit: fetchLimit, mode, deferExposures: true, ...(wantedDomain !== undefined ? { domain: wantedDomain } : {}) }))
|
|
3246
|
+
: await recallHybrid(globalRoot, query, { limit: fetchLimit, mode, deferExposures: true, ...(wantedDomain !== undefined ? { domain: wantedDomain } : {}) });
|
|
3247
|
+
globalHits = g.hits;
|
|
3248
|
+
}
|
|
3249
|
+
const merged = readGlobal
|
|
3250
|
+
? mergeStoreHits(boost ? boost.hits : result.hits, globalHits, (h) => h.pattern.pattern)
|
|
3251
|
+
: (boost ? boost.hits : result.hits);
|
|
3252
|
+
const hits = merged.slice(0, limit);
|
|
3074
3253
|
// Computed ONCE, honoured by EVERY return path. It used to live only on the text tail, so the two
|
|
3075
3254
|
// paths that return earlier — `--json` and the zero-hits branch — still reported success. That
|
|
3076
3255
|
// made the contract change invisible to exactly the caller the ADR justifies it by: a script
|
|
@@ -3086,6 +3265,22 @@ async function cmdRecall(options, flags, cwd, write) {
|
|
|
3086
3265
|
// "foreign-domain lessons kept" about the pre-cut list while the printed list was
|
|
3087
3266
|
// missing one. Count it and say so; the reader can act on it (raise --limit).
|
|
3088
3267
|
const displaced = boost !== null ? countDisplacedByCut(result.hits, boost.hits, limit) : 0;
|
|
3268
|
+
// lesson-bandit-rerank FR-8/AC-11: the bandit block must describe the POST-cut list the reader
|
|
3269
|
+
// actually sees. `dz recall` over-fetches under --domain and truncates AGAIN here, so reporting
|
|
3270
|
+
// the core's own (already post-merge-cut) count would still describe a list one cut too early.
|
|
3271
|
+
// ABSENT while disarmed — its presence is what tells a reader the feature ran.
|
|
3272
|
+
const shownDzIds = hits.map((h) => patternRecordId(h.pattern));
|
|
3273
|
+
const banditBlock = result.bandit === undefined
|
|
3274
|
+
? undefined
|
|
3275
|
+
: narrowBanditReport(result.bandit, shownDzIds);
|
|
3276
|
+
// Exposures were DEFERRED (`deferExposures: true`) precisely so this cut happens first: `dz recall`
|
|
3277
|
+
// over-fetches under --domain and truncates again here, and counting an over-fetched hit as "seen"
|
|
3278
|
+
// both inflates the health metrics and mislabels a candidate the reader never laid eyes on.
|
|
3279
|
+
result.commitExposures?.(shownDzIds);
|
|
3280
|
+
const renderBanditNote = (b) => ` ℹ bandit payoff: ${b.moved} of ${b.armsConsidered} shown hit(s) moved (ctx ${b.contextKey}` +
|
|
3281
|
+
`, ${b.unknownArms} with no measured payoff yet, ${b.quarantinedExcluded} quarantined excluded` +
|
|
3282
|
+
`${b.exploration ? `, exploration ON — ${b.explored} trial impression(s)` : ''}` +
|
|
3283
|
+
`${b.reason !== null ? `, state ${b.reason}` : ''})`;
|
|
3089
3284
|
if (asJson) {
|
|
3090
3285
|
// Portable contract UNCHANGED (I-7/AC-6): a plain PatternRecord[] — round-trips through
|
|
3091
3286
|
// `dz teach --from-json` regardless of which backend ranked each hit.
|
|
@@ -3117,6 +3312,11 @@ async function cmdRecall(options, flags, cwd, write) {
|
|
|
3117
3312
|
if (cutNoteJson !== '')
|
|
3118
3313
|
process.stderr.write(`${cutNoteJson}\n`);
|
|
3119
3314
|
}
|
|
3315
|
+
// STDERR, not stdout: `--json`'s stdout contract is a plain PatternRecord[] that round-trips
|
|
3316
|
+
// through `dz teach --from-json`, and wrapping it in an object to make room for one report
|
|
3317
|
+
// would break every existing consumer. The block travels beside the other honesty notes.
|
|
3318
|
+
if (banditBlock !== undefined)
|
|
3319
|
+
process.stderr.write(`${JSON.stringify({ bandit: banditBlock })}\n`);
|
|
3120
3320
|
return semanticUnserved ? 1 : 0;
|
|
3121
3321
|
}
|
|
3122
3322
|
if (hits.length === 0) {
|
|
@@ -3150,7 +3350,17 @@ async function cmdRecall(options, flags, cwd, write) {
|
|
|
3150
3350
|
? `${lexLabel} only (the semantic tier returned ${result.semanticCandidates} stale id(s) — run: dz consolidate)`
|
|
3151
3351
|
: `${lexLabel} only (semantic tier empty — run: dz vector reindex)`
|
|
3152
3352
|
: `${lexLabel} ranking (lexical)`;
|
|
3153
|
-
|
|
3353
|
+
// The store COUNT appears only when a second store actually contributed. Saying "1 store" where
|
|
3354
|
+
// nothing was said before would break the byte-identity property for every existing user; with a
|
|
3355
|
+
// single store the store-location line below already names WHICH one.
|
|
3356
|
+
const storesRead = readGlobal ? `, ${storeCountLabel(2)}` : '';
|
|
3357
|
+
write(`dz recall "${shownQuery}" — ${hits.length} hit(s), ${ranking}${storesRead}`);
|
|
3358
|
+
write(storeLocationLine(describeStoreLocation(projectRoot, options.get('project')), 'read'));
|
|
3359
|
+
if (readGlobal) {
|
|
3360
|
+
// Naming only the project store while the header says "2 stores" would leave the reader
|
|
3361
|
+
// guessing which second one answered.
|
|
3362
|
+
write(` store (read): ${join(globalRoot, '.dz')} [cross-project]`);
|
|
3363
|
+
}
|
|
3154
3364
|
let sawQuarantined = false;
|
|
3155
3365
|
for (const h of hits) {
|
|
3156
3366
|
const backendTag = vectorOn ? ` ⟨${h.backend}⟩` : '';
|
|
@@ -3179,7 +3389,14 @@ async function cmdRecall(options, flags, cwd, write) {
|
|
|
3179
3389
|
const width = flags.has('full') ? Number.POSITIVE_INFINITY : 160;
|
|
3180
3390
|
const oneLined = oneLine(h.pattern.pattern);
|
|
3181
3391
|
const shown = width === Number.POSITIVE_INFINITY ? oneLined : oneLined.slice(0, width);
|
|
3182
|
-
|
|
3392
|
+
// WHICH store this hit came from. A merged list that does not say re-creates the fragmentation
|
|
3393
|
+
// blindness the store-location line just removed, one level down: the reader would see more
|
|
3394
|
+
// results and have no way to tell whether the global store is even connected.
|
|
3395
|
+
// Empty when only one store was read, so a single-store run stays byte-identical.
|
|
3396
|
+
const originTag = readGlobal && 'origin' in h
|
|
3397
|
+
? ` {${h.origin}}`
|
|
3398
|
+
: '';
|
|
3399
|
+
write(` [${h.pattern.reward.toFixed(2)}] (${oneLine(h.pattern.domain)})${backendAndSim}${qTag}${originTag} ${shown}`);
|
|
3183
3400
|
}
|
|
3184
3401
|
// The apply leg records itself. `dz recall` wrote NOTHING to the usage log — MEASURED 2026-08-24,
|
|
3185
3402
|
// 1106 rows before the call and 1106 after — so "how many lessons were recalled" was underivable,
|
|
@@ -3216,6 +3433,12 @@ async function cmdRecall(options, flags, cwd, write) {
|
|
|
3216
3433
|
// The loop stays VISIBLE (ADR D2): a quarantined hit is shown, marked, and explained.
|
|
3217
3434
|
write(' ⚠q = quarantined (unproven hypothesis, rank damped) — confirm with dz teach --reinforce, or dz recall --promote <dzId> --apply');
|
|
3218
3435
|
}
|
|
3436
|
+
if (banditBlock !== undefined) {
|
|
3437
|
+
// Say what the payoff term did — INCLUDING when it did nothing. `moved: 0` over many queries
|
|
3438
|
+
// means the feature is armed and inert, which is precisely the outcome nobody would notice
|
|
3439
|
+
// without this line.
|
|
3440
|
+
write(renderBanditNote(banditBlock));
|
|
3441
|
+
}
|
|
3219
3442
|
if (boost !== null && shownDomain !== undefined) {
|
|
3220
3443
|
// Say what the boost did — INCLUDING when it did nothing. A silent reorder would
|
|
3221
3444
|
// let the reader believe the ranking was domain-aware when it had no match to work with.
|
|
@@ -3288,7 +3511,15 @@ function renderHarmonize(report, write) {
|
|
|
3288
3511
|
* and `dz teach --harmonize` — ONE implementation, no bespoke second path (QR-6). Guards (AC-6):
|
|
3289
3512
|
* `--apply` + `--dry-run` together is rejected; `--threshold` must be in `(0, 1]`; no flag ⇒ dry-run.
|
|
3290
3513
|
*/
|
|
3291
|
-
async function runHarmonize(projectRoot, options, flags, write
|
|
3514
|
+
async function runHarmonize(projectRoot, options, flags, write,
|
|
3515
|
+
/**
|
|
3516
|
+
* Where this harmonize is pointed and what chose it. Under `--json` the human store line is
|
|
3517
|
+
* suppressed to keep stdout ONE document, so the destination has to travel INSIDE that document
|
|
3518
|
+
* or `--harmonize --apply --to global` mutates ~/.dz while revealing nothing (cross-family QE
|
|
3519
|
+
* round 3, 2026-08-27 — the round-2 fix closed a parse break and reopened the silent-store
|
|
3520
|
+
* hazard the whole feature exists to close).
|
|
3521
|
+
*/
|
|
3522
|
+
storeAnnotation) {
|
|
3292
3523
|
const apply = flags.has('apply');
|
|
3293
3524
|
if (apply && flags.has('dry-run')) {
|
|
3294
3525
|
write('dz vector harmonize: --apply and --dry-run are mutually exclusive');
|
|
@@ -3304,7 +3535,7 @@ async function runHarmonize(projectRoot, options, flags, write) {
|
|
|
3304
3535
|
}
|
|
3305
3536
|
const report = await harmonizeVectorStore(projectRoot, { apply, ...(threshold !== undefined ? { threshold } : {}) });
|
|
3306
3537
|
if (flags.has('json')) {
|
|
3307
|
-
write(JSON.stringify(report));
|
|
3538
|
+
write(JSON.stringify(storeAnnotation !== undefined ? { ...report, ...storeAnnotation } : report));
|
|
3308
3539
|
return report.error !== undefined ? 1 : 0;
|
|
3309
3540
|
}
|
|
3310
3541
|
renderHarmonize(report, write);
|
|
@@ -4193,7 +4424,7 @@ async function cmdSetup(options, flags, cwd, write, writeErr) {
|
|
|
4193
4424
|
// nothing writes to (audit code#3).
|
|
4194
4425
|
const wiring = setupResult.steps.find((s) => s.name === 'agentdb wiring');
|
|
4195
4426
|
const backendLabel = memoryOpt === 'agentdb'
|
|
4196
|
-
? (wiring?.status === 'done' ? 'agentdb (.dz/agentdb.db,
|
|
4427
|
+
? (wiring?.status === 'done' ? 'agentdb (.dz/agentdb.db + .dz/agentdb-mcp.db, separate stores)' : `agentdb INCOMPLETE — see setup steps`)
|
|
4197
4428
|
: 'sessions.jsonl + patterns.jsonl';
|
|
4198
4429
|
write(`║ Learning: ${backendLabel.padEnd(41)}║`);
|
|
4199
4430
|
write(`║ Hooks: ${flags.has('no-hooks') ? 'disabled' : 'session-start + session-end'}${' '.repeat(flags.has('no-hooks') ? 30 : 15)}║`);
|
|
@@ -4206,7 +4437,17 @@ async function cmdSetup(options, flags, cwd, write, writeErr) {
|
|
|
4206
4437
|
}
|
|
4207
4438
|
// A hook that was written but never witnessed firing is NOT a completed setup (ADR-002 §5): the
|
|
4208
4439
|
// step is reported failed, the process was not aborted.
|
|
4209
|
-
|
|
4440
|
+
//
|
|
4441
|
+
// …and neither is a setup that PRINTED an error step. The first version returned on codexHooksOk
|
|
4442
|
+
// alone, so `dz setup` could report `agentdb wiring: error` — the very invariant this feature
|
|
4443
|
+
// defends — and still exit 0, which is what a CI job reads (cross-family QE, Codex gpt-5.6-sol).
|
|
4444
|
+
// An exit code that disagrees with the report on screen is the same class of lie as a green run
|
|
4445
|
+
// on a stale build.
|
|
4446
|
+
const erroredSteps = setupResult.steps.filter((s) => s.status === 'error').map((s) => s.name);
|
|
4447
|
+
if (erroredSteps.length > 0) {
|
|
4448
|
+
write(`\n✗ setup reported ${erroredSteps.length} failed step(s): ${erroredSteps.join(', ')} — exit 1`);
|
|
4449
|
+
}
|
|
4450
|
+
return codexHooksOk && erroredSteps.length === 0 ? 0 : 1;
|
|
4210
4451
|
}
|
|
4211
4452
|
function cmdPretrain(options, cwd, write) {
|
|
4212
4453
|
const projectRoot = resolve(cwd, options.get('project') ?? '.');
|
|
@@ -4753,16 +4994,10 @@ function cmdVerifyPack(options, flags, cwd, write) {
|
|
|
4753
4994
|
write(`dz verify-pack: ${MANIFEST_NAME} is not valid JSON`);
|
|
4754
4995
|
return 1;
|
|
4755
4996
|
}
|
|
4756
|
-
//
|
|
4757
|
-
//
|
|
4758
|
-
|
|
4759
|
-
|
|
4760
|
-
shippedForVerify = npmPackedPaths(packDir);
|
|
4761
|
-
}
|
|
4762
|
-
catch {
|
|
4763
|
-
shippedForVerify = undefined;
|
|
4764
|
-
}
|
|
4765
|
-
const res = verifyManifest(packDir, signed, readFileSync(pubPath, 'utf8'), shippedForVerify);
|
|
4997
|
+
// The verifier sweeps the directory it was handed. A caller-supplied shipment list could omit an
|
|
4998
|
+
// unsigned file and turn the bidirectional check off, so verification has no narrowing option.
|
|
4999
|
+
// Release/publish verifies an extracted tarball separately; `verify-pack` verifies this exact tree.
|
|
5000
|
+
const res = verifyManifest(packDir, signed, readFileSync(pubPath, 'utf8'));
|
|
4766
5001
|
if (res.ok) {
|
|
4767
5002
|
write(`dz verify-pack: OK — ${packDir} matches its signed manifest`);
|
|
4768
5003
|
return 0;
|
|
@@ -7597,12 +7832,17 @@ async function cmdDownloads(cwd, write) {
|
|
|
7597
7832
|
* The pure engine lives in harness-core; this is the thin I/O shell (scan disk + git, render).
|
|
7598
7833
|
*/
|
|
7599
7834
|
function cmdArchitecture(options, flags, cwd, write) {
|
|
7600
|
-
// Resolve repo root:
|
|
7601
|
-
|
|
7835
|
+
// Resolve repo root: an explicit --project wins, then git toplevel, then cwd (matches cmdRoam /
|
|
7836
|
+
// cmdStats duality). The --project leg is the same fix as cmdProjectSkills got for field report
|
|
7837
|
+
// doc-25b, applied to its neighbour before it costs a second report: this command also reads an
|
|
7838
|
+
// `architecture/` tree, and on a feature-adr run against a target checkout the Step-0 product-vision
|
|
7839
|
+
// сверка silently no-ops for exactly that reason.
|
|
7840
|
+
const explicitRoot = (options.get('project') ?? '').trim();
|
|
7841
|
+
let repoRoot = explicitRoot !== '' ? resolve(cwd, explicitRoot) : cwd;
|
|
7602
7842
|
try {
|
|
7603
|
-
repoRoot = execSync('git rev-parse --show-toplevel', { cwd, encoding: 'utf-8' }).trim() ||
|
|
7843
|
+
repoRoot = execSync('git rev-parse --show-toplevel', { cwd: repoRoot, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || repoRoot;
|
|
7604
7844
|
}
|
|
7605
|
-
catch { /* not a git repo — use
|
|
7845
|
+
catch { /* not a git repo — use the root as given */ }
|
|
7606
7846
|
const manifest = loadSubsystemManifest(repoRoot);
|
|
7607
7847
|
if (!manifest) {
|
|
7608
7848
|
write('dz architecture: architecture/subsystems.manifest.json not found — run from the repo root.');
|
|
@@ -7640,12 +7880,22 @@ function cmdArchitecture(options, flags, cwd, write) {
|
|
|
7640
7880
|
* --stages-json { hasManifest, design, code, qe, report } — ready to thread into prompts
|
|
7641
7881
|
* No manifest ⇒ empty guidance strings ⇒ byte-identical run (FR-7).
|
|
7642
7882
|
*/
|
|
7643
|
-
function cmdProjectSkills(flags, cwd, write) {
|
|
7644
|
-
|
|
7883
|
+
function cmdProjectSkills(options, flags, cwd, write) {
|
|
7884
|
+
// `--project <dir>` names the root EXPLICITLY. Until 2026-08-25 this command took only `cwd`, so
|
|
7885
|
+
// the manifest was reachable exclusively from the workspace you happened to stand in — and because
|
|
7886
|
+
// the known-flag list is deliberately FLAT, `--project` passed validation on a command that never
|
|
7887
|
+
// read it: exit 0, no warning, no manifest. MEASURED that day: a feature-adr run against a target
|
|
7888
|
+
// repo probed with `cd REPO`, found nothing, and fell open to a generic run — the honest
|
|
7889
|
+
// `polymorphism:null` was recorded and not one project lens reached any stage.
|
|
7890
|
+
const explicitRoot = (options.get('project') ?? '').trim();
|
|
7891
|
+
let repoRoot = explicitRoot !== '' ? resolve(cwd, explicitRoot) : cwd;
|
|
7645
7892
|
try {
|
|
7646
|
-
|
|
7893
|
+
// stderr is SWALLOWED: a non-repo directory is an ordinary case here, and letting git print
|
|
7894
|
+
// "fatal: not a git repository" onto the operator's terminal made a working command look broken.
|
|
7895
|
+
// The sibling cmdFeatureAdrSetup already redirects; this one did not.
|
|
7896
|
+
repoRoot = execSync('git rev-parse --show-toplevel', { cwd: repoRoot, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || repoRoot;
|
|
7647
7897
|
}
|
|
7648
|
-
catch { /* not a git repo — use
|
|
7898
|
+
catch { /* not a git repo — use the root as given */ }
|
|
7649
7899
|
const plan = planProjectSkills(repoRoot);
|
|
7650
7900
|
const hasManifest = plan.injections.length > 0 || plan.skipped.length > 0;
|
|
7651
7901
|
if (flags.has('stages-json')) {
|
|
@@ -7739,6 +7989,8 @@ async function cmdMrRakes(options, flags, cwd, write) {
|
|
|
7739
7989
|
]);
|
|
7740
7990
|
if (options.has('project'))
|
|
7741
7991
|
opts.set('project', options.get('project'));
|
|
7992
|
+
if (options.has('to'))
|
|
7993
|
+
opts.set('to', options.get('to')); // same reason as in cmdRetro
|
|
7742
7994
|
await cmdTeach(opts, new Set(['guard']), cwd, write);
|
|
7743
7995
|
}
|
|
7744
7996
|
}
|
|
@@ -7774,10 +8026,27 @@ async function cmdRetro(options, flags, cwd, write) {
|
|
|
7774
8026
|
const events = streamSessionEvents(transcript);
|
|
7775
8027
|
const hits = detectProcessRakes(events);
|
|
7776
8028
|
// Ledger: count prior 'retro'-domain records per signature in the dz-teach store.
|
|
7777
|
-
|
|
8029
|
+
// It MUST be read from the store the co-learning write below will append to. A session mode
|
|
8030
|
+
// (`DZ_LEARN=global`) once split the two — the count was read from the project while the record
|
|
8031
|
+
// landed in the home store, so the recurrence never advanced and the drill threshold could never
|
|
8032
|
+
// be reached (cross-family QE, 2026-08-27). A read and its write resolve the same way, or the
|
|
8033
|
+
// counter they share is a fiction.
|
|
8034
|
+
let ledgerRoot;
|
|
8035
|
+
try {
|
|
8036
|
+
ledgerRoot = resolveLearningStore(options, cwd).storeRoot;
|
|
8037
|
+
}
|
|
8038
|
+
catch (e) {
|
|
8039
|
+
// Refuse HERE rather than after the whole retro has been rendered — the same refusal the
|
|
8040
|
+
// teach below would raise, but before any work is spent on it.
|
|
8041
|
+
if (e instanceof TeachTargetError) {
|
|
8042
|
+
write('dz retro: ' + e.message);
|
|
8043
|
+
return 1;
|
|
8044
|
+
}
|
|
8045
|
+
throw e;
|
|
8046
|
+
}
|
|
7778
8047
|
const ledger = new Map();
|
|
7779
8048
|
try {
|
|
7780
|
-
const records = loadStorePatternsSync(
|
|
8049
|
+
const records = loadStorePatternsSync(ledgerRoot).filter((r) => r.domain === RETRO_DOMAIN);
|
|
7781
8050
|
for (const sig of PROCESS_SIGNATURES) {
|
|
7782
8051
|
const lesson = retroLessonText(sig.id);
|
|
7783
8052
|
ledger.set(sig.id, records.filter((r) => r.pattern === lesson).length);
|
|
@@ -7804,6 +8073,12 @@ async function cmdRetro(options, flags, cwd, write) {
|
|
|
7804
8073
|
]);
|
|
7805
8074
|
if (options.has('project'))
|
|
7806
8075
|
opts.set('project', options.get('project'));
|
|
8076
|
+
// `--to` MUST travel with it. Without this the ledger above resolves with the flag and the
|
|
8077
|
+
// write below resolves without it, so `dz retro --to global` counted a global ledger while
|
|
8078
|
+
// recording into the project — the very split Decision 5 exists to close, reopened one level
|
|
8079
|
+
// down (cross-family QE round 2, 2026-08-27).
|
|
8080
|
+
if (options.has('to'))
|
|
8081
|
+
opts.set('to', options.get('to'));
|
|
7807
8082
|
await cmdTeach(opts, new Set(['guard', 'no-mirror']), cwd, write);
|
|
7808
8083
|
}
|
|
7809
8084
|
}
|
|
@@ -8455,25 +8730,47 @@ function cmdMutationGate(options, flags, cwd, write) {
|
|
|
8455
8730
|
// F-2 — rule-3 containment root: the scratch copy AS THE FILESYSTEM sees it. Every mutation
|
|
8456
8731
|
// write below is asserted to RESOLVE inside this root before it happens.
|
|
8457
8732
|
const realScratchRoot = realpathSync(copyDir);
|
|
8733
|
+
const requireCompletionReceipt = parsed.registry.requireCompletionReceipt === true;
|
|
8458
8734
|
const runSuite = () => {
|
|
8459
|
-
|
|
8460
|
-
|
|
8461
|
-
|
|
8462
|
-
|
|
8463
|
-
|
|
8464
|
-
|
|
8465
|
-
|
|
8466
|
-
|
|
8467
|
-
|
|
8468
|
-
|
|
8735
|
+
const run = spawnSync(testCmd, {
|
|
8736
|
+
cwd: copyDir,
|
|
8737
|
+
shell: true,
|
|
8738
|
+
encoding: 'utf-8',
|
|
8739
|
+
timeout,
|
|
8740
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
8741
|
+
env: { ...process.env, FORCE_COLOR: '0' },
|
|
8742
|
+
});
|
|
8743
|
+
const errorCode = run.error && 'code' in run.error && typeof run.error.code === 'string'
|
|
8744
|
+
? run.error.code
|
|
8745
|
+
: undefined;
|
|
8746
|
+
const signal = typeof run.signal === 'string' ? run.signal : undefined;
|
|
8747
|
+
let failureReason;
|
|
8748
|
+
if (typeof run.status !== 'number') {
|
|
8749
|
+
if (errorCode === 'ETIMEDOUT')
|
|
8750
|
+
failureReason = `timeout after ${timeout}ms${signal === undefined ? '' : `; signal=${signal}`}`;
|
|
8751
|
+
else if (errorCode === 'ENOBUFS')
|
|
8752
|
+
failureReason = 'maxBuffer exceeded (ENOBUFS; 67108864-byte output ceiling)';
|
|
8753
|
+
else if (signal !== undefined)
|
|
8754
|
+
failureReason = `child killed by signal ${signal}`;
|
|
8755
|
+
else if (errorCode !== undefined)
|
|
8756
|
+
failureReason = `spawn failure code ${errorCode}`;
|
|
8757
|
+
else
|
|
8758
|
+
failureReason = 'spawn failure with no error code or signal';
|
|
8469
8759
|
}
|
|
8760
|
+
return {
|
|
8761
|
+
exitCode: typeof run.status === 'number' ? run.status : null,
|
|
8762
|
+
// Receipt markers may be on stderr. Preserve both streams even on exit 0; stdout-only
|
|
8763
|
+
// collection would silently lose a green-run marker.
|
|
8764
|
+
output: `${String(run.stdout ?? '')}\n${String(run.stderr ?? '')}`,
|
|
8765
|
+
...(failureReason !== undefined ? { failureReason } : {}),
|
|
8766
|
+
};
|
|
8470
8767
|
};
|
|
8471
8768
|
// Baseline BEFORE any mutation: a red copy proves nothing, and reading it as a mutation
|
|
8472
8769
|
// result would be this gate shipping the defect class it exists to catch.
|
|
8473
8770
|
if (!json)
|
|
8474
8771
|
write(`mutation-gate: baseline suite in scratch copy of ${pkgDir} …`);
|
|
8475
8772
|
const base = runSuite();
|
|
8476
|
-
baseline = classifyBaseline(base.exitCode);
|
|
8773
|
+
baseline = classifyBaseline(base.exitCode, base.failureReason);
|
|
8477
8774
|
if (!baseline.ok) {
|
|
8478
8775
|
if (json) {
|
|
8479
8776
|
write(JSON.stringify({ packageDir: pkgDir, registryPath, testCommand: testCmd, baseline, results: [], exitCode: 2 }, null, 2));
|
|
@@ -8550,6 +8847,12 @@ function cmdMutationGate(options, flags, cwd, write) {
|
|
|
8550
8847
|
// 'file-load' ⇒ MUTATION_LOAD_FATAL (structural); 'unrecognised' ⇒ INCONCLUSIVE (a
|
|
8551
8848
|
// runner-coverage gap of this tool, loud, never PROVEN); 'assertions' ⇒ behavioural, the
|
|
8552
8849
|
// count-based verdicts apply.
|
|
8850
|
+
const completionReceipt = run === null ? undefined : detectSuiteCompletionReceipt(run.output);
|
|
8851
|
+
let receiptMismatch = run === null ? undefined : detectSuiteReceiptMismatch(run.output);
|
|
8852
|
+
if (run !== null && requireCompletionReceipt
|
|
8853
|
+
&& completionReceipt === undefined && receiptMismatch === undefined) {
|
|
8854
|
+
receiptMismatch = 'required mutation-suite-receipt-ok completion receipt missing';
|
|
8855
|
+
}
|
|
8553
8856
|
let fileLoadFailure;
|
|
8554
8857
|
let outputUnrecognised;
|
|
8555
8858
|
if (run !== null && run.exitCode !== null && run.exitCode !== 0) {
|
|
@@ -8563,14 +8866,17 @@ function cmdMutationGate(options, flags, cwd, write) {
|
|
|
8563
8866
|
}
|
|
8564
8867
|
// Route-b guard (per-entry mode): a red mutated run is attributable only if the RESTORED
|
|
8565
8868
|
// tree reproduces green — otherwise a flaky neighbour may be what went red. Skipped when the
|
|
8566
|
-
// classification already failed the entry
|
|
8567
|
-
// verdicts outrank the rebaseline check, so the extra suite run would buy nothing.
|
|
8869
|
+
// classification already failed the entry (file-load / unrecognised / receipt mismatch):
|
|
8870
|
+
// those verdicts outrank the rebaseline check, so the extra suite run would buy nothing.
|
|
8568
8871
|
let rebaselineExitCode;
|
|
8872
|
+
let rebaselineFailureReason;
|
|
8569
8873
|
if (rebaselineMode === 'per-entry' && run !== null && run.exitCode !== null && run.exitCode !== 0
|
|
8570
|
-
&& fileLoadFailure === undefined && outputUnrecognised === undefined) {
|
|
8874
|
+
&& fileLoadFailure === undefined && outputUnrecognised === undefined && receiptMismatch === undefined) {
|
|
8571
8875
|
if (!json)
|
|
8572
8876
|
write(`mutation-gate: ${entry.id} — re-baselining the restored tree …`);
|
|
8573
|
-
|
|
8877
|
+
const rebaselineRun = runSuite();
|
|
8878
|
+
rebaselineExitCode = rebaselineRun.exitCode;
|
|
8879
|
+
rebaselineFailureReason = rebaselineRun.failureReason;
|
|
8574
8880
|
}
|
|
8575
8881
|
const obs = {
|
|
8576
8882
|
entry,
|
|
@@ -8580,7 +8886,10 @@ function cmdMutationGate(options, flags, cwd, write) {
|
|
|
8580
8886
|
...(parseError !== undefined ? { parseError } : {}),
|
|
8581
8887
|
...(fileLoadFailure !== undefined ? { fileLoadFailure } : {}),
|
|
8582
8888
|
...(outputUnrecognised !== undefined ? { outputUnrecognised } : {}),
|
|
8889
|
+
...(receiptMismatch !== undefined ? { receiptMismatch } : {}),
|
|
8890
|
+
...(run?.failureReason !== undefined ? { runFailureReason: run.failureReason } : {}),
|
|
8583
8891
|
...(rebaselineExitCode !== undefined ? { rebaselineExitCode } : {}),
|
|
8892
|
+
...(rebaselineFailureReason !== undefined ? { rebaselineFailureReason } : {}),
|
|
8584
8893
|
};
|
|
8585
8894
|
observations.push(obs);
|
|
8586
8895
|
results.push(classifyMutationOutcome(obs));
|
|
@@ -8589,17 +8898,22 @@ function cmdMutationGate(options, flags, cwd, write) {
|
|
|
8589
8898
|
// verdict of this run is downgraded (any of them may have been the flake, and there is no
|
|
8590
8899
|
// per-entry evidence to say which) — re-classifying with the final exit turns them
|
|
8591
8900
|
// INCONCLUSIVE while leaving NOT_APPLIED / UNDEFENDED / MUTATION_UNPARSEABLE /
|
|
8592
|
-
// MUTATION_LOAD_FATAL untouched.
|
|
8901
|
+
// MUTATION_LOAD_FATAL / RECEIPT_MISMATCH untouched.
|
|
8593
8902
|
if (rebaselineMode === 'final') {
|
|
8594
8903
|
if (!json)
|
|
8595
8904
|
write('mutation-gate: final re-baseline of the restored tree …');
|
|
8596
|
-
const
|
|
8905
|
+
const finalRun = runSuite();
|
|
8906
|
+
const finalExit = finalRun.exitCode;
|
|
8597
8907
|
if (finalExit !== 0) {
|
|
8598
|
-
const what = finalExit === null ?
|
|
8908
|
+
const what = finalExit === null ? `no exit code: ${finalRun.failureReason ?? 'unknown timeout / spawn failure'}` : `exit ${finalExit}`;
|
|
8599
8909
|
warnings.push(`final re-baseline NOT green (${what}) — the suite is flaky; red-based verdicts downgraded to INCONCLUSIVE`);
|
|
8600
8910
|
if (!json)
|
|
8601
8911
|
write(`mutation-gate: final re-baseline NOT green (${what}) — red-based verdicts downgraded to INCONCLUSIVE`);
|
|
8602
|
-
const reclassified = observations.map((obs) => classifyMutationOutcome({
|
|
8912
|
+
const reclassified = observations.map((obs) => classifyMutationOutcome({
|
|
8913
|
+
...obs,
|
|
8914
|
+
rebaselineExitCode: finalExit,
|
|
8915
|
+
...(finalRun.failureReason !== undefined ? { rebaselineFailureReason: finalRun.failureReason } : {}),
|
|
8916
|
+
}));
|
|
8603
8917
|
results.length = 0;
|
|
8604
8918
|
results.push(...reclassified);
|
|
8605
8919
|
}
|
|
@@ -11772,10 +12086,15 @@ function cmdCompounding(options, flags, cwd, write) {
|
|
|
11772
12086
|
}
|
|
11773
12087
|
}
|
|
11774
12088
|
const report = assembleCompoundingReport({ lessons, usage, guard, nowTs: new Date().toISOString(), evidenceLogs });
|
|
12089
|
+
// lesson-bandit-rerank §11: the payoff axis joins THIS report rather than growing a private
|
|
12090
|
+
// dashboard — the `rewardEvents : exposureEvents` row asks exactly the question this command
|
|
12091
|
+
// already asks of the reinforcement loop (is the apply leg alive, or is it a write-only log?).
|
|
12092
|
+
// Read-only, and INSUFFICIENT_DATA on an absent state file — never a fake verdict.
|
|
12093
|
+
const bandit = banditStats(root);
|
|
11775
12094
|
if (json)
|
|
11776
|
-
write(JSON.stringify({ ...report, exitCode: 0 }, null, 2));
|
|
12095
|
+
write(JSON.stringify({ ...report, bandit, exitCode: 0 }, null, 2));
|
|
11777
12096
|
else
|
|
11778
|
-
write(renderCompoundingReport(report));
|
|
12097
|
+
write(`${renderCompoundingReport(report)}\n\n${renderBanditHealth(bandit)}`);
|
|
11779
12098
|
return 0;
|
|
11780
12099
|
}
|
|
11781
12100
|
// ── `dz epoch-replay` (feature epoch-replay) ────────────────────────────────────────────────────
|
|
@@ -13354,6 +13673,314 @@ async function cmdImportEcc(options, flags, cwd, write) {
|
|
|
13354
13673
|
write(`╚══════════════════════════════════════════════════════════════╝`);
|
|
13355
13674
|
return report.skippedError > 0 && report.imported === 0 ? 1 : 0;
|
|
13356
13675
|
}
|
|
13676
|
+
// ── dz profile (feature operator-profile, ADR-001) ─────────────────────────────────────────────
|
|
13677
|
+
//
|
|
13678
|
+
// WHO the assistant is talking to: a per-user store at ~/.dz/profile.json (0600, NEVER under a
|
|
13679
|
+
// project root) delivered as a marked block in ~/.claude/CLAUDE.md — loaded in every project on
|
|
13680
|
+
// the machine, dz installed or not. Four subcommands: init (five questions), show (path + age +
|
|
13681
|
+
// rendered block — the path ALWAYS prints, per the teach-target announce-the-store precedent),
|
|
13682
|
+
// set (register / language / teaches / deep|weak add|rm), sync (write the block; runs
|
|
13683
|
+
// automatically after init and set). An unknown register is REFUSED naming the accepted set —
|
|
13684
|
+
// never silently defaulted.
|
|
13685
|
+
/** The refusal line for a bad register — one place, so init/set refuse identically. */
|
|
13686
|
+
function profileRegisterRefusal(raw) {
|
|
13687
|
+
return `dz profile: unknown register ${JSON.stringify(raw)} — accepted: ${REGISTERS.join(' | ')} (или своими словами: профи | профи лайт | просто)`;
|
|
13688
|
+
}
|
|
13689
|
+
function profileRegisterEcho(register) {
|
|
13690
|
+
return `${register} (${registerOwnerWord(register)})`;
|
|
13691
|
+
}
|
|
13692
|
+
/** Shared epilogue of init/set/sync: push the block into ~/.claude/CLAUDE.md and say what happened. */
|
|
13693
|
+
function profileSyncAndReport(profile, write, writeErr) {
|
|
13694
|
+
const res = syncProfileBlock(profile);
|
|
13695
|
+
if (res.problem !== null) {
|
|
13696
|
+
writeErr(`dz profile: sync failed: ${res.problem}`);
|
|
13697
|
+
return 1;
|
|
13698
|
+
}
|
|
13699
|
+
if (res.changed) {
|
|
13700
|
+
const backupNote = res.backup === null ? '' : ' (backup: ' + res.backup + ')';
|
|
13701
|
+
write(`synced block into ${res.target}` + backupNote);
|
|
13702
|
+
}
|
|
13703
|
+
else {
|
|
13704
|
+
write(`block in ${res.target} already up to date`);
|
|
13705
|
+
}
|
|
13706
|
+
return 0;
|
|
13707
|
+
}
|
|
13708
|
+
async function cmdProfile(options, flags, write, writeErr) {
|
|
13709
|
+
const sub = options.get('_positional_0') ?? 'show';
|
|
13710
|
+
const json = flags.has('json');
|
|
13711
|
+
// --json contract (cross-family finding, 2026-08-28): stdout carries exactly ONE JSON document
|
|
13712
|
+
// per invocation. Before this, `set --json` printed a human field echo BEFORE the JSON and the
|
|
13713
|
+
// sync status line AFTER it, `sync --json` ignored json entirely and `init --json` emitted its
|
|
13714
|
+
// prompts to stdout — none of the three parsed. Human lines now go through `say` (dropped in
|
|
13715
|
+
// json mode) and every refusal in json mode IS the one document.
|
|
13716
|
+
const say = (line) => { if (!json)
|
|
13717
|
+
write(line); };
|
|
13718
|
+
const fail = (code, error) => {
|
|
13719
|
+
if (json)
|
|
13720
|
+
write(JSON.stringify({ ok: false, error }));
|
|
13721
|
+
else
|
|
13722
|
+
writeErr(error);
|
|
13723
|
+
return code;
|
|
13724
|
+
};
|
|
13725
|
+
if (sub === 'show') {
|
|
13726
|
+
const { profile, path, problem } = readProfile();
|
|
13727
|
+
if (profile === null) {
|
|
13728
|
+
// The path prints on EVERY exit — announcing the store is the point (ADR Decision 4).
|
|
13729
|
+
if (json) {
|
|
13730
|
+
write(JSON.stringify({ path, profile: null, problem }));
|
|
13731
|
+
return 1;
|
|
13732
|
+
}
|
|
13733
|
+
write(`store: ${path}`);
|
|
13734
|
+
writeErr(`dz profile: ${problem === 'missing' ? 'no profile yet — run `dz profile init`' : problem}`);
|
|
13735
|
+
return 1;
|
|
13736
|
+
}
|
|
13737
|
+
const age = profileAgeDays(profile);
|
|
13738
|
+
const drift = checkProfileDrift();
|
|
13739
|
+
const block = renderProfileBlock(profile);
|
|
13740
|
+
if (json) {
|
|
13741
|
+
write(JSON.stringify({ path, ageDays: age, drift, profile, block }));
|
|
13742
|
+
return 0;
|
|
13743
|
+
}
|
|
13744
|
+
write(`store: ${path}`);
|
|
13745
|
+
const ageNote = age === null ? '' : ' (' + age + (age === 1 ? ' day' : ' days') + ' ago)';
|
|
13746
|
+
write(`updated: ${profile.updatedAt}` + ageNote);
|
|
13747
|
+
write(`register: ${profileRegisterEcho(profile.register)} · language: ${profile.language} · teaches: ${profile.teaches ? 'yes' : 'no'}`);
|
|
13748
|
+
write(`block: ${drift.verdict}` + (drift.verdict === 'in-sync' ? '' : ' — ' + drift.detail));
|
|
13749
|
+
write('');
|
|
13750
|
+
write(block);
|
|
13751
|
+
return 0;
|
|
13752
|
+
}
|
|
13753
|
+
if (sub === 'init') {
|
|
13754
|
+
// Five questions, node:readline, under two minutes. An existing profile pre-fills every
|
|
13755
|
+
// default (domains included) — init is safe to re-run; Enter keeps what is stored.
|
|
13756
|
+
const existing = readProfile().profile;
|
|
13757
|
+
const { createInterface } = await import('node:readline');
|
|
13758
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
13759
|
+
// Buffered ask, not rl.question(): with PIPED stdin the answer lines can all arrive while no
|
|
13760
|
+
// question is pending, and readline DROPS a 'line' nobody listens for — the next question()
|
|
13761
|
+
// then never settles and the process dies with an unsettled top-level await (MEASURED on the
|
|
13762
|
+
// first smoke of this command, exit 13). Buffering every line makes piped `printf 'a\nb\nc' |
|
|
13763
|
+
// dz profile init` and an interactive TTY behave identically. null = stdin closed early.
|
|
13764
|
+
const buffered = [];
|
|
13765
|
+
let pendingAsk = null;
|
|
13766
|
+
let stdinClosed = false;
|
|
13767
|
+
rl.on('line', (line) => {
|
|
13768
|
+
if (pendingAsk !== null) {
|
|
13769
|
+
const resolve = pendingAsk;
|
|
13770
|
+
pendingAsk = null;
|
|
13771
|
+
resolve(line);
|
|
13772
|
+
}
|
|
13773
|
+
else
|
|
13774
|
+
buffered.push(line);
|
|
13775
|
+
});
|
|
13776
|
+
rl.on('close', () => {
|
|
13777
|
+
stdinClosed = true;
|
|
13778
|
+
if (pendingAsk !== null) {
|
|
13779
|
+
const resolve = pendingAsk;
|
|
13780
|
+
pendingAsk = null;
|
|
13781
|
+
resolve(null);
|
|
13782
|
+
}
|
|
13783
|
+
});
|
|
13784
|
+
const ask = (prompt) => {
|
|
13785
|
+
// In --json mode the prompts go to STDERR — stdout must stay exactly one JSON document.
|
|
13786
|
+
(json ? process.stderr : process.stdout).write(prompt);
|
|
13787
|
+
if (buffered.length > 0)
|
|
13788
|
+
return Promise.resolve(buffered.shift());
|
|
13789
|
+
if (stdinClosed)
|
|
13790
|
+
return Promise.resolve(null);
|
|
13791
|
+
return new Promise((resolve) => { pendingAsk = resolve; });
|
|
13792
|
+
};
|
|
13793
|
+
const eofRefusal = () => fail(1, 'dz profile: stdin closed before the five questions were answered — nothing written');
|
|
13794
|
+
// Domain question: an EXISTING list renders as the default an Enter keeps; on a fresh
|
|
13795
|
+
// profile Enter honestly means "none" — onboarding never forces an answer.
|
|
13796
|
+
const askDomains = async (prompt, current) => {
|
|
13797
|
+
const hint = current.length > 0 ? ' [' + domainListText(current) + ']' : ' (Enter — пропустить)';
|
|
13798
|
+
const answer = await ask(prompt + hint + ': ');
|
|
13799
|
+
if (answer === null)
|
|
13800
|
+
return null;
|
|
13801
|
+
if (answer.trim() === '')
|
|
13802
|
+
return current;
|
|
13803
|
+
return parseDomainList(answer);
|
|
13804
|
+
};
|
|
13805
|
+
// y/n: an unrecognised answer RE-ASKS once; a second unrecognised answer takes the
|
|
13806
|
+
// documented default and SAYS so. Measured defect this closes: a domains line fed to the
|
|
13807
|
+
// old y/n question silently became `teaches: no`.
|
|
13808
|
+
const askYesNo = async (prompt, dflt) => {
|
|
13809
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
13810
|
+
const answer = await ask(prompt + ' y/n [' + (dflt ? 'y' : 'n') + ']: ');
|
|
13811
|
+
if (answer === null)
|
|
13812
|
+
return null;
|
|
13813
|
+
if (answer.trim() === '')
|
|
13814
|
+
return dflt;
|
|
13815
|
+
const parsed = parseYesNo(answer);
|
|
13816
|
+
if (parsed !== null)
|
|
13817
|
+
return parsed;
|
|
13818
|
+
if (attempt === 0)
|
|
13819
|
+
writeErr('dz profile: did not understand ' + JSON.stringify(answer.trim()) + ' — answer y or n (да/нет)');
|
|
13820
|
+
}
|
|
13821
|
+
(json ? writeErr : write)('unrecognised again — taking the default: ' + (dflt ? 'yes' : 'no'));
|
|
13822
|
+
return dflt;
|
|
13823
|
+
};
|
|
13824
|
+
try {
|
|
13825
|
+
const langDefault = existing?.language ?? 'ru';
|
|
13826
|
+
const langAnswer = await ask(`1/5 Dialogue language (ru, en, …) [${langDefault}]: `);
|
|
13827
|
+
if (langAnswer === null)
|
|
13828
|
+
return eofRefusal();
|
|
13829
|
+
const language = langAnswer.trim() === '' ? langDefault : langAnswer.trim();
|
|
13830
|
+
const regDefault = existing?.register ?? 'pro-lite';
|
|
13831
|
+
let register = null;
|
|
13832
|
+
while (register === null) {
|
|
13833
|
+
const regAnswer = await ask(`2/5 Default register — pro / pro-lite / plain (профи / профи лайт / просто) [${profileRegisterEcho(regDefault)}]: `);
|
|
13834
|
+
if (regAnswer === null)
|
|
13835
|
+
return eofRefusal();
|
|
13836
|
+
const regRaw = regAnswer.trim();
|
|
13837
|
+
if (regRaw === '') {
|
|
13838
|
+
register = regDefault;
|
|
13839
|
+
break;
|
|
13840
|
+
}
|
|
13841
|
+
register = parseRegister(regRaw);
|
|
13842
|
+
if (register === null)
|
|
13843
|
+
writeErr(profileRegisterRefusal(regRaw));
|
|
13844
|
+
}
|
|
13845
|
+
// ADR Decision 2: a single register dial cannot express the owner — the domain lists ARE
|
|
13846
|
+
// the profile, and the weak list is the load-bearing one. So onboarding asks for BOTH,
|
|
13847
|
+
// phrased so no self-assessment is needed (the design-report wording, tested with the owner).
|
|
13848
|
+
const deepDomains = await askDomains('3/5 Назовите 2–4 области, где вам НЕ нужно пояснять термины (сертификации, чем занимались 5+ лет) — через запятую', existing?.deepDomains ?? []);
|
|
13849
|
+
if (deepDomains === null)
|
|
13850
|
+
return eofRefusal();
|
|
13851
|
+
const weakDomains = await askDomains('4/5 Где наоборот — терминам нужна одна поясняющая фраза? — через запятую', existing?.weakDomains ?? []);
|
|
13852
|
+
if (weakDomains === null)
|
|
13853
|
+
return eofRefusal();
|
|
13854
|
+
const teaches = await askYesNo('5/5 Do you teach — must explanations be re-tellable?', existing?.teaches ?? true);
|
|
13855
|
+
if (teaches === null)
|
|
13856
|
+
return eofRefusal();
|
|
13857
|
+
const profile = {
|
|
13858
|
+
version: 1,
|
|
13859
|
+
updatedAt: new Date().toISOString(),
|
|
13860
|
+
language,
|
|
13861
|
+
register,
|
|
13862
|
+
deepDomains,
|
|
13863
|
+
weakDomains,
|
|
13864
|
+
teaches,
|
|
13865
|
+
};
|
|
13866
|
+
const initWrite = writeProfile(profile);
|
|
13867
|
+
if (initWrite.problem !== undefined)
|
|
13868
|
+
return fail(2, `dz profile: ${initWrite.problem}`);
|
|
13869
|
+
const { path } = initWrite;
|
|
13870
|
+
say(`wrote ${path} (0600) — register ${profileRegisterEcho(register)}, language ${language}, teaches ${teaches ? 'yes' : 'no'}`);
|
|
13871
|
+
say('deep: ' + (deepDomains.length > 0 ? domainListText(deepDomains) : '(none)') + ' · weak: ' + (weakDomains.length > 0 ? domainListText(weakDomains) : '(none)'));
|
|
13872
|
+
if (deepDomains.length === 0 && weakDomains.length === 0) {
|
|
13873
|
+
say('no domains yet — the profile is a single dial until you add them: `dz profile set deep add <tag> [note]` · `dz profile set weak add <tag>`');
|
|
13874
|
+
}
|
|
13875
|
+
if (json) {
|
|
13876
|
+
const sync = syncProfileBlock(profile);
|
|
13877
|
+
write(JSON.stringify({ ok: sync.problem === null, path, profile, sync }));
|
|
13878
|
+
return sync.problem === null ? 0 : 1;
|
|
13879
|
+
}
|
|
13880
|
+
return profileSyncAndReport(profile, write, writeErr);
|
|
13881
|
+
}
|
|
13882
|
+
finally {
|
|
13883
|
+
rl.close();
|
|
13884
|
+
}
|
|
13885
|
+
}
|
|
13886
|
+
if (sub === 'set') {
|
|
13887
|
+
const read = readProfile();
|
|
13888
|
+
if (read.profile === null) {
|
|
13889
|
+
return fail(1, 'dz profile: ' + (read.problem === 'missing' ? 'no profile at ' + read.path + ' — run `dz profile init` first' : read.path + ': ' + read.problem));
|
|
13890
|
+
}
|
|
13891
|
+
const key = options.get('_positional_1') ?? '';
|
|
13892
|
+
const val = options.get('_positional_2') ?? '';
|
|
13893
|
+
let next;
|
|
13894
|
+
// The field echo is DEFERRED until writeProfile succeeds (round-5 P2-3): `dz profile set
|
|
13895
|
+
// language '<marker>'` used to print `language: <marker>` BEFORE the write check exited 2 —
|
|
13896
|
+
// a success-looking confirmation of a mutation that was never applied. In --json mode nothing
|
|
13897
|
+
// changes: echoes go through `say`, which json drops.
|
|
13898
|
+
let echo;
|
|
13899
|
+
if (key === 'register') {
|
|
13900
|
+
const register = parseRegister(val);
|
|
13901
|
+
if (register === null) {
|
|
13902
|
+
return fail(2, profileRegisterRefusal(val));
|
|
13903
|
+
}
|
|
13904
|
+
next = { ...read.profile, register };
|
|
13905
|
+
echo = `register: ${profileRegisterEcho(register)}`;
|
|
13906
|
+
}
|
|
13907
|
+
else if (key === 'language') {
|
|
13908
|
+
if (val.trim() === '') {
|
|
13909
|
+
return fail(2, 'dz profile: usage: dz profile set language <code>');
|
|
13910
|
+
}
|
|
13911
|
+
next = { ...read.profile, language: val.trim() };
|
|
13912
|
+
echo = `language: ${next.language}`;
|
|
13913
|
+
}
|
|
13914
|
+
else if (key === 'teaches') {
|
|
13915
|
+
const t = val.trim().toLowerCase();
|
|
13916
|
+
if (!['on', 'off', 'true', 'false', 'yes', 'no', 'y', 'n', 'да', 'нет'].includes(t)) {
|
|
13917
|
+
return fail(2, 'dz profile: usage: dz profile set teaches on|off');
|
|
13918
|
+
}
|
|
13919
|
+
next = { ...read.profile, teaches: ['on', 'true', 'yes', 'y', 'да'].includes(t) };
|
|
13920
|
+
echo = `teaches: ${next.teaches ? 'yes' : 'no'}`;
|
|
13921
|
+
}
|
|
13922
|
+
else if (key === 'deep' || key === 'weak') {
|
|
13923
|
+
const op = val;
|
|
13924
|
+
const tag = (options.get('_positional_3') ?? '').trim();
|
|
13925
|
+
const note = options.get('_positional_4');
|
|
13926
|
+
const listKey = key === 'deep' ? 'deepDomains' : 'weakDomains';
|
|
13927
|
+
const list = read.profile[listKey];
|
|
13928
|
+
if (op === 'add') {
|
|
13929
|
+
if (tag === '') {
|
|
13930
|
+
return fail(2, `dz profile: usage: dz profile set ${key} add <tag> [note]`);
|
|
13931
|
+
}
|
|
13932
|
+
const rest = list.filter((d) => d.tag !== tag);
|
|
13933
|
+
const domain = note === undefined || note.trim() === '' ? { tag } : { tag, note: note.trim() };
|
|
13934
|
+
next = { ...read.profile, [listKey]: [...rest, domain] };
|
|
13935
|
+
echo = key + ': + ' + (domain.note ? tag + ' (' + domain.note + ')' : tag);
|
|
13936
|
+
}
|
|
13937
|
+
else if (op === 'rm') {
|
|
13938
|
+
if (tag === '') {
|
|
13939
|
+
return fail(2, `dz profile: usage: dz profile set ${key} rm <tag>`);
|
|
13940
|
+
}
|
|
13941
|
+
if (!list.some((d) => d.tag === tag)) {
|
|
13942
|
+
return fail(1, `dz profile: no ${key} domain ${JSON.stringify(tag)} — have: ${list.map((d) => d.tag).join(', ') || '(none)'}`);
|
|
13943
|
+
}
|
|
13944
|
+
next = { ...read.profile, [listKey]: list.filter((d) => d.tag !== tag) };
|
|
13945
|
+
echo = `${key}: - ${tag}`;
|
|
13946
|
+
}
|
|
13947
|
+
else {
|
|
13948
|
+
return fail(2, `dz profile: usage: dz profile set ${key} add|rm <tag> [note]`);
|
|
13949
|
+
}
|
|
13950
|
+
}
|
|
13951
|
+
else {
|
|
13952
|
+
return fail(2, 'dz profile: usage: dz profile set register|language|teaches|deep|weak …');
|
|
13953
|
+
}
|
|
13954
|
+
const stamped = { ...next, updatedAt: new Date().toISOString() };
|
|
13955
|
+
const setWrite = writeProfile(stamped);
|
|
13956
|
+
// A refused write must never fall through to sync: syncing an unwritten profile would put the
|
|
13957
|
+
// poisoned value into CLAUDE.md while the store still holds the old one (round-4 fix). And it
|
|
13958
|
+
// must never have echoed either — the echo below only runs on an APPLIED mutation.
|
|
13959
|
+
if (setWrite.problem !== undefined)
|
|
13960
|
+
return fail(2, `dz profile: ${setWrite.problem}`);
|
|
13961
|
+
say(echo);
|
|
13962
|
+
const { path } = setWrite;
|
|
13963
|
+
if (json) {
|
|
13964
|
+
const sync = syncProfileBlock(stamped);
|
|
13965
|
+
write(JSON.stringify({ ok: sync.problem === null, path, profile: stamped, sync }));
|
|
13966
|
+
return sync.problem === null ? 0 : 1;
|
|
13967
|
+
}
|
|
13968
|
+
return profileSyncAndReport(stamped, write, writeErr);
|
|
13969
|
+
}
|
|
13970
|
+
if (sub === 'sync') {
|
|
13971
|
+
const read = readProfile();
|
|
13972
|
+
if (read.profile === null) {
|
|
13973
|
+
return fail(1, 'dz profile: ' + (read.problem === 'missing' ? 'no profile at ' + read.path + ' — run `dz profile init` first' : read.path + ': ' + read.problem));
|
|
13974
|
+
}
|
|
13975
|
+
if (json) {
|
|
13976
|
+
const res = syncProfileBlock(read.profile);
|
|
13977
|
+
write(JSON.stringify({ ok: res.problem === null, target: res.target, changed: res.changed, backup: res.backup, problem: res.problem }));
|
|
13978
|
+
return res.problem === null ? 0 : 1;
|
|
13979
|
+
}
|
|
13980
|
+
return profileSyncAndReport(read.profile, write, writeErr);
|
|
13981
|
+
}
|
|
13982
|
+
return fail(2, `dz profile: unknown subcommand ${JSON.stringify(sub)} — accepted: init | show | set | sync`);
|
|
13983
|
+
}
|
|
13357
13984
|
export async function runCli(argv, io = {}) {
|
|
13358
13985
|
const cwd = io.cwd ?? process.cwd();
|
|
13359
13986
|
const write = io.write ?? ((line) => { console.log(line); });
|
|
@@ -13511,7 +14138,7 @@ export async function runCli(argv, io = {}) {
|
|
|
13511
14138
|
case 'architecture':
|
|
13512
14139
|
return cmdArchitecture(options, flags, cwd, write);
|
|
13513
14140
|
case 'project-skills':
|
|
13514
|
-
return cmdProjectSkills(flags, cwd, write);
|
|
14141
|
+
return cmdProjectSkills(options, flags, cwd, write);
|
|
13515
14142
|
case 'mr-rakes':
|
|
13516
14143
|
return await cmdMrRakes(options, flags, cwd, write);
|
|
13517
14144
|
case 'retro':
|
|
@@ -13538,6 +14165,8 @@ export async function runCli(argv, io = {}) {
|
|
|
13538
14165
|
return cmdRecap(options, flags, cwd, write);
|
|
13539
14166
|
case 'cadence':
|
|
13540
14167
|
return cmdCadence(options, flags, cwd, write);
|
|
14168
|
+
case 'qe-rounds':
|
|
14169
|
+
return cmdQeRounds(options, flags, cwd, write);
|
|
13541
14170
|
case 'tg-post':
|
|
13542
14171
|
return cmdTgPost(options, flags, cwd, write);
|
|
13543
14172
|
case 'name-check':
|
|
@@ -13550,6 +14179,8 @@ export async function runCli(argv, io = {}) {
|
|
|
13550
14179
|
return cmdAmendmentCheck(options, flags, cwd, write);
|
|
13551
14180
|
case 'feature-adr-checkpoint':
|
|
13552
14181
|
return cmdFeatureAdrCheckpoint(options, flags, cwd, write);
|
|
14182
|
+
case 'profile':
|
|
14183
|
+
return await cmdProfile(options, flags, write, writeErr);
|
|
13553
14184
|
case 'reqe':
|
|
13554
14185
|
return cmdReqe(options, flags, cwd, write);
|
|
13555
14186
|
case 'qe-bridge':
|