@dzhechkov/harness-cli 0.3.254 → 0.3.256

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 CHANGED
@@ -9,7 +9,7 @@
9
9
  },
10
10
  {
11
11
  "path": "README.md",
12
- "sha256": "fc57c105e5031d2d6751ef08b64769dc463cd79c5cf7637b48bc8cb7190e5b3a"
12
+ "sha256": "5f73e2c6d9c0ea38d5324f058cbd921bdb28dab60aa97ded97979ed994426731"
13
13
  },
14
14
  {
15
15
  "path": "coverage/coverage-final.json",
@@ -41,15 +41,15 @@
41
41
  },
42
42
  {
43
43
  "path": "dist/cli.d.ts.map",
44
- "sha256": "4268f56e38f72625c2c490add4b17ee12d7ff7db991a2090f426afbb026b98a9"
44
+ "sha256": "18d5f55ed3c56718e470fef8bd677c55de08f4505e188b1dd29a0f0bbec09d4b"
45
45
  },
46
46
  {
47
47
  "path": "dist/cli.js",
48
- "sha256": "fe24568930cc226e0bad8b4ccba559e2f837bb4cb068c2cc37750b5a10c27a70"
48
+ "sha256": "c47d11fd3ed7e6049b1080cd9292ed3498e113d8bc74a9af65a027d70fa96435"
49
49
  },
50
50
  {
51
51
  "path": "dist/cli.js.map",
52
- "sha256": "4a0b554fab78cd50fc46cff653411b2003b7dd9a4df1b16af6e9f5a0e24379ff"
52
+ "sha256": "684ea0c6fa4edbbbea1ffcd943167cd374166eb196100433d4e8d02d70560794"
53
53
  },
54
54
  {
55
55
  "path": "dist/index.d.ts",
@@ -73,7 +73,7 @@
73
73
  },
74
74
  {
75
75
  "path": "package.json",
76
- "sha256": "c6469d0f7539ce6c0a8176d0b795952a667df2b975c9f93c66e7cd6eba843383"
76
+ "sha256": "154755702a4652597bf8ca1410f9a3303b76c6a8dae8dacd867921a88c7b3626"
77
77
  },
78
78
  {
79
79
  "path": "src/bin.ts",
@@ -81,7 +81,7 @@
81
81
  },
82
82
  {
83
83
  "path": "src/cli.ts",
84
- "sha256": "13d3946992186e019310c1c8666fd724ce74880b8fda19850eeb8b6e0399e3d7"
84
+ "sha256": "0dce827134ab5f7aaf55bd76325b5e1e17881770310e64e0d66bafbedc91687e"
85
85
  },
86
86
  {
87
87
  "path": "src/index.ts",
@@ -95,6 +95,10 @@
95
95
  "path": "test/epoch-replay-cli.test.ts",
96
96
  "sha256": "a5a48585b565288ca1ea96030b02b2ccb37a621ef7b3cfe54cd7aaa7164d6904"
97
97
  },
98
+ {
99
+ "path": "test/guard-promote-cli.test.ts",
100
+ "sha256": "f8e35085106a98be239104394539c3aec350123eed4a115abf14acc2cb6b5bb9"
101
+ },
98
102
  {
99
103
  "path": "tsconfig.json",
100
104
  "sha256": "e80ef2911c3347a713dc3dca6a5f6034d91e938e58e71345ea920dd9833bcdbb"
@@ -105,5 +109,5 @@
105
109
  }
106
110
  ]
107
111
  },
108
- "signature": "lRzsYJCin4HX25rutyyh0Veo7d29P+AOTtJyKdgPcBrzIY44kdm7AHYRYCKcTrH5esXg8nVfhxd1Uw74PjtZAg=="
112
+ "signature": "5nXiD5ii68CSKw5EWFT2WXxbELjZYvHBup3O5E4oLDcTszEC2kyuHXBy7VBzcrkgzNtICsxp8+hJJMcGXa2+Cw=="
109
113
  }
package/README.md CHANGED
@@ -890,6 +890,7 @@ dz backlog <sub> add "<idea>" | list | show <id> | goals [--validate] | roul
890
890
  dz sign --init --out <path> | --pack <dir> --key <path> # --init: generate the Ed25519 keypair (private OUTSIDE the repo, prints the public key for keys/dz.pub); else sign a pack's manifest + CycloneDX SBOM
891
891
  dz sbom --pack <dir> [--out <file>] # emit the CycloneDX 1.5 SBOM for a pack standalone (file-level bill of materials); print to stdout or write to a file
892
892
  dz guard check --op <publish|teach|consolidate|reindex> [--text <s>] [--json] [--force <reason>] # declarative constraint layer before self-mutating ops: HARD violation → block (exit 1), SOFT → warn; zero-config defaults, .dz/guard.json to customise; dz guard --init | dz guard log (append-only audit). dz publish runs it automatically (--no-guard "<reason>" = logged escape hatch)
893
+ dz guard promote [--dry-run | --apply] [--window-days <N>] [--periods <N>] [--json] # lesson → guard-rule promotion: ranks lessons by firings × cost, SHADOW-replays each candidate over real commits, and proposes a rule only after TWO consecutive wins AND two window-lengths of REAL elapsed time since first observation (a window with zero firings resets the counter; a thin window is skipped, never a loss; commit dates are author-supplied, so elapsed time is measured by the local clock in .dz/promotion-state.json). Proposes by default (--dry-run writes nothing — and so never starts that clock; --apply installs it SOFT — a promoted rule can never block, and a same-id/different-body clash is a CONFLICT that exits 1 rather than claiming success); promotions AND refusals are recorded in features/guard-promotion/promotions/
893
894
  dz feature-adr-setup --guards [--loc-cap <n>] [--apply] # P3: scaffold DETERMINISTIC guard tests into the project — guards.config.json + a zero-dependency check.mjs runner (LOC cap, secret scan, frozen-file sha256 pins, waivers-with-reasons); wire `node architecture/guards/check.mjs` into CI
894
895
  dz publish [--filter <name>] [--bump-only] [--claim-check <off|warn|error>] (dry-run by default; pass --yes/--confirm to go live; claim-check gate defaults to warn — surfaces README claim findings, never blocks)
895
896
  dz parity [--target <name>] [--json] # honest feature×target map COMPUTED from the capability model — full / manual (via which form) / absent, per target
@@ -2085,6 +2086,79 @@ zero specifiers, and then report every real dependency as "not recorded". The ru
2085
2086
  `.dz/guard.json` cannot promote it to HARD, since a parser designed to admit "I may not understand this
2086
2087
  file" must never be able to block a publish.
2087
2088
 
2089
+ #### `dz guard promote` — when a LESSON has earned the right to become a rule
2090
+
2091
+ `dz compounding` MEASURED the uncomfortable half of the learning loop (reproducer: `dz compounding
2092
+ --json` on this repo, 2026-07-29): 27 of 154 lessons had ever been injected — ~82 % write-only —
2093
+ while the lessons that *did* become guard rules collapsed their own violation rate
2094
+ (`no-workspace-star` 31 → 0, `readme-first` 49 → 4). Rules compound; notes-to-self do not.
2095
+ `dz guard promote` is the elevator between them — the cost-of-detection ladder made executable.
2096
+
2097
+ ```bash
2098
+ dz guard promote --dry-run # inspect only: computes and prints, writes NOTHING
2099
+ dz guard promote # writes the proposal + refusal documents (never .dz/guard.json)
2100
+ dz guard promote --apply # installs the promoted rule(s) — SOFT severity, always
2101
+ dz guard promote --window-days 30 --periods 4 # widen the evidence window for a rarer violation
2102
+ ```
2103
+ ```
2104
+ dz guard promote — lesson → guard-rule promotion (two consecutive shadow wins required)
2105
+
2106
+ corpus: 1 lesson(s) · 0 quarantined · 13 change(s) over 4 × 7d window(s)
2107
+
2108
+ RANKED CANDIDATES (score = firings × cost, cost = 1 + lesson uses — cost is a PROXY, not a token figure):
2109
+ ★ [ 12] promoted-pairing-package-json-changelog-md PROMOTE
2110
+ promote: 2 consecutive shadow win(s) over 2 evaluated period(s), 12 real firing(s)
2111
+ periods (oldest→newest): –0/0 –0/0 W7/7 W5/6
2112
+ evidence: 24f0d72ab2be: package.json changed without any **/CHANGELOG.md
2113
+
2114
+ WROTE: features/guard-promotion/promotions/001-promoted-pairing-package-json-changelog-md.md
2115
+ Nothing was written to .dz/guard.json — re-run with --apply to install the promoted rule(s).
2116
+ ```
2117
+
2118
+ **A candidate must win twice, on real history.** Each lesson is reduced to a fixed *rule template*
2119
+ (`pairing-check` = X changed without Y · `absence-check` · `format-match`), then **shadow-replayed
2120
+ over actual commits** in consecutive time windows. A window in which the check would have fired on a
2121
+ real commit is a *win*; a window with zero firings is a *loss* and **resets the counter**; a window
2122
+ with fewer than 5 commits is *skipped*, never counted against it. Two consecutive wins are required,
2123
+ and every win cites a commit sha you can `git show`. Windows are wall-clock, so re-running the
2124
+ command can never manufacture a win — the counter is recomputed from history, never accumulated.
2125
+
2126
+ **And it must have been watched for two real window-lengths.** Commit timestamps are author-supplied
2127
+ (`GIT_COMMITTER_DATE`, a rebase, a repo import, clock skew), so history that *looks* like two windows
2128
+ can be laid down in one afternoon. A second clock closes that: promotion also requires
2129
+ `now − firstSeen ≥ 2 × --window-days` of real elapsed time, measured by the local clock and
2130
+ journalled in `.dz/promotion-state.json` the first time the candidate is recorded. Consequences worth
2131
+ knowing up front: a brand-new repo waits 14 days for its first promotion however strong the evidence,
2132
+ and a `--dry-run`-only workflow never promotes, because the run that would start the clock is exactly
2133
+ the one that writes nothing. (The `wait` reason spells both out.) This is a guard against accidental
2134
+ self-gaming, not a cryptographic one — the state file is local and editable.
2135
+
2136
+ **It refuses out loud.** Rule code is never synthesised from lesson text — a lesson that fits no
2137
+ template is listed as not-promotable *with the reason*, and the refusals are recorded in
2138
+ `features/guard-promotion/promotions/` alongside the promotions. A candidate already covered by an
2139
+ existing rule is refused as a duplicate naming that rule; a quarantined lesson (an unproven
2140
+ hypothesis) is refused but still told what it *would* become, so the list reads as a roadmap:
2141
+ ```
2142
+ ✗ [ 0] promoted-pairing-package-json-pnpm-lock-yaml NOT-PROMOTABLE
2143
+ not-promotable: quarantined (an unproven hypothesis must not become an enforced rule) — it WOULD
2144
+ classify as pairing-check, and is already covered by 'lockfile-in-sync'; confirm it with
2145
+ `dz teach --reinforce` to make it eligible
2146
+ ```
2147
+
2148
+ **A promoted rule can never block.** It is written `soft`, and `resolveRules` forces SOFT for every
2149
+ template-backed rule — a hand-edited `"severity": "hard"` in `.dz/guard.json` is ignored. It is a real
2150
+ rule, though, not an inert config entry: it fires in `dz guard check --op publish` from the next run.
2151
+
2152
+ **`--apply` compares rule BODIES, not ids.** Re-applying an identical rule is a no-op and exits 0.
2153
+ An existing `.dz/guard.json` rule that shares the derived id but has a different body is a
2154
+ **conflict**: nothing is written, the clash is printed on a `✗ CONFLICT` line (and in the JSON
2155
+ `conflicts[]`), and the command exits **1** — a zero exit for work that was deliberately not done
2156
+ would be the same kind of lie the feature exists to remove.
2157
+
2158
+ *When to use it:* after a stretch of work that taught you something mechanical ("X must change with
2159
+ Y"), run `dz guard promote --dry-run` at session end. If a candidate has two wins, `--apply` it and
2160
+ that class of mistake becomes a layer-1 check instead of something you have to remember.
2161
+
2088
2162
  ### `dz feature-adr-setup --guards` — when a project rule should be a TEST, not a reviewer's memory
2089
2163
 
2090
2164
  A 700-line cap enforced by reviewer judgment catches one file and misses its sibling in the same change;
package/dist/cli.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAwVH,2EAA2E;AAC3E,MAAM,WAAW,KAAK;IACpB,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC;;;;OAIG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB;;;;;OAKG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,iBAAiB,CAAC;CAC5C;AAED,yFAAyF;AACzF,MAAM,MAAM,iBAAiB,GAAG,CAC9B,GAAG,EAAE,MAAM,EACX,IAAI,EAAE;IAAE,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;CAAE,KACvD;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC;AAopN9E,wBAAsB,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,GAAE,KAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CA4J5E"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAuWH,2EAA2E;AAC3E,MAAM,WAAW,KAAK;IACpB,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC;;;;OAIG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB;;;;;OAKG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE,iBAAiB,CAAC;CAC5C;AAED,yFAAyF;AACzF,MAAM,MAAM,iBAAiB,GAAG,CAC9B,GAAG,EAAE,MAAM,EACX,IAAI,EAAE;IAAE,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;CAAE,KACvD;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC;AA4gO9E,wBAAsB,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,GAAE,KAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CA4J5E"}
package/dist/cli.js CHANGED
@@ -6,10 +6,12 @@
6
6
  import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmdirSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
7
7
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
- import { execSync, spawn } from 'node:child_process';
9
+ import { execFileSync, execSync, spawn } from 'node:child_process';
10
10
  import { homedir, tmpdir } from 'node:os';
11
11
  import { createRequire } from 'node:module';
12
- import { createSkill, getSkillInfo, getWorkflow, isTargetName, listSkills, runDoctor, runInit, benchmarkSkill, benchmarkSkills, scanMcp, reconcileCapabilities, RECONCILE_BANNER, buildRegistry, discoverSkillPackDirs, checkUpstream, compareSkills, checkAllUpstream, sweepSkillDrift, syncCanonicalSkill, checkUpgrades, discoverPackages, discoverSourcePackages, fetchAllDownloads, filterByCategory, pretrain, recommend, generatePlugin, publishPackages, runSetup, runMigrate, searchRegistry, runSync, runVerify, runInitAgentsMd, runInitGeminiMd, TARGET_NAMES, buildParityMatrix, TARGET_CAPABILITIES, TARGET_SHORT_LABELS, WORKFLOW_NAMES, 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, deriveUsageCalibration, normalizeClaudeUsageModelKey, readUsageLimits, claimCheck, summarize, queryBookKnowledge, loadStorePatternsSync, patternRecordId, loadStoreRecords, recordToPattern, bundleSkills, brainHome, listBrain, promoteProjectToBrain, updateBrainSource, queryBrain, groundPrompt, expandKu, reindexBrainVectors, buildPrimer, exportBrainSlice, importBrainSlice, registerKusToBrain, RECALL_USAGE_LOG_RELATIVE, RECALL_USAGE_LOG_MAX_BYTES, parseRecallUsageLog, buildRecallUsageReport, buildManifest, buildSbom, resolveTrustRoot, decideVerifyPolicy, generateSigningKeypair, evaluateGuard, resolveRules, auditRecord, guardExitCode, DEFAULT_RULES, parsePnpmLockImporters, 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, pickAdversaryModel, CHALLENGE_QUESTIONS, loadOutcomes, renderOutcomes, statsForKey, selectAutoCost, recordProvisional, finalizeOutcome, COST_LADDER, splitScenarios, budgetPlan, selectWinner, proseScopeOk, renderProseDiff, readScenarioIds, DEFAULT_MAX_JUDGE_RUNS, collectDeliveryFacts, planDeliveryCheck, renderDeliveryBrief, classifyDelivery, isUsablePlaneResult, renderDeliveryReview, scanSkillsLayout, parseInitFacts, verifyRegistration, buildContentProbePrompt, classifyContentProbe, renderContentProbe, findNonRegistrableSkillDirs, assembleCompoundingReport,
12
+ import { createSkill, getSkillInfo, getWorkflow, isTargetName, listSkills, runDoctor, runInit, benchmarkSkill, benchmarkSkills, scanMcp, reconcileCapabilities, RECONCILE_BANNER, buildRegistry, discoverSkillPackDirs, checkUpstream, compareSkills, checkAllUpstream, sweepSkillDrift, syncCanonicalSkill, checkUpgrades, discoverPackages, discoverSourcePackages, fetchAllDownloads, filterByCategory, pretrain, recommend, generatePlugin, publishPackages, runSetup, runMigrate, searchRegistry, runSync, runVerify, runInitAgentsMd, runInitGeminiMd, TARGET_NAMES, buildParityMatrix, TARGET_CAPABILITIES, TARGET_SHORT_LABELS, WORKFLOW_NAMES, 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, deriveUsageCalibration, normalizeClaudeUsageModelKey, readUsageLimits, claimCheck, summarize, queryBookKnowledge, loadStorePatternsSync, patternRecordId, loadStoreRecords, recordToPattern, bundleSkills, brainHome, listBrain, promoteProjectToBrain, updateBrainSource, queryBrain, groundPrompt, expandKu, reindexBrainVectors, buildPrimer, exportBrainSlice, importBrainSlice, registerKusToBrain, RECALL_USAGE_LOG_RELATIVE, RECALL_USAGE_LOG_MAX_BYTES, parseRecallUsageLog, buildRecallUsageReport, buildManifest, buildSbom, resolveTrustRoot, decideVerifyPolicy, generateSigningKeypair, evaluateGuard, resolveRules, auditRecord, guardExitCode, DEFAULT_RULES, parsePnpmLockImporters,
13
+ // guard-promotion (feature guard-promotion, scout idea #1)
14
+ 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, pickAdversaryModel, CHALLENGE_QUESTIONS, loadOutcomes, renderOutcomes, statsForKey, selectAutoCost, recordProvisional, finalizeOutcome, COST_LADDER, splitScenarios, budgetPlan, selectWinner, proseScopeOk, renderProseDiff, readScenarioIds, DEFAULT_MAX_JUDGE_RUNS, collectDeliveryFacts, planDeliveryCheck, renderDeliveryBrief, classifyDelivery, isUsablePlaneResult, renderDeliveryReview, scanSkillsLayout, parseInitFacts, verifyRegistration, buildContentProbePrompt, classifyContentProbe, renderContentProbe, findNonRegistrableSkillDirs, assembleCompoundingReport,
13
15
  // Cold-vs-warm EPOCH RUNNER (feature epoch-replay) — orchestrates + scores, never calls a model.
14
16
  replayableInstances, buildWorkOrder, buildJudgePrompts, unblindJudgments, verifyWorkOrder, isValidMargin, DIGEST_HONEST_SCOPE, scoreEpochReplay, generateMockOutcomes, renderEpochReplayResult, renderWorkOrderSummary, renderJudgePromptsSummary, WORK_ORDER_KIND, DEFAULT_MOCK_N, DEFAULT_MOCK_SEED, scoreRun, renderScorecard, renderCompoundingReport, readReinforcementState, readQuarantineState, registrationExitCode, renderRegistrationReport,
15
17
  // Smart Backlog (feature smart-backlog) — goal-directed idea pipeline over the Brain vector engine.
@@ -4605,6 +4607,47 @@ function gatherGuardFacts(op, root, text, storeCap) {
4605
4607
  catch {
4606
4608
  facts['lockfile'] = { parsed: false }; /* no lockfile (not a pnpm workspace) — rule stays silent */
4607
4609
  }
4610
+ // change: the working-tree diff, for PROMOTED (template) rules. Without this fact a rule written
4611
+ // by `dz guard promote --apply` would be INERT — present in the config and enforcing nothing.
4612
+ // Contents are read only for the globs an active `format-match` rule actually asks about.
4613
+ try {
4614
+ const status = execSync('git status --porcelain', { cwd: root, encoding: 'utf-8' });
4615
+ const files = status
4616
+ .split('\n')
4617
+ .map((l) => l.slice(3).trim())
4618
+ .map((p) => (p.includes(' -> ') ? p.split(' -> ')[1].trim() : p)) // renames: the destination is the changed path
4619
+ .filter((p) => p !== '');
4620
+ const formatGlobs = (Array.isArray(loadGuardConfig(root).rules) ? loadGuardConfig(root).rules : [])
4621
+ .filter((r) => r?.template === 'format-match' && typeof r?.params?.file === 'string')
4622
+ .map((r) => r.params.file);
4623
+ const contents = {};
4624
+ if (formatGlobs.length > 0) {
4625
+ for (const f of files) {
4626
+ if (!formatGlobs.some((g) => globMatch(g, f)))
4627
+ continue;
4628
+ const abs = resolve(root, f);
4629
+ // Containment: a `git status` path is repo-relative, but `..` in one must never let the
4630
+ // LIVE reader step outside the repo the HISTORICAL reader is confined to.
4631
+ if (abs !== root && !abs.startsWith(root + sep))
4632
+ continue;
4633
+ try {
4634
+ // lstat, NOT stat (Codex QE MED-3). `git show <sha>:<path>` yields the SYMLINK TARGET
4635
+ // TEXT, never the file it points at, so a live reader that follows links answers a
4636
+ // different question than the replay — and `/dev/zero` behind a symlink hangs the read.
4637
+ // Skipping non-regular files restores replay/live equivalence and closes the DoS.
4638
+ const st = lstatSync(abs);
4639
+ if (!st.isFile())
4640
+ continue;
4641
+ if (st.size > MAX_CONTENT_BYTES)
4642
+ continue; // too large to be a spec file — undecidable, never guessed
4643
+ contents[f] = readFileSync(abs, 'utf8');
4644
+ }
4645
+ catch { /* deleted — leave it undecidable, never guess */ }
4646
+ }
4647
+ }
4648
+ facts['change'] = { files, ...(Object.keys(contents).length > 0 ? { contents } : {}) };
4649
+ }
4650
+ catch { /* not a git repo — every template rule stays silent (fail-open) */ }
4608
4651
  }
4609
4652
  if (op === 'consolidate') {
4610
4653
  try {
@@ -4648,6 +4691,373 @@ function runGuardEvaluation(root, op, text, overrideReason) {
4648
4691
  catch { /* audit is best-effort, never blocks the verdict */ }
4649
4692
  return result;
4650
4693
  }
4694
+ // ── `dz guard promote` (feature guard-promotion, scout idea #1) ─────────────────────────────────
4695
+ const PROMOTIONS_DIR = join('features', 'guard-promotion', 'promotions');
4696
+ const PROMOTION_STATE_FILE = join('.dz', 'promotion-state.json');
4697
+ /**
4698
+ * Ceiling on a single file read, applied IDENTICALLY to the historical (`git show`) and live
4699
+ * (working-tree) readers. A `format-match` target is a spec/manifest file; anything larger is not
4700
+ * one, and an unbounded read of a symlinked `/dev/zero` is a hang, not a measurement.
4701
+ */
4702
+ const MAX_CONTENT_BYTES = 1024 * 1024;
4703
+ const GUARD_PROMOTE_USAGE = [
4704
+ 'dz guard promote [--project <dir>] [--json] [--dry-run | --apply]',
4705
+ ' [--window-days <N>] [--periods <N>] [--limit <N>]',
4706
+ ].join('\n ');
4707
+ /**
4708
+ * Read real commit history as {@link ChangeSet}s — the shadow-replay corpus. `--name-only` gives the
4709
+ * change shape every v1 template consumes. Merges are excluded (their file list is a union of the
4710
+ * branches, not a decision anyone made).
4711
+ *
4712
+ * A missing/failing `git` yields `[]`, which makes every candidate `insufficient-data` — the command
4713
+ * still exits 0 and says why. No history is not a promotion.
4714
+ */
4715
+ function readGitChanges(root, sinceIso) {
4716
+ let out = '';
4717
+ try {
4718
+ // execFileSync (argv form), NOT a shell string: `--name-only` paths come straight from the repo,
4719
+ // and a filename containing `$(…)` or a backtick would EXPAND inside a double-quoted shell
4720
+ // argument. `-z` is not used because the pretty header needs line framing; the argv form removes
4721
+ // the shell entirely instead.
4722
+ out = execFileSync('git', ['log', `--since=${sinceIso}`, '--no-merges', '--name-only', '--pretty=format:%x01%H%x09%cI'], {
4723
+ cwd: root,
4724
+ encoding: 'utf-8',
4725
+ maxBuffer: 64 * 1024 * 1024,
4726
+ });
4727
+ }
4728
+ catch {
4729
+ return [];
4730
+ }
4731
+ const changes = [];
4732
+ let current = null;
4733
+ for (const raw of out.split('\n')) {
4734
+ if (raw.startsWith('\x01')) {
4735
+ if (current)
4736
+ changes.push(current);
4737
+ const [id, ts] = raw.slice(1).split('\t');
4738
+ current = { id: (id ?? '').slice(0, 12), ts: ts ?? '', files: [] };
4739
+ continue;
4740
+ }
4741
+ const line = raw.trim();
4742
+ if (line === '' || current === null)
4743
+ continue;
4744
+ current.files.push(line);
4745
+ }
4746
+ if (current)
4747
+ changes.push(current);
4748
+ return changes;
4749
+ }
4750
+ /**
4751
+ * Attach file text at each historical commit for the `format-match` candidates that need it.
4752
+ *
4753
+ * HARD CAP (`MAX_CONTENT_FETCHES`): over it we STOP fetching, which leaves those changes without
4754
+ * contents, which makes `templateFires` return `undecidable`, which makes the candidate
4755
+ * `insufficient-data`. What we deliberately do NOT do is fall back to the file's CURRENT content —
4756
+ * evaluating a historical commit against today's file is exactly the fabricated-win shape ADR-002
4757
+ * refused for `presence-check`.
4758
+ */
4759
+ function attachChangeContents(root, changes, globs) {
4760
+ if (globs.length === 0)
4761
+ return [...changes];
4762
+ let budget = MAX_CONTENT_FETCHES;
4763
+ return changes.map((c) => {
4764
+ const wanted = c.files.filter((f) => globs.some((g) => globMatch(g, f)));
4765
+ if (wanted.length === 0)
4766
+ return c;
4767
+ const contents = {};
4768
+ for (const f of wanted) {
4769
+ if (budget <= 0)
4770
+ return c; // over cap ⇒ leave this change undecidable, never guess
4771
+ budget -= 1;
4772
+ try {
4773
+ // argv form, NOT a shell string: a repo path is untrusted input and `$(…)`/backticks would
4774
+ // expand inside a quoted shell argument. No `--` terminator — `git show -- <rev>:<path>`
4775
+ // exits 0 with EMPTY output (the terminator turns the rev-with-path into a pathspec). The
4776
+ // option-smuggling risk it would have covered is absent anyway: the argument always begins
4777
+ // with a 12-hex sha.
4778
+ const text = execFileSync('git', ['show', `${c.id}:${f}`], { cwd: root, encoding: 'utf-8', maxBuffer: MAX_CONTENT_BYTES });
4779
+ // Same size ceiling as the live reader, so replay and live agree on what is too big to judge.
4780
+ if (text.length > MAX_CONTENT_BYTES)
4781
+ return c;
4782
+ // EMPTY OUTPUT IS NOT CONTENT. git reported this path as changed in this commit, so an
4783
+ // empty body is far more likely a failed lookup than a genuinely empty file — and treating
4784
+ // it as content is the worst possible failure: `''.includes(x)` is false, so the rule would
4785
+ // FIRE on every single file and fabricate a clean sweep of wins. Undecidable instead.
4786
+ if (text === '')
4787
+ return c;
4788
+ contents[f] = text;
4789
+ }
4790
+ catch {
4791
+ return c; // deleted/renamed at that commit — undecidable, not clean
4792
+ }
4793
+ }
4794
+ return { ...c, contents };
4795
+ });
4796
+ }
4797
+ /** Every rule the engine would run: the built-ins plus any template rules already in `.dz/guard.json`. */
4798
+ function existingRuleViews(root) {
4799
+ const cfg = loadGuardConfig(root);
4800
+ const configRules = Array.isArray(cfg.rules) ? cfg.rules : [];
4801
+ const disabled = new Set(configRules.filter((r) => r?.enabled === false && typeof r.id === 'string').map((r) => r.id));
4802
+ // A built-in the operator has DISABLED does not cover anything — otherwise the promoter would
4803
+ // refuse a candidate as a duplicate of a rule that is not running, and the gap would stay open.
4804
+ const views = DEFAULT_RULES.filter((r) => !disabled.has(r.id)).map((r) => ({ id: r.id }));
4805
+ for (const o of configRules) {
4806
+ if (typeof o?.id !== 'string' || o.enabled === false)
4807
+ continue;
4808
+ // A rule op-scoped AWAY from publish covers nothing a change-shaped promotion targets — letting
4809
+ // it suppress a candidate as a "duplicate" keeps the gap open (Codex re-QE MED, mirror of the
4810
+ // disabled-builtin rationale above).
4811
+ const ops = o.ops;
4812
+ if (Array.isArray(ops) && !ops.includes('publish'))
4813
+ continue;
4814
+ if (views.some((v) => v.id === o.id))
4815
+ continue;
4816
+ views.push({ id: o.id, ...(typeof o.template === 'string' ? { template: o.template } : {}), ...(o.params && typeof o.params === 'object' ? { params: o.params } : {}) });
4817
+ }
4818
+ return views;
4819
+ }
4820
+ /** Atomic JSON write — tmp + rename, so a crash mid-write never leaves a half-parsed state file. */
4821
+ function writeJsonAtomic(path, value) {
4822
+ mkdirSync(dirname(path), { recursive: true });
4823
+ const tmp = `${path}.tmp.${process.pid}`;
4824
+ writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, 'utf-8');
4825
+ renameSync(tmp, path);
4826
+ }
4827
+ /** The rolled-up refusal record (ADR-004): one file, regenerated in place, one row per lesson. */
4828
+ function renderNotPromotableRollup(report, nowTs) {
4829
+ const refused = report.candidates.filter((c) => c.verdict === 'not-promotable');
4830
+ const out = [];
4831
+ out.push('# 000 — Not promotable (rolled-up refusal record)');
4832
+ out.push('');
4833
+ out.push(`**Decision:** REFUSED · **Date:** ${nowTs} · **Count:** ${refused.length} of ${report.totalLessons} lesson(s)`);
4834
+ out.push('');
4835
+ out.push('These lessons do not reduce to a v1 `dz guard promote` rule template. Rule code is NEVER');
4836
+ out.push('synthesised from lesson text (ADR-002), so a lesson that fits no template is refused aloud');
4837
+ out.push('rather than force-fitted. This file is regenerated in place on every run.');
4838
+ out.push('');
4839
+ out.push('| lesson | reason | first 90 chars |');
4840
+ out.push('|---|---|---|');
4841
+ for (const c of refused) {
4842
+ const t = c.lessonText.replace(/\|/g, '\\|').replace(/\n/g, ' ').slice(0, 90);
4843
+ out.push(`| \`${c.lessonId}\` | ${c.reason.replace(/\|/g, '\\|')} | ${t} |`);
4844
+ }
4845
+ return out.join('\n') + '\n';
4846
+ }
4847
+ /**
4848
+ * `dz guard promote` — lesson → guard-rule promotion with a "win twice to promote" gate.
4849
+ *
4850
+ * Thin by design: gather (lessons, existing rules, real commit history, state) → the PURE
4851
+ * `assembleCandidates` → render → write. Default PROPOSES (documents + journal only); `--dry-run`
4852
+ * writes nothing at all; `--apply` is the only path that touches `.dz/guard.json`, always SOFT.
4853
+ */
4854
+ function cmdGuardPromote(options, flags, root, write) {
4855
+ const json = flags.has('json');
4856
+ const fail = (msg) => {
4857
+ write(json ? JSON.stringify({ error: msg, exitCode: 1 }) : `dz guard promote: ${msg}\n usage: ${GUARD_PROMOTE_USAGE}`);
4858
+ return 1;
4859
+ };
4860
+ for (const f of flags)
4861
+ if (!['json', 'dry-run', 'apply', 'help'].includes(f))
4862
+ return fail(`unknown option --${f}`);
4863
+ for (const k of options.keys()) {
4864
+ if (k === '_positional_0')
4865
+ continue; // the `promote` subcommand token itself
4866
+ if (k.startsWith('_positional_'))
4867
+ return fail(`unexpected argument "${options.get(k)}"`);
4868
+ if (!['project', 'window-days', 'periods', 'limit'].includes(k))
4869
+ return fail(`unknown option --${k}`);
4870
+ }
4871
+ if (flags.has('help')) {
4872
+ write(`dz guard promote — promote a learned lesson to a deterministic guard rule\n usage: ${GUARD_PROMOTE_USAGE}`);
4873
+ write(' A candidate must SHADOW-WIN twice consecutively over real commit history before it is proposed.');
4874
+ write(' Default: writes proposal/refusal documents only. --dry-run: writes nothing. --apply: writes the SOFT rule into .dz/guard.json.');
4875
+ return 0;
4876
+ }
4877
+ const dryRun = flags.has('dry-run');
4878
+ const apply = flags.has('apply');
4879
+ // NOT a silent precedence: two contradictory intents is an error, not a coin flip.
4880
+ if (dryRun && apply)
4881
+ return fail('--dry-run and --apply are mutually exclusive');
4882
+ const num = (key, dflt, lo, hi) => {
4883
+ const raw = options.get(key);
4884
+ if (raw === undefined)
4885
+ return dflt;
4886
+ const n = Number(raw);
4887
+ if (!Number.isFinite(n) || !Number.isInteger(n) || n < lo || n > hi)
4888
+ return null;
4889
+ return n;
4890
+ };
4891
+ const windowDays = num('window-days', DEFAULT_WINDOW_DAYS, 1, 365);
4892
+ if (windowDays === null)
4893
+ return fail('--window-days expects an integer in [1, 365]');
4894
+ const periods = num('periods', DEFAULT_PERIODS, 2, 52);
4895
+ if (periods === null)
4896
+ return fail('--periods expects an integer in [2, 52]');
4897
+ const limit = num('limit', 15, 1, 1000);
4898
+ if (limit === null)
4899
+ return fail('--limit expects an integer in [1, 1000]');
4900
+ const nowTs = new Date().toISOString();
4901
+ const sinceIso = new Date(Date.now() - windowDays * periods * 86_400_000).toISOString();
4902
+ // lessons — the SAME readers `dz compounding` uses; no second store.
4903
+ const lessons = loadStoreRecords(root).map((r) => ({
4904
+ dzId: r.id,
4905
+ text: typeof r.text === 'string' ? r.text : '',
4906
+ quarantined: readQuarantineState(r).quarantined,
4907
+ uses: readReinforcementState(r).uses,
4908
+ }));
4909
+ const existingRules = existingRuleViews(root);
4910
+ const changes = readGitChanges(root, sinceIso);
4911
+ // State is read on EVERY run, including --dry-run, because it carries the LOCAL-clock `firstSeen`
4912
+ // the elapsed gate reads (MED-7). --dry-run still writes nothing — which is exactly why a
4913
+ // dry-run-only workflow never starts that clock, and the wait reason says so.
4914
+ const state = normalizePromotionState((() => {
4915
+ try {
4916
+ return JSON.parse(readFileSync(join(root, PROMOTION_STATE_FILE), 'utf-8'));
4917
+ }
4918
+ catch {
4919
+ return null;
4920
+ }
4921
+ })());
4922
+ const firstSeen = {};
4923
+ for (const [id, e] of Object.entries(state.entries))
4924
+ if (e.firstSeenTs !== '')
4925
+ firstSeen[id] = e.firstSeenTs;
4926
+ // Pass 1 discovers which `format-match` candidates exist, so contents are fetched only for the
4927
+ // globs that actually need them (and only up to the cap).
4928
+ const pass1 = assembleCandidates({ lessons, existingRules, changes, nowTs, windowDays, periods, firstSeen });
4929
+ const formatGlobs = [...new Set(pass1.candidates.filter((c) => c.template === 'format-match' && typeof c.params?.file === 'string').map((c) => c.params.file))];
4930
+ const report = formatGlobs.length === 0
4931
+ ? pass1
4932
+ : assembleCandidates({ lessons, existingRules, changes: attachChangeContents(root, changes, formatGlobs), nowTs, windowDays, periods, firstSeen });
4933
+ // ── write side ────────────────────────────────────────────────────────────────────────────────
4934
+ const written = [];
4935
+ const applied = [];
4936
+ const conflicts = [];
4937
+ if (!dryRun) {
4938
+ const adrSeqs = {};
4939
+ let seq = state.nextAdrSeq;
4940
+ let allocated = 0;
4941
+ for (const c of report.candidates) {
4942
+ if (c.verdict !== 'promote' && c.verdict !== 'duplicate')
4943
+ continue;
4944
+ const key = c.ruleId;
4945
+ // ONE document per CANDIDATE, not per run — but the reused thing is the integer SEQUENCE, never
4946
+ // a path. State is attacker-shaped input (a JSON file anyone can corrupt), and a path taken
4947
+ // from it and handed to writeFileSync overwrites whatever it names — with no `--apply`, no
4948
+ // promotion, and no way to notice. The path is DERIVED from the validated id + that integer.
4949
+ const existingSeq = state.entries[key]?.adrSeq;
4950
+ const useSeq = existingSeq ?? seq;
4951
+ const rel = promotionAdrRelPath(key, useSeq);
4952
+ if (rel === null)
4953
+ continue; // an id or sequence that fails validation writes NOTHING
4954
+ if (existingSeq === undefined) {
4955
+ seq += 1;
4956
+ allocated += 1;
4957
+ }
4958
+ // Belt to the derivation's braces: resolve and assert containment before writing. A derivation
4959
+ // that is correct today is not a substitute for checking the thing you are about to write.
4960
+ const abs = resolve(root, rel);
4961
+ const dir = resolve(root, PROMOTIONS_DIR);
4962
+ if (abs !== dir && !abs.startsWith(dir + sep))
4963
+ continue;
4964
+ try {
4965
+ mkdirSync(dirname(abs), { recursive: true });
4966
+ // Lexical containment is not PHYSICAL containment (Codex re-QE HIGH): a symlinked
4967
+ // promotions/ directory (or a symlink planted at the ADR leaf) redirects the write outside
4968
+ // the repo while every string check passes. realpath the directory that actually exists on
4969
+ // disk and require it to be the real promotions dir under the real root; refuse a leaf that
4970
+ // is a symlink.
4971
+ const realDir = realpathSync(dirname(abs));
4972
+ const expectedReal = join(realpathSync(root), PROMOTIONS_DIR.split('/').join(sep));
4973
+ if (realDir !== expectedReal)
4974
+ continue;
4975
+ if (existsSync(abs) && lstatSync(abs).isSymbolicLink())
4976
+ continue;
4977
+ writeFileSync(abs, renderPromotionAdr(c, useSeq, nowTs), 'utf-8');
4978
+ adrSeqs[key] = useSeq;
4979
+ written.push(rel);
4980
+ }
4981
+ catch { /* a document we cannot write must not lose the verdict */ }
4982
+ }
4983
+ if (report.candidates.some((c) => c.verdict === 'not-promotable')) {
4984
+ const rel = join(PROMOTIONS_DIR, '000-not-promotable.md');
4985
+ try {
4986
+ mkdirSync(join(root, PROMOTIONS_DIR), { recursive: true });
4987
+ writeFileSync(join(root, rel), renderNotPromotableRollup(report, nowTs), 'utf-8');
4988
+ written.push(rel);
4989
+ }
4990
+ catch { /* best-effort */ }
4991
+ }
4992
+ if (apply) {
4993
+ const cfg = loadGuardConfig(root);
4994
+ const rules = Array.isArray(cfg.rules) ? [...cfg.rules] : [];
4995
+ for (const c of report.candidates) {
4996
+ if (c.verdict !== 'promote' || c.proposedRule === null)
4997
+ continue;
4998
+ const want = c.proposedRule;
4999
+ const clash = rules.find((r) => r?.id === want.id);
5000
+ if (clash !== undefined) {
5001
+ // ID EQUALITY IS NOT IDEMPOTENCE (Codex QE MED-5). A rule that merely SHARES the id — a
5002
+ // hand-written bare entry, or a same-id rule with different params — is not the rule we
5003
+ // are promoting. Skipping it silently reports "applied" while installing nothing (the bare
5004
+ // entry does not even enforce, since resolveRules drops an unknown id with no template).
5005
+ // Same id + same BODY is genuine idempotence; same id + different body is a conflict, and a
5006
+ // conflict is refused out loud rather than resolved by guessing which side to keep.
5007
+ const sameBody = clash.template === want.template && JSON.stringify(clash.params ?? null) === JSON.stringify(want.params);
5008
+ // Same body but DISABLED (or op-scoped away from publish) is NOT idempotence: the rule
5009
+ // exists on paper and enforces nothing — "already installed" would be a false success
5010
+ // (Codex re-QE MED). Refuse loudly so the operator re-enables or removes it.
5011
+ const clashEnabled = clash.enabled !== false;
5012
+ const clashOps = clash.ops;
5013
+ const clashCoversPublish = !Array.isArray(clashOps) || clashOps.includes('publish');
5014
+ if (sameBody && clashEnabled && clashCoversPublish)
5015
+ continue; // already installed AND active — genuine idempotence
5016
+ if (sameBody) {
5017
+ conflicts.push(`${want.id}: an identical rule exists in .dz/guard.json but is ${clashEnabled ? 'op-scoped away from publish' : 'DISABLED'} — it enforces nothing; re-enable it (or remove it and re-run --apply) instead of trusting a rule that is not running`);
5018
+ continue;
5019
+ }
5020
+ conflicts.push(`${want.id}: an existing .dz/guard.json rule shares this id but has a different body (existing template=${JSON.stringify(clash.template ?? null)} params=${JSON.stringify(clash.params ?? null)}; promoted template=${JSON.stringify(want.template)} params=${JSON.stringify(want.params)}) — refusing to overwrite or to claim success; rename or remove the existing rule`);
5021
+ continue;
5022
+ }
5023
+ rules.push(want);
5024
+ applied.push(want.id);
5025
+ }
5026
+ if (applied.length > 0)
5027
+ writeJsonAtomic(join(root, '.dz', 'guard.json'), { ...cfg, rules });
5028
+ }
5029
+ const next = nextPromotionState(state, report, nowTs, adrSeqs, allocated);
5030
+ const withApplied = applied.length === 0 ? next : {
5031
+ ...next,
5032
+ entries: Object.fromEntries(Object.entries(next.entries).map(([k, v]) => (applied.includes(k) ? [k, { ...v, appliedTs: nowTs }] : [k, v]))),
5033
+ };
5034
+ try {
5035
+ writeJsonAtomic(join(root, PROMOTION_STATE_FILE), withApplied);
5036
+ }
5037
+ catch { /* best-effort */ }
5038
+ }
5039
+ // A refused conflict means the requested apply did NOT fully happen — exit non-zero rather than
5040
+ // let a zero exit report success for work that was deliberately not done.
5041
+ const exitCode = conflicts.length > 0 ? 1 : 0;
5042
+ if (json) {
5043
+ write(JSON.stringify({ ...report, mode: dryRun ? 'dry-run' : apply ? 'apply' : 'propose', written, applied, conflicts, exitCode }, null, 2));
5044
+ return exitCode;
5045
+ }
5046
+ write(renderPromotionReport(report, limit));
5047
+ write('');
5048
+ if (dryRun)
5049
+ write(' MODE: --dry-run — nothing was written (not even .dz/promotion-state.json)');
5050
+ else {
5051
+ write(` WROTE: ${written.length === 0 ? '(no decisions to record)' : written.join(', ')}`);
5052
+ if (apply)
5053
+ write(` APPLIED to .dz/guard.json: ${applied.length === 0 ? '(none)' : applied.join(', ')} — SOFT severity, always`);
5054
+ else
5055
+ write(' Nothing was written to .dz/guard.json — re-run with --apply to install the promoted rule(s).');
5056
+ }
5057
+ for (const c of conflicts)
5058
+ write(` ✗ CONFLICT — ${c}`);
5059
+ return exitCode;
5060
+ }
4651
5061
  /**
4652
5062
  * `dz guard` — the declarative constraint layer that refuses a self-mutating op when a HARD invariant is
4653
5063
  * violated. Simple outside: `dz guard check --op publish` works with zero config (built-in defaults).
@@ -4703,8 +5113,13 @@ function cmdGuard(options, flags, cwd, write) {
4703
5113
  }
4704
5114
  return 0;
4705
5115
  }
5116
+ // `promote` — lesson → guard-rule promotion. It lives HERE, not as a top-level `dz promote`,
5117
+ // because its object IS a guard rule: its evidence is .dz/guard-audit.jsonl + real commit history
5118
+ // and its write target is .dz/guard.json (ADR-001).
5119
+ if (sub === 'promote')
5120
+ return cmdGuardPromote(options, flags, options.get('project') !== undefined ? resolve(cwd, options.get('project')) : root, write);
4706
5121
  if (sub !== 'check') {
4707
- write(`dz guard: unknown subcommand '${sub}' — use: check --op <op> | --init | log`);
5122
+ write(`dz guard: unknown subcommand '${sub}' — use: check --op <op> | promote | --init | log`);
4708
5123
  return 1;
4709
5124
  }
4710
5125
  // check