@dzhechkov/harness-cli 0.3.228 → 0.3.230
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/README.md +26 -2
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +239 -3
- package/dist/cli.js.map +1 -1
- package/package.json +2 -2
- package/src/cli.ts +194 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dzhechkov/harness-cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.230",
|
|
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.
|
|
58
|
+
"@dzhechkov/harness-core": "0.3.122"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
61
|
"@types/node": "^25.6.0",
|
package/src/cli.ts
CHANGED
|
@@ -102,6 +102,11 @@ import {
|
|
|
102
102
|
resolveTrustRoot,
|
|
103
103
|
decideVerifyPolicy,
|
|
104
104
|
generateSigningKeypair,
|
|
105
|
+
evaluateGuard,
|
|
106
|
+
resolveRules,
|
|
107
|
+
auditRecord,
|
|
108
|
+
guardExitCode,
|
|
109
|
+
DEFAULT_RULES,
|
|
105
110
|
decideProvenance,
|
|
106
111
|
isInsideTree,
|
|
107
112
|
signManifest,
|
|
@@ -3384,8 +3389,8 @@ function cmdPublish(options: Map<string, string>, flags: Set<string>, cwd: strin
|
|
|
3384
3389
|
// Reject unknown flags/options so a typo (e.g. `--dry-rum`) can NEVER be
|
|
3385
3390
|
// silently swallowed and flip the command into live-publish mode.
|
|
3386
3391
|
const allowedFlags = new Set(['dry-run', 'no-dry-run', 'yes', 'confirm', 'bump-only', 'help', 'require-signing', 'provenance', 'no-provenance']);
|
|
3387
|
-
const allowedOptions = new Set(['filter', 'claim-check']);
|
|
3388
|
-
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)';
|
|
3389
3394
|
for (const flag of flags) {
|
|
3390
3395
|
if (!allowedFlags.has(flag)) {
|
|
3391
3396
|
write(`dz publish: unknown option --${flag}`);
|
|
@@ -3402,6 +3407,29 @@ function cmdPublish(options: Map<string, string>, flags: Set<string>, cwd: strin
|
|
|
3402
3407
|
}
|
|
3403
3408
|
}
|
|
3404
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
|
+
|
|
3405
3433
|
// ADR-001 (publish-provenance): decide BEFORE any work — flag validation, then a pre-flight that
|
|
3406
3434
|
// refuses `--provenance` where no OIDC token can be minted. `off` is an escape hatch that names itself.
|
|
3407
3435
|
if (flags.has('provenance') && flags.has('no-provenance')) {
|
|
@@ -4193,6 +4221,168 @@ function cmdDriftCheck(options: Map<string, string>, flags: Set<string>, cwd: st
|
|
|
4193
4221
|
return driftExit || sigFatal;
|
|
4194
4222
|
}
|
|
4195
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);
|
|
4285
|
+
}
|
|
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 };
|
|
4294
|
+
}
|
|
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);
|
|
4384
|
+
}
|
|
4385
|
+
|
|
4196
4386
|
/**
|
|
4197
4387
|
* `dz sync-canonical <skill>` — the healer. Treats the resolved canonical (`--from` →
|
|
4198
4388
|
* `skills-meta/<skill>` → `--auto` most-complete copy) as authoritative and overwrites every other
|
|
@@ -5169,6 +5359,8 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
|
|
|
5169
5359
|
return cmdSign(options, flags, cwd, write);
|
|
5170
5360
|
case 'sbom':
|
|
5171
5361
|
return cmdSbom(options, flags, cwd, write);
|
|
5362
|
+
case 'guard':
|
|
5363
|
+
return cmdGuard(options, flags, cwd, write);
|
|
5172
5364
|
case 'verify-pack':
|
|
5173
5365
|
return cmdVerifyPack(options, flags, cwd, write);
|
|
5174
5366
|
case 'setup':
|