@dzhechkov/harness-cli 0.3.199 → 0.3.201

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.199",
3
+ "version": "0.3.201",
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",
@@ -37,15 +37,7 @@
37
37
  "src",
38
38
  "README.md"
39
39
  ],
40
- "scripts": {
41
- "build": "tsc -p tsconfig.json",
42
- "test": "vitest run",
43
- "test:watch": "vitest",
44
- "typecheck": "tsc -p tsconfig.json --noEmit",
45
- "lint": "tsc -p tsconfig.json --noEmit"
46
- },
47
40
  "dependencies": {
48
- "@dzhechkov/harness-core": "workspace:*",
49
41
  "@dzhechkov/harness-presets": "^0.5.0",
50
42
  "@dzhechkov/scout": "^0.8.0",
51
43
  "@dzhechkov/skills-devops": "^0.3.0",
@@ -61,7 +53,8 @@
61
53
  "@dzhechkov/skills-idea2prd": "^0.1.0",
62
54
  "@dzhechkov/skills-reverse-engineering": "^0.1.0",
63
55
  "@dzhechkov/skills-presentation-storyteller": "^0.1.0",
64
- "@dzhechkov/skills-website-cloner": "^0.1.0"
56
+ "@dzhechkov/skills-website-cloner": "^0.1.0",
57
+ "@dzhechkov/harness-core": "0.3.101"
65
58
  },
66
59
  "devDependencies": {
67
60
  "@types/node": "^25.6.0",
@@ -79,5 +72,12 @@
79
72
  "url": "https://github.com/djd1m/dz-harness-hub.git",
80
73
  "directory": "packages/@dzhechkov/harness-cli"
81
74
  },
82
- "homepage": "https://github.com/djd1m/dz-harness-hub/tree/main/packages/@dzhechkov/harness-cli#readme"
83
- }
75
+ "homepage": "https://github.com/djd1m/dz-harness-hub/tree/main/packages/@dzhechkov/harness-cli#readme",
76
+ "scripts": {
77
+ "build": "tsc -p tsconfig.json",
78
+ "test": "vitest run",
79
+ "test:watch": "vitest",
80
+ "typecheck": "tsc -p tsconfig.json --noEmit",
81
+ "lint": "tsc -p tsconfig.json --noEmit"
82
+ }
83
+ }
package/src/cli.ts CHANGED
@@ -67,7 +67,11 @@ import {
67
67
  statuslineData,
68
68
  writeFeatureAdrState,
69
69
  computeUsage,
70
+ deriveUsageCalibration,
71
+ normalizeClaudeUsageModelKey,
70
72
  readUsageLimits,
73
+ claimCheck,
74
+ summarize,
71
75
  queryBookKnowledge,
72
76
  loadStorePatternsSync,
73
77
  bundleSkills,
@@ -84,7 +88,7 @@ import {
84
88
  importBrainSlice,
85
89
  registerKusToBrain,
86
90
  } from '@dzhechkov/harness-core';
87
- import type { PatternRecord, TargetName, BookKU, HarmonizeReport } from '@dzhechkov/harness-core';
91
+ import type { ClaudeUsageModel, PatternRecord, TargetName, BookKU, HarmonizeReport, UsageCalibrationPlan, ClaimFinding } from '@dzhechkov/harness-core';
88
92
  import { getPreset, PRESET_NAMES } from '@dzhechkov/harness-presets';
89
93
  import { scanGitHub, analyzeRepo, generateReport, deepAnalyze, scanAllSources, ScoutMemory } from '@dzhechkov/scout';
90
94
 
@@ -104,7 +108,7 @@ Usage:
104
108
  dz workflow <task> [--dry-run]
105
109
  dz install <npm-pkg> [--target <name>] [--project <dir>] [--force]
106
110
  dz bundle [--preset <name> | --select id,id,...] [--out <dir>] [--skills-dir <dir>] [--force] (portable self-contained skill bundles for a generic/LangGraph consumer)
107
- dz publish [--filter <name>] [--bump-only] (dry-run by default; pass --yes/--confirm/--no-dry-run to go live)
111
+ dz publish [--filter <name>] [--bump-only] [--claim-check <off|warn|error>] (dry-run by default; pass --yes/--confirm/--no-dry-run to go live; claim-check gate default warn — surfaces README claim findings, never blocks; error fails an offending package)
108
112
  dz setup --target <name> [--preset <name>] [--select id,id,...] [--skills-dir <dir>] [--project <dir>] [--memory agentdb] [--no-memory] [--no-hooks] [--install-driver] [--force] [--enrich]
109
113
  dz teach "<pattern>" [--reward <0-1>] [--domain <name>] [--type rule|success-pattern|lesson-learned] [--project <dir>] [--no-mirror] (--project pins the learned store to <dir>/.dz, not the cwd — pin to a canonical brain)
110
114
  dz teach --from-json <file> [--project <dir>] [--no-mirror] (bulk-import a 'dz recall --all --json' export — share a learned store across machines)
@@ -127,7 +131,8 @@ Usage:
127
131
  dz brain init [--project <dir>] [--k <N>] (wire the grounding hook into .claude/settings.json — opt-in)
128
132
  dz statusline [--json] [--install] [--project <dir>] (live self-learning panel for Claude Code's status bar; reads the CC JSON payload from STDIN)
129
133
  dz statusline --fa-record --slug <s> --step "<label>" [--recalled <n>] [--stored <n>] [--mode <m>] (feature-adr: record live per-run learning state → 📐 panel segment)
130
- dz usage [--json] [--project <dir>] (ESTIMATE Claude session (5h-block) + weekly (7d) token usage from local transcripts; exit 0 ALWAYS; pct=null when memory.usage.{sessionTokenLimit,weeklyTokenLimit} unconfigured in .dz/config.json)
134
+ dz usage [--json] [--project <dir>] | dz usage --calibrate --session <pct> --weekly <pct> [--model fable=<pct>] [--project <dir>] (ESTIMATE Claude usage from fixed reset windows; optional per-model weekly binding; exit 0 ALWAYS; pct=null when limits unconfigured)
135
+ dz claim-check [paths...] [--json] [--fail-on high|medium|none] [--project <dir>] (enforce the Integrity Rule: flag untagged/overstated accuracy claims; default scan = root README.md + every discovered package's README.md + features/*/08_qe_report.md; exit 1 only at/above --fail-on, default high)
131
136
  dz pretrain [--project <dir>]
132
137
  dz recommend "<task description>"
133
138
  dz compose <preset1+preset2+...> [--target <name>]
@@ -170,12 +175,14 @@ export interface CliIo {
170
175
  interface ParsedArgs {
171
176
  readonly command: string;
172
177
  readonly options: Map<string, string>;
178
+ readonly optionLists: Map<string, string[]>;
173
179
  readonly flags: Set<string>;
174
180
  }
175
181
 
176
182
  /** Parse `<command> [--key value] [--flag]` argv. */
177
183
  function parseArgs(argv: string[]): ParsedArgs {
178
184
  const options = new Map<string, string>();
185
+ const optionLists = new Map<string, string[]>();
179
186
  const flags = new Set<string>();
180
187
  const positional: string[] = [];
181
188
 
@@ -186,6 +193,9 @@ function parseArgs(argv: string[]): ParsedArgs {
186
193
  const next = argv[index + 1];
187
194
  if (next !== undefined && !next.startsWith('--')) {
188
195
  options.set(key, next);
196
+ const list = optionLists.get(key) ?? [];
197
+ list.push(next);
198
+ optionLists.set(key, list);
189
199
  index += 1;
190
200
  } else {
191
201
  flags.add(key);
@@ -200,7 +210,7 @@ function parseArgs(argv: string[]): ParsedArgs {
200
210
  options.set(`_positional_${pi - 1}`, positional[pi] ?? '');
201
211
  }
202
212
 
203
- return { command: positional[0] ?? '', options, flags };
213
+ return { command: positional[0] ?? '', options, optionLists, flags };
204
214
  }
205
215
 
206
216
  type Write = (line: string) => void;
@@ -1112,18 +1122,187 @@ function cmdStatusline(
1112
1122
  }
1113
1123
  }
1114
1124
 
1125
+ function isJsonRecord(value: unknown): value is Record<string, unknown> {
1126
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
1127
+ }
1128
+
1129
+ function usageConfigPath(projectRoot: string): string {
1130
+ return join(projectRoot, '.dz', 'config.json');
1131
+ }
1132
+
1133
+ function readProjectConfigForUsage(projectRoot: string): { config: Record<string, unknown>; warning?: string } {
1134
+ const path = usageConfigPath(projectRoot);
1135
+ try {
1136
+ if (!existsSync(path)) return { config: {} };
1137
+ const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown;
1138
+ if (isJsonRecord(parsed)) return { config: parsed };
1139
+ return { config: {}, warning: 'existing config is not a JSON object; writing a minimal config' };
1140
+ } catch {
1141
+ return { config: {}, warning: 'existing config could not be parsed; writing a minimal config' };
1142
+ }
1143
+ }
1144
+
1145
+ function applyUsageCalibrationToConfig(config: Record<string, unknown>, plan: UsageCalibrationPlan): Record<string, unknown> {
1146
+ const next: Record<string, unknown> = { ...config };
1147
+ const memory = isJsonRecord(next['memory']) ? { ...next['memory'] } : {};
1148
+ const usage = isJsonRecord(memory['usage']) ? { ...memory['usage'] } : {};
1149
+
1150
+ for (const change of plan.changes) {
1151
+ if (change.key === 'session') {
1152
+ usage['sessionTokenLimit'] = change.after;
1153
+ } else if (change.key === 'weekly') {
1154
+ usage['weeklyTokenLimit'] = change.after;
1155
+ } else {
1156
+ const model = normalizeClaudeUsageModelKey(change.key);
1157
+ if (model) {
1158
+ const existingByModel = isJsonRecord(usage['weeklyTokenLimitByModel']) ? { ...usage['weeklyTokenLimitByModel'] } : {};
1159
+ existingByModel[model] = change.after;
1160
+ usage['weeklyTokenLimitByModel'] = existingByModel;
1161
+ }
1162
+ }
1163
+ }
1164
+
1165
+ if (plan.changes.length > 0) {
1166
+ usage['calibratedAt'] = plan.after.calibratedAt;
1167
+ usage['source'] = plan.after.source;
1168
+ }
1169
+
1170
+ memory['usage'] = usage;
1171
+ next['memory'] = memory;
1172
+ return next;
1173
+ }
1174
+
1175
+ function parseUsageModelArgs(modelArgs: readonly string[]): { modelPct: Record<string, unknown>; skipped: string[] } {
1176
+ const modelPct: Record<string, unknown> = {};
1177
+ const skipped: string[] = [];
1178
+ for (const raw of modelArgs) {
1179
+ const eq = raw.indexOf('=');
1180
+ if (eq <= 0 || eq === raw.length - 1) {
1181
+ skipped.push(`model ${raw}: skipped malformed model=pct argument`);
1182
+ continue;
1183
+ }
1184
+ const modelName = raw.slice(0, eq).trim();
1185
+ const model = normalizeClaudeUsageModelKey(modelName);
1186
+ if (!model) {
1187
+ skipped.push(`model ${modelName}: skipped unknown model`);
1188
+ continue;
1189
+ }
1190
+ modelPct[model] = raw.slice(eq + 1).trim();
1191
+ }
1192
+ return { modelPct, skipped };
1193
+ }
1194
+
1195
+ function writeUsageCalibrationSummary(opts: {
1196
+ projectRoot: string;
1197
+ plan: UsageCalibrationPlan;
1198
+ preSkipped: readonly string[];
1199
+ configWarning?: string | undefined;
1200
+ wrote: boolean;
1201
+ write: Write;
1202
+ }): void {
1203
+ opts.write('usage calibrate: estimated local transcript counts; claude.ai/settings/usage is authoritative');
1204
+ opts.write(`usage calibrate: project ${opts.projectRoot}`);
1205
+ if (opts.configWarning) opts.write(`usage calibrate: ${opts.configWarning}`);
1206
+ for (const change of opts.plan.changes) {
1207
+ opts.write(
1208
+ `usage calibrate: ${change.key} tokens=${change.tokens} pct=${change.pct}% limit ${change.before ?? 'null'} -> ${change.after}`,
1209
+ );
1210
+ }
1211
+ const skipped = [...opts.preSkipped, ...opts.plan.skipped];
1212
+ for (const item of skipped) opts.write(`usage calibrate: skipped ${item}`);
1213
+ if (opts.wrote) {
1214
+ opts.write('usage calibrate: wrote .dz/config.json with source claude.ai/settings/usage');
1215
+ } else {
1216
+ opts.write('usage calibrate: no config changes written');
1217
+ }
1218
+ }
1219
+
1220
+ function cmdUsageCalibrate(
1221
+ options: Map<string, string>,
1222
+ optionLists: Map<string, string[]>,
1223
+ cwd: string,
1224
+ write: Write,
1225
+ ): number {
1226
+ const projectRoot = resolve(cwd, options.get('project') ?? '.');
1227
+ const suppliedModels = optionLists.get('model') ?? [];
1228
+ const parsedModels = parseUsageModelArgs(suppliedModels);
1229
+ const modelPct = parsedModels.modelPct;
1230
+ const hasModelPct = Object.keys(modelPct).length > 0;
1231
+ const input = {
1232
+ ...(options.has('session') ? { sessionPct: options.get('session') } : {}),
1233
+ ...(options.has('weekly') ? { weeklyPct: options.get('weekly') } : {}),
1234
+ ...(hasModelPct ? { modelPct } : {}),
1235
+ calibratedAt: new Date().toISOString(),
1236
+ source: 'claude.ai/settings/usage' as const,
1237
+ };
1238
+ const missingInputs: string[] = [];
1239
+ if (!options.has('session') && !options.has('weekly') && !hasModelPct) {
1240
+ missingInputs.push('no calibration percentages supplied');
1241
+ }
1242
+
1243
+ try {
1244
+ const current = computeUsage(projectRoot);
1245
+ const before = readUsageLimits(projectRoot);
1246
+ const plan = deriveUsageCalibration(current, before, input);
1247
+ if (plan.changes.length === 0) {
1248
+ writeUsageCalibrationSummary({
1249
+ projectRoot,
1250
+ plan,
1251
+ preSkipped: [...parsedModels.skipped, ...missingInputs],
1252
+ wrote: false,
1253
+ write,
1254
+ });
1255
+ return 0;
1256
+ }
1257
+
1258
+ const existing = readProjectConfigForUsage(projectRoot);
1259
+ const nextConfig = applyUsageCalibrationToConfig(existing.config, plan);
1260
+ try {
1261
+ mkdirSync(join(projectRoot, '.dz'), { recursive: true });
1262
+ writeFileSync(usageConfigPath(projectRoot), JSON.stringify(nextConfig, null, 2) + '\n');
1263
+ writeUsageCalibrationSummary({
1264
+ projectRoot,
1265
+ plan,
1266
+ preSkipped: [...parsedModels.skipped, ...missingInputs],
1267
+ configWarning: existing.warning,
1268
+ wrote: true,
1269
+ write,
1270
+ });
1271
+ } catch {
1272
+ writeUsageCalibrationSummary({
1273
+ projectRoot,
1274
+ plan,
1275
+ preSkipped: [...parsedModels.skipped, ...missingInputs, 'write failed'],
1276
+ configWarning: existing.warning,
1277
+ wrote: false,
1278
+ write,
1279
+ });
1280
+ }
1281
+ return 0;
1282
+ } catch {
1283
+ write('usage calibrate: skipped internal error; no config changes written');
1284
+ return 0;
1285
+ }
1286
+ }
1287
+
1115
1288
  /**
1116
- * `dz usage` — print an ESTIMATE of Claude session (active 5h-block) + weekly (rolling 7d) token
1117
- * usage, aggregated READONLY from the local transcript store (see {@link computeUsage}). `--json`
1118
- * emits the single-line contract the feature-adr usage-probe agent parses; the human path prints a
1119
- * compact `session ~74% (resets 19:00) · week ~52% (resets Sat)` or an unconfigured hint.
1289
+ * `dz usage` — print an ESTIMATE of Claude session + weekly usage from fixed reset windows,
1290
+ * aggregated READONLY from the local transcript store (see {@link computeUsage}). `--json` emits
1291
+ * the single-line contract the feature-adr usage-probe agent parses; `--calibrate` is the only
1292
+ * write path and records human-transcribed claude.ai percentages in `.dz/config.json`.
1120
1293
  *
1121
1294
  * **Exit code is 0 ALWAYS** — including on internal error the whole body is guarded and prints the
1122
1295
  * all-null JSON, so a probe can NEVER distinguish "usage unknown" from "command failed" via a
1123
- * non-zero exit (unknown is a first-class value, INV-3). `--project <dir>` scopes ONLY the
1124
- * `.dz/config.json` limits read; the measurement is account-wide (all projects, FR-1.6).
1296
+ * non-zero exit. `--project <dir>` scopes ONLY the `.dz/config.json` read/write; measurement is
1297
+ * account-wide (all projects).
1125
1298
  */
1126
- function cmdUsage(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
1299
+ function cmdUsage(
1300
+ options: Map<string, string>,
1301
+ optionLists: Map<string, string[]>,
1302
+ flags: Set<string>,
1303
+ cwd: string,
1304
+ write: Write,
1305
+ ): number {
1127
1306
  const projectRoot = resolve(cwd, options.get('project') ?? '.');
1128
1307
  const nullContract = () =>
1129
1308
  JSON.stringify({
@@ -1136,19 +1315,40 @@ function cmdUsage(options: Map<string, string>, flags: Set<string>, cwd: string,
1136
1315
  estimated: true,
1137
1316
  });
1138
1317
  try {
1318
+ if (flags.has('calibrate')) return cmdUsageCalibrate(options, optionLists, cwd, write);
1319
+
1139
1320
  const u = computeUsage(projectRoot);
1140
1321
  const lim = readUsageLimits(projectRoot);
1322
+ const modelLimits = lim.weeklyTokenLimitByModel;
1323
+ const hasModelLimits = modelLimits !== undefined && Object.keys(modelLimits).length > 0;
1141
1324
  if (flags.has('json')) {
1325
+ const limitsPayload: {
1326
+ session: number | null;
1327
+ weekly: number | null;
1328
+ weeklyByModel?: Partial<Record<ClaudeUsageModel, number>>;
1329
+ } = { session: lim.sessionTokenLimit ?? null, weekly: lim.weeklyTokenLimit ?? null };
1330
+ if (hasModelLimits) limitsPayload.weeklyByModel = { ...modelLimits };
1331
+ const payload: {
1332
+ sessionPct: number | null;
1333
+ weeklyPct: number | null;
1334
+ sessionTokens: number;
1335
+ weeklyTokens: number;
1336
+ resetsAt: { session: string | null; weekly: string | null };
1337
+ limits: typeof limitsPayload;
1338
+ weeklyByModel?: typeof u.weeklyByModel;
1339
+ estimated: true;
1340
+ } = {
1341
+ sessionPct: u.sessionPct,
1342
+ weeklyPct: u.weeklyPct,
1343
+ sessionTokens: u.sessionTokens,
1344
+ weeklyTokens: u.weeklyTokens,
1345
+ resetsAt: { session: u.sessionResetsAt, weekly: u.weeklyResetsAt },
1346
+ limits: limitsPayload,
1347
+ estimated: true,
1348
+ };
1349
+ if (hasModelLimits && u.weeklyByModel !== undefined) payload.weeklyByModel = u.weeklyByModel;
1142
1350
  write(
1143
- JSON.stringify({
1144
- sessionPct: u.sessionPct,
1145
- weeklyPct: u.weeklyPct,
1146
- sessionTokens: u.sessionTokens,
1147
- weeklyTokens: u.weeklyTokens,
1148
- resetsAt: { session: u.sessionResetsAt, weekly: u.weeklyResetsAt },
1149
- limits: { session: lim.sessionTokenLimit ?? null, weekly: lim.weeklyTokenLimit ?? null },
1150
- estimated: true,
1151
- }),
1351
+ JSON.stringify(payload),
1152
1352
  );
1153
1353
  return 0;
1154
1354
  }
@@ -1168,7 +1368,8 @@ function cmdUsage(options: Map<string, string>, flags: Set<string>, cwd: string,
1168
1368
  }
1169
1369
  };
1170
1370
  const s = u.sessionPct === null ? 'n/a' : '~' + u.sessionPct + '%';
1171
- const w = u.weeklyPct === null ? 'n/a' : '~' + u.weeklyPct + '%';
1371
+ const binding = hasModelLimits && u.weeklyBindingModel !== undefined ? ' ' + u.weeklyBindingModel + '-bound' : '';
1372
+ const w = u.weeklyPct === null ? 'n/a' : '~' + u.weeklyPct + '%' + binding;
1172
1373
  write('usage: session ' + s + ' (resets ' + clock(u.sessionResetsAt) + ') · week ' + w + ' (resets ' + clock(u.weeklyResetsAt) + ') · estimated');
1173
1374
  return 0;
1174
1375
  } catch {
@@ -2628,11 +2829,12 @@ function cmdPublish(options: Map<string, string>, flags: Set<string>, cwd: strin
2628
2829
  // Reject unknown flags/options so a typo (e.g. `--dry-rum`) can NEVER be
2629
2830
  // silently swallowed and flip the command into live-publish mode.
2630
2831
  const allowedFlags = new Set(['dry-run', 'no-dry-run', 'yes', 'confirm', 'bump-only', 'help']);
2631
- const allowedOptions = new Set(['filter']);
2832
+ const allowedOptions = new Set(['filter', 'claim-check']);
2833
+ const allowedHelp = ' allowed: --dry-run (default), --yes/--confirm/--no-dry-run (go live), --bump-only, --filter <substr>, --claim-check <off|warn|error>';
2632
2834
  for (const flag of flags) {
2633
2835
  if (!allowedFlags.has(flag)) {
2634
2836
  write(`dz publish: unknown option --${flag}`);
2635
- write(` allowed: --dry-run (default), --yes/--confirm/--no-dry-run (go live), --bump-only, --filter <substr>`);
2837
+ write(allowedHelp);
2636
2838
  return 1;
2637
2839
  }
2638
2840
  }
@@ -2640,11 +2842,21 @@ function cmdPublish(options: Map<string, string>, flags: Set<string>, cwd: strin
2640
2842
  if (key.startsWith('_positional_')) continue;
2641
2843
  if (!allowedOptions.has(key)) {
2642
2844
  write(`dz publish: unknown option --${key}`);
2643
- write(` allowed: --dry-run (default), --yes/--confirm/--no-dry-run (go live), --bump-only, --filter <substr>`);
2845
+ write(allowedHelp);
2644
2846
  return 1;
2645
2847
  }
2646
2848
  }
2647
2849
 
2850
+ // Pre-publish claim-check gate strictness: reject (never coerce) an invalid value. Default 'warn'
2851
+ // per ADR-001 — findings are SURFACED on every publish, but 'warn' never changes publish status,
2852
+ // so the success path is unchanged. 'off' disables the gate; 'error' fails an offending package.
2853
+ const claimCheckRaw = options.get('claim-check');
2854
+ if (claimCheckRaw !== undefined && !['off', 'warn', 'error'].includes(claimCheckRaw)) {
2855
+ write(`dz publish: invalid --claim-check '${claimCheckRaw}' (expected off|warn|error)`);
2856
+ return 1;
2857
+ }
2858
+ const claimCheckOpt = (claimCheckRaw as 'off' | 'warn' | 'error' | undefined) ?? 'warn';
2859
+
2648
2860
  const bumpOnly = flags.has('bump-only');
2649
2861
  const filterStr = options.get('filter');
2650
2862
  // SAFETY: trim + drop empty segments (mirrors --select at the top of cmdInit).
@@ -2690,14 +2902,18 @@ function cmdPublish(options: Map<string, string>, flags: Set<string>, cwd: strin
2690
2902
  write(`╚══════════════════════════════════════════════════════════════════════╝`);
2691
2903
  }
2692
2904
 
2693
- const report = publishPackages(cwd, { dryRun, filter, bumpOnly });
2905
+ const report = publishPackages(cwd, { dryRun, filter, bumpOnly, claimGate: claimCheckOpt });
2694
2906
 
2695
- write(`\ndz publish${dryRun ? ' --dry-run' : ''}${bumpOnly ? ' --bump-only' : ''}`);
2907
+ write(`\ndz publish${dryRun ? ' --dry-run' : ''}${bumpOnly ? ' --bump-only' : ''}${claimCheckOpt !== 'warn' ? ` --claim-check ${claimCheckOpt}` : ''}`);
2696
2908
  write(` Published: ${report.published} Skipped: ${report.skipped} Errors: ${report.errors}\n`);
2697
2909
  for (const pkg of report.packages) {
2698
2910
  const icon = pkg.status === 'published' ? '✓' : pkg.status === 'skipped' ? '○' : '✗';
2699
2911
  const detail = pkg.error ? ` (${pkg.error.slice(0, 60)})` : '';
2700
2912
  write(` ${icon} ${pkg.name.padEnd(35)} ${pkg.oldVersion} → ${pkg.newVersion} ${pkg.status}${detail}`);
2913
+ // Surface warn-mode findings that did not block the publish.
2914
+ if (pkg.claimCheck && pkg.claimCheck.findings > 0 && pkg.status !== 'error') {
2915
+ write(` ⚠ claim-check: ${pkg.claimCheck.findings} finding(s) (${pkg.claimCheck.high} high) in README.md`);
2916
+ }
2701
2917
  }
2702
2918
  return report.errors > 0 ? 1 : 0;
2703
2919
  }
@@ -2871,6 +3087,139 @@ function cmdBenchmark(options: Map<string, string>, flags: Set<string>, cwd: str
2871
3087
  return score.passRate >= 80 ? 0 : 1;
2872
3088
  }
2873
3089
 
3090
+ /**
3091
+ * Exit-code contract for `dz claim-check` (named in the ADR, locked by tests):
3092
+ * exit 0 when no finding at/above `failOn` exists; exit 1 only when one does.
3093
+ * `--fail-on none` never exits non-zero. Severity order: high > medium > none.
3094
+ */
3095
+ function computeClaimExit(findings: readonly { severity: 'high' | 'medium' }[], failOn: 'high' | 'medium' | 'none'): number {
3096
+ const rank = { none: 0, medium: 1, high: 2 } as const;
3097
+ if (failOn === 'none') return 0;
3098
+ return findings.some((f) => rank[f.severity] >= rank[failOn]) ? 1 : 0;
3099
+ }
3100
+
3101
+ /**
3102
+ * Default scan set when no paths are given: the repo root README, every published
3103
+ * package README under packages/@dzhechkov, and every feature's 08_qe_report.md. Each
3104
+ * entry is guarded by existsSync; a missing packages/ or features/ dir is skipped, never fatal.
3105
+ */
3106
+ function defaultClaimScanSet(root: string): string[] {
3107
+ const set: string[] = [];
3108
+ const rootReadme = join(root, 'README.md');
3109
+ if (existsSync(rootReadme)) set.push(rootReadme);
3110
+ try {
3111
+ for (const p of discoverPackages(root)) {
3112
+ const readme = join(p.dir, 'README.md');
3113
+ if (existsSync(readme)) set.push(readme);
3114
+ }
3115
+ } catch { /* no packages/@dzhechkov dir — skip */ }
3116
+ try {
3117
+ const featuresDir = join(root, 'features');
3118
+ if (existsSync(featuresDir)) {
3119
+ for (const e of readdirSync(featuresDir, { withFileTypes: true })) {
3120
+ if (!e.isDirectory()) continue;
3121
+ const qe = join(featuresDir, e.name, '08_qe_report.md');
3122
+ if (existsSync(qe)) set.push(qe);
3123
+ }
3124
+ }
3125
+ } catch { /* no features dir — skip */ }
3126
+ return set;
3127
+ }
3128
+
3129
+ /** Cheap binary sniff: a NUL byte in the first 512 chars ⇒ skip (never scan binaries). */
3130
+ function looksBinaryText(text: string): boolean {
3131
+ const n = Math.min(text.length, 512);
3132
+ for (let i = 0; i < n; i += 1) if (text.charCodeAt(i) === 0) return true;
3133
+ return false;
3134
+ }
3135
+
3136
+ /**
3137
+ * `dz claim-check [paths...] [--json] [--fail-on high|medium|none] [--project <dir>]`
3138
+ *
3139
+ * I/O adapter over the pure `claimCheck` engine: resolves the scan set, reads each file
3140
+ * never-throw (unreadable/binary/missing files are skipped and reported in `scanned`), merges
3141
+ * per-file findings (each enriched with its `file`), and applies the exit-code contract.
3142
+ * `--json` ALWAYS emits valid JSON `{ok, findings, scanned}`, even on the failure path.
3143
+ */
3144
+ function cmdClaimCheck(
3145
+ options: Map<string, string>,
3146
+ _optionLists: Map<string, string[]>,
3147
+ flags: Set<string>,
3148
+ cwd: string,
3149
+ write: Write,
3150
+ ): number {
3151
+ // Reject (never silently coerce) an invalid --fail-on.
3152
+ const failOnRaw = options.get('fail-on') ?? 'high';
3153
+ if (!['high', 'medium', 'none'].includes(failOnRaw)) {
3154
+ write(`dz claim-check: invalid --fail-on '${failOnRaw}' (expected high|medium|none)`);
3155
+ return 1;
3156
+ }
3157
+ const failOn = failOnRaw as 'high' | 'medium' | 'none';
3158
+ const root = resolve(cwd, options.get('project') ?? '.');
3159
+
3160
+ // `--json <path>` is captured by parseArgs as the OPTION `json=<path>` (the boolean flag ate the
3161
+ // next token — the same gotcha cmdMcpScan recovers). Recover both: mark json, adopt the eaten
3162
+ // token as the first path. `--json` alone (at end / before another --flag) lands as a bare flag.
3163
+ let json = flags.has('json');
3164
+ const paths: string[] = [];
3165
+ const jsonConsumed = options.get('json');
3166
+ if (jsonConsumed !== undefined) {
3167
+ json = true;
3168
+ if (jsonConsumed !== 'true') paths.push(jsonConsumed);
3169
+ }
3170
+ // Variadic positional paths land as _positional_0, _positional_1, … (see parseArgs).
3171
+ for (let i = 0; ; i += 1) {
3172
+ const p = options.get(`_positional_${i}`);
3173
+ if (p === undefined) break;
3174
+ paths.push(p);
3175
+ }
3176
+
3177
+ const scanSet = paths.length > 0 ? paths.map((p) => resolve(root, p)) : defaultClaimScanSet(root);
3178
+
3179
+ const findings: (ClaimFinding & { file: string })[] = [];
3180
+ const scanned: { path: string; status: 'scanned' | 'skipped'; findings?: number; reason?: string }[] = [];
3181
+
3182
+ for (const abs of scanSet) {
3183
+ // Show a repo-relative path for in-tree files; fall back to the absolute path for
3184
+ // anything outside root (avoids an ugly ../../.. chain for an explicit external path).
3185
+ const relRaw = relative(root, abs);
3186
+ const rel = relRaw && !relRaw.startsWith('..') ? relRaw : abs;
3187
+ let text: string;
3188
+ try {
3189
+ text = readFileSync(abs, 'utf-8');
3190
+ } catch (err) {
3191
+ scanned.push({ path: rel, status: 'skipped', reason: err instanceof Error ? err.message : 'not found' });
3192
+ continue;
3193
+ }
3194
+ if (looksBinaryText(text)) {
3195
+ scanned.push({ path: rel, status: 'skipped', reason: 'binary' });
3196
+ continue;
3197
+ }
3198
+ const result = claimCheck(text);
3199
+ for (const f of result.findings) findings.push({ ...f, file: rel });
3200
+ scanned.push({ path: rel, status: 'scanned', findings: result.findings.length });
3201
+ }
3202
+
3203
+ const ok = findings.length === 0;
3204
+
3205
+ if (json) {
3206
+ write(JSON.stringify({ ok, findings, scanned })); // ALWAYS valid JSON, pass or fail
3207
+ return computeClaimExit(findings, failOn);
3208
+ }
3209
+
3210
+ // Human output.
3211
+ write(summarize({ ok, findings }));
3212
+ for (const f of findings) {
3213
+ write(` [${f.severity}] ${f.file}:${f.line} — ${f.reason}`);
3214
+ write(` ${f.excerpt}`);
3215
+ write(` ↳ ${f.suggestion}`);
3216
+ }
3217
+ const skipped = scanned.filter((s) => s.status === 'skipped');
3218
+ write(`\n ${scanned.length} file(s) in scan set, ${skipped.length} skipped.`);
3219
+ for (const s of skipped) write(` skipped ${s.path} (${s.reason})`);
3220
+ return computeClaimExit(findings, failOn);
3221
+ }
3222
+
2874
3223
  function cmdMcpScan(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
2875
3224
  // The arg parser captures `--boolFlag <next>` as an OPTION value, so a path
2876
3225
  // typed AFTER a boolean flag (e.g. `dz mcp-scan --reconcile .`) lands as that
@@ -3461,7 +3810,7 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
3461
3810
  return '';
3462
3811
  }
3463
3812
  };
3464
- const { command, options, flags } = parseArgs(argv);
3813
+ const { command, options, optionLists, flags } = parseArgs(argv);
3465
3814
 
3466
3815
  if (command === '' || command === 'help' || flags.has('help')) {
3467
3816
  write(USAGE);
@@ -3482,7 +3831,7 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
3482
3831
  case 'create-skill':
3483
3832
  return cmdCreateSkill(options, flags, cwd, write);
3484
3833
  case 'info':
3485
- return cmdInfo(options, { command, options, flags }, cwd, write);
3834
+ return cmdInfo(options, { command, options, optionLists, flags }, cwd, write);
3486
3835
  case 'scout':
3487
3836
  return await cmdScout(options, flags, cwd, write);
3488
3837
  case 'workflow':
@@ -3508,7 +3857,9 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
3508
3857
  case 'statusline':
3509
3858
  return cmdStatusline(options, flags, cwd, write, readStdin);
3510
3859
  case 'usage':
3511
- return cmdUsage(options, flags, cwd, write);
3860
+ return cmdUsage(options, optionLists, flags, cwd, write);
3861
+ case 'claim-check':
3862
+ return cmdClaimCheck(options, optionLists, flags, cwd, write);
3512
3863
  case 'setup':
3513
3864
  return await cmdSetup(options, flags, cwd, write);
3514
3865
  case 'pretrain':