@dzhechkov/harness-cli 0.7.4 → 0.7.6
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/.dz-manifest.json +51 -11
- package/README.md +35 -5
- package/dist/boolean-flags.d.ts +26 -0
- package/dist/boolean-flags.d.ts.map +1 -0
- package/dist/boolean-flags.js +118 -0
- package/dist/boolean-flags.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +619 -21
- package/dist/cli.js.map +1 -1
- package/dist/install-spec.d.ts +36 -0
- package/dist/install-spec.d.ts.map +1 -0
- package/dist/install-spec.js +68 -0
- package/dist/install-spec.js.map +1 -0
- package/dist/known-flags.d.ts.map +1 -1
- package/dist/known-flags.js +9 -0
- package/dist/known-flags.js.map +1 -1
- package/package.json +18 -18
- package/sbom.json +110 -10
- package/src/boolean-flags.ts +118 -0
- package/src/cli.ts +564 -21
- package/src/install-spec.ts +86 -0
- package/src/known-flags.ts +9 -0
package/src/cli.ts
CHANGED
|
@@ -7,8 +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 { request as httpsRequest } from 'node:https';
|
|
10
11
|
import { KNOWN_CLI_FLAGS } from './known-flags.js';
|
|
11
|
-
import {
|
|
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';
|
|
12
15
|
import { createHash, randomBytes } from 'node:crypto';
|
|
13
16
|
import { homedir, tmpdir } from 'node:os';
|
|
14
17
|
import { createRequire } from 'node:module';
|
|
@@ -319,6 +322,11 @@ import {
|
|
|
319
322
|
buildRecap,
|
|
320
323
|
renderRecap,
|
|
321
324
|
parseSourceManifest,
|
|
325
|
+
tgPostHtmlIssues,
|
|
326
|
+
tgVisibleLength,
|
|
327
|
+
decideTgSend,
|
|
328
|
+
TG_TEXT_LIMIT,
|
|
329
|
+
countRecallEventsForRun,
|
|
322
330
|
unknownFlagNotice,
|
|
323
331
|
mirrorWriterExplanation,
|
|
324
332
|
appendRecallUsage,
|
|
@@ -443,8 +451,12 @@ import {
|
|
|
443
451
|
decideRecordWrite,
|
|
444
452
|
decideReadBack,
|
|
445
453
|
recordVerdictLine,
|
|
454
|
+
buildCadenceReport,
|
|
455
|
+
tgVisibleSha256,
|
|
456
|
+
CADENCE_WINDOW_DAYS,
|
|
446
457
|
} from '@dzhechkov/harness-core';
|
|
447
458
|
import type { RecordKind, RecordDecision } from '@dzhechkov/harness-core';
|
|
459
|
+
import type { CadenceWindow } from '@dzhechkov/harness-core';
|
|
448
460
|
import type { MutationEntryResult, MutationObservation, MutationRegistryEntry } from '@dzhechkov/harness-core';
|
|
449
461
|
import type { SkillApplyFailure, SkillLoadFailure } from '@dzhechkov/harness-core';
|
|
450
462
|
import type { ReqeDebt } from '@dzhechkov/harness-core';
|
|
@@ -487,6 +499,7 @@ Usage:
|
|
|
487
499
|
dz release [--filter <name>] [--tag] [--publish] [--json] [--dry-run] [--no-issue] (VERIFIED release: 4 HARD gates in FRONT of dz publish — full package test suites, audit >=high, node --check of every dist/bin file, bin smoke-boot via "node <bin> --help" — any red gate STOPS the release (exit 1) + best-effort gh issue; all green ⇒ re-sign reminder, then prints the ready dz publish command (or chains with --publish); never duplicates publish's own gates)
|
|
488
500
|
dz parity [--target <name>] [--json] (the honest feature×target map, COMPUTED from the capability model — which harness feature is full / manual / absent on each of the ${TARGET_NAMES.length} targets, and via which form)
|
|
489
501
|
dz delivery-check --slug <slug> [--context-only] [--findings <f.json>] [--strict] [--author <model>] [--json] (portable Step-10 Delivery Gate: prints the 4-plane review brief + artifact probes; --findings classifies a fed-back review into a fail-closed ready|blocked hand-off and writes features/<slug>/10_delivery_review.md; --strict exits 1 on blocked)
|
|
502
|
+
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)
|
|
490
503
|
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)
|
|
491
504
|
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)
|
|
492
505
|
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)
|
|
@@ -495,6 +508,8 @@ Usage:
|
|
|
495
508
|
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)
|
|
496
509
|
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)
|
|
497
510
|
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)
|
|
511
|
+
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)
|
|
512
|
+
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)
|
|
498
513
|
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)
|
|
499
514
|
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)
|
|
500
515
|
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)
|
|
@@ -641,10 +656,24 @@ function parseArgs(argv: string[]): ParsedArgs {
|
|
|
641
656
|
const flags = new Set<string>();
|
|
642
657
|
const positional: string[] = [];
|
|
643
658
|
|
|
659
|
+
// The command decides `--force`'s arity (valued only under `dz guard`); it is always the first
|
|
660
|
+
// token when present — a leading `--flag` means there is no command at all.
|
|
661
|
+
const command = (argv[0] ?? '').startsWith('--') ? '' : argv[0] ?? '';
|
|
644
662
|
for (let index = 0; index < argv.length; index += 1) {
|
|
645
663
|
const arg = argv[index] ?? '';
|
|
646
|
-
|
|
664
|
+
// A token that BEGINS with -- but contains whitespace cannot be a flag — no real flag carries a
|
|
665
|
+
// space (values arrive as separate argv tokens). It is quoted TEXT that happens to open with
|
|
666
|
+
// dashes: `dz backlog add "--semantic идея…"` was rejected twice with «an idea text is
|
|
667
|
+
// required» while the text sat right there, eaten as an option (f18fc89e).
|
|
668
|
+
if (arg.startsWith('--') && !/\s/.test(arg)) {
|
|
647
669
|
const key = arg.slice(2);
|
|
670
|
+
// A DECLARED boolean flag never swallows the next token (backlog 247ddcfa: `dz sync
|
|
671
|
+
// --dry-run .` used to disarm the safety flag silently and then WRITE). The declaration is
|
|
672
|
+
// data with a two-way drift test — see src/boolean-flags.ts.
|
|
673
|
+
if (isBooleanFlag(key, command)) {
|
|
674
|
+
flags.add(key);
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
648
677
|
const next = argv[index + 1];
|
|
649
678
|
if (next !== undefined && !next.startsWith('--')) {
|
|
650
679
|
options.set(key, next);
|
|
@@ -906,7 +935,15 @@ async function cmdSync(options: Map<string, string>, flags: Set<string>, cwd: st
|
|
|
906
935
|
// Auto-discover all skills-* packs, or use explicit --canonical
|
|
907
936
|
let canonicalDirs: string[];
|
|
908
937
|
if (canonicalArg !== undefined) {
|
|
909
|
-
|
|
938
|
+
const dir = resolve(cwd, canonicalArg);
|
|
939
|
+
if (!existsSync(dir)) {
|
|
940
|
+
// MEASURED 2026-08-24: a TYPO here used to print `0/0 in sync` and exit 0 — green exactly
|
|
941
|
+
// when nothing was compared, and in CI that reads as "all skills healthy". An explicit path
|
|
942
|
+
// that does not exist is an answer about the INVOCATION, not about the skills.
|
|
943
|
+
write(`dz sync: --canonical ${dir} does not exist — nothing was compared, and nothing-compared is not a clean sync`);
|
|
944
|
+
return 3;
|
|
945
|
+
}
|
|
946
|
+
canonicalDirs = [dir];
|
|
910
947
|
} else {
|
|
911
948
|
const baseDir = join(projectRoot, 'packages', '@dzhechkov');
|
|
912
949
|
canonicalDirs = existsSync(baseDir)
|
|
@@ -923,9 +960,20 @@ async function cmdSync(options: Map<string, string>, flags: Set<string>, cwd: st
|
|
|
923
960
|
force: flags.has('force'),
|
|
924
961
|
});
|
|
925
962
|
const { total, inSync, missing, drift } = report.summary;
|
|
963
|
+
// The DONE half, beside the SEEN half: a run that wrote its fixes used to print only what it had
|
|
964
|
+
// seen before writing, and exit 1 — "returns 0 when it did nothing and 1 when it worked". Status
|
|
965
|
+
// counts describe the tree as found; `wrote` describes what this run changed about it.
|
|
966
|
+
const wrote = report.skills.filter((skill) => skill.written.length > 0);
|
|
926
967
|
write(
|
|
927
|
-
`dz sync${report.dryRun ? ' --dry-run' : ''}: ${inSync}/${total} in sync, ${missing} missing, ${drift} drift`,
|
|
968
|
+
`dz sync${report.dryRun ? ' --dry-run' : ''}: ${inSync}/${total} in sync, ${missing} missing, ${drift} drift${wrote.length > 0 ? ` — wrote ${wrote.length} skill(s)` : ''}`,
|
|
928
969
|
);
|
|
970
|
+
if (total === 0) {
|
|
971
|
+
// Nothing was compared. Exit 3, the house not-established convention (`dz workflow-lint`,
|
|
972
|
+
// `check-plan-completeness`) — never 0: a gate that is green when it never ran is the defect
|
|
973
|
+
// this repo keeps finding elsewhere (the publish gate, the scout 401, the recall log).
|
|
974
|
+
write(`dz sync: 0 canonical skill(s) found under ${canonicalDirs.join(', ')} — nothing was compared, and nothing-compared is not a clean sync`);
|
|
975
|
+
return 3;
|
|
976
|
+
}
|
|
929
977
|
// Skip-and-collect (D1): the broken canonical skills are NAMED on stderr, and their
|
|
930
978
|
// presence keeps the exit code non-zero — a partial sync is not a clean sync.
|
|
931
979
|
if (report.failures.length > 0) {
|
|
@@ -934,7 +982,10 @@ async function cmdSync(options: Map<string, string>, flags: Set<string>, cwd: st
|
|
|
934
982
|
for (const line of formatSkillLoadFailures(report.failures)) writeErr(line);
|
|
935
983
|
return 1;
|
|
936
984
|
}
|
|
937
|
-
|
|
985
|
+
// The exit reflects the OUTCOME: a missing/drift skill this run wrote is resolved, not pending.
|
|
986
|
+
// A dry run writes nothing, so its exit keeps the CI meaning — 1 whenever work exists.
|
|
987
|
+
const unresolved = report.skills.filter((skill) => skill.status !== 'in-sync' && skill.written.length === 0);
|
|
988
|
+
return unresolved.length === 0 ? 0 : 1;
|
|
938
989
|
}
|
|
939
990
|
|
|
940
991
|
function cmdCreateSkill(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
|
|
@@ -1905,6 +1956,32 @@ async function cmdInstall(
|
|
|
1905
1956
|
return 1;
|
|
1906
1957
|
}
|
|
1907
1958
|
|
|
1959
|
+
// install-spec-honesty (43a52cf2 + c999786b): resolve the spec to {npmSpec, dirName} BEFORE any
|
|
1960
|
+
// npm process runs — the versioned form used to npm-install SUCCESSFULLY (mutating the project's
|
|
1961
|
+
// package.json) and then die on an invented node_modules path.
|
|
1962
|
+
const specResolution = resolveInstallSpec(pkg, (p) => resolve(cwd, p), {
|
|
1963
|
+
isFile: (p) => { try { return statSync(p).isFile(); } catch { return false; } },
|
|
1964
|
+
isDir: (p) => { try { return statSync(p).isDirectory(); } catch { return false; } },
|
|
1965
|
+
readTarballName: (p) => {
|
|
1966
|
+
try {
|
|
1967
|
+
const out = execSync(`tar -xzOf ${JSON.stringify(p)} package/package.json`, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
1968
|
+
const name = (JSON.parse(out) as { name?: unknown }).name;
|
|
1969
|
+
return typeof name === 'string' && name !== '' ? name : null;
|
|
1970
|
+
} catch { return null; }
|
|
1971
|
+
},
|
|
1972
|
+
readDirName: (p) => {
|
|
1973
|
+
try {
|
|
1974
|
+
const name = (JSON.parse(readFileSync(join(p, 'package.json'), 'utf-8')) as { name?: unknown }).name;
|
|
1975
|
+
return typeof name === 'string' && name !== '' ? name : null;
|
|
1976
|
+
} catch { return null; }
|
|
1977
|
+
},
|
|
1978
|
+
});
|
|
1979
|
+
if (specResolution.kind === 'refused') {
|
|
1980
|
+
write(`dz install: ${specResolution.reason}`);
|
|
1981
|
+
write(` ${specResolution.hint}`);
|
|
1982
|
+
return 1;
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1908
1985
|
const targetOpt = options.get('target') ?? 'claude-code';
|
|
1909
1986
|
const targetResolution = resolveTargetName(targetOpt);
|
|
1910
1987
|
if (targetResolution.kind === 'unknown') {
|
|
@@ -1917,8 +1994,8 @@ async function cmdInstall(
|
|
|
1917
1994
|
const projectRoot = resolve(cwd, options.get('project') ?? '.');
|
|
1918
1995
|
|
|
1919
1996
|
// Step 1: npm install the package (installRunner is the CliIo test seam — unset in production)
|
|
1920
|
-
write(`Installing ${
|
|
1921
|
-
const installCmd = `npm install ${
|
|
1997
|
+
write(`Installing ${specResolution.npmSpec}${specResolution.kind === 'name' ? '' : ` (${specResolution.kind} → node_modules/${specResolution.dirName})`}...`);
|
|
1998
|
+
const installCmd = `npm install ${JSON.stringify(specResolution.npmSpec)} --save-dev --no-fund --no-audit`;
|
|
1922
1999
|
try {
|
|
1923
2000
|
if (installRunner) installRunner(installCmd, projectRoot);
|
|
1924
2001
|
else execSync(installCmd, { cwd: projectRoot, stdio: 'pipe', encoding: 'utf-8' });
|
|
@@ -1927,8 +2004,9 @@ async function cmdInstall(
|
|
|
1927
2004
|
return 1;
|
|
1928
2005
|
}
|
|
1929
2006
|
|
|
1930
|
-
// Step 2: Find SKILL.md files in the installed package
|
|
1931
|
-
|
|
2007
|
+
// Step 2: Find SKILL.md files in the installed package — under the RESOLVED dir name, never the
|
|
2008
|
+
// raw spec (path.join concatenates an absolute segment; a version suffix invents a dir).
|
|
2009
|
+
const pkgDir = join(projectRoot, 'node_modules', specResolution.dirName);
|
|
1932
2010
|
if (!existsSync(pkgDir)) {
|
|
1933
2011
|
write(`dz install: package not found at ${pkgDir}`);
|
|
1934
2012
|
return 1;
|
|
@@ -2186,6 +2264,31 @@ function cmdStatuslineInstall(options: Map<string, string>, cwd: string, write:
|
|
|
2186
2264
|
function cmdStatuslineFaRecord(options: Map<string, string>, cwd: string, write: Write): number {
|
|
2187
2265
|
const slug = (options.get('slug') ?? '').trim();
|
|
2188
2266
|
const step = (options.get('step') ?? '').trim();
|
|
2267
|
+
|
|
2268
|
+
// `--recalled auto` derives the count from the recall-usage log for `--run <id>`, replacing the
|
|
2269
|
+
// pipeline's hardcoded `--recalled 3` (a literal at three call sites — the fallback writer that
|
|
2270
|
+
// lights the panel had nowhere to get a real number until `dz recall` began recording itself).
|
|
2271
|
+
if ((options.get('recalled') ?? '').trim() === 'auto') {
|
|
2272
|
+
const runKey = (options.get('run') ?? '').trim();
|
|
2273
|
+
if (runKey === '') {
|
|
2274
|
+
write('dz statusline --fa-record: --recalled auto needs --run <id> — without a run key there is nothing to count');
|
|
2275
|
+
return 2;
|
|
2276
|
+
}
|
|
2277
|
+
// The COUNT's source and the PANEL's home are two different addresses. The pipeline records
|
|
2278
|
+
// recalls in the canonical BRAIN store while the panel state lives in the project — with a
|
|
2279
|
+
// separate `args.brain` the count read from `--project` would be a silent zero over the wrong
|
|
2280
|
+
// log (the dz sync 0/0 class, again). `--count-project` names the log's home explicitly and
|
|
2281
|
+
// defaults to `--project` for the common single-root case.
|
|
2282
|
+
const countRoot = resolve((options.get('count-project') ?? options.get('project') ?? cwd).trim() || cwd);
|
|
2283
|
+
const derived = countRecallEventsForRun(countRoot, runKey);
|
|
2284
|
+
if (derived === null) {
|
|
2285
|
+
// An unreadable log is not zero. Refusing keeps the panel's PREVIOUS state, which is the
|
|
2286
|
+
// honest outcome: no new claim, rather than a wrong one (the dz sync 0/0 class).
|
|
2287
|
+
write('dz statusline --fa-record: the recall-usage log could not be read — recording nothing rather than a wrong number');
|
|
2288
|
+
return 2;
|
|
2289
|
+
}
|
|
2290
|
+
options.set('recalled', String(derived));
|
|
2291
|
+
}
|
|
2189
2292
|
if (slug === '' || step === '') {
|
|
2190
2293
|
write('dz statusline --fa-record: --slug and --step are both required');
|
|
2191
2294
|
write(' Example: dz statusline --fa-record --slug add-user-auth --step "Step 0" --recalled 5 --stored 2');
|
|
@@ -2332,6 +2435,9 @@ function applyUsageCalibrationToConfig(config: Record<string, unknown>, plan: Us
|
|
|
2332
2435
|
if (plan.changes.length > 0) {
|
|
2333
2436
|
usage['calibratedAt'] = plan.after.calibratedAt;
|
|
2334
2437
|
usage['source'] = plan.after.source;
|
|
2438
|
+
// A fresh calibration re-arms routing for THIS account and clears the legacy free-text switch:
|
|
2439
|
+
// the calibration is the very act the disable-note demanded.
|
|
2440
|
+
usage['calibrationAccount'] = plan.after.calibrationAccount ?? null;
|
|
2335
2441
|
}
|
|
2336
2442
|
|
|
2337
2443
|
memory['usage'] = usage;
|
|
@@ -2521,6 +2627,36 @@ function cmdUsageByStage(
|
|
|
2521
2627
|
* non-zero exit. `--project <dir>` scopes ONLY the `.dz/config.json` read/write; measurement is
|
|
2522
2628
|
* account-wide (all projects).
|
|
2523
2629
|
*/
|
|
2630
|
+
function cmdCadence(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
|
|
2631
|
+
const root = resolve(cwd, options.get('project') ?? '.');
|
|
2632
|
+
const windowRaw = (options.get('window') ?? 'week').trim() as CadenceWindow;
|
|
2633
|
+
if (!(windowRaw in CADENCE_WINDOW_DAYS)) {
|
|
2634
|
+
write('dz cadence: --window must be one of ' + Object.keys(CADENCE_WINDOW_DAYS).join('|'));
|
|
2635
|
+
return 1;
|
|
2636
|
+
}
|
|
2637
|
+
const r = buildCadenceReport(root, windowRaw);
|
|
2638
|
+
if (flags.has('json')) { write(JSON.stringify(r)); return r.decision.ok ? 0 : 2; }
|
|
2639
|
+
write('dz cadence — window ' + r.window + ', record depth ' + r.depthDays + ' day(s)');
|
|
2640
|
+
if (!r.decision.ok) {
|
|
2641
|
+
write(' ' + r.decision.reason);
|
|
2642
|
+
if (r.decision.largestAllowed) write(' cure: dz cadence --window ' + r.decision.largestAllowed);
|
|
2643
|
+
return 2;
|
|
2644
|
+
}
|
|
2645
|
+
const weeks = [...new Set([...Object.keys(r.shipments.graded), ...Object.keys(r.npmPublishes.weekly), ...Object.keys(r.recalls.weekly)])].sort();
|
|
2646
|
+
write(' week shipped(graded) npm-publishes recalls');
|
|
2647
|
+
for (const w of weeks) {
|
|
2648
|
+
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));
|
|
2649
|
+
}
|
|
2650
|
+
write(' graded ' + r.shipments.gradedTotal + ' (' + Object.entries(r.shipments.byGrade).sort().map(([g, n]) => g + '×' + n).join(', ') + ') · UNGRADED ' + r.shipments.ungraded + ' (named, not hidden)');
|
|
2651
|
+
if (r.guard.decay.length > 0) {
|
|
2652
|
+
write(' guard repeat decay (FIXED set — rules with pre-window history only):');
|
|
2653
|
+
for (const d of r.guard.decay.slice(0, 8)) write(' ' + d.rule.padEnd(28) + 'before×' + d.before + ' → in-window×' + d.inWindow);
|
|
2654
|
+
}
|
|
2655
|
+
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(', '));
|
|
2656
|
+
for (const dgr of [r.npmPublishes.degraded, r.guard.degraded, r.recalls.degraded]) if (dgr) write(' DEGRADED: ' + dgr);
|
|
2657
|
+
return 0;
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2524
2660
|
function cmdUsage(
|
|
2525
2661
|
options: Map<string, string>,
|
|
2526
2662
|
optionLists: Map<string, string[]>,
|
|
@@ -2564,6 +2700,8 @@ function cmdUsage(
|
|
|
2564
2700
|
weeklyByModel?: typeof u.weeklyByModel;
|
|
2565
2701
|
estimated: true;
|
|
2566
2702
|
reqeDue?: number;
|
|
2703
|
+
notEstablished?: readonly string[];
|
|
2704
|
+
estimatesNotForRouting?: { sessionPct: number | null; weeklyPct: number | null };
|
|
2567
2705
|
} = {
|
|
2568
2706
|
sessionPct: u.sessionPct,
|
|
2569
2707
|
weeklyPct: u.weeklyPct,
|
|
@@ -2573,6 +2711,10 @@ function cmdUsage(
|
|
|
2573
2711
|
limits: limitsPayload,
|
|
2574
2712
|
estimated: true,
|
|
2575
2713
|
};
|
|
2714
|
+
// ADR-001 usage-honesty: a consumer that reads null pcts deserves the WHY (closed reason
|
|
2715
|
+
// set), and a human deserves the raw estimates when POLICY (not measurement) nulled them.
|
|
2716
|
+
if (u.notEstablished.length > 0) payload.notEstablished = u.notEstablished;
|
|
2717
|
+
if (u.estimatesNotForRouting !== undefined) payload.estimatesNotForRouting = u.estimatesNotForRouting;
|
|
2576
2718
|
if (hasModelLimits && u.weeklyByModel !== undefined) payload.weeklyByModel = u.weeklyByModel;
|
|
2577
2719
|
// re-QE debt surfacing (backlog 6b40e667 — QE #9: the json contract must carry the debt too,
|
|
2578
2720
|
// a probe is exactly the consumer that needs it). The field appears ONLY when a debt exists,
|
|
@@ -2586,6 +2728,22 @@ function cmdUsage(
|
|
|
2586
2728
|
);
|
|
2587
2729
|
return 0;
|
|
2588
2730
|
}
|
|
2731
|
+
if (u.sessionPct === null && u.weeklyPct === null && u.notEstablished.length > 0) {
|
|
2732
|
+
// Limits may be fully configured and the pcts STILL null — that is the honesty, not a config
|
|
2733
|
+
// gap. Say why, and show the human the raw estimates when only policy nulled them.
|
|
2734
|
+
write('usage: not established — ' + u.notEstablished.join(', '));
|
|
2735
|
+
if (u.estimatesNotForRouting !== undefined) {
|
|
2736
|
+
const e = u.estimatesNotForRouting;
|
|
2737
|
+
write(' estimates (NOT for routing): session ~' + (e.sessionPct ?? '?') + '% · week ~' + (e.weeklyPct ?? '?') + '% — recalibrate on THIS account: dz usage --calibrate --session <pct> --weekly <pct>');
|
|
2738
|
+
} else {
|
|
2739
|
+
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');
|
|
2740
|
+
}
|
|
2741
|
+
try {
|
|
2742
|
+
const reqe = scanReqeDebts(resolve(cwd, options.get('project') ?? '.'));
|
|
2743
|
+
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');
|
|
2744
|
+
} catch { /* advisory only */ }
|
|
2745
|
+
return 0;
|
|
2746
|
+
}
|
|
2589
2747
|
if (u.sessionPct === null && u.weeklyPct === null) {
|
|
2590
2748
|
write(
|
|
2591
2749
|
'usage: unconfigured — set memory.usage.sessionTokenLimit / weeklyTokenLimit in .dz/config.json (percentages are ESTIMATES calibrated from observed exhaustion)',
|
|
@@ -3313,10 +3471,34 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
3313
3471
|
limit,
|
|
3314
3472
|
...(bookFilter !== undefined ? { book: bookFilter } : {}),
|
|
3315
3473
|
});
|
|
3316
|
-
|
|
3317
|
-
|
|
3474
|
+
let booksResult;
|
|
3475
|
+
try {
|
|
3476
|
+
booksResult = asJson ? await withForeignStdoutOnStderr(runBooks) : await runBooks();
|
|
3477
|
+
} catch (err) {
|
|
3478
|
+
// A corrupted store used to surface as a bare top-level `dz: file is not a database` — exit 1
|
|
3479
|
+
// (honest) but with no path and no cure (a2a574a9/c7aec002). Name both.
|
|
3480
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
3481
|
+
write(`dz recall --books: the book store is unreadable — ${msg}`);
|
|
3482
|
+
write(` store: ${join(projectRoot, '.dz', 'memory', 'books.sqlite')}`);
|
|
3483
|
+
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');
|
|
3484
|
+
return 1;
|
|
3485
|
+
}
|
|
3486
|
+
const { hits, error } = booksResult;
|
|
3487
|
+
// f1451a6a: an EXPLICIT --semantic request whose vector instrument did not run must not read as
|
|
3488
|
+
// a clean 0-hit search. error!==undefined here means the INSTRUMENT failed (embedder/sqlite
|
|
3489
|
+
// unavailable), not that the shelf is empty — house convention exit 3 = not-established.
|
|
3490
|
+
const semanticNotEstablished = flags.has('semantic') && error !== undefined;
|
|
3491
|
+
if (asJson) {
|
|
3492
|
+
if (semanticNotEstablished) { write(JSON.stringify({ hits, notEstablished: error })); return 3; }
|
|
3493
|
+
write(JSON.stringify(hits));
|
|
3494
|
+
return 0;
|
|
3495
|
+
}
|
|
3318
3496
|
write(`dz recall "${query}" --books${bookFilter !== undefined ? ` --book ${bookFilter}` : ''} — ${hits.length} KU hit(s)`);
|
|
3319
3497
|
if (error !== undefined) write(` (${error})`);
|
|
3498
|
+
if (semanticNotEstablished) {
|
|
3499
|
+
write(' --semantic was EXPLICIT and the vector instrument did not run — nothing was established (exit 3, not a clean 0)');
|
|
3500
|
+
return 3;
|
|
3501
|
+
}
|
|
3320
3502
|
for (const h of hits) {
|
|
3321
3503
|
const src = h.chapter !== undefined ? ` [${h.book} гл.${h.chapter}${h.pages ? ` с.${h.pages[0]}-${h.pages[1]}` : ''}]` : ` [${h.book}]`;
|
|
3322
3504
|
write(` (${h.type}) ${h.name}${src}`);
|
|
@@ -3432,6 +3614,12 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
3432
3614
|
write(JSON.stringify(hits.map((h) => ({
|
|
3433
3615
|
...h.pattern,
|
|
3434
3616
|
relevance: boost === null && 'score' in h && typeof h.score === 'number' ? h.score : null,
|
|
3617
|
+
// The TRUE cosine, as a second companion key — the design named it and the first ship missed
|
|
3618
|
+
// it, so a scripted consumer STILL could not threshold (found while recalibrating the floors:
|
|
3619
|
+
// every probe returned "no similarity" through --json while the human output showed sim=).
|
|
3620
|
+
// Unlike `relevance` it SURVIVES a domain boost: closeness is order-independent (ADR-001 of
|
|
3621
|
+
// recall-true-closeness), and null means "not measured for this hit", never zero.
|
|
3622
|
+
similarity: 'similarity' in h && typeof h.similarity === 'number' ? h.similarity : null,
|
|
3435
3623
|
}))));
|
|
3436
3624
|
// The honesty notes go to STDERR here rather than being skipped: the JSON branch
|
|
3437
3625
|
// used to return before them, so a scripted caller was told nothing about a boost
|
|
@@ -3515,9 +3703,13 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
|
|
|
3515
3703
|
if (vectorOn && hits.length > 0) {
|
|
3516
3704
|
const measured = hits.filter((h) => typeof h.similarity === 'number' && Number.isFinite(h.similarity));
|
|
3517
3705
|
if (measured.length > 0) {
|
|
3706
|
+
const runId = (options.get('run') ?? '').trim();
|
|
3518
3707
|
appendRecallUsage({
|
|
3519
3708
|
projectRoot: projectRoot,
|
|
3520
3709
|
query: shownQuery,
|
|
3710
|
+
// `--run` threads a caller's run key into the log, so a later `--recalled auto` can count
|
|
3711
|
+
// THIS run's events instead of asserting a literal (the /feature-adr panel's `--recalled 3`).
|
|
3712
|
+
...(runId === '' ? {} : { runId }),
|
|
3521
3713
|
hits: measured.map((h) => ({ dzId: patternRecordId(h.pattern), score: h.similarity as number })),
|
|
3522
3714
|
});
|
|
3523
3715
|
}
|
|
@@ -7039,7 +7231,7 @@ function gatherGuardFacts(op: string, root: string, text: string | undefined, st
|
|
|
7039
7231
|
}
|
|
7040
7232
|
facts['licenceHold'] = holds;
|
|
7041
7233
|
} catch { /* unreadable tree — the rule reports nothing rather than inventing a violation */ }
|
|
7042
|
-
try { facts['drift'] = sweepSkillDrift(root, { scope: '
|
|
7234
|
+
try { facts['drift'] = sweepSkillDrift(root, { scope: 'installs', allowlist: readDriftAllowlist(root) }).drifted.map((d) => d.name); } catch { /* skip */ }
|
|
7043
7235
|
facts['counts'] = gatherReadmeCounts(root);
|
|
7044
7236
|
// readme-first: from the WORKING-TREE diff (publishes happen pre-commit here), per package: does the
|
|
7045
7237
|
// change set contain its package.json (the version-bump signal) without its README.md?
|
|
@@ -7203,7 +7395,7 @@ function gatherGuardFacts(op: string, root: string, text: string | undefined, st
|
|
|
7203
7395
|
if (Array.isArray(stubWaivers)) facts['stubWaivers'] = stubWaivers;
|
|
7204
7396
|
}
|
|
7205
7397
|
if (op === 'consolidate') {
|
|
7206
|
-
try { facts['drift'] = sweepSkillDrift(root, { scope: '
|
|
7398
|
+
try { facts['drift'] = sweepSkillDrift(root, { scope: 'installs', allowlist: readDriftAllowlist(root) }).drifted.map((d) => d.name); } catch { /* skip */ }
|
|
7207
7399
|
}
|
|
7208
7400
|
if (op === 'teach' || op === 'consolidate') {
|
|
7209
7401
|
if (op === 'teach' && text) facts['secretTargets'] = [{ label: 'lesson', text }];
|
|
@@ -9499,6 +9691,26 @@ function packNpmName(packDir: string): string | undefined {
|
|
|
9499
9691
|
}
|
|
9500
9692
|
}
|
|
9501
9693
|
|
|
9694
|
+
/**
|
|
9695
|
+
* Parse `pnpm pack --json` STDOUT robustly: a package with a `prepack` script echoes lifecycle
|
|
9696
|
+
* banners first, and the banner text itself may contain '[' or '{' (skills-feature-adr's guard
|
|
9697
|
+
* does) — so candidates are tried from the LAST line-start opener backwards; pnpm's JSON is the
|
|
9698
|
+
* final thing on stdout. MEASURED 2026-08-25: byte-0 parse failed on the banner, first-opener
|
|
9699
|
+
* parse failed on the banner's own array literal.
|
|
9700
|
+
*/
|
|
9701
|
+
function parsePnpmPackJson(out: string): unknown {
|
|
9702
|
+
const starts: number[] = [];
|
|
9703
|
+
for (let li = 0; li < out.length; li = out.indexOf('\n', li) + 1) {
|
|
9704
|
+
const ch = out[li];
|
|
9705
|
+
if (ch === '{' || ch === '[') starts.push(li);
|
|
9706
|
+
if (out.indexOf('\n', li) === -1) break;
|
|
9707
|
+
}
|
|
9708
|
+
for (let ci = starts.length - 1; ci >= 0; ci--) {
|
|
9709
|
+
try { return JSON.parse(out.slice(starts[ci])); } catch { /* try an earlier candidate */ }
|
|
9710
|
+
}
|
|
9711
|
+
throw new Error('pnpm pack emitted no parseable JSON');
|
|
9712
|
+
}
|
|
9713
|
+
|
|
9502
9714
|
function npmPackedPaths(packDir: string): string[] {
|
|
9503
9715
|
// `pnpm`, not `npm`: the PUBLISHER is `pnpm publish` (see `publishArgv`), and the two packers do not
|
|
9504
9716
|
// agree. MEASURED 2026-08-21 on `skills-news`: `npm pack` emits a 1051-byte package.json identical
|
|
@@ -9510,7 +9722,7 @@ function npmPackedPaths(packDir: string): string[] {
|
|
|
9510
9722
|
encoding: 'utf-8',
|
|
9511
9723
|
maxBuffer: 64 * 1024 * 1024,
|
|
9512
9724
|
});
|
|
9513
|
-
const parsed =
|
|
9725
|
+
const parsed = parsePnpmPackJson(out) as { files?: { path: string }[] } | { files?: { path: string }[] }[];
|
|
9514
9726
|
const entry = Array.isArray(parsed) ? parsed[0] : parsed;
|
|
9515
9727
|
const files = entry?.files ?? [];
|
|
9516
9728
|
return files.map((f) => f.path.replace(/^package\//, '')).sort();
|
|
@@ -9540,7 +9752,7 @@ function extractIntoTempDir(packDir: string, tmp: string): { dir: string; cleanu
|
|
|
9540
9752
|
encoding: 'utf-8',
|
|
9541
9753
|
maxBuffer: 64 * 1024 * 1024,
|
|
9542
9754
|
});
|
|
9543
|
-
const parsed =
|
|
9755
|
+
const parsed = parsePnpmPackJson(out) as { filename?: string } | { filename?: string }[];
|
|
9544
9756
|
const entry = Array.isArray(parsed) ? parsed[0] : parsed;
|
|
9545
9757
|
const tgz = entry?.filename;
|
|
9546
9758
|
if (tgz === undefined) throw new Error(`pnpm pack did not name a tarball for ${packDir}`);
|
|
@@ -9672,11 +9884,11 @@ function recapReuse(repo: string, window: RecapWindow): ReuseFacts | null {
|
|
|
9672
9884
|
}
|
|
9673
9885
|
|
|
9674
9886
|
/** Publishes come from a cache ONLY. 51 packages cost 18.3s over the network — never inside a report. */
|
|
9675
|
-
function recapPublishes(repo: string): { dataStart: string | null; items: Publish[] } | null {
|
|
9887
|
+
function recapPublishes(repo: string): { dataStart: string | null; items: Publish[]; fetchedAt?: string; failed?: readonly string[] } | null {
|
|
9676
9888
|
const path = join(repo, '.dz', 'recap', 'npm-times.json');
|
|
9677
9889
|
if (!existsSync(path)) return null;
|
|
9678
9890
|
try {
|
|
9679
|
-
const raw = JSON.parse(readFileSync(path, 'utf-8')) as { packages?: Record<string, { versions?: Record<string, string> }> };
|
|
9891
|
+
const raw = JSON.parse(readFileSync(path, 'utf-8')) as { fetchedAt?: string; failed?: string[]; packages?: Record<string, { versions?: Record<string, string> }> };
|
|
9680
9892
|
const items: Publish[] = [];
|
|
9681
9893
|
for (const [pkg, entry] of Object.entries(raw.packages ?? {})) {
|
|
9682
9894
|
for (const [version, iso] of Object.entries(entry.versions ?? {})) {
|
|
@@ -9685,7 +9897,12 @@ function recapPublishes(repo: string): { dataStart: string | null; items: Publis
|
|
|
9685
9897
|
}
|
|
9686
9898
|
}
|
|
9687
9899
|
const sorted = items.map((i) => i.iso).sort();
|
|
9688
|
-
return {
|
|
9900
|
+
return {
|
|
9901
|
+
dataStart: sorted.length > 0 ? (sorted[0] as string).slice(0, 10) : null,
|
|
9902
|
+
items,
|
|
9903
|
+
...(typeof raw.fetchedAt === 'string' ? { fetchedAt: raw.fetchedAt } : {}),
|
|
9904
|
+
...(Array.isArray(raw.failed) && raw.failed.length > 0 ? { failed: raw.failed.filter((f): f is string => typeof f === 'string') } : {}),
|
|
9905
|
+
};
|
|
9689
9906
|
} catch {
|
|
9690
9907
|
return null;
|
|
9691
9908
|
}
|
|
@@ -9968,10 +10185,319 @@ function cmdNameCheck(options: Map<string, string>, flags: Set<string>, cwd: str
|
|
|
9968
10185
|
return decision.exit;
|
|
9969
10186
|
}
|
|
9970
10187
|
|
|
9971
|
-
|
|
10188
|
+
// ── recap publish-times cache (feature recap-publish-cache) ──────────────────
|
|
10189
|
+
//
|
|
10190
|
+
// The Publishes section is the one recap section whose timestamps a third party holds — and the one
|
|
10191
|
+
// that was always `unavailable`, because nothing ever filled its cache. The fetch lives HERE, behind
|
|
10192
|
+
// an explicit flag, never inside the report: 51 packages cost 18.3s sequentially (measured in the
|
|
10193
|
+
// recap design), and a report that does network I/O is a report that fails when the network does.
|
|
10194
|
+
|
|
10195
|
+
const RECAP_NPM_CACHE_REL = '.dz/recap/npm-times.json';
|
|
10196
|
+
|
|
10197
|
+
interface NpmTimesCache {
|
|
10198
|
+
readonly version: 1;
|
|
10199
|
+
readonly fetchedAt: string;
|
|
10200
|
+
readonly packages: Record<string, { versions: Record<string, string> }>;
|
|
10201
|
+
/** Packages the registry did not answer for. A zero from a failed source is not a measured zero. */
|
|
10202
|
+
readonly failed: readonly string[];
|
|
10203
|
+
}
|
|
10204
|
+
|
|
10205
|
+
/**
|
|
10206
|
+
* Ask the registry when every non-private workspace package's versions were published.
|
|
10207
|
+
*
|
|
10208
|
+
* Genuinely parallel in batches of 8 (`execFile`, not `spawnSync` — a sync call in a loop stays
|
|
10209
|
+
* sequential whatever the batch size claims, which is exactly the kind of false label this pipeline
|
|
10210
|
+
* keeps removing): measured in the recap design at 5.7s against 18.3s sequential over 51 packages.
|
|
10211
|
+
* A package whose lookup fails lands in `failed`, never silently absent — the same class as the
|
|
10212
|
+
* scout 401 that printed `github: 0` and read as "nothing new exists".
|
|
10213
|
+
*/
|
|
10214
|
+
async function fetchNpmPublishTimes(repoRoot: string, write: Write): Promise<NpmTimesCache> {
|
|
10215
|
+
const pkgsRoot = join(repoRoot, 'packages', '@dzhechkov');
|
|
10216
|
+
const names: string[] = [];
|
|
10217
|
+
if (existsSync(pkgsRoot)) {
|
|
10218
|
+
for (const e of readdirSync(pkgsRoot, { withFileTypes: true })) {
|
|
10219
|
+
if (!e.isDirectory()) continue;
|
|
10220
|
+
const pj = join(pkgsRoot, e.name, 'package.json');
|
|
10221
|
+
if (!existsSync(pj)) continue;
|
|
10222
|
+
try {
|
|
10223
|
+
const meta = JSON.parse(readFileSync(pj, 'utf-8')) as { name?: string; private?: boolean };
|
|
10224
|
+
if (meta.private === true || typeof meta.name !== 'string' || meta.name === '') continue;
|
|
10225
|
+
// Deduplicated: two workspace directories can declare one npm name, and a failure would
|
|
10226
|
+
// then count twice — "2 FAILED" about one distinct package (review round 2).
|
|
10227
|
+
if (!names.includes(meta.name)) names.push(meta.name);
|
|
10228
|
+
} catch { /* an unreadable package.json is not a publishable package */ }
|
|
10229
|
+
}
|
|
10230
|
+
}
|
|
10231
|
+
const packages: Record<string, { versions: Record<string, string> }> = {};
|
|
10232
|
+
const failed: string[] = [];
|
|
10233
|
+
const one = (name: string): Promise<void> => new Promise((done) => {
|
|
10234
|
+
execFile('npm', ['view', name, 'time', '--json'], { timeout: 30_000, maxBuffer: 8 * 1024 * 1024 }, (err: Error | null, stdout: string) => {
|
|
10235
|
+
if (err !== null) { failed.push(name); done(); return; }
|
|
10236
|
+
try {
|
|
10237
|
+
const times = JSON.parse(stdout) as Record<string, string>;
|
|
10238
|
+
const versions: Record<string, string> = {};
|
|
10239
|
+
for (const [k, v] of Object.entries(times)) {
|
|
10240
|
+
if (k === 'created' || k === 'modified' || typeof v !== 'string') continue;
|
|
10241
|
+
versions[k] = v;
|
|
10242
|
+
}
|
|
10243
|
+
// A published package has at least one version by construction — npm will not host an
|
|
10244
|
+
// empty one. Valid-but-empty JSON is therefore an ANOMALY, not a zero: recording it as a
|
|
10245
|
+
// success would let the report read "0 publishes" about a package whose answer was broken
|
|
10246
|
+
// (cross-family review, codex gpt-5.6-sol, 2026-08-24, grade B).
|
|
10247
|
+
if (Object.keys(versions).length === 0) failed.push(name);
|
|
10248
|
+
else packages[name] = { versions };
|
|
10249
|
+
} catch {
|
|
10250
|
+
failed.push(name);
|
|
10251
|
+
}
|
|
10252
|
+
done();
|
|
10253
|
+
});
|
|
10254
|
+
});
|
|
10255
|
+
const BATCH = 8;
|
|
10256
|
+
for (let i = 0; i < names.length; i += BATCH) {
|
|
10257
|
+
await Promise.all(names.slice(i, i + BATCH).map(one));
|
|
10258
|
+
write(`dz recap: registry queried ${Math.min(i + BATCH, names.length)}/${names.length}…`);
|
|
10259
|
+
}
|
|
10260
|
+
// `failed` sorted so the cache is deterministic for a given outcome set.
|
|
10261
|
+
return { version: 1, fetchedAt: new Date().toISOString(), packages, failed: [...failed].sort() };
|
|
10262
|
+
}
|
|
10263
|
+
|
|
10264
|
+
// ── `dz tg-post` (feature genai-tweets-channel, стадия 0→1) ──────────────────
|
|
10265
|
+
//
|
|
10266
|
+
// The sender for an APPROVED draft, implementing the channel's own accepted ADRs: HTML mode (never
|
|
10267
|
+
// MarkdownV2 — 18 escapes against 3), link preview off by default (x.com previews in Telegram have
|
|
10268
|
+
// been broken since 2022), the 00:00-06:00 MSK quiet window, and ADR-004's standing order that
|
|
10269
|
+
// publishing stays MANUAL — the default run is a dry-run, and a real send needs --send --yes.
|
|
10270
|
+
//
|
|
10271
|
+
// The provenance gate runs IN-PROCESS before any send: the draft's sources.json goes through the
|
|
10272
|
+
// same classification `dz provenance-check` uses, and anything but ALLOWED refuses. A draft with no
|
|
10273
|
+
// manifest is refused too — unchecked is not approved.
|
|
10274
|
+
|
|
10275
|
+
function tgReadToken(repoRoot: string, write: Write): string | null {
|
|
10276
|
+
// Token sources, in order: env, then a tokenFile named in .dz/config.json. The token itself is
|
|
10277
|
+
// NEVER printed, logged, or included in any error — only where it was looked for.
|
|
10278
|
+
const env = process.env['TELEGRAM_BOT_TOKEN'];
|
|
10279
|
+
if (env !== undefined && env.trim() !== '') return env.trim();
|
|
10280
|
+
try {
|
|
10281
|
+
const cfg = JSON.parse(readFileSync(join(repoRoot, '.dz', 'config.json'), 'utf-8')) as { telegram?: { tokenFile?: string } };
|
|
10282
|
+
const file = cfg.telegram?.tokenFile;
|
|
10283
|
+
if (typeof file === 'string' && file.trim() !== '' && existsSync(file)) {
|
|
10284
|
+
const text = readFileSync(file, 'utf-8');
|
|
10285
|
+
const m = /TELEGRAM_BOT_TOKEN\s*=\s*"?([^"\n]+)"?/.exec(text);
|
|
10286
|
+
if (m?.[1] !== undefined) return m[1].trim();
|
|
10287
|
+
const bare = text.trim();
|
|
10288
|
+
if (bare !== '' && !bare.includes('\n')) return bare;
|
|
10289
|
+
}
|
|
10290
|
+
} catch { /* fall through to the honest null */ }
|
|
10291
|
+
write('dz tg-post: no bot token — set TELEGRAM_BOT_TOKEN or telegram.tokenFile in .dz/config.json');
|
|
10292
|
+
return null;
|
|
10293
|
+
}
|
|
10294
|
+
|
|
10295
|
+
function tgApi(token: string, method: string, body: Record<string, unknown>): Promise<{ ok: boolean; description?: string; messageId?: number }> {
|
|
10296
|
+
return new Promise((done) => {
|
|
10297
|
+
const payload = JSON.stringify(body);
|
|
10298
|
+
const req = httpsRequest({
|
|
10299
|
+
hostname: 'api.telegram.org',
|
|
10300
|
+
path: `/bot${token}/${method}`,
|
|
10301
|
+
method: 'POST',
|
|
10302
|
+
headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) },
|
|
10303
|
+
timeout: 30_000,
|
|
10304
|
+
}, (res) => {
|
|
10305
|
+
let data = '';
|
|
10306
|
+
res.on('data', (c: Buffer) => { data += c.toString('utf-8'); });
|
|
10307
|
+
res.on('end', () => {
|
|
10308
|
+
try {
|
|
10309
|
+
const parsed = JSON.parse(data) as { ok?: boolean; description?: string; result?: { message_id?: number } };
|
|
10310
|
+
done({ ok: parsed.ok === true, ...(typeof parsed.description === 'string' ? { description: parsed.description } : {}), ...(typeof parsed.result?.message_id === 'number' ? { messageId: parsed.result.message_id } : {}) });
|
|
10311
|
+
} catch {
|
|
10312
|
+
done({ ok: false, description: `unparseable response (HTTP ${res.statusCode ?? '?'})` });
|
|
10313
|
+
}
|
|
10314
|
+
});
|
|
10315
|
+
});
|
|
10316
|
+
req.on('error', (e: Error) => done({ ok: false, description: e.message }));
|
|
10317
|
+
req.on('timeout', () => { req.destroy(); done({ ok: false, description: 'timeout after 30s' }); });
|
|
10318
|
+
req.write(payload);
|
|
10319
|
+
req.end();
|
|
10320
|
+
});
|
|
10321
|
+
}
|
|
10322
|
+
|
|
10323
|
+
async function cmdTgPost(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): Promise<number> {
|
|
10324
|
+
const repoRoot = resolve(options.get('project') ?? cwd);
|
|
10325
|
+
const json = flags.has('json');
|
|
10326
|
+
const draftPath = (options.get('draft') ?? '').trim();
|
|
10327
|
+
if (draftPath === '') { write('dz tg-post: --draft <file.html> is required'); return 2; }
|
|
10328
|
+
let draft: string;
|
|
10329
|
+
try {
|
|
10330
|
+
draft = readFileSync(resolve(cwd, draftPath), 'utf-8');
|
|
10331
|
+
} catch {
|
|
10332
|
+
write(`dz tg-post: cannot read draft ${draftPath}`);
|
|
10333
|
+
return 2;
|
|
10334
|
+
}
|
|
10335
|
+
|
|
10336
|
+
const issues = tgPostHtmlIssues(draft);
|
|
10337
|
+
|
|
10338
|
+
// Provenance, in-process. `--manifest` names the draft's sources; its absence is a refusal at the
|
|
10339
|
+
// decision layer, not a quiet pass here.
|
|
10340
|
+
let provenanceOutcome: 'allowed' | 'blocked' | 'not-established' | 'skipped' = 'skipped';
|
|
10341
|
+
const manifestPath = (options.get('manifest') ?? '').trim();
|
|
10342
|
+
if (manifestPath !== '') {
|
|
10343
|
+
let manifest: ReturnType<typeof parseSourceManifest> = null;
|
|
10344
|
+
try { manifest = parseSourceManifest(readFileSync(resolve(cwd, manifestPath), 'utf-8')); } catch { manifest = null; }
|
|
10345
|
+
const publicRecords = new Set<string>();
|
|
10346
|
+
const show = spawnSync('git', ['-C', repoRoot, 'show', `HEAD:${PROVENANCE_PUBLIC_REL}`], { encoding: 'utf-8', maxBuffer: 8 * 1024 * 1024 });
|
|
10347
|
+
if (show.error === undefined && show.status === 0) {
|
|
10348
|
+
try {
|
|
10349
|
+
const raw = JSON.parse(show.stdout ?? '') as { records?: unknown };
|
|
10350
|
+
if (Array.isArray(raw.records)) for (const r of raw.records) if (typeof r === 'string') publicRecords.add(r);
|
|
10351
|
+
} catch { /* an unparseable committed list clears nothing */ }
|
|
10352
|
+
}
|
|
10353
|
+
const resolved = new Map<string, string | null>();
|
|
10354
|
+
const toAsk: string[] = [];
|
|
10355
|
+
for (const c of manifest?.claims ?? []) {
|
|
10356
|
+
if (c.kind !== 'path' || typeof c.source !== 'string' || c.source.trim() === '') continue;
|
|
10357
|
+
const src = c.source.trim();
|
|
10358
|
+
if (resolved.has(src)) continue;
|
|
10359
|
+
const real = provenanceResolve(repoRoot, src);
|
|
10360
|
+
resolved.set(src, real);
|
|
10361
|
+
if (real !== null) toAsk.push(real);
|
|
10362
|
+
}
|
|
10363
|
+
const tracked = new Set<string>();
|
|
10364
|
+
const dirty = new Set<string>();
|
|
10365
|
+
if (toAsk.length > 0) {
|
|
10366
|
+
const ls = spawnSync('git', ['-C', repoRoot, 'ls-files', '-z', '--', ...toAsk], { encoding: 'utf-8', maxBuffer: 16 * 1024 * 1024 });
|
|
10367
|
+
if (ls.error === undefined && ls.status === 0) {
|
|
10368
|
+
for (const rel of (ls.stdout ?? '').split('\0')) if (rel !== '') tracked.add(resolve(repoRoot, rel));
|
|
10369
|
+
}
|
|
10370
|
+
const onDisk = spawnSync('git', ['-C', repoRoot, 'hash-object', '--no-filters', '--', ...toAsk], { encoding: 'utf-8', maxBuffer: 16 * 1024 * 1024 });
|
|
10371
|
+
const inHead = spawnSync('git', ['-C', repoRoot, 'ls-tree', '-z', 'HEAD', '--', ...toAsk], { encoding: 'utf-8', maxBuffer: 16 * 1024 * 1024 });
|
|
10372
|
+
if (onDisk.error !== undefined || onDisk.status !== 0 || inHead.error !== undefined || inHead.status !== 0) {
|
|
10373
|
+
for (const p of toAsk) dirty.add(p);
|
|
10374
|
+
} else {
|
|
10375
|
+
const diskHashes = (onDisk.stdout ?? '').split('\n').map((l: string) => l.trim()).filter((l: string) => l !== '');
|
|
10376
|
+
const headHash = new Map<string, string>();
|
|
10377
|
+
for (const rec of (inHead.stdout ?? '').split('\0')) {
|
|
10378
|
+
if (rec === '') continue;
|
|
10379
|
+
const tab = rec.indexOf('\t');
|
|
10380
|
+
if (tab < 0) continue;
|
|
10381
|
+
const parts = rec.slice(0, tab).split(/\s+/);
|
|
10382
|
+
headHash.set(resolve(repoRoot, rec.slice(tab + 1)), parts[2] ?? '');
|
|
10383
|
+
}
|
|
10384
|
+
toAsk.forEach((abs, i) => {
|
|
10385
|
+
const committed = headHash.get(abs);
|
|
10386
|
+
if (committed === undefined || committed !== diskHashes[i]) dirty.add(abs);
|
|
10387
|
+
});
|
|
10388
|
+
}
|
|
10389
|
+
}
|
|
10390
|
+
const decision = decideSourceProvenance(manifest, {
|
|
10391
|
+
ignoredPaths: provenanceIgnored(repoRoot, toAsk),
|
|
10392
|
+
publicRecords,
|
|
10393
|
+
resolved,
|
|
10394
|
+
trackedPaths: tracked,
|
|
10395
|
+
dirtyPaths: dirty,
|
|
10396
|
+
});
|
|
10397
|
+
provenanceOutcome = decision.outcome;
|
|
10398
|
+
if (!json) for (const line of renderSourceProvenance(decision)) write(line);
|
|
10399
|
+
}
|
|
10400
|
+
|
|
10401
|
+
// ADR-005 autopublish guards: read the stop-cord and the send journal as FACTS, then let the pure
|
|
10402
|
+
// decideTgSend judge. An UNREADABLE journal stays undefined ⇒ decideTgSend fails closed on G4/G5.
|
|
10403
|
+
const tgDir = join(repoRoot, '.dz', 'tg-post');
|
|
10404
|
+
const halted = existsSync(join(tgDir, 'HALT'));
|
|
10405
|
+
const sha256 = tgVisibleSha256(draft);
|
|
10406
|
+
let sentLog: { sha256: string; ts: string; status?: 'pending' | 'sent' }[] | undefined;
|
|
10407
|
+
const sentLogPath = join(tgDir, 'sent-log.jsonl');
|
|
10408
|
+
if (!existsSync(sentLogPath)) {
|
|
10409
|
+
sentLog = []; // no journal yet is an EMPTY journal, not an unreadable one — a fresh channel sends
|
|
10410
|
+
} else {
|
|
10411
|
+
try {
|
|
10412
|
+
sentLog = [];
|
|
10413
|
+
for (const line of readFileSync(sentLogPath, 'utf-8').split('\n')) {
|
|
10414
|
+
if (line.trim() === '') continue;
|
|
10415
|
+
const r = JSON.parse(line) as { sha256?: unknown; ts?: unknown; status?: unknown };
|
|
10416
|
+
if (typeof r.sha256 === 'string' && typeof r.ts === 'string') {
|
|
10417
|
+
sentLog.push({ sha256: r.sha256, ts: r.ts, ...(r.status === 'pending' || r.status === 'sent' ? { status: r.status } : {}) });
|
|
10418
|
+
}
|
|
10419
|
+
}
|
|
10420
|
+
} catch {
|
|
10421
|
+
sentLog = undefined; // genuinely unreadable ⇒ fail-closed
|
|
10422
|
+
}
|
|
10423
|
+
}
|
|
10424
|
+
const decision = decideTgSend({
|
|
10425
|
+
issues,
|
|
10426
|
+
provenanceOutcome,
|
|
10427
|
+
confirmed: flags.has('send') && flags.has('yes'),
|
|
10428
|
+
nowUtcIso: new Date().toISOString(),
|
|
10429
|
+
nightOverride: flags.has('night'),
|
|
10430
|
+
halted,
|
|
10431
|
+
sha256,
|
|
10432
|
+
sentLog,
|
|
10433
|
+
maxPostsPerDay: (() => { const n = Number(options.get('max-per-day')); return Number.isFinite(n) && n > 0 ? n : 10; })(),
|
|
10434
|
+
});
|
|
10435
|
+
|
|
10436
|
+
const visible = tgVisibleLength(draft);
|
|
10437
|
+
if (!json) {
|
|
10438
|
+
for (const i of issues) write(` [${i.kind}] ${i.detail}`);
|
|
10439
|
+
write(`dz tg-post: ${decision.action === 'send' ? 'SENDING' : 'DRY-RUN / REFUSED'} — ${decision.reason}`);
|
|
10440
|
+
write(` ${visible} visible character(s) of ${TG_TEXT_LIMIT}; provenance: ${provenanceOutcome}`);
|
|
10441
|
+
}
|
|
10442
|
+
|
|
10443
|
+
if (decision.action !== 'send') {
|
|
10444
|
+
if (json) write(JSON.stringify({ ok: false, action: decision.action, reason: decision.reason, issues, visible, provenance: provenanceOutcome }));
|
|
10445
|
+
// A refused send exits 1; a clean DRY-RUN (no --send asked) exits 0 — asking to see is not a failure.
|
|
10446
|
+
return flags.has('send') ? 1 : issues.length > 0 ? 1 : 0;
|
|
10447
|
+
}
|
|
10448
|
+
|
|
10449
|
+
const token = tgReadToken(repoRoot, write);
|
|
10450
|
+
if (token === null) return 2;
|
|
10451
|
+
const channel = (options.get('channel') ?? '').trim();
|
|
10452
|
+
if (channel === '') { write('dz tg-post: --channel @name (or a chat id) is required to send'); return 2; }
|
|
10453
|
+
|
|
10454
|
+
// Two-phase journal (Codex A-): write a PENDING row BEFORE the network call. If the process dies
|
|
10455
|
+
// between send and the sent-row, G5 dedup sees this pending row on the next run and refuses to
|
|
10456
|
+
// double-publish. Best-effort — a pending-write failure is warned, not fatal (better to risk one
|
|
10457
|
+
// duplicate than to block a legitimate send on a full disk).
|
|
10458
|
+
try { mkdirSync(tgDir, { recursive: true }); appendFileSync(sentLogPath, JSON.stringify({ sha256, ts: new Date().toISOString(), channel, status: 'pending' }) + '\n'); }
|
|
10459
|
+
catch (e) { write(`dz tg-post: WARNING — could not write the PENDING journal row (${(e as Error).message}); the crash-window dedup guard is degraded for this send`); }
|
|
10460
|
+
|
|
10461
|
+
const sent = await tgApi(token, 'sendMessage', {
|
|
10462
|
+
chat_id: channel,
|
|
10463
|
+
text: draft,
|
|
10464
|
+
parse_mode: 'HTML',
|
|
10465
|
+
// ADR-004: x.com previews in Telegram are broken — never rely on them.
|
|
10466
|
+
link_preview_options: { is_disabled: !flags.has('preview') },
|
|
10467
|
+
});
|
|
10468
|
+
if (!sent.ok) {
|
|
10469
|
+
write(`dz tg-post: Telegram refused — ${sent.description ?? 'no description'}`);
|
|
10470
|
+
if (json) write(JSON.stringify({ ok: false, action: 'send', reason: sent.description ?? 'refused', visible }));
|
|
10471
|
+
return 1;
|
|
10472
|
+
}
|
|
10473
|
+
// Record the send AFTER Telegram accepted it (never before — a failed send must not eat the daily
|
|
10474
|
+
// limit or block a retry via dedup). Best-effort append; a journal write failure is logged, not
|
|
10475
|
+
// fatal — the post already went out.
|
|
10476
|
+
try {
|
|
10477
|
+
mkdirSync(tgDir, { recursive: true });
|
|
10478
|
+
appendFileSync(sentLogPath, JSON.stringify({ sha256, ts: new Date().toISOString(), channel, messageId: sent.messageId ?? null, status: 'sent' }) + '\n');
|
|
10479
|
+
} catch (e) {
|
|
10480
|
+
write(`dz tg-post: WARNING — sent, but could not record it in ${sentLogPath} (${(e as Error).message}) — the daily limit and dedup may under-count until fixed`);
|
|
10481
|
+
}
|
|
10482
|
+
write(`dz tg-post: sent to ${channel}${sent.messageId !== undefined ? ` (message ${sent.messageId})` : ''}`);
|
|
10483
|
+
if (json) write(JSON.stringify({ ok: true, action: 'send', channel, messageId: sent.messageId ?? null, visible }));
|
|
10484
|
+
return 0;
|
|
10485
|
+
}
|
|
10486
|
+
|
|
10487
|
+
async function cmdRecap(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): Promise<number> {
|
|
9972
10488
|
const repo = resolve(options.get('project') ?? cwd);
|
|
9973
10489
|
const json = flags.has('json');
|
|
9974
10490
|
|
|
10491
|
+
if (flags.has('refresh-publishes')) {
|
|
10492
|
+
const cache = await fetchNpmPublishTimes(repo, json ? () => {} : write);
|
|
10493
|
+
const cachePath = join(repo, ...RECAP_NPM_CACHE_REL.split('/'));
|
|
10494
|
+
mkdirSync(dirname(cachePath), { recursive: true });
|
|
10495
|
+
writeFileSync(cachePath, `${JSON.stringify(cache, null, 2)}\n`);
|
|
10496
|
+
if (!json) {
|
|
10497
|
+
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(', ')}` : ''}`);
|
|
10498
|
+
}
|
|
10499
|
+
}
|
|
10500
|
+
|
|
9975
10501
|
// Every horizon is RECOGNISED, including the refused ones — swallowing `--year` silently would be
|
|
9976
10502
|
// the same defect as a gate that passes because it never ran.
|
|
9977
10503
|
const asked = (['day', 'week', 'month', ...REFUSED_HORIZONS] as const).filter((h) => flags.has(h));
|
|
@@ -10003,8 +10529,21 @@ function cmdRecap(options: Map<string, string>, flags: Set<string>, cwd: string,
|
|
|
10003
10529
|
reuse: recapReuse(repo, window),
|
|
10004
10530
|
uncommittedSlugs: deliveries.uncommitted.sort(),
|
|
10005
10531
|
});
|
|
10006
|
-
if (json)
|
|
10007
|
-
|
|
10532
|
+
if (json) {
|
|
10533
|
+
write(JSON.stringify({ ...report, publishesMeta: publishes === null ? null : { fetchedAt: publishes.fetchedAt ?? null, failed: publishes.failed ?? [] } }));
|
|
10534
|
+
} else {
|
|
10535
|
+
for (const line of renderRecap(report)) write(line);
|
|
10536
|
+
// The cache's AGE and its failures print beside the numbers they qualify: third-party
|
|
10537
|
+
// timestamps are only as fresh as the last explicit --refresh-publishes, and a package the
|
|
10538
|
+
// registry did not answer for is MISSING from the section above — said, not implied.
|
|
10539
|
+
if (publishes?.fetchedAt !== undefined) {
|
|
10540
|
+
const days = Math.floor((Date.parse(`${at.slice(0, 10)}T00:00:00Z`) - Date.parse(publishes.fetchedAt)) / 86_400_000);
|
|
10541
|
+
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`);
|
|
10542
|
+
}
|
|
10543
|
+
if (publishes?.failed !== undefined) {
|
|
10544
|
+
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`);
|
|
10545
|
+
}
|
|
10546
|
+
}
|
|
10008
10547
|
return 0;
|
|
10009
10548
|
}
|
|
10010
10549
|
|
|
@@ -13029,6 +13568,10 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
|
|
|
13029
13568
|
return cmdScore(options, flags, cwd, write);
|
|
13030
13569
|
case 'recap':
|
|
13031
13570
|
return cmdRecap(options, flags, cwd, write);
|
|
13571
|
+
case 'cadence':
|
|
13572
|
+
return cmdCadence(options, flags, cwd, write);
|
|
13573
|
+
case 'tg-post':
|
|
13574
|
+
return cmdTgPost(options, flags, cwd, write);
|
|
13032
13575
|
case 'name-check':
|
|
13033
13576
|
return cmdNameCheck(options, flags, cwd, write);
|
|
13034
13577
|
case 'provenance-check':
|