@dzhechkov/harness-cli 0.8.2 → 0.8.5

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/src/cli.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * @packageDocumentation
5
5
  */
6
6
 
7
- import { appendFileSync, chmodSync, closeSync, cpSync, existsSync, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, readlinkSync, realpathSync, renameSync, rmdirSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
7
+ import { appendFileSync, chmodSync, closeSync, cpSync, existsSync, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, readlinkSync, realpathSync, renameSync, rmdirSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync, type Dirent } from 'node:fs';
8
8
  import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
9
9
  import { fileURLToPath } from 'node:url';
10
10
  import { request as httpsRequest } from 'node:https';
@@ -158,6 +158,11 @@ import {
158
158
  parseWeeklyResetAnchor,
159
159
  claimCheck,
160
160
  summarize,
161
+ BUNDLED_SLOP_REGISTRY_URL,
162
+ DEFAULT_SLOP_CONFIG,
163
+ parseSlopRegistry,
164
+ slopLint,
165
+ validateSlopLintConfig,
161
166
  queryBookKnowledge,
162
167
  loadStorePatternsSync,
163
168
  patternRecordId,
@@ -302,6 +307,13 @@ import {
302
307
  renderContentProbe,
303
308
  findNonRegistrableSkillDirs,
304
309
  assembleCompoundingReport,
310
+ buildDeadwoodReport,
311
+ compactCmdUsageIfNeeded,
312
+ measureCmdUsageDepthDays,
313
+ recordCommandInvocation,
314
+ resolveCmdUsageRoot,
315
+ renderDeadwoodReport,
316
+ CMD_USAGE_LOG_RELATIVE,
305
317
  banditStats,
306
318
  narrowBanditReport,
307
319
  renderBanditHealth,
@@ -457,6 +469,9 @@ import {
457
469
  amendmentVerdictLine,
458
470
  amendmentsMissingFromPlan,
459
471
  AMENDMENT_VACUITY_NOTE,
472
+ extractContractChecklist,
473
+ parseContractVerdictReport,
474
+ verifyContractVerdicts,
460
475
  decideSignableSet,
461
476
  signableSetLine,
462
477
  decideRecordWrite,
@@ -467,6 +482,7 @@ import {
467
482
  CADENCE_WINDOW_DAYS,
468
483
  readQeRounds,
469
484
  QE_ROUNDS_DEFAULT_CEILING,
485
+ adviseRestart,
470
486
  describeStoreLocation,
471
487
  storeLocationLine,
472
488
  resolveTeachTarget,
@@ -502,10 +518,30 @@ import type { IdeaRecord, IdeaStatus } from '@dzhechkov/harness-core';
502
518
  import type { Family, ModelRung, Candidate as BtoCandidate, DimScores } from '@dzhechkov/harness-core';
503
519
  import type { SetupSpec } from '@dzhechkov/harness-core';
504
520
  import type { LogTail } from '@dzhechkov/harness-core';
505
- import type { ProvenanceMode, PackVerdict, ClaudeUsageModel, PatternRecord, TargetName, BookKU, HarmonizeReport, UsageCalibrationPlan, ClaimFinding, RecallUsagePatternRow, GateExecution, GateStep } from '@dzhechkov/harness-core';
521
+ import type { DeadwoodInventoryItem } from '@dzhechkov/harness-core';
522
+ import type { ContractDiagnostic, ContractEvidenceReader } from '@dzhechkov/harness-core';
523
+ import type { ProvenanceMode, PackVerdict, ClaudeUsageModel, PatternRecord, TargetName, BookKU, HarmonizeReport, UsageCalibrationPlan, ClaimFinding, RecallUsagePatternRow, GateExecution, GateStep, SlopFinding, SlopLintConfig, SlopRegistry } from '@dzhechkov/harness-core';
506
524
  import { getPreset, PRESET_NAMES } from '@dzhechkov/harness-presets';
507
525
  import { scanGitHub, analyzeRepo, generateReport, deepAnalyze, scanAllSources, ScoutMemory } from '@dzhechkov/scout';
508
526
 
527
+ /** Literal command inventory, pinned against the main dispatch switch by a layer-1 test. */
528
+ export const DZ_COMMANDS: readonly string[] = [
529
+ 'init', 'verify', 'sync', 'update', 'list', 'create-skill', 'info', 'scout',
530
+ 'workflow', 'workflow-lint', 'workflow-trace', 'migrate', 'doctor', 'install',
531
+ 'bundle', 'teach', 'consolidate', 'recall', 'vector', 'brain', 'statusline',
532
+ 'usage', 'claim-check', 'lint', 'sign', 'sbom', 'guard', 'verify-pack', 'setup',
533
+ 'pretrain', 'compose', 'diff', 'recommend', 'upgrade', 'auto-canonicalize',
534
+ 'publish', 'release', 'parity', 'registry', 'benchmark', 'mcp-scan',
535
+ 'sync-upstream', 'drift-check', 'hooks-sync', 'agents-sync', 'sync-canonical',
536
+ 'plugin', 'downloads', 'stats', 'architecture', 'project-skills', 'mr-rakes',
537
+ 'retro', 'feature-adr-setup', 'challenge', 'discrimination-check',
538
+ 'mutation-gate', 'delivery-check', 'skills-verify', 'compounding', 'deadwood',
539
+ 'epoch-replay', 'score', 'recap', 'cadence', 'qe-rounds', 'restart-advisor', 'tg-post',
540
+ 'name-check', 'provenance-check', 'feature-adr-record', 'amendment-check', 'contract-check',
541
+ 'feature-adr-checkpoint', 'profile', 'reqe', 'qe-bridge', 'backlog', 'routing',
542
+ 'bto-optimize', 'dashboard', 'roam', 'import-ecc',
543
+ ];
544
+
509
545
  const USAGE = `dz - DZ cross-platform harness CLI
510
546
 
511
547
  Usage:
@@ -539,6 +575,7 @@ Usage:
539
575
  dz challenge --plan <plan.md> [--author <model>] (the deterministic cartridge behind the challenge-panel adversarial plan-gate (R6): assembles the wide brief — plan + architecture/vision.md + testing.md + map.json + degradations.md — and prints the C1-C8 adversary prompt naming the cross-family reviewer to dispatch. exit 0 brief printed / 1 plan missing or empty)
540
576
  dz skills-verify [--dir <project>] [--expect a,b] [--static] [--strict] [--json] (does .claude/skills/ actually REGISTER? --static = instant layout scan for CI; default reads the authoritative system/init listing from a real session. exit 0 pass / 1 fail / 2 inconclusive — never a false pass)
541
577
  dz compounding [--project <dir>] [--json] (honest learning-loop payoff report: pool write-only ratio, guard repeat-violation trajectory, cold-vs-warm replay readiness, instrumentation health — a gate without enough data says INSUFFICIENT_DATA, never a fake verdict)
578
+ dz deadwood [--weeks <n>] [--json] (advisory zero-usage candidates for human deprecation review; safety-excluded surfaces carry reasons; never deletes or deprecates anything; shallow history says INSUFFICIENT_DATA)
542
579
  dz epoch-replay --mock [--n <N>] [--effect <-1..1>] [--tie-rate <0..1>] [--seed <N>] [--slice <name>] [--json] ($0 synthetic run — exercises the verdict math, NOT evidence)
543
580
  dz epoch-replay --emit [--project <dir>] [--limit <N>] [--seed <N>] [--out <file>] (cold-vs-warm work order: instances + PRE-REGISTERED blind A/B assignment; the runner never calls a model)
544
581
  dz epoch-replay --judge <filled-work-order.json> [--out <file>] (blind judge prompts from the filled plans)
@@ -547,6 +584,7 @@ Usage:
547
584
  dz recap [--day|--week|--month] [--at <ISO date>] [--project <dir>] [--json] (what was done over a window, from records only: deliveries with the grade an independent review STATED — a report naming two grades is reported ambiguous, never guessed — registry publishes, gate verdicts, knowledge reuse. --quarter/--half-year/--year are RECOGNISED and REFUSED with the real span in days: there is one complete quarter and the longest record is 174 days. Every section carries its own data-start date, and "the source was not read" never prints as zero. Contaminated measures — commit count, lines, tokens, learning-event volume, inventory counts, lesson count — are not computed, and the report says so. exit 0 reported / 2 refused)
548
585
  dz cadence [--window day|week|month|quarter|halfyear|year] [--json] (the WHAT-SHIPPED aggregator: graded-shipment cadence by ISO week + npm-publish cadence (recap cache) + guard repeat decay on the FIXED rule set + recall reuse; a window deeper than 2× the record is REFUSED with the depth named (ADR: a cadence from one point is scale forgery); exit 0 report / 2 refused-window / 1 usage)
549
586
  dz qe-rounds (--slug <feature> | --feature-dir <abs>) [--ceiling <n>] [--project <dir>] [--json] (how many Step-8 review rounds has this feature ALREADY had? Reads what dz qe-bridge already wrote — signoff-<runId>.json and failed-*.json under features/<slug>/.fa-state/qe-bridge — and writes nothing itself, so it can answer for runs already past. A round is a runId, not a file; an attempt with no verdict is counted SEPARATELY and never merged; an unreadable record is NAMED and the count is declared a LOWER BOUND. ONE directory, never a union across checkouts. exit 0 under the ceiling / 1 at-or-over — owner decides, the command does not judge whether the rounds were warranted / 2 NOT ESTABLISHED, which is never "zero rounds")
587
+ dz restart-advisor --slug <s> [--threshold C|D] [--rounds N] [--json] (read-only advisory decision over features/<slug>/.fa-state/checkpoints.jsonl and .dz/fa-training/<slug>/qe.jsonl. Defaults: threshold D, rounds 2 — both origins are printed. Equal sources corroborate; conflicts, torn/unreadable evidence, gaps, and unsafe paths are NOT ESTABLISHED. RECOMMENDATION ONLY: autoAction=false; never invokes feature-adr, deletes a stage, or writes advisor state. exit 0 established recommendation/no-recommendation / 2 NOT ESTABLISHED or invalid input / 1 unexpected runtime failure)
550
588
  dz tg-post --draft <file.html> [--manifest <sources.json>] [--channel <@name|id>] [--send --yes] [--night] [--preview] [--json] (the sender for an APPROVED channel post, per the accepted genai-tweets-channel ADRs: HTML mode only — never MarkdownV2; link preview OFF by default (x.com previews in Telegram are broken); the 00:00-06:00 MSK quiet window refuses without an explicit --night. DEFAULT IS A DRY-RUN: it validates the draft (tag balance, allowed tags, bare &/<, the 4096 visible-character limit with the overshoot counted) and runs the provenance gate over --manifest IN-PROCESS — a draft with no manifest is refused as unchecked, and anything but ALLOWED refuses. A real send needs --send --yes, stating ADR-004's manual-publishing decision out loud each time. The token comes from TELEGRAM_BOT_TOKEN or telegram.tokenFile in .dz/config.json and is never printed. exit 0 sent or clean dry-run / 1 refused or Telegram error / 2 usage)
551
589
  dz name-check [--command <n>] [--module <basename>] [--export <a,b>] [--project <dir>] [--json] (is this name free, BEFORE a line of code? Scans workspace SOURCE — never dist, because a stale build answers 'free' confidently. Checks a dz command name against the dispatcher AND the help block, a module basename against every package's src/, and exported identifiers against every declaration in the workspace. exit 0 all free / 1 at least one taken, naming where / 2 nothing asked or the scan did not run — an empty sweep is never a clean bill. Honest limit, printed on the passing path: it reads declarations, so a re-export under a different name stays the build's job)
552
590
  dz provenance-check --manifest <sources.json> [--project <dir>] [--json] (nothing goes out citing a source that may not leave this machine. Checks PROVENANCE, not words: every claim names its source, and only a KNOWN kind that resolves safely is cleared. Repo paths go through 'git -C <root> check-ignore' over the RESOLVED path — a symlink into an ignored directory is REFUSED (git classifies the string and never dereferences, MEASURED), and the verdict does not change with your working directory. Store records must be named in the git-TRACKED provenance-public.json, so declaring one public is a reviewable commit rather than a field inside an ignored store. An undeclared kind is refused, never inferred from the path's shape. exit 0 allowed / 1 blocked / 3 NOT ESTABLISHED — an empty manifest, an unreadable one, or an oracle that did not run is never a pass. It proves what was CITED: it cannot see a paraphrase with no citation, nor confidential text pasted by hand into an allowed file)
@@ -556,6 +594,7 @@ Usage:
556
594
  dz architecture [--check --slug <s> --desc <text>] [--project <dir>] [--revise] (the live product map + vision: --check is the soft Step-0 сверка of a new feature against them, reporting {signal,confidence} rather than blocking)
557
595
  dz sbom [--pack <name>] [--out <file>] (CycloneDX software bill of materials for the workspace, or for one pack with --pack)
558
596
  dz amendment-check --slug <slug> | --feature-dir <dir> | --all [--json] (the deterministic Step-8 amendment gate: every AM-N row must resolve to a test found INSIDE the file the row names; the PLAN is authoritative when it carries rows, and an ideation amendment the plan drops is a failure. exit 0 pass/skip, 1 fail, 3 NOT-ESTABLISHED — a section that parsed ZERO rows is never a pass. --all is a CENSUS and always exits 0. Does NOT prove non-vacuity — that is dz discrimination-check)
597
+ dz contract-check --slug <s> [--json] (read-only retrospective feature contract gate: extracts canonical AC-N + ADR Confirmation items, requires one artifact-anchored met|unmet|not-testable verdict per CC-N, and rejects A/B with unmet. exit 0 pass / 1 readable contract or verdict violation / 2 invalid invocation or unreadable/not-established artifacts)
559
598
  dz feature-adr-record --kind ledger|training-pair --stage <s> [--slug <s>] [--row|--pair <json>] [--mark <n>] [--once] [--json] (the witnessed writer for the run-cost ledger and training pairs: the payload arrives as an ARGUMENT, never as shell; a malformed or wrong-kind payload is REFUSED before any write; the timestamp is stamped before serialising; the append is verified by re-reading the tail. exit 0 written|duplicate|skipped, 2 refused, 3 not-verified — a record failure is never blocking)
560
599
  dz feature-adr-checkpoint (--slug <feature> | --feature-dir <abs>) --stage <s> --input-hash <h> --result <json> [--artifact a,b] [--json] (record a pipeline stage ONLY after measuring its artifacts on disk; refuses a null result, an absent artifact, or a stage that declares none — the subagent runs a COMMAND instead of hand-writing durable state)
561
600
  dz profile [init|show|set|sync] [--json] (WHO the assistant is talking to — per-user store at ~/.dz/profile.json (0600, NEVER in a project), delivered as a marked block in ~/.claude/CLAUDE.md so it loads in EVERY project, dz installed or not. init = five questions (language, register, deep/weak domains as comma lists — "networking (CCIE; NSX)" keeps the parenthetical as the note, Enter skips — teaches y/n with one re-ask, never a silent default); show ALWAYS prints the store path + age + drift verdict + the rendered block; set register|language|teaches <v> or set deep|weak add|rm <tag> [note] — register accepts the owner's own words (профи / профи лайт / просто), an unknown value is REFUSED naming the accepted set; sync re-writes the block (runs automatically after init/set; foreign content byte-for-byte, timestamped backup before every modifying write). The register changes FORM, never FACTS, and governs dialogue only — never ADRs/commits/QE reports; both rules are baked into the rendered block at every level. exit 0 done / 1 no profile or failed / 2 refused input)
@@ -600,6 +639,7 @@ Usage:
600
639
  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)
601
640
  dz usage --by-stage [--run <runId> | --slug <slug>] [--epsilon <0..1>] [--write <file.jsonl>] [--json] (per-stage cost ledger for ONE feature-adr run + the reconciliation invariant: accounted + unaccounted = run total; verdict BALANCED | DEFECT | INSUFFICIENT_DATA; local transcript ESTIMATES — catches ATTRIBUTION errors, not pricing errors)
602
641
  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 + docs/**/*.md (historical feature artifacts are NOT scanned — pass paths explicitly); exit 1 only at/above --fail-on, default high)
642
+ dz lint [paths...] [--json] [--config <file>] [--registry <file>] [--project <dir>] (advisory EN/RU prose-style lint; findings exit 0, incomplete input/policy exits 1, usage exits 2)
603
643
  dz pretrain [--project <dir>]
604
644
  dz recommend "<task description>"
605
645
  dz compose <preset1+preset2+...> [--target <name>]
@@ -2741,6 +2781,114 @@ function cmdQeRounds(options: Map<string, string>, flags: Set<string>, cwd: stri
2741
2781
  return 0;
2742
2782
  }
2743
2783
 
2784
+ type RestartAdvisorDiskRead =
2785
+ | { readonly text: string | null; readonly diagnostic: null }
2786
+ | { readonly text: null; readonly diagnostic: string };
2787
+
2788
+ /** Read one fixed advisor evidence path without following a symlink or escaping the project root. */
2789
+ function readRestartAdvisorEvidence(root: string, relativePath: string): RestartAdvisorDiskRead {
2790
+ const contained = containedUnderRoot(root, relativePath);
2791
+ if (!contained.ok) {
2792
+ return { text: null, diagnostic: `${relativePath}: ${contained.why}` };
2793
+ }
2794
+ let stat;
2795
+ try {
2796
+ stat = lstatSync(contained.path);
2797
+ } catch (error) {
2798
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { text: null, diagnostic: null };
2799
+ return { text: null, diagnostic: `${relativePath}: evidence cannot be inspected (${(error as Error).message})` };
2800
+ }
2801
+ if (stat.isSymbolicLink()) {
2802
+ return { text: null, diagnostic: `${relativePath}: symlink evidence is refused` };
2803
+ }
2804
+ if (!stat.isFile()) {
2805
+ return { text: null, diagnostic: `${relativePath}: evidence exists but is not a regular file` };
2806
+ }
2807
+ try {
2808
+ return { text: readFileSync(contained.path, 'utf-8'), diagnostic: null };
2809
+ } catch (error) {
2810
+ return { text: null, diagnostic: `${relativePath}: evidence cannot be read (${(error as Error).message})` };
2811
+ }
2812
+ }
2813
+
2814
+ /** Manual restart recommendation only. I/O ends here; the core remains deterministic and pure. */
2815
+ function cmdRestartAdvisor(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
2816
+ const json = flags.has('json');
2817
+ const rawSlug = options.get('slug') ?? '';
2818
+ const slug = rawSlug.trim();
2819
+ const thresholdRaw = options.get('threshold');
2820
+ const roundsRaw = options.get('rounds');
2821
+ const thresholdOrigin = thresholdRaw === undefined ? 'default' as const : 'flag' as const;
2822
+ const roundsOrigin = roundsRaw === undefined ? 'default' as const : 'flag' as const;
2823
+
2824
+ const emit = (advice: ReturnType<typeof adviseRestart>): number => {
2825
+ if (json) {
2826
+ write(JSON.stringify(advice));
2827
+ } else {
2828
+ write(`dz restart-advisor: ${advice.recommendation} — advisory only; autoAction=false`);
2829
+ write(` policy: threshold ${String(advice.policy.threshold)} (${advice.policy.thresholdOrigin}), rounds ${String(advice.policy.rounds)} (${advice.policy.roundsOrigin})`);
2830
+ write(` source: ${advice.sourcePath ?? 'none'}${advice.corroborated ? ' (corroborated by both stores)' : ''}`);
2831
+ for (const diagnostic of advice.diagnostics) write(` diagnostic: ${JSON.stringify(diagnostic)}`);
2832
+ if (advice.diagnosticsSummary.truncated) {
2833
+ write(` diagnostics: ${advice.diagnosticsSummary.returned}/${advice.diagnosticsSummary.total} shown`);
2834
+ }
2835
+ write(advice.decisionLogLine);
2836
+ }
2837
+ return advice.recommendation === 'RESTART_CODE_STAGE'
2838
+ || advice.recommendation === 'NO_RESTART_RECOMMENDATION'
2839
+ ? 0
2840
+ : 2;
2841
+ };
2842
+
2843
+ const inputErrors: string[] = [];
2844
+ for (const flag of flags) {
2845
+ if (flag !== 'json' && flag !== 'help') inputErrors.push(`--${flag} requires a value or is not supported`);
2846
+ }
2847
+ for (const key of options.keys()) {
2848
+ if (key !== 'slug' && key !== 'threshold' && key !== 'rounds') {
2849
+ inputErrors.push(key.startsWith('_positional_')
2850
+ ? `unexpected argument ${JSON.stringify(options.get(key))}`
2851
+ : `unsupported option --${key}`);
2852
+ }
2853
+ }
2854
+ if (!isSafeSlug(slug)) {
2855
+ inputErrors.push(`--slug ${JSON.stringify(rawSlug)} must be one kebab-case path segment (max 40 characters)`);
2856
+ }
2857
+ const threshold = thresholdRaw ?? 'D';
2858
+ if (threshold !== 'C' && threshold !== 'D') {
2859
+ inputErrors.push(`--threshold ${JSON.stringify(thresholdRaw)} must be exactly C or D`);
2860
+ }
2861
+ const roundsValue = roundsRaw === undefined ? 2 : Number(roundsRaw);
2862
+ if (!Number.isFinite(roundsValue) || !Number.isInteger(roundsValue) || roundsValue < 1) {
2863
+ inputErrors.push(`--rounds ${JSON.stringify(roundsRaw)} must be a positive integer`);
2864
+ }
2865
+ if (inputErrors.length > 0) {
2866
+ return emit(adviseRestart({ slug, inputErrors }, {
2867
+ thresholdOrigin,
2868
+ roundsOrigin,
2869
+ }));
2870
+ }
2871
+
2872
+ const root = resolve(cwd);
2873
+ const checkpointPath = `features/${slug}/.fa-state/checkpoints.jsonl`;
2874
+ const trainingPath = `.dz/fa-training/${slug}/qe.jsonl`;
2875
+ const checkpoints = readRestartAdvisorEvidence(root, checkpointPath);
2876
+ const trainingPairs = readRestartAdvisorEvidence(root, trainingPath);
2877
+ const readDiagnostics = [checkpoints.diagnostic, trainingPairs.diagnostic]
2878
+ .filter((entry): entry is string => entry !== null);
2879
+ return emit(adviseRestart({
2880
+ slug,
2881
+ checkpointsJsonl: checkpoints.text,
2882
+ trainingPairsJsonl: trainingPairs.text,
2883
+ readDiagnostics,
2884
+ }, {
2885
+ threshold: threshold as 'C' | 'D',
2886
+ rounds: roundsValue,
2887
+ thresholdOrigin,
2888
+ roundsOrigin,
2889
+ }));
2890
+ }
2891
+
2744
2892
  function cmdCadence(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
2745
2893
  const root = resolve(cwd, options.get('project') ?? '.');
2746
2894
  const windowRaw = (options.get('window') ?? 'week').trim() as CadenceWindow;
@@ -6478,6 +6626,256 @@ function cmdBenchmark(options: Map<string, string>, flags: Set<string>, cwd: str
6478
6626
  return score.passRate >= 80 ? 0 : 1;
6479
6627
  }
6480
6628
 
6629
+ interface SlopScanRow {
6630
+ readonly path: string;
6631
+ readonly status: 'scanned' | 'skipped';
6632
+ readonly paragraphs?: number;
6633
+ readonly findings?: number;
6634
+ readonly reason?: string;
6635
+ }
6636
+
6637
+ interface SlopCliError {
6638
+ readonly path?: string;
6639
+ readonly message: string;
6640
+ }
6641
+
6642
+ interface SlopCliFinding extends SlopFinding {
6643
+ readonly file: string;
6644
+ }
6645
+
6646
+ interface SlopCliReport {
6647
+ readonly schema: 'dz-slop-lint/1';
6648
+ readonly advisory: true;
6649
+ readonly ok: boolean;
6650
+ readonly status: 'clean' | 'findings' | 'incomplete';
6651
+ readonly findings: readonly SlopCliFinding[];
6652
+ readonly scanned: readonly SlopScanRow[];
6653
+ readonly skipped: readonly SlopScanRow[];
6654
+ readonly errors: readonly SlopCliError[];
6655
+ }
6656
+
6657
+ const SLOP_MAX_FILE_BYTES = 2 * 1024 * 1024;
6658
+ const SLOP_COURSE_TEXT_KEYS = new Set([
6659
+ 'back', 'courseDescription', 'courseTitle', 'description', 'explanation', 'front', 'instruction',
6660
+ 'keyConcept', 'keyConcepts', 'note', 'options', 'question', 'reflection', 'shortTitle', 'strengths',
6661
+ 'theory', 'title', 'weaknesses', 'wrapup',
6662
+ ]);
6663
+
6664
+ function slopDisplayPath(root: string, absolute: string): string {
6665
+ const rel = relative(root, absolute);
6666
+ return rel !== '' && !rel.startsWith(`..${sep}`) && rel !== '..' && !isAbsolute(rel) ? rel : absolute;
6667
+ }
6668
+
6669
+ function slopWalk(dir: string, depth = 0): string[] {
6670
+ if (depth > 16) return [];
6671
+ const out: string[] = [];
6672
+ let entries: Dirent<string>[];
6673
+ try {
6674
+ entries = readdirSync(dir, { withFileTypes: true });
6675
+ } catch {
6676
+ return out;
6677
+ }
6678
+ entries.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
6679
+ for (const entry of entries) {
6680
+ if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === 'dist' || entry.name === 'build') continue;
6681
+ if (entry.isSymbolicLink()) continue;
6682
+ const full = join(dir, entry.name);
6683
+ if (entry.isDirectory()) out.push(...slopWalk(full, depth + 1));
6684
+ else if (entry.isFile() && (['.md', '.mdx'].includes(extname(entry.name).toLowerCase()) || entry.name === 'course.json')) out.push(full);
6685
+ }
6686
+ return out;
6687
+ }
6688
+
6689
+ function defaultSlopScanSet(root: string): string[] {
6690
+ const paths: string[] = [];
6691
+ const readme = join(root, 'README.md');
6692
+ if (existsSync(readme)) paths.push(readme);
6693
+ try {
6694
+ for (const pkg of discoverPackages(root)) {
6695
+ const packageReadme = join(pkg.dir, 'README.md');
6696
+ if (existsSync(packageReadme)) paths.push(packageReadme);
6697
+ }
6698
+ } catch { /* a foreign project need not be a dz workspace */ }
6699
+ paths.push(...slopWalk(join(root, 'packages', '@dzhechkov', 'sitedoc', 'src', 'content'))
6700
+ .filter((path) => ['.md', '.mdx'].includes(extname(path).toLowerCase())));
6701
+ try {
6702
+ const featureDir = join(root, 'features');
6703
+ for (const entry of readdirSync(featureDir, { withFileTypes: true })) {
6704
+ if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
6705
+ const course = join(featureDir, entry.name, 'course.json');
6706
+ if (existsSync(course)) paths.push(course);
6707
+ }
6708
+ } catch { /* no features directory */ }
6709
+ return [...new Set(paths.map((path) => resolve(path)))].sort((a, b) => {
6710
+ const left = slopDisplayPath(root, a);
6711
+ const right = slopDisplayPath(root, b);
6712
+ return left < right ? -1 : left > right ? 1 : 0;
6713
+ });
6714
+ }
6715
+
6716
+ function decodeSlopUtf8(path: string): string {
6717
+ const stat = lstatSync(path);
6718
+ if (!stat.isFile()) throw new Error('not a regular file');
6719
+ if (stat.size > SLOP_MAX_FILE_BYTES) throw new Error(`file exceeds ${SLOP_MAX_FILE_BYTES} byte limit`);
6720
+ const bytes = readFileSync(path);
6721
+ return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
6722
+ }
6723
+
6724
+ function projectCourseJson(text: string): string | null {
6725
+ let value: unknown;
6726
+ try {
6727
+ value = JSON.parse(text);
6728
+ } catch {
6729
+ return null;
6730
+ }
6731
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return null;
6732
+ const root = value as Record<string, unknown>;
6733
+ if (typeof root.language !== 'string' || typeof root.courseTitle !== 'string' ||
6734
+ !Array.isArray(root.topics) || !Array.isArray(root.sections)) return null;
6735
+ const output: string[] = [];
6736
+ const visit = (current: unknown, key = ''): void => {
6737
+ if (typeof current === 'string') {
6738
+ if (SLOP_COURSE_TEXT_KEYS.has(key) && current.trim() !== '') output.push(current);
6739
+ return;
6740
+ }
6741
+ if (Array.isArray(current)) {
6742
+ for (const item of current) visit(item, key);
6743
+ return;
6744
+ }
6745
+ if (current === null || typeof current !== 'object') return;
6746
+ for (const [childKey, child] of Object.entries(current as Record<string, unknown>)) visit(child, childKey);
6747
+ };
6748
+ visit(root);
6749
+ return output.join('\n\n');
6750
+ }
6751
+
6752
+ function slopLoadJson<T>(path: string, validate: (value: unknown) => { readonly ok: true; readonly value: T } | { readonly ok: false; readonly errors: readonly { readonly field: string; readonly value: unknown; readonly reason: string }[] }): T {
6753
+ const decoded = decodeSlopUtf8(path);
6754
+ let parsed: unknown;
6755
+ try {
6756
+ parsed = JSON.parse(decoded);
6757
+ } catch (error) {
6758
+ throw new Error(`invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
6759
+ }
6760
+ const result = validate(parsed);
6761
+ if (!result.ok) throw new Error(result.errors.map((error) => `${error.field}=${JSON.stringify(error.value)}: ${error.reason}`).join('; '));
6762
+ return result.value;
6763
+ }
6764
+
6765
+ /**
6766
+ * Read-only adapter over the pure slopLint core. Style findings are advisory by construction:
6767
+ * only usage errors return 2 and incomplete policy/input evidence returns 1.
6768
+ */
6769
+ function cmdLint(
6770
+ options: Map<string, string>,
6771
+ flags: Set<string>,
6772
+ cwd: string,
6773
+ write: Write,
6774
+ ): number {
6775
+ const json = flags.has('json');
6776
+ const root = resolve(cwd, options.get('project') ?? '.');
6777
+ const usageError = (message: string): number => {
6778
+ if (json) write(JSON.stringify({ schema: 'dz-slop-lint/1', advisory: true, ok: false, status: 'incomplete', findings: [], scanned: [], skipped: [], errors: [{ message }] }));
6779
+ else write(`dz lint: ${message}`);
6780
+ return 2;
6781
+ };
6782
+ if (flags.has('config')) return usageError('--config requires a file');
6783
+ if (flags.has('registry')) return usageError('--registry requires a file');
6784
+
6785
+ let config: SlopLintConfig = DEFAULT_SLOP_CONFIG;
6786
+ let registry: SlopRegistry;
6787
+ const setupErrors: SlopCliError[] = [];
6788
+ try {
6789
+ if (options.has('config')) config = slopLoadJson(resolve(root, options.get('config')!), validateSlopLintConfig);
6790
+ } catch (error) {
6791
+ const path = options.get('config');
6792
+ setupErrors.push({ ...(path === undefined ? {} : { path }), message: `config: ${error instanceof Error ? error.message : String(error)}` });
6793
+ }
6794
+ try {
6795
+ const registryPath = options.has('registry') ? resolve(root, options.get('registry')!) : fileURLToPath(BUNDLED_SLOP_REGISTRY_URL);
6796
+ registry = slopLoadJson(registryPath, parseSlopRegistry);
6797
+ } catch (error) {
6798
+ const path = options.get('registry');
6799
+ setupErrors.push({ ...(path === undefined ? {} : { path }), message: `registry: ${error instanceof Error ? error.message : String(error)}` });
6800
+ registry = { schema: 'dz-slop-registry/1', metadata: {} as SlopRegistry['metadata'], markers: [], adjectives: [] };
6801
+ }
6802
+
6803
+ const requested: string[] = [];
6804
+ for (let index = 0; ; index += 1) {
6805
+ const value = options.get(`_positional_${index}`);
6806
+ if (value === undefined) break;
6807
+ requested.push(resolve(root, value));
6808
+ }
6809
+ const explicit = requested.length > 0;
6810
+ const candidates = explicit ? requested.flatMap((path) => {
6811
+ try {
6812
+ const stat = lstatSync(path);
6813
+ if (stat.isSymbolicLink()) return [path];
6814
+ return stat.isDirectory() ? slopWalk(path) : [path];
6815
+ } catch {
6816
+ return [path];
6817
+ }
6818
+ }) : defaultSlopScanSet(root);
6819
+ const scanSet = [...new Set(candidates.map((path) => resolve(path)))].sort((a, b) => {
6820
+ const left = slopDisplayPath(root, a);
6821
+ const right = slopDisplayPath(root, b);
6822
+ return left < right ? -1 : left > right ? 1 : 0;
6823
+ });
6824
+
6825
+ const findings: SlopCliFinding[] = [];
6826
+ const scanned: SlopScanRow[] = [];
6827
+ const errors = [...setupErrors];
6828
+ if (setupErrors.length === 0) {
6829
+ for (const path of scanSet) {
6830
+ const display = slopDisplayPath(root, path);
6831
+ let source: string;
6832
+ try {
6833
+ source = decodeSlopUtf8(path);
6834
+ } catch (error) {
6835
+ const message = error instanceof TypeError ? `invalid UTF-8: ${error.message}` : error instanceof Error ? error.message : String(error);
6836
+ scanned.push({ path: display, status: 'skipped', reason: message });
6837
+ errors.push({ path: display, message });
6838
+ continue;
6839
+ }
6840
+ if (basename(path) === 'course.json') {
6841
+ const projected = projectCourseJson(source);
6842
+ if (projected === null) {
6843
+ const message = 'unsupported course.json shape';
6844
+ scanned.push({ path: display, status: 'skipped', reason: message });
6845
+ if (explicit) errors.push({ path: display, message });
6846
+ continue;
6847
+ }
6848
+ source = projected;
6849
+ } else if (!['.md', '.mdx'].includes(extname(path).toLowerCase())) {
6850
+ const message = 'unsupported input type; expected Markdown, MDX, or recognized course.json';
6851
+ scanned.push({ path: display, status: 'skipped', reason: message });
6852
+ errors.push({ path: display, message });
6853
+ continue;
6854
+ }
6855
+ const result = slopLint(source, { config, registry });
6856
+ if (result.paragraphCount === 0) errors.push({ path: display, message: 'no analyzable prose' });
6857
+ for (const diagnostic of result.diagnostics) errors.push({ path: display, message: `${diagnostic.code} at line ${diagnostic.line}: ${diagnostic.message}` });
6858
+ for (const finding of result.findings) findings.push({ ...finding, file: display });
6859
+ scanned.push({ path: display, status: 'scanned', paragraphs: result.paragraphCount, findings: result.findings.length });
6860
+ }
6861
+ }
6862
+ // A valid empty directory/default scope is a clean advisory no-op. Explicit missing paths stay
6863
+ // in scanSet and fail above, while a supported file containing no prose remains incomplete.
6864
+
6865
+ const skipped = scanned.filter((row) => row.status === 'skipped');
6866
+ const status: SlopCliReport['status'] = errors.length > 0 ? 'incomplete' : findings.length > 0 ? 'findings' : 'clean';
6867
+ const report: SlopCliReport = {
6868
+ schema: 'dz-slop-lint/1', advisory: true, ok: errors.length === 0, status, findings, scanned, skipped, errors,
6869
+ };
6870
+ if (json) write(JSON.stringify(report));
6871
+ else {
6872
+ write(`dz lint: ${findings.length} finding(s), ${scanned.filter((row) => row.status === 'scanned').length} file(s) scanned — ${status}`);
6873
+ for (const finding of findings) write(` [${finding.ruleId}] ${finding.file}:${finding.lineStart}:${finding.columnStart} — ${finding.excerpt}`);
6874
+ for (const error of errors) write(` incomplete${error.path ? ` ${error.path}` : ''}: ${error.message}`);
6875
+ }
6876
+ return errors.length > 0 ? 1 : 0;
6877
+ }
6878
+
6481
6879
  /**
6482
6880
  * Exit-code contract for `dz claim-check` (named in the ADR, locked by tests):
6483
6881
  * exit 0 when no finding at/above `failOn` exists; exit 1 only when one does.
@@ -11107,6 +11505,265 @@ function cmdFeatureAdrRecord(options: Map<string, string>, flags: Set<string>, c
11107
11505
  return emit(decideReadBack(lineToWrite, lastLine), { target });
11108
11506
  }
11109
11507
 
11508
+ type ContractDiskRead =
11509
+ | { readonly ok: true; readonly text: string; readonly realPath: string }
11510
+ | { readonly ok: false; readonly diagnostic: ContractDiagnostic };
11511
+
11512
+ type ContractDirectoryRead =
11513
+ | { readonly ok: true; readonly realPath: string }
11514
+ | { readonly ok: false; readonly diagnostic: ContractDiagnostic };
11515
+
11516
+ function contractRepoRoot(cwd: string): string {
11517
+ let root = cwd;
11518
+ try {
11519
+ root = execSync('git rev-parse --show-toplevel', {
11520
+ cwd,
11521
+ encoding: 'utf-8',
11522
+ stdio: ['ignore', 'pipe', 'ignore'],
11523
+ }).trim() || cwd;
11524
+ } catch { /* temporary repository fixtures intentionally use cwd as their root */ }
11525
+ return resolve(root);
11526
+ }
11527
+
11528
+ function contractReadConfined(repoRoot: string, absolute: string, artifact: string): ContractDiskRead {
11529
+ let rootReal: string;
11530
+ let targetReal: string;
11531
+ try {
11532
+ rootReal = realpathSync(repoRoot);
11533
+ } catch {
11534
+ return {
11535
+ ok: false,
11536
+ diagnostic: { code: 'repository-unreadable', message: 'repository root cannot be resolved', artifact: '.' },
11537
+ };
11538
+ }
11539
+ try {
11540
+ targetReal = realpathSync(absolute);
11541
+ } catch {
11542
+ return {
11543
+ ok: false,
11544
+ diagnostic: { code: 'artifact-unreadable', message: `required artifact cannot be resolved or read: ${artifact}`, artifact },
11545
+ };
11546
+ }
11547
+ const rel = relative(rootReal, targetReal);
11548
+ if (rel === '' || rel.startsWith(`..${sep}`) || rel === '..' || isAbsolute(rel)) {
11549
+ return {
11550
+ ok: false,
11551
+ diagnostic: { code: 'artifact-outside-repository', message: `artifact resolves outside the repository: ${artifact}`, artifact },
11552
+ };
11553
+ }
11554
+ try {
11555
+ if (!statSync(targetReal).isFile()) throw new Error('not a regular file');
11556
+ return { ok: true, text: readFileSync(targetReal, 'utf-8'), realPath: targetReal };
11557
+ } catch {
11558
+ return {
11559
+ ok: false,
11560
+ diagnostic: { code: 'artifact-unreadable', message: `required artifact cannot be read as a file: ${artifact}`, artifact },
11561
+ };
11562
+ }
11563
+ }
11564
+
11565
+ function contractDirectoryConfined(repoRoot: string, absolute: string, artifact: string): ContractDirectoryRead {
11566
+ let rootReal: string;
11567
+ let targetReal: string;
11568
+ try {
11569
+ rootReal = realpathSync(repoRoot);
11570
+ } catch {
11571
+ return {
11572
+ ok: false,
11573
+ diagnostic: { code: 'repository-unreadable', message: 'repository root cannot be resolved', artifact: '.' },
11574
+ };
11575
+ }
11576
+ try {
11577
+ targetReal = realpathSync(absolute);
11578
+ } catch {
11579
+ return {
11580
+ ok: false,
11581
+ diagnostic: { code: 'adr-directory-unreadable', message: `required ADR directory cannot be resolved: ${artifact}`, artifact },
11582
+ };
11583
+ }
11584
+ const rel = relative(rootReal, targetReal);
11585
+ if (rel === '' || rel.startsWith(`..${sep}`) || rel === '..' || isAbsolute(rel)) {
11586
+ return {
11587
+ ok: false,
11588
+ diagnostic: { code: 'artifact-outside-repository', message: `artifact resolves outside the repository: ${artifact}`, artifact },
11589
+ };
11590
+ }
11591
+ try {
11592
+ if (!statSync(targetReal).isDirectory()) throw new Error('not a directory');
11593
+ return { ok: true, realPath: targetReal };
11594
+ } catch {
11595
+ return {
11596
+ ok: false,
11597
+ diagnostic: { code: 'adr-directory-unreadable', message: `required ADR directory is not readable: ${artifact}`, artifact },
11598
+ };
11599
+ }
11600
+ }
11601
+
11602
+ function contractDiagnosticLine(entry: ContractDiagnostic): string {
11603
+ const where = [entry.artifact, entry.contractId ?? entry.sourceId].filter((part): part is string => part !== undefined).join(' · ');
11604
+ return ` [${entry.code}]${where === '' ? '' : ` ${where} —`} ${entry.message}`;
11605
+ }
11606
+
11607
+ function cmdContractCheck(
11608
+ options: Map<string, string>,
11609
+ flags: Set<string>,
11610
+ cwd: string,
11611
+ write: Write,
11612
+ writeErr: WriteErr,
11613
+ ): number {
11614
+ const json = flags.has('json');
11615
+ const emitEarly = (outcome: 'fail' | 'not-established', exitCode: 1 | 2, diagnostics: readonly ContractDiagnostic[]): number => {
11616
+ if (json) {
11617
+ write(JSON.stringify({ outcome, exitCode, diagnostics }));
11618
+ } else {
11619
+ for (const entry of diagnostics) writeErr(contractDiagnosticLine(entry));
11620
+ write(`contract-check: ${outcome === 'fail' ? 'FAIL' : 'NOT-ESTABLISHED'} — ${diagnostics[0]?.message ?? 'no trustworthy verdict'}`);
11621
+ }
11622
+ return exitCode;
11623
+ };
11624
+
11625
+ for (const flag of flags) {
11626
+ if (flag !== 'json' && flag !== 'help') {
11627
+ return emitEarly('not-established', 2, [{ code: 'usage-invalid', message: `unknown option --${flag}`, observed: `--${flag}` }]);
11628
+ }
11629
+ }
11630
+ for (const key of options.keys()) {
11631
+ if (key !== 'slug') {
11632
+ return emitEarly('not-established', 2, [{
11633
+ code: 'usage-invalid',
11634
+ message: key.startsWith('_positional_') ? `unexpected argument ${JSON.stringify(options.get(key))}` : `unknown option --${key}`,
11635
+ observed: key.startsWith('_positional_') ? options.get(key) ?? '' : `--${key}`,
11636
+ }]);
11637
+ }
11638
+ }
11639
+ const slug = (options.get('slug') ?? '').trim();
11640
+ if (!isSafeSlug(slug)) {
11641
+ return emitEarly('not-established', 2, [{
11642
+ code: 'slug-invalid',
11643
+ message: 'a kebab-case --slug <feature> is required (one path segment, max 40 characters)',
11644
+ observed: slug,
11645
+ }]);
11646
+ }
11647
+
11648
+ const repoRoot = contractRepoRoot(cwd);
11649
+ const featureDir = join(repoRoot, 'features', slug);
11650
+ const requirementsRel = `features/${slug}/01_requirements.md`;
11651
+ const reportRel = `features/${slug}/08_qe_report.md`;
11652
+ const requirementsRead = contractReadConfined(repoRoot, join(featureDir, '01_requirements.md'), requirementsRel);
11653
+ if (!requirementsRead.ok) return emitEarly('not-established', 2, [requirementsRead.diagnostic]);
11654
+
11655
+ const adrDirRel = `features/${slug}/03_adr`;
11656
+ const adrDirectory = contractDirectoryConfined(repoRoot, join(featureDir, '03_adr'), adrDirRel);
11657
+ if (!adrDirectory.ok) return emitEarly('not-established', 2, [adrDirectory.diagnostic]);
11658
+ const adrDir = adrDirectory.realPath;
11659
+ let adrNames: string[];
11660
+ try {
11661
+ adrNames = readdirSync(adrDir, { withFileTypes: true })
11662
+ .filter((entry) => entry.name.endsWith('.md') && (entry.isFile() || entry.isSymbolicLink()))
11663
+ .map((entry) => entry.name)
11664
+ .sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
11665
+ } catch {
11666
+ return emitEarly('not-established', 2, [{
11667
+ code: 'adr-directory-unreadable',
11668
+ message: `required ADR directory cannot be read: ${adrDirRel}`,
11669
+ artifact: adrDirRel,
11670
+ }]);
11671
+ }
11672
+ if (adrNames.length === 0) {
11673
+ return emitEarly('not-established', 2, [{
11674
+ code: 'adr-artifacts-missing',
11675
+ message: `no direct ADR Markdown artifacts exist under ${adrDirRel}`,
11676
+ artifact: adrDirRel,
11677
+ observed: 0,
11678
+ }]);
11679
+ }
11680
+ const adrs: Array<{ path: string; text: string }> = [];
11681
+ const adrReadErrors: ContractDiagnostic[] = [];
11682
+ for (const name of adrNames) {
11683
+ const rel = `features/${slug}/03_adr/${name}`;
11684
+ const read = contractReadConfined(repoRoot, join(adrDir, name), rel);
11685
+ if (read.ok) adrs.push({ path: rel, text: read.text });
11686
+ else adrReadErrors.push(read.diagnostic);
11687
+ }
11688
+ if (adrReadErrors.length > 0) return emitEarly('not-established', 2, adrReadErrors);
11689
+
11690
+ const extracted = extractContractChecklist({
11691
+ requirements: { path: requirementsRel, text: requirementsRead.text },
11692
+ adrs,
11693
+ });
11694
+ if (!extracted.ok) return emitEarly('fail', 1, extracted.diagnostics);
11695
+
11696
+ const reportRead = contractReadConfined(repoRoot, join(featureDir, '08_qe_report.md'), reportRel);
11697
+ if (!reportRead.ok) return emitEarly('not-established', 2, [reportRead.diagnostic]);
11698
+
11699
+ const parsed = parseContractVerdictReport(reportRead.text);
11700
+ if (!parsed.ok) {
11701
+ const diagnostics = parsed.diagnostics.map((entry) => ({
11702
+ ...entry,
11703
+ artifact: entry.artifact ?? reportRel,
11704
+ }));
11705
+ return emitEarly(parsed.established ? 'fail' : 'not-established', parsed.established ? 1 : 2, diagnostics);
11706
+ }
11707
+
11708
+ const evidenceCache = new Map<string, ReturnType<ContractEvidenceReader['read']>>();
11709
+ const reader: ContractEvidenceReader = {
11710
+ reportArtifact: reportRel,
11711
+ read(artifact) {
11712
+ const cached = evidenceCache.get(artifact);
11713
+ if (cached !== undefined) return cached;
11714
+ const disk = contractReadConfined(repoRoot, join(repoRoot, artifact), artifact);
11715
+ let result: ReturnType<ContractEvidenceReader['read']>;
11716
+ if (!disk.ok) {
11717
+ result = { ok: false, code: `evidence-${disk.diagnostic.code}`, detail: disk.diagnostic.message };
11718
+ } else if (disk.realPath === reportRead.realPath) {
11719
+ result = { ok: false, code: 'evidence-self-citation', detail: `${artifact} resolves to the QE verdict payload itself` };
11720
+ } else {
11721
+ result = { ok: true, text: disk.text };
11722
+ }
11723
+ evidenceCache.set(artifact, result);
11724
+ return result;
11725
+ },
11726
+ };
11727
+ const rawVerification = verifyContractVerdicts(extracted.checklist, parsed.report, reader);
11728
+ const withReportArtifact = (entry: ContractDiagnostic): ContractDiagnostic => ({
11729
+ ...entry,
11730
+ artifact: entry.artifact ?? reportRel,
11731
+ });
11732
+ const verification = {
11733
+ ...rawVerification,
11734
+ diagnostics: rawVerification.diagnostics.map(withReportArtifact),
11735
+ items: rawVerification.items.map((item) => ({
11736
+ ...item,
11737
+ diagnostics: item.diagnostics.map(withReportArtifact),
11738
+ })),
11739
+ };
11740
+ if (json) {
11741
+ write(JSON.stringify({
11742
+ contract: extracted.checklist,
11743
+ report: parsed.report,
11744
+ items: verification.items,
11745
+ diagnostics: verification.diagnostics,
11746
+ counts: verification.counts,
11747
+ overallGrade: verification.overallGrade,
11748
+ outcome: verification.outcome,
11749
+ exitCode: verification.exitCode,
11750
+ }));
11751
+ return verification.exitCode;
11752
+ }
11753
+ for (const item of verification.items) {
11754
+ const reason = item.reason === undefined ? '' : ` — ${item.reason}`;
11755
+ write(` ${item.id}: ${item.verdict ?? 'missing'} · evidence ${item.evidence}${reason}`);
11756
+ }
11757
+ write(` counts: contract=${verification.counts.contractItems} verdict=${verification.counts.verdictItems} met=${verification.counts.met} unmet=${verification.counts.unmet} not-testable=${verification.counts.notTestable} invalid-evidence=${verification.counts.invalidEvidence}`);
11758
+ write(` overall grade: ${verification.overallGrade}`);
11759
+ for (const entry of verification.diagnostics) writeErr(contractDiagnosticLine(entry));
11760
+ const summary = verification.outcome === 'pass'
11761
+ ? `${verification.counts.met} contract item(s) met`
11762
+ : `${verification.diagnostics.length} contract or evidence violation(s)`;
11763
+ write(`contract-check: ${verification.outcome === 'pass' ? 'PASS' : 'FAIL'} — ${summary}`);
11764
+ return verification.exitCode;
11765
+ }
11766
+
11110
11767
  function cmdAmendmentCheck(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
11111
11768
  const json = flags.has('json');
11112
11769
  const readOr = (abs: string): string | null => {
@@ -12307,6 +12964,90 @@ function readRecallUsageEvents(
12307
12964
  return usage;
12308
12965
  }
12309
12966
 
12967
+ function readOptionalText(path: string): string {
12968
+ try {
12969
+ return readFileSync(path, 'utf8');
12970
+ } catch {
12971
+ return '';
12972
+ }
12973
+ }
12974
+
12975
+ function deadwoodAllowlistText(): string {
12976
+ const require = createRequire(import.meta.url);
12977
+ const corePackage = require.resolve('@dzhechkov/harness-core/package.json');
12978
+ return readFileSync(join(dirname(corePackage), 'src', 'deadwood-allowlist.json'), 'utf8');
12979
+ }
12980
+
12981
+ function cmdUsageDepthDays(root: string, now: Date): number | null {
12982
+ const text = readOptionalText(join(root, CMD_USAGE_LOG_RELATIVE));
12983
+ if (text === '') return null;
12984
+ return measureCmdUsageDepthDays(text, now);
12985
+ }
12986
+
12987
+ /** Canonical deadwood candidates keyed to every alternate top-level dispatch token. */
12988
+ const DEADWOOD_COMMAND_ALIASES: Readonly<Record<string, readonly string[]>> = {
12989
+ sync: ['update'],
12990
+ };
12991
+
12992
+ function deadwoodInventory(root: string): DeadwoodInventoryItem[] {
12993
+ const aliasTokens = new Set(Object.values(DEADWOOD_COMMAND_ALIASES).flat());
12994
+ const inventory: DeadwoodInventoryItem[] = DZ_COMMANDS
12995
+ .filter((surface) => !aliasTokens.has(surface))
12996
+ .map((surface) => ({
12997
+ surface,
12998
+ kind: 'command',
12999
+ ...(DEADWOOD_COMMAND_ALIASES[surface] === undefined
13000
+ ? {}
13001
+ : { aliases: DEADWOOD_COMMAND_ALIASES[surface] }),
13002
+ }));
13003
+ for (const rule of DEFAULT_RULES) inventory.push({ surface: rule.id, kind: 'rule' });
13004
+ const skillDir = resolve(root, '.claude/skills');
13005
+ const { skills } = listSkillsDetailed(skillDir);
13006
+ for (const skill of skills) inventory.push({ surface: skill.id, kind: 'skill' });
13007
+ return inventory;
13008
+ }
13009
+
13010
+ /** `dz deadwood` is a read-only advisory report; findings never affect the exit code. */
13011
+ function cmdDeadwood(
13012
+ options: Map<string, string>,
13013
+ flags: Set<string>,
13014
+ cwd: string,
13015
+ write: Write,
13016
+ writeErr: WriteErr,
13017
+ ): number {
13018
+ if (flags.has('weeks')) {
13019
+ writeErr('dz deadwood: --weeks requires an integer value');
13020
+ return 1;
13021
+ }
13022
+ const rawWeeks = options.get('weeks') ?? '8';
13023
+ const weeks = Number(rawWeeks);
13024
+ if (!Number.isInteger(weeks) || weeks <= 0 || weeks > 520) {
13025
+ writeErr(`dz deadwood: --weeks must be an integer from 1 to 520 (received ${JSON.stringify(rawWeeks)})`);
13026
+ return 1;
13027
+ }
13028
+ const root = resolve(cwd);
13029
+ try {
13030
+ // Observe integrity before maintenance: compaction may discard malformed lines, but this run
13031
+ // still has to report that they existed rather than laundering the count to zero.
13032
+ const cmdUsageText = readOptionalText(join(resolveCmdUsageRoot(root), CMD_USAGE_LOG_RELATIVE));
13033
+ const report = buildDeadwoodReport({
13034
+ cmdUsageText,
13035
+ guardAuditText: readOptionalText(join(root, '.dz', 'guard-audit.jsonl')),
13036
+ inventory: deadwoodInventory(root),
13037
+ allowlistText: deadwoodAllowlistText(),
13038
+ weeks,
13039
+ now: new Date(),
13040
+ });
13041
+ compactCmdUsageIfNeeded(root);
13042
+ if (flags.has('json')) write(JSON.stringify({ ...report, exitCode: 0 }, null, 2));
13043
+ else write(renderDeadwoodReport(report, 'text'));
13044
+ return 0;
13045
+ } catch (error) {
13046
+ writeErr(`dz deadwood: ${error instanceof Error ? error.message : String(error)}`);
13047
+ return 1;
13048
+ }
13049
+ }
13050
+
12310
13051
  /**
12311
13052
  * `dz compounding` — does the learning loop actually PAY? (feature compounding, scout C2.)
12312
13053
  * Gathers the facts (store rows, apply-leg usage log, guard audit) and hands them to the PURE
@@ -12383,7 +13124,15 @@ function cmdCompounding(options: Map<string, string>, flags: Set<string>, cwd: s
12383
13124
  }
12384
13125
  }
12385
13126
 
12386
- const report = assembleCompoundingReport({ lessons, usage, guard, nowTs: new Date().toISOString(), evidenceLogs });
13127
+ const now = new Date();
13128
+ const report = assembleCompoundingReport({
13129
+ lessons,
13130
+ usage,
13131
+ guard,
13132
+ nowTs: now.toISOString(),
13133
+ evidenceLogs,
13134
+ cmdUsageDepthDays: cmdUsageDepthDays(root, now),
13135
+ });
12387
13136
  // lesson-bandit-rerank §11: the payoff axis joins THIS report rather than growing a private
12388
13137
  // dashboard — the `rewardEvents : exposureEvents` row asks exactly the question this command
12389
13138
  // already asks of the reinforcement loop (is the apply leg alive, or is it a write-only log?).
@@ -14175,8 +14924,10 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
14175
14924
  // reads appear nowhere in help, and static extraction over the dispatch table lost `--week` from
14176
14925
  // `dz recap` — a refusal built on either list would reject working commands, which is a worse
14177
14926
  // failure than the one being fixed. Goes to STDERR so a `--json` consumer's stdout stays clean.
14178
- for (const notice of unknownFlagNotice([...flags, ...options.keys()].filter((k) => !k.startsWith('_positional_')), KNOWN_CLI_FLAGS)) {
14179
- writeErr(notice.line);
14927
+ if (command !== 'contract-check') {
14928
+ for (const notice of unknownFlagNotice([...flags, ...options.keys()].filter((k) => !k.startsWith('_positional_')), KNOWN_CLI_FLAGS)) {
14929
+ writeErr(notice.line);
14930
+ }
14180
14931
  }
14181
14932
 
14182
14933
  // ── `dz --version` / `dz -v` / `dz version` — PRE-DISPATCH, before the help branch ──
@@ -14204,6 +14955,14 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
14204
14955
  return 0;
14205
14956
  }
14206
14957
 
14958
+ // Only registered command identifiers are telemetry. An unknown first argv token may be a path,
14959
+ // typo, or secret-like value; persisting it would violate the command-name-only privacy boundary.
14960
+ // `contract-check` has an explicit byte-for-byte read-only contract: even the advisory command
14961
+ // usage ledger would mutate the repository being audited and invalidate its own safety proof.
14962
+ if (command !== 'contract-check') {
14963
+ recordCommandInvocation(cwd, DZ_COMMANDS.includes(command) ? command : '', new Date());
14964
+ }
14965
+
14207
14966
  try {
14208
14967
  switch (command) {
14209
14968
  case 'init':
@@ -14253,6 +15012,8 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
14253
15012
  return cmdUsage(options, optionLists, flags, cwd, write);
14254
15013
  case 'claim-check':
14255
15014
  return cmdClaimCheck(options, optionLists, flags, cwd, write);
15015
+ case 'lint':
15016
+ return cmdLint(options, flags, cwd, write);
14256
15017
  case 'sign':
14257
15018
  return cmdSign(options, flags, cwd, write);
14258
15019
  case 'sbom':
@@ -14325,6 +15086,8 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
14325
15086
  return cmdSkillsVerify(options, flags, cwd, write);
14326
15087
  case 'compounding':
14327
15088
  return cmdCompounding(options, flags, cwd, write);
15089
+ case 'deadwood':
15090
+ return cmdDeadwood(options, flags, cwd, write, writeErr);
14328
15091
  case 'epoch-replay':
14329
15092
  return cmdEpochReplay(options, flags, cwd, write);
14330
15093
  case 'score':
@@ -14335,6 +15098,8 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
14335
15098
  return cmdCadence(options, flags, cwd, write);
14336
15099
  case 'qe-rounds':
14337
15100
  return cmdQeRounds(options, flags, cwd, write);
15101
+ case 'restart-advisor':
15102
+ return cmdRestartAdvisor(options, flags, cwd, write);
14338
15103
  case 'tg-post':
14339
15104
  return cmdTgPost(options, flags, cwd, write);
14340
15105
  case 'name-check':
@@ -14345,6 +15110,8 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
14345
15110
  return cmdFeatureAdrRecord(options, flags, cwd, write);
14346
15111
  case 'amendment-check':
14347
15112
  return cmdAmendmentCheck(options, flags, cwd, write);
15113
+ case 'contract-check':
15114
+ return cmdContractCheck(options, flags, cwd, write, writeErr);
14348
15115
  case 'feature-adr-checkpoint':
14349
15116
  return cmdFeatureAdrCheckpoint(options, flags, cwd, write);
14350
15117
  case 'profile':