@dzhechkov/harness-cli 0.3.254 → 0.3.257
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 -8
- package/README.md +118 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +472 -6
- package/dist/cli.js.map +1 -1
- package/package.json +2 -2
- package/sbom.json +17 -7
- package/src/cli.ts +447 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dzhechkov/harness-cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.257",
|
|
4
4
|
"description": "The dz CLI — install AI skills for Claude Code, Codex, OpenCode, Hermes, OpenClaude, GitHub Copilot. 35 commands, 13 presets, 6 platform targets.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"sbom.json"
|
|
42
42
|
],
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@dzhechkov/harness-core": "^0.3.
|
|
44
|
+
"@dzhechkov/harness-core": "^0.3.146",
|
|
45
45
|
"@dzhechkov/harness-presets": "^0.5.0",
|
|
46
46
|
"@dzhechkov/scout": "^0.8.0",
|
|
47
47
|
"@dzhechkov/skills-devops": "^0.3.0",
|
package/sbom.json
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"hashes": [
|
|
26
26
|
{
|
|
27
27
|
"alg": "SHA-256",
|
|
28
|
-
"content": "
|
|
28
|
+
"content": "7333dd44bcbeb6536e8ee0f0490a57fefca211e706535da7f44cea7012388213"
|
|
29
29
|
}
|
|
30
30
|
]
|
|
31
31
|
},
|
|
@@ -105,7 +105,7 @@
|
|
|
105
105
|
"hashes": [
|
|
106
106
|
{
|
|
107
107
|
"alg": "SHA-256",
|
|
108
|
-
"content": "
|
|
108
|
+
"content": "818293a250cdf1bd10b5d69267b714d9a8ab90017c1fa18de29fc24e28ad8a55"
|
|
109
109
|
}
|
|
110
110
|
]
|
|
111
111
|
},
|
|
@@ -115,7 +115,7 @@
|
|
|
115
115
|
"hashes": [
|
|
116
116
|
{
|
|
117
117
|
"alg": "SHA-256",
|
|
118
|
-
"content": "
|
|
118
|
+
"content": "9c37499ed55b571232b5718c863b83b31f7a79faab46a9dcbe3283b993b1862a"
|
|
119
119
|
}
|
|
120
120
|
]
|
|
121
121
|
},
|
|
@@ -125,7 +125,7 @@
|
|
|
125
125
|
"hashes": [
|
|
126
126
|
{
|
|
127
127
|
"alg": "SHA-256",
|
|
128
|
-
"content": "
|
|
128
|
+
"content": "6cf537925ccb4b1ffff84c8fd255b487438b78b56a46f9d91d33b25500ae520d"
|
|
129
129
|
}
|
|
130
130
|
]
|
|
131
131
|
},
|
|
@@ -185,7 +185,7 @@
|
|
|
185
185
|
"hashes": [
|
|
186
186
|
{
|
|
187
187
|
"alg": "SHA-256",
|
|
188
|
-
"content": "
|
|
188
|
+
"content": "7abb07cdda09bea2b8e948cecdbaed4579371e8d0e8cee4436e7fcb20f430cdd"
|
|
189
189
|
}
|
|
190
190
|
]
|
|
191
191
|
},
|
|
@@ -205,7 +205,7 @@
|
|
|
205
205
|
"hashes": [
|
|
206
206
|
{
|
|
207
207
|
"alg": "SHA-256",
|
|
208
|
-
"content": "
|
|
208
|
+
"content": "d64af623372238bb5f2ead34e733130d7b013c49058a26f4795f09586b2b5dda"
|
|
209
209
|
}
|
|
210
210
|
]
|
|
211
211
|
},
|
|
@@ -225,7 +225,7 @@
|
|
|
225
225
|
"hashes": [
|
|
226
226
|
{
|
|
227
227
|
"alg": "SHA-256",
|
|
228
|
-
"content": "
|
|
228
|
+
"content": "555aed692218a73f23a343df2f2b38ce028e694dd9d4aff37e727096f88eb50e"
|
|
229
229
|
}
|
|
230
230
|
]
|
|
231
231
|
},
|
|
@@ -239,6 +239,16 @@
|
|
|
239
239
|
}
|
|
240
240
|
]
|
|
241
241
|
},
|
|
242
|
+
{
|
|
243
|
+
"type": "file",
|
|
244
|
+
"name": "test/guard-promote-cli.test.ts",
|
|
245
|
+
"hashes": [
|
|
246
|
+
{
|
|
247
|
+
"alg": "SHA-256",
|
|
248
|
+
"content": "f8e35085106a98be239104394539c3aec350123eed4a115abf14acc2cb6b5bb9"
|
|
249
|
+
}
|
|
250
|
+
]
|
|
251
|
+
},
|
|
242
252
|
{
|
|
243
253
|
"type": "file",
|
|
244
254
|
"name": "tsconfig.json",
|
package/src/cli.ts
CHANGED
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
* @packageDocumentation
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmdirSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { chmodSync, closeSync, existsSync, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, readlinkSync, realpathSync, renameSync, rmdirSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
8
8
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
9
9
|
import { fileURLToPath } from 'node:url';
|
|
10
|
-
import { execSync, spawn } from 'node:child_process';
|
|
10
|
+
import { execFileSync, execSync, spawn } from 'node:child_process';
|
|
11
11
|
import { homedir, tmpdir } from 'node:os';
|
|
12
12
|
import { createRequire } from 'node:module';
|
|
13
13
|
|
|
@@ -107,6 +107,11 @@ import {
|
|
|
107
107
|
RECALL_USAGE_LOG_MAX_BYTES,
|
|
108
108
|
parseRecallUsageLog,
|
|
109
109
|
buildRecallUsageReport,
|
|
110
|
+
EVENT_CHAIN_TAIL_BYTES,
|
|
111
|
+
EMPTY_LOG_TAIL,
|
|
112
|
+
readTailInfo,
|
|
113
|
+
appendChainedLines,
|
|
114
|
+
verifyEventChainText,
|
|
110
115
|
buildManifest,
|
|
111
116
|
buildSbom,
|
|
112
117
|
resolveTrustRoot,
|
|
@@ -118,6 +123,21 @@ import {
|
|
|
118
123
|
guardExitCode,
|
|
119
124
|
DEFAULT_RULES,
|
|
120
125
|
parsePnpmLockImporters,
|
|
126
|
+
// guard-promotion (feature guard-promotion, scout idea #1)
|
|
127
|
+
assembleCandidates,
|
|
128
|
+
renderPromotionReport,
|
|
129
|
+
renderPromotionAdr,
|
|
130
|
+
normalizePromotionState,
|
|
131
|
+
nextPromotionState,
|
|
132
|
+
globMatch,
|
|
133
|
+
promotionAdrRelPath,
|
|
134
|
+
DEFAULT_WINDOW_DAYS,
|
|
135
|
+
DEFAULT_PERIODS,
|
|
136
|
+
MAX_CONTENT_FETCHES,
|
|
137
|
+
BUILTIN_COVERAGE,
|
|
138
|
+
type ChangeSet,
|
|
139
|
+
type ExistingRuleView,
|
|
140
|
+
type PromotionReport,
|
|
121
141
|
decideProvenance,
|
|
122
142
|
isInsideTree,
|
|
123
143
|
signManifest,
|
|
@@ -254,6 +274,7 @@ import {
|
|
|
254
274
|
import type { IdeaRecord, IdeaStatus } from '@dzhechkov/harness-core';
|
|
255
275
|
import type { Family, ModelRung, Candidate as BtoCandidate, DimScores } from '@dzhechkov/harness-core';
|
|
256
276
|
import type { SetupSpec } from '@dzhechkov/harness-core';
|
|
277
|
+
import type { LogTail } from '@dzhechkov/harness-core';
|
|
257
278
|
import type { ProvenanceMode, PackVerdict, ClaudeUsageModel, PatternRecord, TargetName, BookKU, HarmonizeReport, UsageCalibrationPlan, ClaimFinding, RecallUsagePatternRow, GateExecution, GateStep } from '@dzhechkov/harness-core';
|
|
258
279
|
import { getPreset, PRESET_NAMES } from '@dzhechkov/harness-presets';
|
|
259
280
|
import { scanGitHub, analyzeRepo, generateReport, deepAnalyze, scanAllSources, ScoutMemory } from '@dzhechkov/scout';
|
|
@@ -4964,6 +4985,42 @@ function gatherGuardFacts(op: string, root: string, text: string | undefined, st
|
|
|
4964
4985
|
facts['lockfile'] = { parsed: true, importers: rows };
|
|
4965
4986
|
}
|
|
4966
4987
|
} catch { facts['lockfile'] = { parsed: false }; /* no lockfile (not a pnpm workspace) — rule stays silent */ }
|
|
4988
|
+
|
|
4989
|
+
// change: the working-tree diff, for PROMOTED (template) rules. Without this fact a rule written
|
|
4990
|
+
// by `dz guard promote --apply` would be INERT — present in the config and enforcing nothing.
|
|
4991
|
+
// Contents are read only for the globs an active `format-match` rule actually asks about.
|
|
4992
|
+
try {
|
|
4993
|
+
const status = execSync('git status --porcelain', { cwd: root, encoding: 'utf-8' });
|
|
4994
|
+
const files = status
|
|
4995
|
+
.split('\n')
|
|
4996
|
+
.map((l) => l.slice(3).trim())
|
|
4997
|
+
.map((p) => (p.includes(' -> ') ? p.split(' -> ')[1]!.trim() : p)) // renames: the destination is the changed path
|
|
4998
|
+
.filter((p) => p !== '');
|
|
4999
|
+
const formatGlobs = (Array.isArray(loadGuardConfig(root).rules) ? (loadGuardConfig(root).rules as { template?: unknown; params?: { file?: unknown } }[]) : [])
|
|
5000
|
+
.filter((r) => r?.template === 'format-match' && typeof r?.params?.file === 'string')
|
|
5001
|
+
.map((r) => r.params!.file as string);
|
|
5002
|
+
const contents: Record<string, string> = {};
|
|
5003
|
+
if (formatGlobs.length > 0) {
|
|
5004
|
+
for (const f of files) {
|
|
5005
|
+
if (!formatGlobs.some((g) => globMatch(g, f))) continue;
|
|
5006
|
+
const abs = resolve(root, f);
|
|
5007
|
+
// Containment: a `git status` path is repo-relative, but `..` in one must never let the
|
|
5008
|
+
// LIVE reader step outside the repo the HISTORICAL reader is confined to.
|
|
5009
|
+
if (abs !== root && !abs.startsWith(root + sep)) continue;
|
|
5010
|
+
try {
|
|
5011
|
+
// lstat, NOT stat (Codex QE MED-3). `git show <sha>:<path>` yields the SYMLINK TARGET
|
|
5012
|
+
// TEXT, never the file it points at, so a live reader that follows links answers a
|
|
5013
|
+
// different question than the replay — and `/dev/zero` behind a symlink hangs the read.
|
|
5014
|
+
// Skipping non-regular files restores replay/live equivalence and closes the DoS.
|
|
5015
|
+
const st = lstatSync(abs);
|
|
5016
|
+
if (!st.isFile()) continue;
|
|
5017
|
+
if (st.size > MAX_CONTENT_BYTES) continue; // too large to be a spec file — undecidable, never guessed
|
|
5018
|
+
contents[f] = readFileSync(abs, 'utf8');
|
|
5019
|
+
} catch { /* deleted — leave it undecidable, never guess */ }
|
|
5020
|
+
}
|
|
5021
|
+
}
|
|
5022
|
+
facts['change'] = { files, ...(Object.keys(contents).length > 0 ? { contents } : {}) };
|
|
5023
|
+
} catch { /* not a git repo — every template rule stays silent (fail-open) */ }
|
|
4967
5024
|
}
|
|
4968
5025
|
if (op === 'consolidate') {
|
|
4969
5026
|
try { facts['drift'] = sweepSkillDrift(root, { scope: 'packages', allowlist: readDriftAllowlist(root) }).drifted.map((d) => d.name); } catch { /* skip */ }
|
|
@@ -4994,11 +5051,379 @@ function runGuardEvaluation(root: string, op: string, text: string | undefined,
|
|
|
4994
5051
|
try {
|
|
4995
5052
|
const rec = auditRecord(result, new Date().toISOString(), overrideReason !== undefined ? { reason: overrideReason } : undefined);
|
|
4996
5053
|
mkdirSync(join(root, '.dz'), { recursive: true });
|
|
4997
|
-
|
|
5054
|
+
const auditPath = join(root, '.dz', 'guard-audit.jsonl');
|
|
5055
|
+
// event-chain (ADR-001): seq + prevHash derived from the LAST LINE ONLY — this file is the
|
|
5056
|
+
// evidence base `dz guard promote` decides on, and a rewrite that loses or duplicates a record
|
|
5057
|
+
// must not be able to look intact. A tail that cannot be read starts a MARKED segment rather
|
|
5058
|
+
// than blocking the audit: the verdict is never held hostage to a broken log.
|
|
5059
|
+
writeFileSync(auditPath, appendChainedLines([rec], readLogTail(auditPath)), { flag: 'a' });
|
|
4998
5060
|
} catch { /* audit is best-effort, never blocks the verdict */ }
|
|
4999
5061
|
return result;
|
|
5000
5062
|
}
|
|
5001
5063
|
|
|
5064
|
+
/**
|
|
5065
|
+
* The tail facts of an append-only log, read from its END — O(1) in the file size, which is what
|
|
5066
|
+
* lets the chain be extended on every append without a full-file scan (FR-2). Anything unreadable
|
|
5067
|
+
* yields {@link EMPTY_LOG_TAIL}; the caller then starts a marked segment rather than blocking.
|
|
5068
|
+
*/
|
|
5069
|
+
function readLogTail(path: string): LogTail {
|
|
5070
|
+
let fd: number | undefined;
|
|
5071
|
+
try {
|
|
5072
|
+
if (!existsSync(path)) return EMPTY_LOG_TAIL;
|
|
5073
|
+
fd = openSync(path, 'r');
|
|
5074
|
+
const size = fstatSync(fd).size;
|
|
5075
|
+
if (!Number.isFinite(size) || size <= 0) return EMPTY_LOG_TAIL;
|
|
5076
|
+
const want = Math.min(size, EVENT_CHAIN_TAIL_BYTES);
|
|
5077
|
+
const buf = Buffer.alloc(want);
|
|
5078
|
+
readSync(fd, buf, 0, want, size - want);
|
|
5079
|
+
return readTailInfo(buf.toString('utf-8'), { partial: want < size });
|
|
5080
|
+
} catch {
|
|
5081
|
+
// A read FAILURE is not an empty file (Codex re-QE LOW): EMPTY_LOG_TAIL means "there is
|
|
5082
|
+
// nothing", which lets the writer open an UNMARKED genesis on a file we merely failed to
|
|
5083
|
+
// read. An unreadable tail must force a marked reset, per the AM-6 contract.
|
|
5084
|
+
return { ...EMPTY_LOG_TAIL, unreadable: true };
|
|
5085
|
+
} finally {
|
|
5086
|
+
if (fd !== undefined) {
|
|
5087
|
+
try { closeSync(fd); } catch { /* nothing to do */ }
|
|
5088
|
+
}
|
|
5089
|
+
}
|
|
5090
|
+
}
|
|
5091
|
+
|
|
5092
|
+
// ── `dz guard promote` (feature guard-promotion, scout idea #1) ─────────────────────────────────
|
|
5093
|
+
|
|
5094
|
+
const PROMOTIONS_DIR = join('features', 'guard-promotion', 'promotions');
|
|
5095
|
+
const PROMOTION_STATE_FILE = join('.dz', 'promotion-state.json');
|
|
5096
|
+
/**
|
|
5097
|
+
* Ceiling on a single file read, applied IDENTICALLY to the historical (`git show`) and live
|
|
5098
|
+
* (working-tree) readers. A `format-match` target is a spec/manifest file; anything larger is not
|
|
5099
|
+
* one, and an unbounded read of a symlinked `/dev/zero` is a hang, not a measurement.
|
|
5100
|
+
*/
|
|
5101
|
+
const MAX_CONTENT_BYTES = 1024 * 1024;
|
|
5102
|
+
|
|
5103
|
+
const GUARD_PROMOTE_USAGE = [
|
|
5104
|
+
'dz guard promote [--project <dir>] [--json] [--dry-run | --apply]',
|
|
5105
|
+
' [--window-days <N>] [--periods <N>] [--limit <N>]',
|
|
5106
|
+
].join('\n ');
|
|
5107
|
+
|
|
5108
|
+
/**
|
|
5109
|
+
* Read real commit history as {@link ChangeSet}s — the shadow-replay corpus. `--name-only` gives the
|
|
5110
|
+
* change shape every v1 template consumes. Merges are excluded (their file list is a union of the
|
|
5111
|
+
* branches, not a decision anyone made).
|
|
5112
|
+
*
|
|
5113
|
+
* A missing/failing `git` yields `[]`, which makes every candidate `insufficient-data` — the command
|
|
5114
|
+
* still exits 0 and says why. No history is not a promotion.
|
|
5115
|
+
*/
|
|
5116
|
+
function readGitChanges(root: string, sinceIso: string): ChangeSet[] {
|
|
5117
|
+
let out = '';
|
|
5118
|
+
try {
|
|
5119
|
+
// execFileSync (argv form), NOT a shell string: `--name-only` paths come straight from the repo,
|
|
5120
|
+
// and a filename containing `$(…)` or a backtick would EXPAND inside a double-quoted shell
|
|
5121
|
+
// argument. `-z` is not used because the pretty header needs line framing; the argv form removes
|
|
5122
|
+
// the shell entirely instead.
|
|
5123
|
+
out = execFileSync('git', ['log', `--since=${sinceIso}`, '--no-merges', '--name-only', '--pretty=format:%x01%H%x09%cI'], {
|
|
5124
|
+
cwd: root,
|
|
5125
|
+
encoding: 'utf-8',
|
|
5126
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
5127
|
+
});
|
|
5128
|
+
} catch {
|
|
5129
|
+
return [];
|
|
5130
|
+
}
|
|
5131
|
+
const changes: ChangeSet[] = [];
|
|
5132
|
+
let current: { id: string; ts: string; files: string[] } | null = null;
|
|
5133
|
+
for (const raw of out.split('\n')) {
|
|
5134
|
+
if (raw.startsWith('\x01')) {
|
|
5135
|
+
if (current) changes.push(current);
|
|
5136
|
+
const [id, ts] = raw.slice(1).split('\t');
|
|
5137
|
+
current = { id: (id ?? '').slice(0, 12), ts: ts ?? '', files: [] };
|
|
5138
|
+
continue;
|
|
5139
|
+
}
|
|
5140
|
+
const line = raw.trim();
|
|
5141
|
+
if (line === '' || current === null) continue;
|
|
5142
|
+
current.files.push(line);
|
|
5143
|
+
}
|
|
5144
|
+
if (current) changes.push(current);
|
|
5145
|
+
return changes;
|
|
5146
|
+
}
|
|
5147
|
+
|
|
5148
|
+
/**
|
|
5149
|
+
* Attach file text at each historical commit for the `format-match` candidates that need it.
|
|
5150
|
+
*
|
|
5151
|
+
* HARD CAP (`MAX_CONTENT_FETCHES`): over it we STOP fetching, which leaves those changes without
|
|
5152
|
+
* contents, which makes `templateFires` return `undecidable`, which makes the candidate
|
|
5153
|
+
* `insufficient-data`. What we deliberately do NOT do is fall back to the file's CURRENT content —
|
|
5154
|
+
* evaluating a historical commit against today's file is exactly the fabricated-win shape ADR-002
|
|
5155
|
+
* refused for `presence-check`.
|
|
5156
|
+
*/
|
|
5157
|
+
function attachChangeContents(root: string, changes: readonly ChangeSet[], globs: readonly string[]): ChangeSet[] {
|
|
5158
|
+
if (globs.length === 0) return [...changes];
|
|
5159
|
+
let budget = MAX_CONTENT_FETCHES;
|
|
5160
|
+
return changes.map((c) => {
|
|
5161
|
+
const wanted = c.files.filter((f) => globs.some((g) => globMatch(g, f)));
|
|
5162
|
+
if (wanted.length === 0) return c;
|
|
5163
|
+
const contents: Record<string, string> = {};
|
|
5164
|
+
for (const f of wanted) {
|
|
5165
|
+
if (budget <= 0) return c; // over cap ⇒ leave this change undecidable, never guess
|
|
5166
|
+
budget -= 1;
|
|
5167
|
+
try {
|
|
5168
|
+
// argv form, NOT a shell string: a repo path is untrusted input and `$(…)`/backticks would
|
|
5169
|
+
// expand inside a quoted shell argument. No `--` terminator — `git show -- <rev>:<path>`
|
|
5170
|
+
// exits 0 with EMPTY output (the terminator turns the rev-with-path into a pathspec). The
|
|
5171
|
+
// option-smuggling risk it would have covered is absent anyway: the argument always begins
|
|
5172
|
+
// with a 12-hex sha.
|
|
5173
|
+
const text = execFileSync('git', ['show', `${c.id}:${f}`], { cwd: root, encoding: 'utf-8', maxBuffer: MAX_CONTENT_BYTES });
|
|
5174
|
+
// Same size ceiling as the live reader, so replay and live agree on what is too big to judge.
|
|
5175
|
+
if (text.length > MAX_CONTENT_BYTES) return c;
|
|
5176
|
+
// EMPTY OUTPUT IS NOT CONTENT. git reported this path as changed in this commit, so an
|
|
5177
|
+
// empty body is far more likely a failed lookup than a genuinely empty file — and treating
|
|
5178
|
+
// it as content is the worst possible failure: `''.includes(x)` is false, so the rule would
|
|
5179
|
+
// FIRE on every single file and fabricate a clean sweep of wins. Undecidable instead.
|
|
5180
|
+
if (text === '') return c;
|
|
5181
|
+
contents[f] = text;
|
|
5182
|
+
} catch {
|
|
5183
|
+
return c; // deleted/renamed at that commit — undecidable, not clean
|
|
5184
|
+
}
|
|
5185
|
+
}
|
|
5186
|
+
return { ...c, contents };
|
|
5187
|
+
});
|
|
5188
|
+
}
|
|
5189
|
+
|
|
5190
|
+
/** Every rule the engine would run: the built-ins plus any template rules already in `.dz/guard.json`. */
|
|
5191
|
+
function existingRuleViews(root: string): ExistingRuleView[] {
|
|
5192
|
+
const cfg = loadGuardConfig(root);
|
|
5193
|
+
const configRules = Array.isArray(cfg.rules) ? (cfg.rules as { id?: unknown; template?: unknown; params?: unknown; enabled?: unknown }[]) : [];
|
|
5194
|
+
const disabled = new Set(configRules.filter((r) => r?.enabled === false && typeof r.id === 'string').map((r) => r.id as string));
|
|
5195
|
+
// A built-in the operator has DISABLED does not cover anything — otherwise the promoter would
|
|
5196
|
+
// refuse a candidate as a duplicate of a rule that is not running, and the gap would stay open.
|
|
5197
|
+
const views: ExistingRuleView[] = DEFAULT_RULES.filter((r) => !disabled.has(r.id)).map((r) => ({ id: r.id }));
|
|
5198
|
+
for (const o of configRules) {
|
|
5199
|
+
if (typeof o?.id !== 'string' || o.enabled === false) continue;
|
|
5200
|
+
// A rule op-scoped AWAY from publish covers nothing a change-shaped promotion targets — letting
|
|
5201
|
+
// it suppress a candidate as a "duplicate" keeps the gap open (Codex re-QE MED, mirror of the
|
|
5202
|
+
// disabled-builtin rationale above).
|
|
5203
|
+
const ops = (o as { ops?: unknown }).ops;
|
|
5204
|
+
if (Array.isArray(ops) && !(ops as unknown[]).includes('publish')) continue;
|
|
5205
|
+
if (views.some((v) => v.id === o.id)) continue;
|
|
5206
|
+
views.push({ id: o.id, ...(typeof o.template === 'string' ? { template: o.template as never } : {}), ...(o.params && typeof o.params === 'object' ? { params: o.params as never } : {}) });
|
|
5207
|
+
}
|
|
5208
|
+
return views;
|
|
5209
|
+
}
|
|
5210
|
+
|
|
5211
|
+
/** Atomic JSON write — tmp + rename, so a crash mid-write never leaves a half-parsed state file. */
|
|
5212
|
+
function writeJsonAtomic(path: string, value: unknown): void {
|
|
5213
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
5214
|
+
const tmp = `${path}.tmp.${process.pid}`;
|
|
5215
|
+
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, 'utf-8');
|
|
5216
|
+
renameSync(tmp, path);
|
|
5217
|
+
}
|
|
5218
|
+
|
|
5219
|
+
/** The rolled-up refusal record (ADR-004): one file, regenerated in place, one row per lesson. */
|
|
5220
|
+
function renderNotPromotableRollup(report: PromotionReport, nowTs: string): string {
|
|
5221
|
+
const refused = report.candidates.filter((c) => c.verdict === 'not-promotable');
|
|
5222
|
+
const out: string[] = [];
|
|
5223
|
+
out.push('# 000 — Not promotable (rolled-up refusal record)');
|
|
5224
|
+
out.push('');
|
|
5225
|
+
out.push(`**Decision:** REFUSED · **Date:** ${nowTs} · **Count:** ${refused.length} of ${report.totalLessons} lesson(s)`);
|
|
5226
|
+
out.push('');
|
|
5227
|
+
out.push('These lessons do not reduce to a v1 `dz guard promote` rule template. Rule code is NEVER');
|
|
5228
|
+
out.push('synthesised from lesson text (ADR-002), so a lesson that fits no template is refused aloud');
|
|
5229
|
+
out.push('rather than force-fitted. This file is regenerated in place on every run.');
|
|
5230
|
+
out.push('');
|
|
5231
|
+
out.push('| lesson | reason | first 90 chars |');
|
|
5232
|
+
out.push('|---|---|---|');
|
|
5233
|
+
for (const c of refused) {
|
|
5234
|
+
const t = c.lessonText.replace(/\|/g, '\\|').replace(/\n/g, ' ').slice(0, 90);
|
|
5235
|
+
out.push(`| \`${c.lessonId}\` | ${c.reason.replace(/\|/g, '\\|')} | ${t} |`);
|
|
5236
|
+
}
|
|
5237
|
+
return out.join('\n') + '\n';
|
|
5238
|
+
}
|
|
5239
|
+
|
|
5240
|
+
/**
|
|
5241
|
+
* `dz guard promote` — lesson → guard-rule promotion with a "win twice to promote" gate.
|
|
5242
|
+
*
|
|
5243
|
+
* Thin by design: gather (lessons, existing rules, real commit history, state) → the PURE
|
|
5244
|
+
* `assembleCandidates` → render → write. Default PROPOSES (documents + journal only); `--dry-run`
|
|
5245
|
+
* writes nothing at all; `--apply` is the only path that touches `.dz/guard.json`, always SOFT.
|
|
5246
|
+
*/
|
|
5247
|
+
function cmdGuardPromote(options: Map<string, string>, flags: Set<string>, root: string, write: Write): number {
|
|
5248
|
+
const json = flags.has('json');
|
|
5249
|
+
const fail = (msg: string): number => {
|
|
5250
|
+
write(json ? JSON.stringify({ error: msg, exitCode: 1 }) : `dz guard promote: ${msg}\n usage: ${GUARD_PROMOTE_USAGE}`);
|
|
5251
|
+
return 1;
|
|
5252
|
+
};
|
|
5253
|
+
for (const f of flags) if (!['json', 'dry-run', 'apply', 'help'].includes(f)) return fail(`unknown option --${f}`);
|
|
5254
|
+
for (const k of options.keys()) {
|
|
5255
|
+
if (k === '_positional_0') continue; // the `promote` subcommand token itself
|
|
5256
|
+
if (k.startsWith('_positional_')) return fail(`unexpected argument "${options.get(k)}"`);
|
|
5257
|
+
if (!['project', 'window-days', 'periods', 'limit'].includes(k)) return fail(`unknown option --${k}`);
|
|
5258
|
+
}
|
|
5259
|
+
if (flags.has('help')) {
|
|
5260
|
+
write(`dz guard promote — promote a learned lesson to a deterministic guard rule\n usage: ${GUARD_PROMOTE_USAGE}`);
|
|
5261
|
+
write(' A candidate must SHADOW-WIN twice consecutively over real commit history before it is proposed.');
|
|
5262
|
+
write(' Default: writes proposal/refusal documents only. --dry-run: writes nothing. --apply: writes the SOFT rule into .dz/guard.json.');
|
|
5263
|
+
return 0;
|
|
5264
|
+
}
|
|
5265
|
+
const dryRun = flags.has('dry-run');
|
|
5266
|
+
const apply = flags.has('apply');
|
|
5267
|
+
// NOT a silent precedence: two contradictory intents is an error, not a coin flip.
|
|
5268
|
+
if (dryRun && apply) return fail('--dry-run and --apply are mutually exclusive');
|
|
5269
|
+
|
|
5270
|
+
const num = (key: string, dflt: number, lo: number, hi: number): number | null => {
|
|
5271
|
+
const raw = options.get(key);
|
|
5272
|
+
if (raw === undefined) return dflt;
|
|
5273
|
+
const n = Number(raw);
|
|
5274
|
+
if (!Number.isFinite(n) || !Number.isInteger(n) || n < lo || n > hi) return null;
|
|
5275
|
+
return n;
|
|
5276
|
+
};
|
|
5277
|
+
const windowDays = num('window-days', DEFAULT_WINDOW_DAYS, 1, 365);
|
|
5278
|
+
if (windowDays === null) return fail('--window-days expects an integer in [1, 365]');
|
|
5279
|
+
const periods = num('periods', DEFAULT_PERIODS, 2, 52);
|
|
5280
|
+
if (periods === null) return fail('--periods expects an integer in [2, 52]');
|
|
5281
|
+
const limit = num('limit', 15, 1, 1000);
|
|
5282
|
+
if (limit === null) return fail('--limit expects an integer in [1, 1000]');
|
|
5283
|
+
|
|
5284
|
+
const nowTs = new Date().toISOString();
|
|
5285
|
+
const sinceIso = new Date(Date.now() - windowDays * periods * 86_400_000).toISOString();
|
|
5286
|
+
|
|
5287
|
+
// lessons — the SAME readers `dz compounding` uses; no second store.
|
|
5288
|
+
const lessons = loadStoreRecords(root).map((r) => ({
|
|
5289
|
+
dzId: r.id,
|
|
5290
|
+
text: typeof r.text === 'string' ? r.text : '',
|
|
5291
|
+
quarantined: readQuarantineState(r).quarantined,
|
|
5292
|
+
uses: readReinforcementState(r).uses,
|
|
5293
|
+
}));
|
|
5294
|
+
const existingRules = existingRuleViews(root);
|
|
5295
|
+
const changes = readGitChanges(root, sinceIso);
|
|
5296
|
+
|
|
5297
|
+
// State is read on EVERY run, including --dry-run, because it carries the LOCAL-clock `firstSeen`
|
|
5298
|
+
// the elapsed gate reads (MED-7). --dry-run still writes nothing — which is exactly why a
|
|
5299
|
+
// dry-run-only workflow never starts that clock, and the wait reason says so.
|
|
5300
|
+
const state = normalizePromotionState(((): unknown => {
|
|
5301
|
+
try { return JSON.parse(readFileSync(join(root, PROMOTION_STATE_FILE), 'utf-8')); } catch { return null; }
|
|
5302
|
+
})());
|
|
5303
|
+
const firstSeen: Record<string, string> = {};
|
|
5304
|
+
for (const [id, e] of Object.entries(state.entries)) if (e.firstSeenTs !== '') firstSeen[id] = e.firstSeenTs;
|
|
5305
|
+
|
|
5306
|
+
// Pass 1 discovers which `format-match` candidates exist, so contents are fetched only for the
|
|
5307
|
+
// globs that actually need them (and only up to the cap).
|
|
5308
|
+
const pass1 = assembleCandidates({ lessons, existingRules, changes, nowTs, windowDays, periods, firstSeen });
|
|
5309
|
+
const formatGlobs = [...new Set(pass1.candidates.filter((c) => c.template === 'format-match' && typeof c.params?.file === 'string').map((c) => c.params!.file!))];
|
|
5310
|
+
const report = formatGlobs.length === 0
|
|
5311
|
+
? pass1
|
|
5312
|
+
: assembleCandidates({ lessons, existingRules, changes: attachChangeContents(root, changes, formatGlobs), nowTs, windowDays, periods, firstSeen });
|
|
5313
|
+
|
|
5314
|
+
// ── write side ────────────────────────────────────────────────────────────────────────────────
|
|
5315
|
+
const written: string[] = [];
|
|
5316
|
+
const applied: string[] = [];
|
|
5317
|
+
const conflicts: string[] = [];
|
|
5318
|
+
if (!dryRun) {
|
|
5319
|
+
const adrSeqs: Record<string, number> = {};
|
|
5320
|
+
let seq = state.nextAdrSeq;
|
|
5321
|
+
let allocated = 0;
|
|
5322
|
+
for (const c of report.candidates) {
|
|
5323
|
+
if (c.verdict !== 'promote' && c.verdict !== 'duplicate') continue;
|
|
5324
|
+
const key = c.ruleId!;
|
|
5325
|
+
// ONE document per CANDIDATE, not per run — but the reused thing is the integer SEQUENCE, never
|
|
5326
|
+
// a path. State is attacker-shaped input (a JSON file anyone can corrupt), and a path taken
|
|
5327
|
+
// from it and handed to writeFileSync overwrites whatever it names — with no `--apply`, no
|
|
5328
|
+
// promotion, and no way to notice. The path is DERIVED from the validated id + that integer.
|
|
5329
|
+
const existingSeq = state.entries[key]?.adrSeq;
|
|
5330
|
+
const useSeq = existingSeq ?? seq;
|
|
5331
|
+
const rel = promotionAdrRelPath(key, useSeq);
|
|
5332
|
+
if (rel === null) continue; // an id or sequence that fails validation writes NOTHING
|
|
5333
|
+
if (existingSeq === undefined) { seq += 1; allocated += 1; }
|
|
5334
|
+
// Belt to the derivation's braces: resolve and assert containment before writing. A derivation
|
|
5335
|
+
// that is correct today is not a substitute for checking the thing you are about to write.
|
|
5336
|
+
const abs = resolve(root, rel);
|
|
5337
|
+
const dir = resolve(root, PROMOTIONS_DIR);
|
|
5338
|
+
if (abs !== dir && !abs.startsWith(dir + sep)) continue;
|
|
5339
|
+
try {
|
|
5340
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
5341
|
+
// Lexical containment is not PHYSICAL containment (Codex re-QE HIGH): a symlinked
|
|
5342
|
+
// promotions/ directory (or a symlink planted at the ADR leaf) redirects the write outside
|
|
5343
|
+
// the repo while every string check passes. realpath the directory that actually exists on
|
|
5344
|
+
// disk and require it to be the real promotions dir under the real root; refuse a leaf that
|
|
5345
|
+
// is a symlink.
|
|
5346
|
+
const realDir = realpathSync(dirname(abs));
|
|
5347
|
+
const expectedReal = join(realpathSync(root), PROMOTIONS_DIR.split('/').join(sep));
|
|
5348
|
+
if (realDir !== expectedReal) continue;
|
|
5349
|
+
if (existsSync(abs) && lstatSync(abs).isSymbolicLink()) continue;
|
|
5350
|
+
writeFileSync(abs, renderPromotionAdr(c, useSeq, nowTs), 'utf-8');
|
|
5351
|
+
adrSeqs[key] = useSeq;
|
|
5352
|
+
written.push(rel);
|
|
5353
|
+
} catch { /* a document we cannot write must not lose the verdict */ }
|
|
5354
|
+
}
|
|
5355
|
+
if (report.candidates.some((c) => c.verdict === 'not-promotable')) {
|
|
5356
|
+
const rel = join(PROMOTIONS_DIR, '000-not-promotable.md');
|
|
5357
|
+
try {
|
|
5358
|
+
mkdirSync(join(root, PROMOTIONS_DIR), { recursive: true });
|
|
5359
|
+
writeFileSync(join(root, rel), renderNotPromotableRollup(report, nowTs), 'utf-8');
|
|
5360
|
+
written.push(rel);
|
|
5361
|
+
} catch { /* best-effort */ }
|
|
5362
|
+
}
|
|
5363
|
+
|
|
5364
|
+
if (apply) {
|
|
5365
|
+
const cfg = loadGuardConfig(root);
|
|
5366
|
+
const rules = Array.isArray(cfg.rules) ? [...(cfg.rules as unknown[])] : [];
|
|
5367
|
+
for (const c of report.candidates) {
|
|
5368
|
+
if (c.verdict !== 'promote' || c.proposedRule === null) continue;
|
|
5369
|
+
const want = c.proposedRule;
|
|
5370
|
+
const clash = rules.find((r) => (r as { id?: unknown })?.id === want.id) as { template?: unknown; params?: unknown } | undefined;
|
|
5371
|
+
if (clash !== undefined) {
|
|
5372
|
+
// ID EQUALITY IS NOT IDEMPOTENCE (Codex QE MED-5). A rule that merely SHARES the id — a
|
|
5373
|
+
// hand-written bare entry, or a same-id rule with different params — is not the rule we
|
|
5374
|
+
// are promoting. Skipping it silently reports "applied" while installing nothing (the bare
|
|
5375
|
+
// entry does not even enforce, since resolveRules drops an unknown id with no template).
|
|
5376
|
+
// Same id + same BODY is genuine idempotence; same id + different body is a conflict, and a
|
|
5377
|
+
// conflict is refused out loud rather than resolved by guessing which side to keep.
|
|
5378
|
+
const sameBody = clash.template === want.template && JSON.stringify(clash.params ?? null) === JSON.stringify(want.params);
|
|
5379
|
+
// Same body but DISABLED (or op-scoped away from publish) is NOT idempotence: the rule
|
|
5380
|
+
// exists on paper and enforces nothing — "already installed" would be a false success
|
|
5381
|
+
// (Codex re-QE MED). Refuse loudly so the operator re-enables or removes it.
|
|
5382
|
+
const clashEnabled = (clash as { enabled?: unknown }).enabled !== false;
|
|
5383
|
+
const clashOps = (clash as { ops?: unknown }).ops;
|
|
5384
|
+
const clashCoversPublish = !Array.isArray(clashOps) || (clashOps as unknown[]).includes('publish');
|
|
5385
|
+
if (sameBody && clashEnabled && clashCoversPublish) continue; // already installed AND active — genuine idempotence
|
|
5386
|
+
if (sameBody) {
|
|
5387
|
+
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`);
|
|
5388
|
+
continue;
|
|
5389
|
+
}
|
|
5390
|
+
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`);
|
|
5391
|
+
continue;
|
|
5392
|
+
}
|
|
5393
|
+
rules.push(want);
|
|
5394
|
+
applied.push(want.id);
|
|
5395
|
+
}
|
|
5396
|
+
if (applied.length > 0) writeJsonAtomic(join(root, '.dz', 'guard.json'), { ...cfg, rules });
|
|
5397
|
+
}
|
|
5398
|
+
|
|
5399
|
+
const next = nextPromotionState(state, report, nowTs, adrSeqs, allocated);
|
|
5400
|
+
const withApplied = applied.length === 0 ? next : {
|
|
5401
|
+
...next,
|
|
5402
|
+
entries: Object.fromEntries(Object.entries(next.entries).map(([k, v]) => (applied.includes(k) ? [k, { ...v, appliedTs: nowTs }] : [k, v]))),
|
|
5403
|
+
};
|
|
5404
|
+
try { writeJsonAtomic(join(root, PROMOTION_STATE_FILE), withApplied); } catch { /* best-effort */ }
|
|
5405
|
+
}
|
|
5406
|
+
|
|
5407
|
+
// A refused conflict means the requested apply did NOT fully happen — exit non-zero rather than
|
|
5408
|
+
// let a zero exit report success for work that was deliberately not done.
|
|
5409
|
+
const exitCode = conflicts.length > 0 ? 1 : 0;
|
|
5410
|
+
|
|
5411
|
+
if (json) {
|
|
5412
|
+
write(JSON.stringify({ ...report, mode: dryRun ? 'dry-run' : apply ? 'apply' : 'propose', written, applied, conflicts, exitCode }, null, 2));
|
|
5413
|
+
return exitCode;
|
|
5414
|
+
}
|
|
5415
|
+
write(renderPromotionReport(report, limit));
|
|
5416
|
+
write('');
|
|
5417
|
+
if (dryRun) write(' MODE: --dry-run — nothing was written (not even .dz/promotion-state.json)');
|
|
5418
|
+
else {
|
|
5419
|
+
write(` WROTE: ${written.length === 0 ? '(no decisions to record)' : written.join(', ')}`);
|
|
5420
|
+
if (apply) write(` APPLIED to .dz/guard.json: ${applied.length === 0 ? '(none)' : applied.join(', ')} — SOFT severity, always`);
|
|
5421
|
+
else write(' Nothing was written to .dz/guard.json — re-run with --apply to install the promoted rule(s).');
|
|
5422
|
+
}
|
|
5423
|
+
for (const c of conflicts) write(` ✗ CONFLICT — ${c}`);
|
|
5424
|
+
return exitCode;
|
|
5425
|
+
}
|
|
5426
|
+
|
|
5002
5427
|
/**
|
|
5003
5428
|
* `dz guard` — the declarative constraint layer that refuses a self-mutating op when a HARD invariant is
|
|
5004
5429
|
* violated. Simple outside: `dz guard check --op publish` works with zero config (built-in defaults).
|
|
@@ -5040,8 +5465,13 @@ function cmdGuard(options: Map<string, string>, flags: Set<string>, cwd: string,
|
|
|
5040
5465
|
return 0;
|
|
5041
5466
|
}
|
|
5042
5467
|
|
|
5468
|
+
// `promote` — lesson → guard-rule promotion. It lives HERE, not as a top-level `dz promote`,
|
|
5469
|
+
// because its object IS a guard rule: its evidence is .dz/guard-audit.jsonl + real commit history
|
|
5470
|
+
// and its write target is .dz/guard.json (ADR-001).
|
|
5471
|
+
if (sub === 'promote') return cmdGuardPromote(options, flags, options.get('project') !== undefined ? resolve(cwd, options.get('project')!) : root, write);
|
|
5472
|
+
|
|
5043
5473
|
if (sub !== 'check') {
|
|
5044
|
-
write(`dz guard: unknown subcommand '${sub}' — use: check --op <op> | --init | log`);
|
|
5474
|
+
write(`dz guard: unknown subcommand '${sub}' — use: check --op <op> | promote | --init | log`);
|
|
5045
5475
|
return 1;
|
|
5046
5476
|
}
|
|
5047
5477
|
|
|
@@ -5899,7 +6329,19 @@ function cmdCompounding(options: Map<string, string>, flags: Set<string>, cwd: s
|
|
|
5899
6329
|
/* no audit yet */
|
|
5900
6330
|
}
|
|
5901
6331
|
|
|
5902
|
-
|
|
6332
|
+
// The evidence logs themselves, verbatim: the report verifies their hash chains (feature
|
|
6333
|
+
// event-chain). Handing over the TEXT rather than a pre-computed verdict keeps one definition of
|
|
6334
|
+
// "the chain is intact" — a second copy here is how a gate and its report start disagreeing.
|
|
6335
|
+
const evidenceLogs: { log: string; text: string }[] = [];
|
|
6336
|
+
for (const rel of ['.dz/recall-usage.jsonl', '.dz/guard-audit.jsonl']) {
|
|
6337
|
+
try {
|
|
6338
|
+
evidenceLogs.push({ log: rel, text: readFileSync(join(root, ...rel.split('/')), 'utf-8') });
|
|
6339
|
+
} catch {
|
|
6340
|
+
/* absent log — reported by its own gate above, not invented here */
|
|
6341
|
+
}
|
|
6342
|
+
}
|
|
6343
|
+
|
|
6344
|
+
const report = assembleCompoundingReport({ lessons, usage, guard, nowTs: new Date().toISOString(), evidenceLogs });
|
|
5903
6345
|
if (json) write(JSON.stringify({ ...report, exitCode: 0 }, null, 2));
|
|
5904
6346
|
else write(renderCompoundingReport(report));
|
|
5905
6347
|
return 0;
|