@dzhechkov/harness-cli 0.3.227 → 0.3.229

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dzhechkov/harness-cli",
3
- "version": "0.3.227",
3
+ "version": "0.3.229",
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",
@@ -55,7 +55,7 @@
55
55
  "@dzhechkov/skills-reverse-engineering": "^0.1.0",
56
56
  "@dzhechkov/skills-presentation-storyteller": "^0.1.0",
57
57
  "@dzhechkov/skills-website-cloner": "^0.1.0",
58
- "@dzhechkov/harness-core": "0.3.120"
58
+ "@dzhechkov/harness-core": "0.3.122"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@types/node": "^25.6.0",
package/src/cli.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * @packageDocumentation
5
5
  */
6
6
 
7
- import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, renameSync, rmdirSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
7
+ import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, 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
10
  import { execSync } from 'node:child_process';
@@ -101,6 +101,12 @@ import {
101
101
  buildSbom,
102
102
  resolveTrustRoot,
103
103
  decideVerifyPolicy,
104
+ generateSigningKeypair,
105
+ evaluateGuard,
106
+ resolveRules,
107
+ auditRecord,
108
+ guardExitCode,
109
+ DEFAULT_RULES,
104
110
  decideProvenance,
105
111
  isInsideTree,
106
112
  signManifest,
@@ -3230,10 +3236,73 @@ function reportPackVerification(
3230
3236
  }
3231
3237
 
3232
3238
  function cmdSign(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
3239
+ // `dz sign --init` — one-time keygen. Writes the PRIVATE key OUTSIDE the repo (default ~/.dz/keys/dz.key,
3240
+ // mode 0600) and prints the PUBLIC key for the operator to commit as keys/dz.pub. The private key never
3241
+ // touches the repo or a tarball (the hard invariant — enforced by assertKeyOutsideTree).
3242
+ if (flags.has('init')) {
3243
+ const outPath = resolve(cwd, options.get('out') ?? join(homedir(), '.dz', 'keys', 'dz.key'));
3244
+ // Resolve symlinks BEFORE the containment check: an --out (or a parent dir) that is a symlink pointing
3245
+ // INTO the repo would otherwise slip the private key inside the tree past a string-only guard — including a
3246
+ // DANGLING symlink whose target does not exist yet (existsSync follows the link and reports false, so the
3247
+ // symlink itself must be resolved via lstat/readlink, not existsSync).
3248
+ const realTarget = ((): string => {
3249
+ // walk up to the deepest path that exists as ANY entry (file, dir, or even a dangling symlink).
3250
+ let anc = outPath;
3251
+ const tail: string[] = [];
3252
+ for (;;) {
3253
+ try { lstatSync(anc); break; } catch { /* not present */ }
3254
+ const parent = dirname(anc);
3255
+ if (parent === anc) return outPath; // reached root without an existing entry
3256
+ tail.unshift(basename(anc));
3257
+ anc = parent;
3258
+ }
3259
+ let base: string;
3260
+ try {
3261
+ base = realpathSync(anc); // resolves dirs/files and any RESOLVABLE symlink chain
3262
+ } catch {
3263
+ // `anc` exists but realpath threw → a dangling symlink: resolve its immediate target by hand.
3264
+ try { base = resolve(dirname(anc), readlinkSync(anc)); } catch { return outPath; }
3265
+ }
3266
+ return tail.length ? join(base, ...tail) : base;
3267
+ })();
3268
+ try {
3269
+ assertKeyOutsideTree(realTarget, cwd);
3270
+ } catch (err) {
3271
+ write(`dz sign --init: ${(err as Error).message}`);
3272
+ write(' choose an --out path (and parent) OUTSIDE the repository (e.g. ~/.dz/keys/dz.key)');
3273
+ return 1;
3274
+ }
3275
+ if (existsSync(outPath) && !flags.has('force')) {
3276
+ write(`dz sign --init: ${outPath} already exists — refusing to overwrite (pass --force to replace)`);
3277
+ write(' overwriting a signing key orphans every pack signed with the old one.');
3278
+ return 1;
3279
+ }
3280
+ const { privateKey, publicKey } = generateSigningKeypair();
3281
+ try {
3282
+ mkdirSync(dirname(outPath), { recursive: true });
3283
+ // Unlink an existing file first: writeFileSync's `mode` is ignored when the file already exists, so a
3284
+ // pre-existing 0644 key would stay world-readable after --force. Removing it forces a fresh 0600 create.
3285
+ if (existsSync(outPath)) rmSync(outPath, { force: true });
3286
+ writeFileSync(outPath, privateKey, { mode: 0o600 });
3287
+ chmodSync(outPath, 0o600); // belt-and-suspenders: guarantee 0600 regardless of umask/prior state
3288
+ } catch (err) {
3289
+ write(`dz sign --init: could not write the private key to ${outPath}: ${(err as Error).message}`);
3290
+ return 1;
3291
+ }
3292
+ write(`dz sign --init: Ed25519 keypair generated.`);
3293
+ write(` private key → ${outPath} (mode 0600, OUTSIDE the repo — never commit it)`);
3294
+ write(` public key → commit the block below as ${TRUST_ROOT_REL}:`);
3295
+ write('');
3296
+ write(publicKey.trimEnd());
3297
+ write('');
3298
+ write(` then: dz sign --pack <dir> --key ${outPath} (sign a pack)`);
3299
+ return 0;
3300
+ }
3301
+
3233
3302
  const pack = options.get('pack');
3234
3303
  const key = options.get('key');
3235
3304
  if (!pack || !key) {
3236
- write('dz sign: --pack <dir> and --key <path> are both required');
3305
+ write('dz sign: --pack <dir> and --key <path> are both required (or: dz sign --init to generate a keypair)');
3237
3306
  write(' the key path MUST be outside the repository working tree (a leaked signing key is not revertible)');
3238
3307
  return 1;
3239
3308
  }
@@ -3285,12 +3354,43 @@ function cmdVerifyPack(options: Map<string, string>, flags: Set<string>, cwd: st
3285
3354
  return 1;
3286
3355
  }
3287
3356
 
3357
+ /**
3358
+ * `dz sbom` — emit a CycloneDX 1.5 SBOM (Software Bill of Materials) for a pack, standalone (no signing).
3359
+ * The same inventory `dz sign` writes as `sbom.json`, available on its own for audit/procurement. Components
3360
+ * are the pack's shipped files with their SHA-256 — a file-level bill of materials for the pack.
3361
+ * --pack <dir> the pack to inventory (required)
3362
+ * --out <file> write here (default: print to stdout)
3363
+ * --json (implied) SPDX/CycloneDX is already JSON
3364
+ */
3365
+ function cmdSbom(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
3366
+ const pack = options.get('pack');
3367
+ if (!pack) { write('dz sbom: --pack <dir> is required'); return 1; }
3368
+ const packDir = resolve(cwd, pack);
3369
+ if (!existsSync(packDir)) { write(`dz sbom: no such pack: ${packDir}`); return 1; }
3370
+
3371
+ const files = packFiles(packDir);
3372
+ if (files.length === 0) { write('dz sbom: the pack contains no files'); return 1; }
3373
+ const manifest = buildManifest(packDir, basename(packDir), files);
3374
+ const sbom = buildSbom(manifest);
3375
+ const out = JSON.stringify(sbom, null, 2);
3376
+
3377
+ const outOpt = options.get('out');
3378
+ if (outOpt !== undefined) {
3379
+ const outPath = resolve(cwd, outOpt);
3380
+ try { writeFileSync(outPath, out + '\n'); } catch (err) { write(`dz sbom: could not write ${outPath}: ${(err as Error).message}`); return 1; }
3381
+ write(`dz sbom: ${sbom.components.length} component(s) → ${outPath} (CycloneDX ${sbom.specVersion})`);
3382
+ return 0;
3383
+ }
3384
+ write(out);
3385
+ return 0;
3386
+ }
3387
+
3288
3388
  function cmdPublish(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
3289
3389
  // Reject unknown flags/options so a typo (e.g. `--dry-rum`) can NEVER be
3290
3390
  // silently swallowed and flip the command into live-publish mode.
3291
3391
  const allowedFlags = new Set(['dry-run', 'no-dry-run', 'yes', 'confirm', 'bump-only', 'help', 'require-signing', 'provenance', 'no-provenance']);
3292
- const allowedOptions = new Set(['filter', 'claim-check']);
3293
- const allowedHelp = ' allowed: --dry-run (default), --yes/--confirm/--no-dry-run (go live), --bump-only, --filter <substr>, --claim-check <off|warn|error>';
3392
+ const allowedOptions = new Set(['filter', 'claim-check', 'no-guard']);
3393
+ const allowedHelp = ' allowed: --dry-run (default), --yes/--confirm/--no-dry-run (go live), --bump-only, --filter <substr>, --claim-check <off|warn|error>, --no-guard "<reason>" (skip the guard pre-flight; logged)';
3294
3394
  for (const flag of flags) {
3295
3395
  if (!allowedFlags.has(flag)) {
3296
3396
  write(`dz publish: unknown option --${flag}`);
@@ -3307,6 +3407,29 @@ function cmdPublish(options: Map<string, string>, flags: Set<string>, cwd: strin
3307
3407
  }
3308
3408
  }
3309
3409
 
3410
+ // dz guard pre-flight (ADR-002 option A): publish is the most dangerous, least-reversible self-mutation, so
3411
+ // it ALWAYS runs the declarative guard first. A HARD violation refuses the publish; `--no-guard "<reason>"`
3412
+ // is the logged escape hatch (the override lands in .dz/guard-audit.jsonl — visible, never silent).
3413
+ {
3414
+ let guardRoot = cwd;
3415
+ try { guardRoot = execSync('git rev-parse --show-toplevel', { cwd, encoding: 'utf-8' }).trim() || cwd; } catch { /* not git */ }
3416
+ const noGuard = options.get('no-guard');
3417
+ if (noGuard !== undefined && noGuard.trim() === '') {
3418
+ write('dz publish: --no-guard requires a reason (it is logged): --no-guard "hotfix, guard re-run after"');
3419
+ return 1;
3420
+ }
3421
+ const guardResult = runGuardEvaluation(guardRoot, 'publish', undefined, noGuard);
3422
+ if (guardResult.verdict === 'block' && noGuard === undefined) {
3423
+ write('dz publish: ✗ BLOCKED by dz guard (HARD invariant violated):');
3424
+ for (const v of guardResult.violations.filter((x) => x.severity === 'hard')) write(` [BLOCK] ${v.rule}: ${v.detail}`);
3425
+ write(' → fix the violation(s), or override with --no-guard "<reason>" (logged to .dz/guard-audit.jsonl).');
3426
+ return 1;
3427
+ }
3428
+ if (guardResult.verdict === 'block') write(`dz publish: ⚠ guard BLOCK overridden via --no-guard: ${noGuard} (logged)`);
3429
+ else if (guardResult.verdict === 'warn') for (const v of guardResult.violations) write(`dz publish: ⚠ guard warn — ${v.rule}: ${v.detail}`);
3430
+ else write('dz publish: ✓ guard pre-flight passed');
3431
+ }
3432
+
3310
3433
  // ADR-001 (publish-provenance): decide BEFORE any work — flag validation, then a pre-flight that
3311
3434
  // refuses `--provenance` where no OIDC token can be minted. `off` is an escape hatch that names itself.
3312
3435
  if (flags.has('provenance') && flags.has('no-provenance')) {
@@ -4062,33 +4185,202 @@ function cmdDriftCheck(options: Map<string, string>, flags: Set<string>, cwd: st
4062
4185
  write(`allowlisted (accepted drift): ${r.allowlisted.map((d) => d.name).join(', ')}`);
4063
4186
  }
4064
4187
  write(`DRIFTED (unexpected, byte-differences between copies): ${r.drifted.length}`);
4188
+ let driftExit = 0;
4065
4189
  if (r.drifted.length === 0) {
4066
4190
  write('✓ no unexpected intra-monorepo skill drift');
4067
- return 0;
4191
+ } else {
4192
+ write('');
4193
+ write('skill'.padEnd(34) + 'copies drift/total');
4194
+ for (const d of r.drifted) {
4195
+ write(
4196
+ d.name.padEnd(34) +
4197
+ String(d.copies).padStart(4) +
4198
+ ' ' +
4199
+ `${d.driftFiles}/${d.totalFiles}` +
4200
+ (d.missingFiles ? ` (+${d.missingFiles} missing)` : ''),
4201
+ );
4202
+ }
4203
+ write('');
4204
+ write('→ fix each: heal drift, then commit. Remediation per skill:');
4205
+ for (const d of r.drifted) {
4206
+ const hasMeta = existsSync(join(root, 'packages', '@dzhechkov', 'skills-meta', d.name));
4207
+ write(
4208
+ hasMeta
4209
+ ? ` dz sync-canonical ${d.name}`
4210
+ : ` dz sync-canonical ${d.name} --from <a-known-good-copy> (no skills-meta canonical)`,
4211
+ );
4212
+ }
4213
+ write(' (accept a drift intentionally: add its name to .dz/drift-allowlist.json with a reason)');
4214
+ driftExit = 1; // EXIT CODE 1 = the CI gate trips
4068
4215
  }
4216
+ // Supplementary CI check: installed-pack signatures. A TAMPERED pack trips the gate (fatal); an unsigned
4217
+ // pack or a missing trust root is reported, not fatal — the same warn/block posture as `dz doctor` (the
4218
+ // primary signature gate). drift-check surfaces it so the CI drift view also flags a tampered pack.
4069
4219
  write('');
4070
- write('skill'.padEnd(34) + 'copies drift/total');
4071
- for (const d of r.drifted) {
4072
- write(
4073
- d.name.padEnd(34) +
4074
- String(d.copies).padStart(4) +
4075
- ' ' +
4076
- `${d.driftFiles}/${d.totalFiles}` +
4077
- (d.missingFiles ? ` (+${d.missingFiles} missing)` : ''),
4078
- );
4220
+ const sigFatal = reportPackVerification(root, options.get('pubkey'), flags.has('require-signing'), write);
4221
+ return driftExit || sigFatal;
4222
+ }
4223
+
4224
+ const DEFAULT_STORE_CAP = 5000;
4225
+
4226
+ /** Read the optional `.dz/guard.json` — `{ rules?: [...], storeCap?: number }`. Missing/broken ⇒ defaults. */
4227
+ function loadGuardConfig(root: string): { rules?: unknown[]; storeCap?: number } {
4228
+ const p = join(root, '.dz', 'guard.json');
4229
+ if (!existsSync(p)) return {};
4230
+ try {
4231
+ const j = JSON.parse(readFileSync(p, 'utf8'));
4232
+ return j && typeof j === 'object' ? j : {};
4233
+ } catch { return {}; }
4234
+ }
4235
+
4236
+ /** Extract labelled (a,b) count pairs from the READMEs that must agree (the parity invariant, inline). */
4237
+ function gatherReadmeCounts(root: string): { label: string; a: number; b: number }[] {
4238
+ const read = (rel: string): string => { try { return readFileSync(join(root, rel), 'utf8'); } catch { return ''; } };
4239
+ const rootMd = read('README.md');
4240
+ const cliMd = read('packages/@dzhechkov/harness-cli/README.md');
4241
+ const num = (s: string, re: RegExp): number | null => { const m = s.match(re); return m && m[1] ? Number(m[1]) : null; };
4242
+ const pairs: { label: string; a: number; b: number }[] = [];
4243
+ const cjm = num(rootMd, /## User Journey — 6 phases, (\d+) commands/);
4244
+ const cliAll = num(cliMd, /## All Commands \((\d+)\)/);
4245
+ const rootAll = num(rootMd, /## All Commands \((\d+)\)/);
4246
+ if (cjm !== null && cliAll !== null) pairs.push({ label: 'commands (root CJM header vs cli All Commands)', a: cjm, b: cliAll });
4247
+ if (rootAll !== null && cliAll !== null) pairs.push({ label: 'All Commands (root vs cli)', a: rootAll, b: cliAll });
4248
+ return pairs;
4249
+ }
4250
+
4251
+ /** Gather the facts one op needs. All I/O is best-effort — a missing signal skips its rule, never crashes. */
4252
+ function gatherGuardFacts(op: string, root: string, text: string | undefined, storeCap: number): Record<string, unknown> {
4253
+ const facts: Record<string, unknown> = { op };
4254
+ if (op === 'publish') {
4255
+ // Read every workspace manifest ONCE: build a name→version map, then resolve each `workspace:*` dep to the
4256
+ // version pnpm WOULD publish it as. In a pnpm workspace (pnpm-workspace.yaml present) `workspace:*` in source
4257
+ // is correct and gets rewritten at publish — so reporting it raw would be a FALSE gate. We mirror the rewrite:
4258
+ // a resolvable workspace dep becomes its real semver (safe → the rule passes); an UNRESOLVABLE one (points at
4259
+ // no workspace package, or not a pnpm workspace) stays `workspace:*` so the rule catches a dep that WOULD ship
4260
+ // raw. That is the genuinely dangerous case the rule exists for.
4261
+ const manifests: { name?: string; version?: string; private?: boolean; dependencies?: Record<string, string> }[] = [];
4262
+ try {
4263
+ const out = execSync('git ls-files "packages/@dzhechkov/*/package.json"', { cwd: root, encoding: 'utf-8' });
4264
+ for (const rel of out.split('\n').map((s) => s.trim()).filter(Boolean)) {
4265
+ try { manifests.push(JSON.parse(readFileSync(join(root, rel), 'utf8')) as typeof manifests[number]); } catch { /* skip unreadable */ }
4266
+ }
4267
+ } catch { /* not a git repo */ }
4268
+ const versionByName = new Map<string, string>();
4269
+ for (const m of manifests) if (m.name && typeof m.version === 'string') versionByName.set(m.name, m.version);
4270
+ const pnpmWorkspace = existsSync(join(root, 'pnpm-workspace.yaml'));
4271
+ const packages: { name: string; deps: Record<string, string> }[] = [];
4272
+ for (const m of manifests) {
4273
+ if (m.private === true) continue; // unpublished packages are exempt
4274
+ const deps: Record<string, string> = {};
4275
+ for (const [dep, spec] of Object.entries(m.dependencies ?? {})) {
4276
+ deps[dep] = (typeof spec === 'string' && spec.startsWith('workspace:') && pnpmWorkspace && versionByName.has(dep))
4277
+ ? versionByName.get(dep)! // pnpm rewrites this to a real semver at publish → safe
4278
+ : spec; // non-pnpm, or an unresolvable workspace dep → keep raw so the rule catches a would-ship-raw dep
4279
+ }
4280
+ packages.push({ name: m.name ?? '(unnamed)', deps });
4281
+ }
4282
+ facts['packages'] = packages;
4283
+ try { facts['drift'] = sweepSkillDrift(root, { scope: 'packages', allowlist: readDriftAllowlist(root) }).drifted.map((d) => d.name); } catch { /* skip */ }
4284
+ facts['counts'] = gatherReadmeCounts(root);
4079
4285
  }
4080
- write('');
4081
- write(' fix each: heal drift, then commit. Remediation per skill:');
4082
- for (const d of r.drifted) {
4083
- const hasMeta = existsSync(join(root, 'packages', '@dzhechkov', 'skills-meta', d.name));
4084
- write(
4085
- hasMeta
4086
- ? ` dz sync-canonical ${d.name}`
4087
- : ` dz sync-canonical ${d.name} --from <a-known-good-copy> (no skills-meta canonical)`,
4088
- );
4286
+ if (op === 'consolidate') {
4287
+ try { facts['drift'] = sweepSkillDrift(root, { scope: 'packages', allowlist: readDriftAllowlist(root) }).drifted.map((d) => d.name); } catch { /* skip */ }
4288
+ }
4289
+ if (op === 'teach' || op === 'consolidate') {
4290
+ if (op === 'teach' && text) facts['secretTargets'] = [{ label: 'lesson', text }];
4291
+ let count = 0;
4292
+ try { count = loadStorePatternsSync(root).length; } catch { /* skip → cap never trips */ count = 0; }
4293
+ facts['store'] = { count, cap: storeCap };
4089
4294
  }
4090
- write(' (accept a drift intentionally: add its name to .dz/drift-allowlist.json with a reason)');
4091
- return 1; // EXIT CODE 1 = the CI gate trips
4295
+ return facts;
4296
+ }
4297
+
4298
+ /**
4299
+ * Load config → resolve rules → gather facts → evaluate → append the audit record. The ONE evaluation path,
4300
+ * shared by `dz guard check` and the `dz publish` pre-flight (ADR-002 option A) so they can never disagree.
4301
+ * `overrideReason` (when the caller forces through a block) is logged, never silent.
4302
+ */
4303
+ function runGuardEvaluation(root: string, op: string, text: string | undefined, overrideReason: string | undefined): ReturnType<typeof evaluateGuard> {
4304
+ const cfg = loadGuardConfig(root);
4305
+ // Number.isFinite, not just > 0: a config `storeCap: 1e400` parses to Infinity, passes `> 0`, and would
4306
+ // silently DISABLE the cap (count <= Infinity always). Non-finite ⇒ fall back to the default.
4307
+ const storeCap = typeof cfg.storeCap === 'number' && Number.isFinite(cfg.storeCap) && cfg.storeCap > 0 ? cfg.storeCap : DEFAULT_STORE_CAP;
4308
+ const rules = resolveRules(Array.isArray(cfg.rules) ? (cfg.rules as never[]) : undefined);
4309
+ const facts = gatherGuardFacts(op, root, text, storeCap);
4310
+ const result = evaluateGuard(facts as never, rules);
4311
+ // audit (append-only). ts is real time here (a CLI, not the sandboxed workflow).
4312
+ try {
4313
+ const rec = auditRecord(result, new Date().toISOString(), overrideReason !== undefined ? { reason: overrideReason } : undefined);
4314
+ mkdirSync(join(root, '.dz'), { recursive: true });
4315
+ writeFileSync(join(root, '.dz', 'guard-audit.jsonl'), JSON.stringify(rec) + '\n', { flag: 'a' });
4316
+ } catch { /* audit is best-effort, never blocks the verdict */ }
4317
+ return result;
4318
+ }
4319
+
4320
+ /**
4321
+ * `dz guard` — the declarative constraint layer that refuses a self-mutating op when a HARD invariant is
4322
+ * violated. Simple outside: `dz guard check --op publish` works with zero config (built-in defaults).
4323
+ * check --op <publish|teach|consolidate|reindex> [--text <s>] [--json] [--force <reason>]
4324
+ * --init scaffold an editable .dz/guard.json (only if you want to customise)
4325
+ * log [--limit N] tail the append-only .dz/guard-audit.jsonl
4326
+ * Exit 1 on a HARD block (0 with --force <reason>, which is logged); 0 on warn/pass.
4327
+ */
4328
+ function cmdGuard(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
4329
+ let root = cwd;
4330
+ try { root = execSync('git rev-parse --show-toplevel', { cwd, encoding: 'utf-8' }).trim() || cwd; } catch { /* not git */ }
4331
+ const sub = options.get('_positional_0') ?? 'check';
4332
+
4333
+ if (flags.has('init') || sub === 'init') {
4334
+ const p = join(root, '.dz', 'guard.json');
4335
+ if (existsSync(p) && !flags.has('force')) { write(`dz guard --init: ${p} already exists (pass --force to overwrite)`); return 1; }
4336
+ const scaffold = {
4337
+ storeCap: DEFAULT_STORE_CAP,
4338
+ rules: DEFAULT_RULES.map((r) => ({ id: r.id, severity: r.severity, enabled: true, description: r.description })),
4339
+ };
4340
+ mkdirSync(dirname(p), { recursive: true });
4341
+ writeFileSync(p, JSON.stringify(scaffold, null, 2) + '\n');
4342
+ write(`dz guard --init: wrote ${p} (edit severity/enabled to customise; delete it to return to built-in defaults)`);
4343
+ return 0;
4344
+ }
4345
+
4346
+ if (sub === 'log') {
4347
+ const p = join(root, '.dz', 'guard-audit.jsonl');
4348
+ if (!existsSync(p)) { write('dz guard log: no audit yet (.dz/guard-audit.jsonl)'); return 0; }
4349
+ const limit = Math.max(1, Number(options.get('limit') ?? '20') || 20);
4350
+ // parse-filter BEFORE emitting: a corrupt line must not make the --json output invalid JSON.
4351
+ const rows = readFileSync(p, 'utf8').split('\n').filter(Boolean).slice(-limit)
4352
+ .map((row) => { try { return JSON.parse(row) as { ts: string; op: string; verdict: string; override?: { reason: string } }; } catch { return null; } })
4353
+ .filter((r): r is { ts: string; op: string; verdict: string; override?: { reason: string } } => r !== null);
4354
+ if (flags.has('json')) { write(JSON.stringify(rows)); return 0; }
4355
+ for (const r of rows) {
4356
+ write(`${r.ts} ${String(r.op).padEnd(11)} ${String(r.verdict).toUpperCase()}${r.override ? ` (forced: ${r.override.reason})` : ''}`);
4357
+ }
4358
+ return 0;
4359
+ }
4360
+
4361
+ if (sub !== 'check') {
4362
+ write(`dz guard: unknown subcommand '${sub}' — use: check --op <op> | --init | log`);
4363
+ return 1;
4364
+ }
4365
+
4366
+ // check
4367
+ const op = options.get('op');
4368
+ if (op === undefined || !['publish', 'teach', 'consolidate', 'reindex'].includes(op)) {
4369
+ write('dz guard check: --op must be one of publish | teach | consolidate | reindex');
4370
+ return 1;
4371
+ }
4372
+ const force = options.get('force');
4373
+ const forced = force !== undefined;
4374
+ const result = runGuardEvaluation(root, op, options.get('text'), force);
4375
+
4376
+ if (flags.has('json')) { write(JSON.stringify({ ...result, forced }, null, 2)); return guardExitCode(result, forced); }
4377
+
4378
+ const glyph = result.verdict === 'block' ? '✗' : result.verdict === 'warn' ? '⚠' : '✓';
4379
+ write(`dz guard (${op}): ${glyph} ${result.verdict.toUpperCase()} [checked: ${result.checked.join(', ') || 'no rules for this op'}]`);
4380
+ for (const v of result.violations) write(` [${v.severity === 'hard' ? 'BLOCK' : 'warn'}] ${v.rule}: ${v.detail}`);
4381
+ if (result.verdict === 'block' && forced) write(` → forced through: ${force} (logged to .dz/guard-audit.jsonl)`);
4382
+ else if (result.verdict === 'block') write(' → blocked. Fix the HARD violation(s), or override with --force "<reason>" (logged).');
4383
+ return guardExitCode(result, forced);
4092
4384
  }
4093
4385
 
4094
4386
  /**
@@ -5065,6 +5357,10 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
5065
5357
  return cmdClaimCheck(options, optionLists, flags, cwd, write);
5066
5358
  case 'sign':
5067
5359
  return cmdSign(options, flags, cwd, write);
5360
+ case 'sbom':
5361
+ return cmdSbom(options, flags, cwd, write);
5362
+ case 'guard':
5363
+ return cmdGuard(options, flags, cwd, write);
5068
5364
  case 'verify-pack':
5069
5365
  return cmdVerifyPack(options, flags, cwd, write);
5070
5366
  case 'setup':