@dzhechkov/harness-cli 0.7.3 → 0.7.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
@@ -7,7 +7,11 @@
7
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';
8
8
  import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
9
9
  import { fileURLToPath } from 'node:url';
10
- import { execFileSync, execSync, spawn, spawnSync, type ChildProcess } from 'node:child_process';
10
+ import { request as httpsRequest } from 'node:https';
11
+ import { KNOWN_CLI_FLAGS } from './known-flags.js';
12
+ import { isBooleanFlag } from './boolean-flags.js';
13
+ import { resolveInstallSpec } from './install-spec.js';
14
+ import { execFile, execFileSync, execSync, spawn, spawnSync, type ChildProcess } from 'node:child_process';
11
15
  import { createHash, randomBytes } from 'node:crypto';
12
16
  import { homedir, tmpdir } from 'node:os';
13
17
  import { createRequire } from 'node:module';
@@ -318,6 +322,12 @@ import {
318
322
  buildRecap,
319
323
  renderRecap,
320
324
  parseSourceManifest,
325
+ tgPostHtmlIssues,
326
+ tgVisibleLength,
327
+ decideTgSend,
328
+ TG_TEXT_LIMIT,
329
+ countRecallEventsForRun,
330
+ unknownFlagNotice,
321
331
  mirrorWriterExplanation,
322
332
  appendRecallUsage,
323
333
  closenessLine,
@@ -441,8 +451,11 @@ import {
441
451
  decideRecordWrite,
442
452
  decideReadBack,
443
453
  recordVerdictLine,
454
+ buildCadenceReport,
455
+ CADENCE_WINDOW_DAYS,
444
456
  } from '@dzhechkov/harness-core';
445
457
  import type { RecordKind, RecordDecision } from '@dzhechkov/harness-core';
458
+ import type { CadenceWindow } from '@dzhechkov/harness-core';
446
459
  import type { MutationEntryResult, MutationObservation, MutationRegistryEntry } from '@dzhechkov/harness-core';
447
460
  import type { SkillApplyFailure, SkillLoadFailure } from '@dzhechkov/harness-core';
448
461
  import type { ReqeDebt } from '@dzhechkov/harness-core';
@@ -493,6 +506,8 @@ Usage:
493
506
  dz epoch-replay --score <judgments.json> --work-order <file> [--slice <name>] [--json] (un-blind against the pre-registered assignment → SUPPORTED only when the two 95% Wilson CIs are DISJOINT, else FALSIFIED / INCONCLUSIVE)
494
507
  dz score --slug <feature> [--project <dir>] [--json] (process scorecard for ONE feature-adr run, from its artifacts: ADR confirmation, discrimination, cross-model QE grade, live verification, README-first, learning loop, amendments — descriptive-only, a low score exits 0)
495
508
  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)
509
+ 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)
510
+ 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)
496
511
  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)
497
512
  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)
498
513
  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)
@@ -639,10 +654,24 @@ function parseArgs(argv: string[]): ParsedArgs {
639
654
  const flags = new Set<string>();
640
655
  const positional: string[] = [];
641
656
 
657
+ // The command decides `--force`'s arity (valued only under `dz guard`); it is always the first
658
+ // token when present — a leading `--flag` means there is no command at all.
659
+ const command = (argv[0] ?? '').startsWith('--') ? '' : argv[0] ?? '';
642
660
  for (let index = 0; index < argv.length; index += 1) {
643
661
  const arg = argv[index] ?? '';
644
- if (arg.startsWith('--')) {
662
+ // A token that BEGINS with -- but contains whitespace cannot be a flag — no real flag carries a
663
+ // space (values arrive as separate argv tokens). It is quoted TEXT that happens to open with
664
+ // dashes: `dz backlog add "--semantic идея…"` was rejected twice with «an idea text is
665
+ // required» while the text sat right there, eaten as an option (f18fc89e).
666
+ if (arg.startsWith('--') && !/\s/.test(arg)) {
645
667
  const key = arg.slice(2);
668
+ // A DECLARED boolean flag never swallows the next token (backlog 247ddcfa: `dz sync
669
+ // --dry-run .` used to disarm the safety flag silently and then WRITE). The declaration is
670
+ // data with a two-way drift test — see src/boolean-flags.ts.
671
+ if (isBooleanFlag(key, command)) {
672
+ flags.add(key);
673
+ continue;
674
+ }
646
675
  const next = argv[index + 1];
647
676
  if (next !== undefined && !next.startsWith('--')) {
648
677
  options.set(key, next);
@@ -904,7 +933,15 @@ async function cmdSync(options: Map<string, string>, flags: Set<string>, cwd: st
904
933
  // Auto-discover all skills-* packs, or use explicit --canonical
905
934
  let canonicalDirs: string[];
906
935
  if (canonicalArg !== undefined) {
907
- canonicalDirs = [resolve(cwd, canonicalArg)];
936
+ const dir = resolve(cwd, canonicalArg);
937
+ if (!existsSync(dir)) {
938
+ // MEASURED 2026-08-24: a TYPO here used to print `0/0 in sync` and exit 0 — green exactly
939
+ // when nothing was compared, and in CI that reads as "all skills healthy". An explicit path
940
+ // that does not exist is an answer about the INVOCATION, not about the skills.
941
+ write(`dz sync: --canonical ${dir} does not exist — nothing was compared, and nothing-compared is not a clean sync`);
942
+ return 3;
943
+ }
944
+ canonicalDirs = [dir];
908
945
  } else {
909
946
  const baseDir = join(projectRoot, 'packages', '@dzhechkov');
910
947
  canonicalDirs = existsSync(baseDir)
@@ -921,9 +958,20 @@ async function cmdSync(options: Map<string, string>, flags: Set<string>, cwd: st
921
958
  force: flags.has('force'),
922
959
  });
923
960
  const { total, inSync, missing, drift } = report.summary;
961
+ // The DONE half, beside the SEEN half: a run that wrote its fixes used to print only what it had
962
+ // seen before writing, and exit 1 — "returns 0 when it did nothing and 1 when it worked". Status
963
+ // counts describe the tree as found; `wrote` describes what this run changed about it.
964
+ const wrote = report.skills.filter((skill) => skill.written.length > 0);
924
965
  write(
925
- `dz sync${report.dryRun ? ' --dry-run' : ''}: ${inSync}/${total} in sync, ${missing} missing, ${drift} drift`,
966
+ `dz sync${report.dryRun ? ' --dry-run' : ''}: ${inSync}/${total} in sync, ${missing} missing, ${drift} drift${wrote.length > 0 ? ` — wrote ${wrote.length} skill(s)` : ''}`,
926
967
  );
968
+ if (total === 0) {
969
+ // Nothing was compared. Exit 3, the house not-established convention (`dz workflow-lint`,
970
+ // `check-plan-completeness`) — never 0: a gate that is green when it never ran is the defect
971
+ // this repo keeps finding elsewhere (the publish gate, the scout 401, the recall log).
972
+ write(`dz sync: 0 canonical skill(s) found under ${canonicalDirs.join(', ')} — nothing was compared, and nothing-compared is not a clean sync`);
973
+ return 3;
974
+ }
927
975
  // Skip-and-collect (D1): the broken canonical skills are NAMED on stderr, and their
928
976
  // presence keeps the exit code non-zero — a partial sync is not a clean sync.
929
977
  if (report.failures.length > 0) {
@@ -932,7 +980,10 @@ async function cmdSync(options: Map<string, string>, flags: Set<string>, cwd: st
932
980
  for (const line of formatSkillLoadFailures(report.failures)) writeErr(line);
933
981
  return 1;
934
982
  }
935
- return missing === 0 && drift === 0 ? 0 : 1;
983
+ // The exit reflects the OUTCOME: a missing/drift skill this run wrote is resolved, not pending.
984
+ // A dry run writes nothing, so its exit keeps the CI meaning — 1 whenever work exists.
985
+ const unresolved = report.skills.filter((skill) => skill.status !== 'in-sync' && skill.written.length === 0);
986
+ return unresolved.length === 0 ? 0 : 1;
936
987
  }
937
988
 
938
989
  function cmdCreateSkill(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
@@ -1903,6 +1954,32 @@ async function cmdInstall(
1903
1954
  return 1;
1904
1955
  }
1905
1956
 
1957
+ // install-spec-honesty (43a52cf2 + c999786b): resolve the spec to {npmSpec, dirName} BEFORE any
1958
+ // npm process runs — the versioned form used to npm-install SUCCESSFULLY (mutating the project's
1959
+ // package.json) and then die on an invented node_modules path.
1960
+ const specResolution = resolveInstallSpec(pkg, (p) => resolve(cwd, p), {
1961
+ isFile: (p) => { try { return statSync(p).isFile(); } catch { return false; } },
1962
+ isDir: (p) => { try { return statSync(p).isDirectory(); } catch { return false; } },
1963
+ readTarballName: (p) => {
1964
+ try {
1965
+ const out = execSync(`tar -xzOf ${JSON.stringify(p)} package/package.json`, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
1966
+ const name = (JSON.parse(out) as { name?: unknown }).name;
1967
+ return typeof name === 'string' && name !== '' ? name : null;
1968
+ } catch { return null; }
1969
+ },
1970
+ readDirName: (p) => {
1971
+ try {
1972
+ const name = (JSON.parse(readFileSync(join(p, 'package.json'), 'utf-8')) as { name?: unknown }).name;
1973
+ return typeof name === 'string' && name !== '' ? name : null;
1974
+ } catch { return null; }
1975
+ },
1976
+ });
1977
+ if (specResolution.kind === 'refused') {
1978
+ write(`dz install: ${specResolution.reason}`);
1979
+ write(` ${specResolution.hint}`);
1980
+ return 1;
1981
+ }
1982
+
1906
1983
  const targetOpt = options.get('target') ?? 'claude-code';
1907
1984
  const targetResolution = resolveTargetName(targetOpt);
1908
1985
  if (targetResolution.kind === 'unknown') {
@@ -1915,8 +1992,8 @@ async function cmdInstall(
1915
1992
  const projectRoot = resolve(cwd, options.get('project') ?? '.');
1916
1993
 
1917
1994
  // Step 1: npm install the package (installRunner is the CliIo test seam — unset in production)
1918
- write(`Installing ${pkg}...`);
1919
- const installCmd = `npm install ${pkg} --save-dev --no-fund --no-audit`;
1995
+ write(`Installing ${specResolution.npmSpec}${specResolution.kind === 'name' ? '' : ` (${specResolution.kind} → node_modules/${specResolution.dirName})`}...`);
1996
+ const installCmd = `npm install ${JSON.stringify(specResolution.npmSpec)} --save-dev --no-fund --no-audit`;
1920
1997
  try {
1921
1998
  if (installRunner) installRunner(installCmd, projectRoot);
1922
1999
  else execSync(installCmd, { cwd: projectRoot, stdio: 'pipe', encoding: 'utf-8' });
@@ -1925,8 +2002,9 @@ async function cmdInstall(
1925
2002
  return 1;
1926
2003
  }
1927
2004
 
1928
- // Step 2: Find SKILL.md files in the installed package
1929
- const pkgDir = join(projectRoot, 'node_modules', pkg);
2005
+ // Step 2: Find SKILL.md files in the installed package — under the RESOLVED dir name, never the
2006
+ // raw spec (path.join concatenates an absolute segment; a version suffix invents a dir).
2007
+ const pkgDir = join(projectRoot, 'node_modules', specResolution.dirName);
1930
2008
  if (!existsSync(pkgDir)) {
1931
2009
  write(`dz install: package not found at ${pkgDir}`);
1932
2010
  return 1;
@@ -2184,6 +2262,31 @@ function cmdStatuslineInstall(options: Map<string, string>, cwd: string, write:
2184
2262
  function cmdStatuslineFaRecord(options: Map<string, string>, cwd: string, write: Write): number {
2185
2263
  const slug = (options.get('slug') ?? '').trim();
2186
2264
  const step = (options.get('step') ?? '').trim();
2265
+
2266
+ // `--recalled auto` derives the count from the recall-usage log for `--run <id>`, replacing the
2267
+ // pipeline's hardcoded `--recalled 3` (a literal at three call sites — the fallback writer that
2268
+ // lights the panel had nowhere to get a real number until `dz recall` began recording itself).
2269
+ if ((options.get('recalled') ?? '').trim() === 'auto') {
2270
+ const runKey = (options.get('run') ?? '').trim();
2271
+ if (runKey === '') {
2272
+ write('dz statusline --fa-record: --recalled auto needs --run <id> — without a run key there is nothing to count');
2273
+ return 2;
2274
+ }
2275
+ // The COUNT's source and the PANEL's home are two different addresses. The pipeline records
2276
+ // recalls in the canonical BRAIN store while the panel state lives in the project — with a
2277
+ // separate `args.brain` the count read from `--project` would be a silent zero over the wrong
2278
+ // log (the dz sync 0/0 class, again). `--count-project` names the log's home explicitly and
2279
+ // defaults to `--project` for the common single-root case.
2280
+ const countRoot = resolve((options.get('count-project') ?? options.get('project') ?? cwd).trim() || cwd);
2281
+ const derived = countRecallEventsForRun(countRoot, runKey);
2282
+ if (derived === null) {
2283
+ // An unreadable log is not zero. Refusing keeps the panel's PREVIOUS state, which is the
2284
+ // honest outcome: no new claim, rather than a wrong one (the dz sync 0/0 class).
2285
+ write('dz statusline --fa-record: the recall-usage log could not be read — recording nothing rather than a wrong number');
2286
+ return 2;
2287
+ }
2288
+ options.set('recalled', String(derived));
2289
+ }
2187
2290
  if (slug === '' || step === '') {
2188
2291
  write('dz statusline --fa-record: --slug and --step are both required');
2189
2292
  write(' Example: dz statusline --fa-record --slug add-user-auth --step "Step 0" --recalled 5 --stored 2');
@@ -2330,6 +2433,9 @@ function applyUsageCalibrationToConfig(config: Record<string, unknown>, plan: Us
2330
2433
  if (plan.changes.length > 0) {
2331
2434
  usage['calibratedAt'] = plan.after.calibratedAt;
2332
2435
  usage['source'] = plan.after.source;
2436
+ // A fresh calibration re-arms routing for THIS account and clears the legacy free-text switch:
2437
+ // the calibration is the very act the disable-note demanded.
2438
+ usage['calibrationAccount'] = plan.after.calibrationAccount ?? null;
2333
2439
  }
2334
2440
 
2335
2441
  memory['usage'] = usage;
@@ -2519,6 +2625,36 @@ function cmdUsageByStage(
2519
2625
  * non-zero exit. `--project <dir>` scopes ONLY the `.dz/config.json` read/write; measurement is
2520
2626
  * account-wide (all projects).
2521
2627
  */
2628
+ function cmdCadence(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
2629
+ const root = resolve(cwd, options.get('project') ?? '.');
2630
+ const windowRaw = (options.get('window') ?? 'week').trim() as CadenceWindow;
2631
+ if (!(windowRaw in CADENCE_WINDOW_DAYS)) {
2632
+ write('dz cadence: --window must be one of ' + Object.keys(CADENCE_WINDOW_DAYS).join('|'));
2633
+ return 1;
2634
+ }
2635
+ const r = buildCadenceReport(root, windowRaw);
2636
+ if (flags.has('json')) { write(JSON.stringify(r)); return r.decision.ok ? 0 : 2; }
2637
+ write('dz cadence — window ' + r.window + ', record depth ' + r.depthDays + ' day(s)');
2638
+ if (!r.decision.ok) {
2639
+ write(' ' + r.decision.reason);
2640
+ if (r.decision.largestAllowed) write(' cure: dz cadence --window ' + r.decision.largestAllowed);
2641
+ return 2;
2642
+ }
2643
+ const weeks = [...new Set([...Object.keys(r.shipments.graded), ...Object.keys(r.npmPublishes.weekly), ...Object.keys(r.recalls.weekly)])].sort();
2644
+ write(' week shipped(graded) npm-publishes recalls');
2645
+ for (const w of weeks) {
2646
+ write(' ' + w.padEnd(12) + String(r.shipments.graded[w] ?? 0).padStart(15) + String(r.npmPublishes.weekly[w] ?? 0).padStart(15) + String(r.recalls.weekly[w] ?? 0).padStart(9));
2647
+ }
2648
+ write(' graded ' + r.shipments.gradedTotal + ' (' + Object.entries(r.shipments.byGrade).sort().map(([g, n]) => g + '×' + n).join(', ') + ') · UNGRADED ' + r.shipments.ungraded + ' (named, not hidden)');
2649
+ if (r.guard.decay.length > 0) {
2650
+ write(' guard repeat decay (FIXED set — rules with pre-window history only):');
2651
+ for (const d of r.guard.decay.slice(0, 8)) write(' ' + d.rule.padEnd(28) + 'before×' + d.before + ' → in-window×' + d.inWindow);
2652
+ }
2653
+ if (r.guard.excludedNewborn.length > 0) write(' excluded newborn rule(s) (no pre-window history — a zero here would be youth, not virtue): ' + r.guard.excludedNewborn.join(', '));
2654
+ for (const dgr of [r.npmPublishes.degraded, r.guard.degraded, r.recalls.degraded]) if (dgr) write(' DEGRADED: ' + dgr);
2655
+ return 0;
2656
+ }
2657
+
2522
2658
  function cmdUsage(
2523
2659
  options: Map<string, string>,
2524
2660
  optionLists: Map<string, string[]>,
@@ -2562,6 +2698,8 @@ function cmdUsage(
2562
2698
  weeklyByModel?: typeof u.weeklyByModel;
2563
2699
  estimated: true;
2564
2700
  reqeDue?: number;
2701
+ notEstablished?: readonly string[];
2702
+ estimatesNotForRouting?: { sessionPct: number | null; weeklyPct: number | null };
2565
2703
  } = {
2566
2704
  sessionPct: u.sessionPct,
2567
2705
  weeklyPct: u.weeklyPct,
@@ -2571,6 +2709,10 @@ function cmdUsage(
2571
2709
  limits: limitsPayload,
2572
2710
  estimated: true,
2573
2711
  };
2712
+ // ADR-001 usage-honesty: a consumer that reads null pcts deserves the WHY (closed reason
2713
+ // set), and a human deserves the raw estimates when POLICY (not measurement) nulled them.
2714
+ if (u.notEstablished.length > 0) payload.notEstablished = u.notEstablished;
2715
+ if (u.estimatesNotForRouting !== undefined) payload.estimatesNotForRouting = u.estimatesNotForRouting;
2574
2716
  if (hasModelLimits && u.weeklyByModel !== undefined) payload.weeklyByModel = u.weeklyByModel;
2575
2717
  // re-QE debt surfacing (backlog 6b40e667 — QE #9: the json contract must carry the debt too,
2576
2718
  // a probe is exactly the consumer that needs it). The field appears ONLY when a debt exists,
@@ -2584,6 +2726,22 @@ function cmdUsage(
2584
2726
  );
2585
2727
  return 0;
2586
2728
  }
2729
+ if (u.sessionPct === null && u.weeklyPct === null && u.notEstablished.length > 0) {
2730
+ // Limits may be fully configured and the pcts STILL null — that is the honesty, not a config
2731
+ // gap. Say why, and show the human the raw estimates when only policy nulled them.
2732
+ write('usage: not established — ' + u.notEstablished.join(', '));
2733
+ if (u.estimatesNotForRouting !== undefined) {
2734
+ const e = u.estimatesNotForRouting;
2735
+ write(' estimates (NOT for routing): session ~' + (e.sessionPct ?? '?') + '% · week ~' + (e.weeklyPct ?? '?') + '% — recalibrate on THIS account: dz usage --calibrate --session <pct> --weekly <pct>');
2736
+ } else {
2737
+ write(' the scan established nothing (' + u.sessionTokens + ' session / ' + u.weeklyTokens + ' weekly tokens counted) — a percentage would be a guess, and routing must not eat guesses');
2738
+ }
2739
+ try {
2740
+ const reqe = scanReqeDebts(resolve(cwd, options.get('project') ?? '.'));
2741
+ if (reqe.debts.length > 0) write('re-QE due: ' + reqe.debts.length + ' usage-switched run(s) kept same-family QE — run `dz reqe` for the cross-family pass');
2742
+ } catch { /* advisory only */ }
2743
+ return 0;
2744
+ }
2587
2745
  if (u.sessionPct === null && u.weeklyPct === null) {
2588
2746
  write(
2589
2747
  'usage: unconfigured — set memory.usage.sessionTokenLimit / weeklyTokenLimit in .dz/config.json (percentages are ESTIMATES calibrated from observed exhaustion)',
@@ -3311,10 +3469,34 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
3311
3469
  limit,
3312
3470
  ...(bookFilter !== undefined ? { book: bookFilter } : {}),
3313
3471
  });
3314
- const { hits, error } = asJson ? await withForeignStdoutOnStderr(runBooks) : await runBooks();
3315
- if (asJson) { write(JSON.stringify(hits)); return 0; }
3472
+ let booksResult;
3473
+ try {
3474
+ booksResult = asJson ? await withForeignStdoutOnStderr(runBooks) : await runBooks();
3475
+ } catch (err) {
3476
+ // A corrupted store used to surface as a bare top-level `dz: file is not a database` — exit 1
3477
+ // (honest) but with no path and no cure (a2a574a9/c7aec002). Name both.
3478
+ const msg = err instanceof Error ? err.message : String(err);
3479
+ write(`dz recall --books: the book store is unreadable — ${msg}`);
3480
+ write(` store: ${join(projectRoot, '.dz', 'memory', 'books.sqlite')}`);
3481
+ write(' cure: restore it from a backup, or delete the file and re-digitize (dz brain add --from-slice / book-digitizer) — deleting loses only this project\'s local shelf, the machine-wide brain is separate');
3482
+ return 1;
3483
+ }
3484
+ const { hits, error } = booksResult;
3485
+ // f1451a6a: an EXPLICIT --semantic request whose vector instrument did not run must not read as
3486
+ // a clean 0-hit search. error!==undefined here means the INSTRUMENT failed (embedder/sqlite
3487
+ // unavailable), not that the shelf is empty — house convention exit 3 = not-established.
3488
+ const semanticNotEstablished = flags.has('semantic') && error !== undefined;
3489
+ if (asJson) {
3490
+ if (semanticNotEstablished) { write(JSON.stringify({ hits, notEstablished: error })); return 3; }
3491
+ write(JSON.stringify(hits));
3492
+ return 0;
3493
+ }
3316
3494
  write(`dz recall "${query}" --books${bookFilter !== undefined ? ` --book ${bookFilter}` : ''} — ${hits.length} KU hit(s)`);
3317
3495
  if (error !== undefined) write(` (${error})`);
3496
+ if (semanticNotEstablished) {
3497
+ write(' --semantic was EXPLICIT and the vector instrument did not run — nothing was established (exit 3, not a clean 0)');
3498
+ return 3;
3499
+ }
3318
3500
  for (const h of hits) {
3319
3501
  const src = h.chapter !== undefined ? ` [${h.book} гл.${h.chapter}${h.pages ? ` с.${h.pages[0]}-${h.pages[1]}` : ''}]` : ` [${h.book}]`;
3320
3502
  write(` (${h.type}) ${h.name}${src}`);
@@ -3430,6 +3612,12 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
3430
3612
  write(JSON.stringify(hits.map((h) => ({
3431
3613
  ...h.pattern,
3432
3614
  relevance: boost === null && 'score' in h && typeof h.score === 'number' ? h.score : null,
3615
+ // The TRUE cosine, as a second companion key — the design named it and the first ship missed
3616
+ // it, so a scripted consumer STILL could not threshold (found while recalibrating the floors:
3617
+ // every probe returned "no similarity" through --json while the human output showed sim=).
3618
+ // Unlike `relevance` it SURVIVES a domain boost: closeness is order-independent (ADR-001 of
3619
+ // recall-true-closeness), and null means "not measured for this hit", never zero.
3620
+ similarity: 'similarity' in h && typeof h.similarity === 'number' ? h.similarity : null,
3433
3621
  }))));
3434
3622
  // The honesty notes go to STDERR here rather than being skipped: the JSON branch
3435
3623
  // used to return before them, so a scripted caller was told nothing about a boost
@@ -3513,9 +3701,13 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
3513
3701
  if (vectorOn && hits.length > 0) {
3514
3702
  const measured = hits.filter((h) => typeof h.similarity === 'number' && Number.isFinite(h.similarity));
3515
3703
  if (measured.length > 0) {
3704
+ const runId = (options.get('run') ?? '').trim();
3516
3705
  appendRecallUsage({
3517
3706
  projectRoot: projectRoot,
3518
3707
  query: shownQuery,
3708
+ // `--run` threads a caller's run key into the log, so a later `--recalled auto` can count
3709
+ // THIS run's events instead of asserting a literal (the /feature-adr panel's `--recalled 3`).
3710
+ ...(runId === '' ? {} : { runId }),
3519
3711
  hits: measured.map((h) => ({ dzId: patternRecordId(h.pattern), score: h.similarity as number })),
3520
3712
  });
3521
3713
  }
@@ -9497,6 +9689,26 @@ function packNpmName(packDir: string): string | undefined {
9497
9689
  }
9498
9690
  }
9499
9691
 
9692
+ /**
9693
+ * Parse `pnpm pack --json` STDOUT robustly: a package with a `prepack` script echoes lifecycle
9694
+ * banners first, and the banner text itself may contain '[' or '{' (skills-feature-adr's guard
9695
+ * does) — so candidates are tried from the LAST line-start opener backwards; pnpm's JSON is the
9696
+ * final thing on stdout. MEASURED 2026-08-25: byte-0 parse failed on the banner, first-opener
9697
+ * parse failed on the banner's own array literal.
9698
+ */
9699
+ function parsePnpmPackJson(out: string): unknown {
9700
+ const starts: number[] = [];
9701
+ for (let li = 0; li < out.length; li = out.indexOf('\n', li) + 1) {
9702
+ const ch = out[li];
9703
+ if (ch === '{' || ch === '[') starts.push(li);
9704
+ if (out.indexOf('\n', li) === -1) break;
9705
+ }
9706
+ for (let ci = starts.length - 1; ci >= 0; ci--) {
9707
+ try { return JSON.parse(out.slice(starts[ci])); } catch { /* try an earlier candidate */ }
9708
+ }
9709
+ throw new Error('pnpm pack emitted no parseable JSON');
9710
+ }
9711
+
9500
9712
  function npmPackedPaths(packDir: string): string[] {
9501
9713
  // `pnpm`, not `npm`: the PUBLISHER is `pnpm publish` (see `publishArgv`), and the two packers do not
9502
9714
  // agree. MEASURED 2026-08-21 on `skills-news`: `npm pack` emits a 1051-byte package.json identical
@@ -9508,7 +9720,7 @@ function npmPackedPaths(packDir: string): string[] {
9508
9720
  encoding: 'utf-8',
9509
9721
  maxBuffer: 64 * 1024 * 1024,
9510
9722
  });
9511
- const parsed = JSON.parse(out) as { files?: { path: string }[] } | { files?: { path: string }[] }[];
9723
+ const parsed = parsePnpmPackJson(out) as { files?: { path: string }[] } | { files?: { path: string }[] }[];
9512
9724
  const entry = Array.isArray(parsed) ? parsed[0] : parsed;
9513
9725
  const files = entry?.files ?? [];
9514
9726
  return files.map((f) => f.path.replace(/^package\//, '')).sort();
@@ -9538,7 +9750,7 @@ function extractIntoTempDir(packDir: string, tmp: string): { dir: string; cleanu
9538
9750
  encoding: 'utf-8',
9539
9751
  maxBuffer: 64 * 1024 * 1024,
9540
9752
  });
9541
- const parsed = JSON.parse(out) as { filename?: string } | { filename?: string }[];
9753
+ const parsed = parsePnpmPackJson(out) as { filename?: string } | { filename?: string }[];
9542
9754
  const entry = Array.isArray(parsed) ? parsed[0] : parsed;
9543
9755
  const tgz = entry?.filename;
9544
9756
  if (tgz === undefined) throw new Error(`pnpm pack did not name a tarball for ${packDir}`);
@@ -9670,11 +9882,11 @@ function recapReuse(repo: string, window: RecapWindow): ReuseFacts | null {
9670
9882
  }
9671
9883
 
9672
9884
  /** Publishes come from a cache ONLY. 51 packages cost 18.3s over the network — never inside a report. */
9673
- function recapPublishes(repo: string): { dataStart: string | null; items: Publish[] } | null {
9885
+ function recapPublishes(repo: string): { dataStart: string | null; items: Publish[]; fetchedAt?: string; failed?: readonly string[] } | null {
9674
9886
  const path = join(repo, '.dz', 'recap', 'npm-times.json');
9675
9887
  if (!existsSync(path)) return null;
9676
9888
  try {
9677
- const raw = JSON.parse(readFileSync(path, 'utf-8')) as { packages?: Record<string, { versions?: Record<string, string> }> };
9889
+ const raw = JSON.parse(readFileSync(path, 'utf-8')) as { fetchedAt?: string; failed?: string[]; packages?: Record<string, { versions?: Record<string, string> }> };
9678
9890
  const items: Publish[] = [];
9679
9891
  for (const [pkg, entry] of Object.entries(raw.packages ?? {})) {
9680
9892
  for (const [version, iso] of Object.entries(entry.versions ?? {})) {
@@ -9683,7 +9895,12 @@ function recapPublishes(repo: string): { dataStart: string | null; items: Publis
9683
9895
  }
9684
9896
  }
9685
9897
  const sorted = items.map((i) => i.iso).sort();
9686
- return { dataStart: sorted.length > 0 ? (sorted[0] as string).slice(0, 10) : null, items };
9898
+ return {
9899
+ dataStart: sorted.length > 0 ? (sorted[0] as string).slice(0, 10) : null,
9900
+ items,
9901
+ ...(typeof raw.fetchedAt === 'string' ? { fetchedAt: raw.fetchedAt } : {}),
9902
+ ...(Array.isArray(raw.failed) && raw.failed.length > 0 ? { failed: raw.failed.filter((f): f is string => typeof f === 'string') } : {}),
9903
+ };
9687
9904
  } catch {
9688
9905
  return null;
9689
9906
  }
@@ -9966,10 +10183,276 @@ function cmdNameCheck(options: Map<string, string>, flags: Set<string>, cwd: str
9966
10183
  return decision.exit;
9967
10184
  }
9968
10185
 
9969
- function cmdRecap(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
10186
+ // ── recap publish-times cache (feature recap-publish-cache) ──────────────────
10187
+ //
10188
+ // The Publishes section is the one recap section whose timestamps a third party holds — and the one
10189
+ // that was always `unavailable`, because nothing ever filled its cache. The fetch lives HERE, behind
10190
+ // an explicit flag, never inside the report: 51 packages cost 18.3s sequentially (measured in the
10191
+ // recap design), and a report that does network I/O is a report that fails when the network does.
10192
+
10193
+ const RECAP_NPM_CACHE_REL = '.dz/recap/npm-times.json';
10194
+
10195
+ interface NpmTimesCache {
10196
+ readonly version: 1;
10197
+ readonly fetchedAt: string;
10198
+ readonly packages: Record<string, { versions: Record<string, string> }>;
10199
+ /** Packages the registry did not answer for. A zero from a failed source is not a measured zero. */
10200
+ readonly failed: readonly string[];
10201
+ }
10202
+
10203
+ /**
10204
+ * Ask the registry when every non-private workspace package's versions were published.
10205
+ *
10206
+ * Genuinely parallel in batches of 8 (`execFile`, not `spawnSync` — a sync call in a loop stays
10207
+ * sequential whatever the batch size claims, which is exactly the kind of false label this pipeline
10208
+ * keeps removing): measured in the recap design at 5.7s against 18.3s sequential over 51 packages.
10209
+ * A package whose lookup fails lands in `failed`, never silently absent — the same class as the
10210
+ * scout 401 that printed `github: 0` and read as "nothing new exists".
10211
+ */
10212
+ async function fetchNpmPublishTimes(repoRoot: string, write: Write): Promise<NpmTimesCache> {
10213
+ const pkgsRoot = join(repoRoot, 'packages', '@dzhechkov');
10214
+ const names: string[] = [];
10215
+ if (existsSync(pkgsRoot)) {
10216
+ for (const e of readdirSync(pkgsRoot, { withFileTypes: true })) {
10217
+ if (!e.isDirectory()) continue;
10218
+ const pj = join(pkgsRoot, e.name, 'package.json');
10219
+ if (!existsSync(pj)) continue;
10220
+ try {
10221
+ const meta = JSON.parse(readFileSync(pj, 'utf-8')) as { name?: string; private?: boolean };
10222
+ if (meta.private === true || typeof meta.name !== 'string' || meta.name === '') continue;
10223
+ // Deduplicated: two workspace directories can declare one npm name, and a failure would
10224
+ // then count twice — "2 FAILED" about one distinct package (review round 2).
10225
+ if (!names.includes(meta.name)) names.push(meta.name);
10226
+ } catch { /* an unreadable package.json is not a publishable package */ }
10227
+ }
10228
+ }
10229
+ const packages: Record<string, { versions: Record<string, string> }> = {};
10230
+ const failed: string[] = [];
10231
+ const one = (name: string): Promise<void> => new Promise((done) => {
10232
+ execFile('npm', ['view', name, 'time', '--json'], { timeout: 30_000, maxBuffer: 8 * 1024 * 1024 }, (err: Error | null, stdout: string) => {
10233
+ if (err !== null) { failed.push(name); done(); return; }
10234
+ try {
10235
+ const times = JSON.parse(stdout) as Record<string, string>;
10236
+ const versions: Record<string, string> = {};
10237
+ for (const [k, v] of Object.entries(times)) {
10238
+ if (k === 'created' || k === 'modified' || typeof v !== 'string') continue;
10239
+ versions[k] = v;
10240
+ }
10241
+ // A published package has at least one version by construction — npm will not host an
10242
+ // empty one. Valid-but-empty JSON is therefore an ANOMALY, not a zero: recording it as a
10243
+ // success would let the report read "0 publishes" about a package whose answer was broken
10244
+ // (cross-family review, codex gpt-5.6-sol, 2026-08-24, grade B).
10245
+ if (Object.keys(versions).length === 0) failed.push(name);
10246
+ else packages[name] = { versions };
10247
+ } catch {
10248
+ failed.push(name);
10249
+ }
10250
+ done();
10251
+ });
10252
+ });
10253
+ const BATCH = 8;
10254
+ for (let i = 0; i < names.length; i += BATCH) {
10255
+ await Promise.all(names.slice(i, i + BATCH).map(one));
10256
+ write(`dz recap: registry queried ${Math.min(i + BATCH, names.length)}/${names.length}…`);
10257
+ }
10258
+ // `failed` sorted so the cache is deterministic for a given outcome set.
10259
+ return { version: 1, fetchedAt: new Date().toISOString(), packages, failed: [...failed].sort() };
10260
+ }
10261
+
10262
+ // ── `dz tg-post` (feature genai-tweets-channel, стадия 0→1) ──────────────────
10263
+ //
10264
+ // The sender for an APPROVED draft, implementing the channel's own accepted ADRs: HTML mode (never
10265
+ // MarkdownV2 — 18 escapes against 3), link preview off by default (x.com previews in Telegram have
10266
+ // been broken since 2022), the 00:00-06:00 MSK quiet window, and ADR-004's standing order that
10267
+ // publishing stays MANUAL — the default run is a dry-run, and a real send needs --send --yes.
10268
+ //
10269
+ // The provenance gate runs IN-PROCESS before any send: the draft's sources.json goes through the
10270
+ // same classification `dz provenance-check` uses, and anything but ALLOWED refuses. A draft with no
10271
+ // manifest is refused too — unchecked is not approved.
10272
+
10273
+ function tgReadToken(repoRoot: string, write: Write): string | null {
10274
+ // Token sources, in order: env, then a tokenFile named in .dz/config.json. The token itself is
10275
+ // NEVER printed, logged, or included in any error — only where it was looked for.
10276
+ const env = process.env['TELEGRAM_BOT_TOKEN'];
10277
+ if (env !== undefined && env.trim() !== '') return env.trim();
10278
+ try {
10279
+ const cfg = JSON.parse(readFileSync(join(repoRoot, '.dz', 'config.json'), 'utf-8')) as { telegram?: { tokenFile?: string } };
10280
+ const file = cfg.telegram?.tokenFile;
10281
+ if (typeof file === 'string' && file.trim() !== '' && existsSync(file)) {
10282
+ const text = readFileSync(file, 'utf-8');
10283
+ const m = /TELEGRAM_BOT_TOKEN\s*=\s*"?([^"\n]+)"?/.exec(text);
10284
+ if (m?.[1] !== undefined) return m[1].trim();
10285
+ const bare = text.trim();
10286
+ if (bare !== '' && !bare.includes('\n')) return bare;
10287
+ }
10288
+ } catch { /* fall through to the honest null */ }
10289
+ write('dz tg-post: no bot token — set TELEGRAM_BOT_TOKEN or telegram.tokenFile in .dz/config.json');
10290
+ return null;
10291
+ }
10292
+
10293
+ function tgApi(token: string, method: string, body: Record<string, unknown>): Promise<{ ok: boolean; description?: string; messageId?: number }> {
10294
+ return new Promise((done) => {
10295
+ const payload = JSON.stringify(body);
10296
+ const req = httpsRequest({
10297
+ hostname: 'api.telegram.org',
10298
+ path: `/bot${token}/${method}`,
10299
+ method: 'POST',
10300
+ headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) },
10301
+ timeout: 30_000,
10302
+ }, (res) => {
10303
+ let data = '';
10304
+ res.on('data', (c: Buffer) => { data += c.toString('utf-8'); });
10305
+ res.on('end', () => {
10306
+ try {
10307
+ const parsed = JSON.parse(data) as { ok?: boolean; description?: string; result?: { message_id?: number } };
10308
+ done({ ok: parsed.ok === true, ...(typeof parsed.description === 'string' ? { description: parsed.description } : {}), ...(typeof parsed.result?.message_id === 'number' ? { messageId: parsed.result.message_id } : {}) });
10309
+ } catch {
10310
+ done({ ok: false, description: `unparseable response (HTTP ${res.statusCode ?? '?'})` });
10311
+ }
10312
+ });
10313
+ });
10314
+ req.on('error', (e: Error) => done({ ok: false, description: e.message }));
10315
+ req.on('timeout', () => { req.destroy(); done({ ok: false, description: 'timeout after 30s' }); });
10316
+ req.write(payload);
10317
+ req.end();
10318
+ });
10319
+ }
10320
+
10321
+ async function cmdTgPost(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): Promise<number> {
10322
+ const repoRoot = resolve(options.get('project') ?? cwd);
10323
+ const json = flags.has('json');
10324
+ const draftPath = (options.get('draft') ?? '').trim();
10325
+ if (draftPath === '') { write('dz tg-post: --draft <file.html> is required'); return 2; }
10326
+ let draft: string;
10327
+ try {
10328
+ draft = readFileSync(resolve(cwd, draftPath), 'utf-8');
10329
+ } catch {
10330
+ write(`dz tg-post: cannot read draft ${draftPath}`);
10331
+ return 2;
10332
+ }
10333
+
10334
+ const issues = tgPostHtmlIssues(draft);
10335
+
10336
+ // Provenance, in-process. `--manifest` names the draft's sources; its absence is a refusal at the
10337
+ // decision layer, not a quiet pass here.
10338
+ let provenanceOutcome: 'allowed' | 'blocked' | 'not-established' | 'skipped' = 'skipped';
10339
+ const manifestPath = (options.get('manifest') ?? '').trim();
10340
+ if (manifestPath !== '') {
10341
+ let manifest: ReturnType<typeof parseSourceManifest> = null;
10342
+ try { manifest = parseSourceManifest(readFileSync(resolve(cwd, manifestPath), 'utf-8')); } catch { manifest = null; }
10343
+ const publicRecords = new Set<string>();
10344
+ const show = spawnSync('git', ['-C', repoRoot, 'show', `HEAD:${PROVENANCE_PUBLIC_REL}`], { encoding: 'utf-8', maxBuffer: 8 * 1024 * 1024 });
10345
+ if (show.error === undefined && show.status === 0) {
10346
+ try {
10347
+ const raw = JSON.parse(show.stdout ?? '') as { records?: unknown };
10348
+ if (Array.isArray(raw.records)) for (const r of raw.records) if (typeof r === 'string') publicRecords.add(r);
10349
+ } catch { /* an unparseable committed list clears nothing */ }
10350
+ }
10351
+ const resolved = new Map<string, string | null>();
10352
+ const toAsk: string[] = [];
10353
+ for (const c of manifest?.claims ?? []) {
10354
+ if (c.kind !== 'path' || typeof c.source !== 'string' || c.source.trim() === '') continue;
10355
+ const src = c.source.trim();
10356
+ if (resolved.has(src)) continue;
10357
+ const real = provenanceResolve(repoRoot, src);
10358
+ resolved.set(src, real);
10359
+ if (real !== null) toAsk.push(real);
10360
+ }
10361
+ const tracked = new Set<string>();
10362
+ const dirty = new Set<string>();
10363
+ if (toAsk.length > 0) {
10364
+ const ls = spawnSync('git', ['-C', repoRoot, 'ls-files', '-z', '--', ...toAsk], { encoding: 'utf-8', maxBuffer: 16 * 1024 * 1024 });
10365
+ if (ls.error === undefined && ls.status === 0) {
10366
+ for (const rel of (ls.stdout ?? '').split('\0')) if (rel !== '') tracked.add(resolve(repoRoot, rel));
10367
+ }
10368
+ const onDisk = spawnSync('git', ['-C', repoRoot, 'hash-object', '--no-filters', '--', ...toAsk], { encoding: 'utf-8', maxBuffer: 16 * 1024 * 1024 });
10369
+ const inHead = spawnSync('git', ['-C', repoRoot, 'ls-tree', '-z', 'HEAD', '--', ...toAsk], { encoding: 'utf-8', maxBuffer: 16 * 1024 * 1024 });
10370
+ if (onDisk.error !== undefined || onDisk.status !== 0 || inHead.error !== undefined || inHead.status !== 0) {
10371
+ for (const p of toAsk) dirty.add(p);
10372
+ } else {
10373
+ const diskHashes = (onDisk.stdout ?? '').split('\n').map((l: string) => l.trim()).filter((l: string) => l !== '');
10374
+ const headHash = new Map<string, string>();
10375
+ for (const rec of (inHead.stdout ?? '').split('\0')) {
10376
+ if (rec === '') continue;
10377
+ const tab = rec.indexOf('\t');
10378
+ if (tab < 0) continue;
10379
+ const parts = rec.slice(0, tab).split(/\s+/);
10380
+ headHash.set(resolve(repoRoot, rec.slice(tab + 1)), parts[2] ?? '');
10381
+ }
10382
+ toAsk.forEach((abs, i) => {
10383
+ const committed = headHash.get(abs);
10384
+ if (committed === undefined || committed !== diskHashes[i]) dirty.add(abs);
10385
+ });
10386
+ }
10387
+ }
10388
+ const decision = decideSourceProvenance(manifest, {
10389
+ ignoredPaths: provenanceIgnored(repoRoot, toAsk),
10390
+ publicRecords,
10391
+ resolved,
10392
+ trackedPaths: tracked,
10393
+ dirtyPaths: dirty,
10394
+ });
10395
+ provenanceOutcome = decision.outcome;
10396
+ if (!json) for (const line of renderSourceProvenance(decision)) write(line);
10397
+ }
10398
+
10399
+ const decision = decideTgSend({
10400
+ issues,
10401
+ provenanceOutcome,
10402
+ confirmed: flags.has('send') && flags.has('yes'),
10403
+ nowUtcIso: new Date().toISOString(),
10404
+ nightOverride: flags.has('night'),
10405
+ });
10406
+
10407
+ const visible = tgVisibleLength(draft);
10408
+ if (!json) {
10409
+ for (const i of issues) write(` [${i.kind}] ${i.detail}`);
10410
+ write(`dz tg-post: ${decision.action === 'send' ? 'SENDING' : 'DRY-RUN / REFUSED'} — ${decision.reason}`);
10411
+ write(` ${visible} visible character(s) of ${TG_TEXT_LIMIT}; provenance: ${provenanceOutcome}`);
10412
+ }
10413
+
10414
+ if (decision.action !== 'send') {
10415
+ if (json) write(JSON.stringify({ ok: false, action: decision.action, reason: decision.reason, issues, visible, provenance: provenanceOutcome }));
10416
+ // A refused send exits 1; a clean DRY-RUN (no --send asked) exits 0 — asking to see is not a failure.
10417
+ return flags.has('send') ? 1 : issues.length > 0 ? 1 : 0;
10418
+ }
10419
+
10420
+ const token = tgReadToken(repoRoot, write);
10421
+ if (token === null) return 2;
10422
+ const channel = (options.get('channel') ?? '').trim();
10423
+ if (channel === '') { write('dz tg-post: --channel @name (or a chat id) is required to send'); return 2; }
10424
+
10425
+ const sent = await tgApi(token, 'sendMessage', {
10426
+ chat_id: channel,
10427
+ text: draft,
10428
+ parse_mode: 'HTML',
10429
+ // ADR-004: x.com previews in Telegram are broken — never rely on them.
10430
+ link_preview_options: { is_disabled: !flags.has('preview') },
10431
+ });
10432
+ if (!sent.ok) {
10433
+ write(`dz tg-post: Telegram refused — ${sent.description ?? 'no description'}`);
10434
+ if (json) write(JSON.stringify({ ok: false, action: 'send', reason: sent.description ?? 'refused', visible }));
10435
+ return 1;
10436
+ }
10437
+ write(`dz tg-post: sent to ${channel}${sent.messageId !== undefined ? ` (message ${sent.messageId})` : ''}`);
10438
+ if (json) write(JSON.stringify({ ok: true, action: 'send', channel, messageId: sent.messageId ?? null, visible }));
10439
+ return 0;
10440
+ }
10441
+
10442
+ async function cmdRecap(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): Promise<number> {
9970
10443
  const repo = resolve(options.get('project') ?? cwd);
9971
10444
  const json = flags.has('json');
9972
10445
 
10446
+ if (flags.has('refresh-publishes')) {
10447
+ const cache = await fetchNpmPublishTimes(repo, json ? () => {} : write);
10448
+ const cachePath = join(repo, ...RECAP_NPM_CACHE_REL.split('/'));
10449
+ mkdirSync(dirname(cachePath), { recursive: true });
10450
+ writeFileSync(cachePath, `${JSON.stringify(cache, null, 2)}\n`);
10451
+ if (!json) {
10452
+ write(`dz recap: publish-times cache written — ${Object.keys(cache.packages).length} package(s)${cache.failed.length > 0 ? `, ${cache.failed.length} FAILED: ${cache.failed.join(', ')}` : ''}`);
10453
+ }
10454
+ }
10455
+
9973
10456
  // Every horizon is RECOGNISED, including the refused ones — swallowing `--year` silently would be
9974
10457
  // the same defect as a gate that passes because it never ran.
9975
10458
  const asked = (['day', 'week', 'month', ...REFUSED_HORIZONS] as const).filter((h) => flags.has(h));
@@ -10001,8 +10484,21 @@ function cmdRecap(options: Map<string, string>, flags: Set<string>, cwd: string,
10001
10484
  reuse: recapReuse(repo, window),
10002
10485
  uncommittedSlugs: deliveries.uncommitted.sort(),
10003
10486
  });
10004
- if (json) write(JSON.stringify(report));
10005
- else for (const line of renderRecap(report)) write(line);
10487
+ if (json) {
10488
+ write(JSON.stringify({ ...report, publishesMeta: publishes === null ? null : { fetchedAt: publishes.fetchedAt ?? null, failed: publishes.failed ?? [] } }));
10489
+ } else {
10490
+ for (const line of renderRecap(report)) write(line);
10491
+ // The cache's AGE and its failures print beside the numbers they qualify: third-party
10492
+ // timestamps are only as fresh as the last explicit --refresh-publishes, and a package the
10493
+ // registry did not answer for is MISSING from the section above — said, not implied.
10494
+ if (publishes?.fetchedAt !== undefined) {
10495
+ const days = Math.floor((Date.parse(`${at.slice(0, 10)}T00:00:00Z`) - Date.parse(publishes.fetchedAt)) / 86_400_000);
10496
+ write(` ℹ publish-times cache fetched ${publishes.fetchedAt.slice(0, 10)}${Number.isFinite(days) && days > 0 ? ` — ${days} day(s) before this report's anchor` : ''}; refresh with --refresh-publishes`);
10497
+ }
10498
+ if (publishes?.failed !== undefined) {
10499
+ write(` ⚠ the registry did not answer for ${publishes.failed.length} package(s) at the last refresh: ${publishes.failed.join(', ')} — their publishes are MISSING above, not zero`);
10500
+ }
10501
+ }
10006
10502
  return 0;
10007
10503
  }
10008
10504
 
@@ -12865,6 +13361,16 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
12865
13361
  };
12866
13362
  const { command, options, optionLists, flags } = parseArgs(argv);
12867
13363
 
13364
+ // An unrecognised `--flag` must not pass in silence. MEASURED 2026-08-24: `dz recall "x" --breif
13365
+ // --limit 2` printed the full ordinary output and exited 0, so a typo read as "the mode worked".
13366
+ // It WARNS rather than refuses, and the reason is measured, not cautious: 53 of the names this CLI
13367
+ // reads appear nowhere in help, and static extraction over the dispatch table lost `--week` from
13368
+ // `dz recap` — a refusal built on either list would reject working commands, which is a worse
13369
+ // failure than the one being fixed. Goes to STDERR so a `--json` consumer's stdout stays clean.
13370
+ for (const notice of unknownFlagNotice([...flags, ...options.keys()].filter((k) => !k.startsWith('_positional_')), KNOWN_CLI_FLAGS)) {
13371
+ writeErr(notice.line);
13372
+ }
13373
+
12868
13374
  // ── `dz --version` / `dz -v` / `dz version` — PRE-DISPATCH, before the help branch ──
12869
13375
  //
12870
13376
  // Until now `dz --version` printed the whole USAGE manual and exited 0 (MEASURED 2026-08-17,
@@ -13017,6 +13523,10 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
13017
13523
  return cmdScore(options, flags, cwd, write);
13018
13524
  case 'recap':
13019
13525
  return cmdRecap(options, flags, cwd, write);
13526
+ case 'cadence':
13527
+ return cmdCadence(options, flags, cwd, write);
13528
+ case 'tg-post':
13529
+ return cmdTgPost(options, flags, cwd, write);
13020
13530
  case 'name-check':
13021
13531
  return cmdNameCheck(options, flags, cwd, write);
13022
13532
  case 'provenance-check':