@dzhechkov/harness-cli 0.7.2 → 0.7.4

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,8 @@
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, type ChildProcess } from 'node:child_process';
10
+ import { KNOWN_CLI_FLAGS } from './known-flags.js';
11
+ import { execFileSync, execSync, spawn, spawnSync, type ChildProcess } from 'node:child_process';
11
12
  import { createHash, randomBytes } from 'node:crypto';
12
13
  import { homedir, tmpdir } from 'node:os';
13
14
  import { createRequire } from 'node:module';
@@ -311,6 +312,33 @@ import {
311
312
  type WorkOrder,
312
313
  type EpochOutcome,
313
314
  scoreRun,
315
+ readQeGrade,
316
+ recapWindow,
317
+ decideHorizon,
318
+ withinWindow,
319
+ buildRecap,
320
+ renderRecap,
321
+ parseSourceManifest,
322
+ unknownFlagNotice,
323
+ mirrorWriterExplanation,
324
+ appendRecallUsage,
325
+ closenessLine,
326
+ anyAboveFloor,
327
+ decideNameCheck,
328
+ renderNameCheck,
329
+ exportedNamesIn,
330
+ dispatchedCommandsIn,
331
+ type NameFacts,
332
+ type NameQuery,
333
+ decideSourceProvenance,
334
+ renderSourceProvenance,
335
+ REFUSED_HORIZONS,
336
+ type RecapHorizon,
337
+ type RecapWindow,
338
+ type Delivery,
339
+ type Publish,
340
+ type GuardRun,
341
+ type ReuseFacts,
314
342
  renderScorecard,
315
343
  renderCompoundingReport,
316
344
  readReinforcementState,
@@ -466,6 +494,9 @@ Usage:
466
494
  dz epoch-replay --judge <filled-work-order.json> [--out <file>] (blind judge prompts from the filled plans)
467
495
  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)
468
496
  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
+ 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)
498
+ 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
+ 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)
469
500
  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)
470
501
  dz feature-adr-record --kind ledger|training-pair --stage <s> [--slug <s>] [--row|--pair <json>] [--mark <n>] [--once] [--json] (the witnessed writer for the run-cost ledger and training pairs: the payload arrives as an ARGUMENT, never as shell; a malformed or wrong-kind payload is REFUSED before any write; the timestamp is stamped before serialising; the append is verified by re-reading the tail. exit 0 written|duplicate|skipped, 2 refused, 3 not-verified — a record failure is never blocking)
471
502
  dz feature-adr-checkpoint (--slug <feature> | --feature-dir <abs>) --stage <s> --input-hash <h> --result <json> [--artifact a,b] [--json] (record a pipeline stage ONLY after measuring its artifacts on disk; refuses a null result, an absent artifact, or a stage that declares none — the subagent runs a COMMAND instead of hand-writing durable state)
@@ -3458,8 +3489,47 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
3458
3489
  // might treat as a break — not just CR/LF. Round 4 forged the domain-boost note
3459
3490
  // twice over: once through `domain`, which was rendered raw, and once through a
3460
3491
  // U+2028 that Python's splitlines() honours and this replace did not.
3461
- const shown = oneLine(h.pattern.pattern).slice(0, 80);
3462
- write(` [${h.pattern.reward.toFixed(2)}] (${oneLine(h.pattern.domain)})${backendTag}${qTag} ${shown}`);
3492
+ // Closeness rides INSIDE the backend tag, and only when the semantic leg ran — the engine-less
3493
+ // output stays byte-identical, which a pinned test asserts. Two axes, never merged: the leading
3494
+ // number is the lesson's own reward ("what did this earn"), `sim` is closeness ("is it about
3495
+ // what you asked"). The ▲/▽ marker is the floor comparison done for the reader, against the
3496
+ // same measured per-language floors the recall hook already trusts.
3497
+ const simTag = vectorOn ? ` ${closenessLine(h.similarity, shownQuery)}` : '';
3498
+ const backendAndSim = vectorOn ? ` ⟨${h.backend}${simTag}⟩` : '';
3499
+ // 160, not 80: at 80 characters the evidence a reader needs to judge relevance sits in the
3500
+ // hidden remainder, and the cosine then appears to describe the visible fragment rather than
3501
+ // the whole lesson. `--full` prints it all, still on one line.
3502
+ const width = flags.has('full') ? Number.POSITIVE_INFINITY : 160;
3503
+ const oneLined = oneLine(h.pattern.pattern);
3504
+ const shown = width === Number.POSITIVE_INFINITY ? oneLined : oneLined.slice(0, width);
3505
+ write(` [${h.pattern.reward.toFixed(2)}] (${oneLine(h.pattern.domain)})${backendAndSim}${qTag} ${shown}`);
3506
+ }
3507
+ // The apply leg records itself. `dz recall` wrote NOTHING to the usage log — MEASURED 2026-08-24,
3508
+ // 1106 rows before the call and 1106 after — so "how many lessons were recalled" was underivable,
3509
+ // and the pipeline banner asserted a hardcoded `--recalled 3` at three call sites instead.
3510
+ //
3511
+ // Only hits with a MEASURED cosine are recorded. The log's `score` is defined as cosine relevance
3512
+ // and its validator requires a finite number; writing an RRF rank there would mix two scales in
3513
+ // one field, which is the exact lie the closeness work just removed from the display. The gap is
3514
+ // printed rather than hidden, so an under-count can never read as "only these were read".
3515
+ if (vectorOn && hits.length > 0) {
3516
+ const measured = hits.filter((h) => typeof h.similarity === 'number' && Number.isFinite(h.similarity));
3517
+ if (measured.length > 0) {
3518
+ appendRecallUsage({
3519
+ projectRoot: projectRoot,
3520
+ query: shownQuery,
3521
+ hits: measured.map((h) => ({ dzId: patternRecordId(h.pattern), score: h.similarity as number })),
3522
+ });
3523
+ }
3524
+ if (measured.length < hits.length) {
3525
+ write(` ℹ ${measured.length} of ${hits.length} read(s) recorded — a hit with no measured closeness carries no score in a log whose score IS the cosine`);
3526
+ }
3527
+ }
3528
+ if (vectorOn && hits.length > 0 && !anyAboveFloor(hits.map((h) => h.similarity), shownQuery)) {
3529
+ // Said ONCE, in words, instead of leaving the reader to compare every number themselves. This
3530
+ // is the difference between "a strong match" and "the best of a weak field", and without it a
3531
+ // list of five ▽ rows reads exactly like a list of five answers.
3532
+ write(` ℹ nothing here clears the measured similarity floor for this query — this is the best of a weak field, not a match`);
3463
3533
  }
3464
3534
  if (sawQuarantined) {
3465
3535
  // The loop stays VISIBLE (ADR D2): a quarantined hit is shown, marked, and explained.
@@ -3592,7 +3662,11 @@ async function cmdVector(options: Map<string, string>, flags: Set<string>, cwd:
3592
3662
  // `pending: 0` used to stand alone, and it reads as "no debt" when it actually means "no queue
3593
3663
  // was ever opened" — an unconfigured project printed the same line as a fully-mirrored store
3594
3664
  // (MEASURED: two projects differing by one config file, 0 vs 1 for the same record).
3595
- write(` Mirror writer: ${st.mirrorWriterEnabled ? 'ON' : 'OFF (.dz/config.json has no memory.backend=agentdb teach is NOT queueing)'}`);
3665
+ // The REASON comes from the same read that decided the state. It used to be one hardcoded
3666
+ // sentence naming a single cause, so a project that set `memory.vector.engine: "off"` was
3667
+ // told it lacked `memory.backend=agentdb` — a diagnosis pointing at something that was not
3668
+ // broken (MEASURED 2026-08-24).
3669
+ write(` Mirror writer: ${st.mirrorWriterEnabled ? 'ON' : 'OFF'} (${mirrorWriterExplanation(st.mirrorWriterState)})`);
3596
3670
  // "not in the mirror" is ALL the set difference proves — a vector written and later deleted is
3597
3671
  // indistinguishable from one never offered, so the label must not claim "never queued".
3598
3672
  // `undefined` has two causes and they are different advice, so they are printed differently.
@@ -9477,6 +9551,463 @@ function extractIntoTempDir(packDir: string, tmp: string): { dir: string; cleanu
9477
9551
  return { dir: join(tmp, 'package'), cleanup: (): void => { try { rmSync(tmp, { recursive: true, force: true }); } catch { /* best-effort */ } } };
9478
9552
  }
9479
9553
 
9554
+ // ── `dz recap` (feature dz-recap) ────────────────────────────────────────────
9555
+ //
9556
+ // What was done over a day, a week or a month — and a loud refusal for anything longer, because the
9557
+ // data does not reach. All the pure decisions live in harness-core/src/recap.ts; this half only
9558
+ // gathers facts, and every gathering step below carries the measurement that shaped it (see
9559
+ // features/dz-recap/03.5_ideation_report.md).
9560
+
9561
+ /** Feature-dir creation dates in ONE git pass. MEASURED: 0.099s here against 10.171s per-dir. */
9562
+ function recapGitCreations(repo: string): { dates: Map<string, string>; ok: boolean } {
9563
+ const dates = new Map<string, string>();
9564
+ let out: string;
9565
+ try {
9566
+ out = execFileSync('git', ['log', '--diff-filter=A', '--name-only', '--format=%aI', '--', 'features/'], {
9567
+ cwd: repo, encoding: 'utf-8', maxBuffer: 64 * 1024 * 1024,
9568
+ });
9569
+ } catch {
9570
+ return { dates, ok: false };
9571
+ }
9572
+ let commitIso = '';
9573
+ for (const raw of out.split('\n')) {
9574
+ const line = raw.trim();
9575
+ if (line === '') continue;
9576
+ if (!line.startsWith('features/')) { commitIso = line; continue; }
9577
+ if (commitIso === '') continue;
9578
+ const parts = line.split('/');
9579
+ // `features/` also holds loose .md files committed directly into it — 5 of them, measured. A
9580
+ // naive parts[1] would file each of those as a slug.
9581
+ if (parts.length < 3) continue;
9582
+ // git walks newest to oldest, so the LAST write per slug is its creation. Taking the min of the
9583
+ // date STRINGS is wrong when commits carry different UTC offsets (reproduced on alpha-to-rc:
9584
+ // string-min picked 07:06:49+00:00 over 08:00:40+03:00, which is 05:00:40Z and earlier).
9585
+ dates.set(parts[1] as string, commitIso);
9586
+ }
9587
+ return { dates, ok: true };
9588
+ }
9589
+
9590
+ function recapDeliveries(repo: string, write: Write): { facts: { dataStart: string | null; items: Delivery[] } | null; uncommitted: string[] } {
9591
+ const featuresDir = join(repo, 'features');
9592
+ if (!existsSync(featuresDir)) return { facts: null, uncommitted: [] };
9593
+ const { dates, ok } = recapGitCreations(repo);
9594
+ if (!ok) {
9595
+ write('dz recap: git log unavailable — the deliveries section cannot be read');
9596
+ return { facts: null, uncommitted: [] };
9597
+ }
9598
+ // Scoped to the TOP-LEVEL features/ only. Six worktrees are live in this repo and one carries its
9599
+ // own diverged .dz store; a recursive walk would merge two chronologies.
9600
+ const onDisk = readdirSync(featuresDir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
9601
+ const items: Delivery[] = [];
9602
+ const uncommitted: string[] = [];
9603
+ for (const slug of onDisk) {
9604
+ const created = dates.get(slug);
9605
+ if (created === undefined) { uncommitted.push(slug); continue; }
9606
+ const reportPath = join(featuresDir, slug, '08_qe_report.md');
9607
+ if (!existsSync(reportPath)) { items.push({ slug, createdIso: created, gradeStatus: 'no-report', grade: null }); continue; }
9608
+ let reading: { status: 'unique' | 'ambiguous' | 'none'; grade: string | null };
9609
+ try { reading = readQeGrade(readFileSync(reportPath, 'utf-8')); } catch { reading = { status: 'none', grade: null }; }
9610
+ // `Delivery` is a discriminated union: `unique` MUST carry a grade, everything else MUST carry
9611
+ // null. Narrowing here is what keeps "graded" and "we have a grade" the same statement.
9612
+ items.push(reading.status === 'unique' && typeof reading.grade === 'string'
9613
+ ? { slug, createdIso: created, gradeStatus: 'unique', grade: reading.grade }
9614
+ : { slug, createdIso: created, gradeStatus: reading.status === 'unique' ? 'none' : reading.status, grade: null });
9615
+ }
9616
+ const starts = [...dates.values()].sort();
9617
+ return { facts: { dataStart: starts.length > 0 ? (starts[0] as string).slice(0, 10) : null, items }, uncommitted };
9618
+ }
9619
+
9620
+ /** Read a JSONL store defensively: BOTH stores grew fields over time, with no schema marker. */
9621
+ function recapReadJsonl(path: string): Record<string, unknown>[] | null {
9622
+ if (!existsSync(path)) return null;
9623
+ try {
9624
+ return readFileSync(path, 'utf-8').split('\n').filter((l) => l.trim() !== '').flatMap((l) => {
9625
+ try { return [JSON.parse(l) as Record<string, unknown>]; } catch { return []; }
9626
+ });
9627
+ } catch {
9628
+ return null;
9629
+ }
9630
+ }
9631
+
9632
+ function recapGuard(repo: string): { dataStart: string | null; items: GuardRun[] } | null {
9633
+ const rows = recapReadJsonl(join(repo, '.dz', 'guard-audit.jsonl'));
9634
+ if (rows === null) return null;
9635
+ const items: GuardRun[] = [];
9636
+ for (const r of rows) {
9637
+ const iso = typeof r['ts'] === 'string' ? r['ts'] : null;
9638
+ if (iso === null) continue;
9639
+ const violations = Array.isArray(r['violations']) ? (r['violations'] as Record<string, unknown>[]) : [];
9640
+ const rules = violations.map((v) => (typeof v['rule'] === 'string' ? v['rule'] : '')).filter((s) => s !== '');
9641
+ items.push({ iso, verdict: typeof r['verdict'] === 'string' ? r['verdict'] : 'unknown', rules });
9642
+ }
9643
+ const sorted = items.map((i) => i.iso).sort();
9644
+ return { dataStart: sorted.length > 0 ? (sorted[0] as string).slice(0, 10) : null, items };
9645
+ }
9646
+
9647
+ /** Probe rows that join to no lesson. Measured: `teach:zzz` and `teach:probe` are test pollution. */
9648
+ const RECAP_PROBE_IDS: ReadonlySet<string> = new Set(['teach:zzz', 'teach:probe']);
9649
+
9650
+ function recapReuse(repo: string, window: RecapWindow): ReuseFacts | null {
9651
+ const rows = recapReadJsonl(join(repo, '.dz', 'recall-usage.jsonl'));
9652
+ if (rows === null) return null;
9653
+ const recalled = new Set<string>();
9654
+ let eventsInWindow = 0;
9655
+ let earliest: string | null = null;
9656
+ for (const r of rows) {
9657
+ const iso = typeof r['ts'] === 'string' ? r['ts'] : null;
9658
+ const id = typeof r['dzId'] === 'string' ? r['dzId'] : null;
9659
+ if (iso === null || id === null || RECAP_PROBE_IDS.has(id)) continue;
9660
+ if (earliest === null || iso < earliest) earliest = iso;
9661
+ recalled.add(id);
9662
+ if (withinWindow(window, iso)) eventsInWindow++;
9663
+ }
9664
+ let lessonsTotal = 0;
9665
+ try { lessonsTotal = loadStorePatternsSync(repo).length; } catch { lessonsTotal = 0; }
9666
+ return {
9667
+ dataStart: earliest === null ? null : earliest.slice(0, 10),
9668
+ eventsInWindow,
9669
+ lessonsEverRecalled: recalled.size,
9670
+ lessonsTotal,
9671
+ };
9672
+ }
9673
+
9674
+ /** 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 {
9676
+ const path = join(repo, '.dz', 'recap', 'npm-times.json');
9677
+ if (!existsSync(path)) return null;
9678
+ try {
9679
+ const raw = JSON.parse(readFileSync(path, 'utf-8')) as { packages?: Record<string, { versions?: Record<string, string> }> };
9680
+ const items: Publish[] = [];
9681
+ for (const [pkg, entry] of Object.entries(raw.packages ?? {})) {
9682
+ for (const [version, iso] of Object.entries(entry.versions ?? {})) {
9683
+ if (version === 'created' || version === 'modified') continue;
9684
+ items.push({ pkg, version, iso });
9685
+ }
9686
+ }
9687
+ const sorted = items.map((i) => i.iso).sort();
9688
+ return { dataStart: sorted.length > 0 ? (sorted[0] as string).slice(0, 10) : null, items };
9689
+ } catch {
9690
+ return null;
9691
+ }
9692
+ }
9693
+
9694
+ // ── `dz provenance-check` (feature provenance-gate) ──────────────────────────
9695
+ //
9696
+ // Nothing goes out citing a source that may not leave this machine. The decisions are pure and live
9697
+ // in harness-core/src/provenance.ts; this half runs the oracle, and every step below carries the
9698
+ // measurement that shaped it (features/provenance-gate/03.5_ideation_report.md).
9699
+
9700
+ /** The tracked file that declares which store records may be cited. Tracked ON PURPOSE (ADR-001). */
9701
+ const PROVENANCE_PUBLIC_REL = 'provenance-public.json';
9702
+
9703
+ /**
9704
+ * Resolve one source path, and refuse anything that lands outside the repository.
9705
+ *
9706
+ * `realpathSync` is the point: `git check-ignore` classifies the STRING and never dereferences a
9707
+ * symlink, so `allowed/pointer.md → ../secret/note.md` came back "not ignored" while `cat` printed
9708
+ * the secret (MEASURED 2026-08-22 in a clean temp repo). Classifying the resolved target closes it.
9709
+ */
9710
+ function provenanceResolve(repoRoot: string, source: string): string | null {
9711
+ try {
9712
+ const abs = isAbsolute(source) ? source : join(repoRoot, source);
9713
+ const real = realpathSync(abs);
9714
+ const rootReal = realpathSync(repoRoot);
9715
+ // `startsWith` alone would accept a sibling directory whose name merely extends the root's.
9716
+ if (real !== rootReal && !real.startsWith(rootReal + sep)) return null;
9717
+ return real;
9718
+ } catch {
9719
+ return null;
9720
+ }
9721
+ }
9722
+
9723
+ /**
9724
+ * Ask git which of these paths are ignored — ONE batch call, and `null` when it did not run.
9725
+ *
9726
+ * Two measured hazards, both closed here:
9727
+ * - `git check-ignore` resolves a relative path against the PROCESS CWD, so the same manifest that
9728
+ * blocked from the repo root cleared everything from a subdirectory, with no error and no
9729
+ * non-zero exit. Always `git -C <root>`, and always absolute paths.
9730
+ * - one out-of-repo path anywhere in the batch prints the matches found so far and then dies with
9731
+ * exit 128, dropping every path queued behind it. So callers must filter those out first, and
9732
+ * any exit outside {0,1} is an ORACLE FAILURE for the whole batch, never partial credit.
9733
+ */
9734
+ const PROVENANCE_CANARY = 'dz-provenance-canary';
9735
+
9736
+ /**
9737
+ * Prove the oracle actually answers before believing its silence.
9738
+ *
9739
+ * `git check-ignore` returns exit 1 for "none of these are ignored" — which is indistinguishable
9740
+ * from a `git` on PATH that does nothing and exits 1, and that reading clears EVERY path
9741
+ * (cross-family review, codex `gpt-5.6-sol`, 2026-08-22). So each run first asks a question whose
9742
+ * answer is known: a canary name made ignorable through a private excludes file. A real git says
9743
+ * "ignored"; anything that cannot is not a usable oracle.
9744
+ *
9745
+ * The private excludes file is passed only on THIS call, so it cannot alter a real verdict —
9746
+ * verified by running: with the canary file supplied, `README.md` is still not ignored.
9747
+ */
9748
+ function provenanceOracleAlive(repoRoot: string): boolean {
9749
+ let excludes = '';
9750
+ try {
9751
+ excludes = join(mkdtempSync(join(tmpdir(), 'dz-prov-canary-')), 'excludes');
9752
+ writeFileSync(excludes, `${PROVENANCE_CANARY}\n`);
9753
+ const probe = spawnSync('git', ['-C', repoRoot, '-c', `core.excludesFile=${excludes}`, 'check-ignore', '-q', PROVENANCE_CANARY], { encoding: 'utf-8' });
9754
+ return probe.error === undefined && probe.status === 0;
9755
+ } catch {
9756
+ return false;
9757
+ } finally {
9758
+ try { if (excludes !== '') rmSync(dirname(excludes), { recursive: true, force: true }); } catch { /* best-effort */ }
9759
+ }
9760
+ }
9761
+
9762
+ function provenanceIgnored(repoRoot: string, absPaths: readonly string[]): Set<string> | null {
9763
+ if (!provenanceOracleAlive(repoRoot)) return null;
9764
+ if (absPaths.length === 0) return new Set();
9765
+ // `-z` on BOTH sides. Without it a path containing a NEWLINE splits into two records, and the
9766
+ // gate answers about a path nobody asked about — a filename may legally contain a newline on
9767
+ // Linux, and one was constructed to prove it (cross-family review, 2026-08-22).
9768
+ const res = spawnSync('git', ['-C', repoRoot, 'check-ignore', '-z', '--stdin', '--no-index'], {
9769
+ input: `${absPaths.join('\0')}\0`,
9770
+ encoding: 'utf-8',
9771
+ maxBuffer: 16 * 1024 * 1024,
9772
+ });
9773
+ // 0 = at least one ignored, 1 = none ignored. Anything else (128 fatal, a signal, a missing git)
9774
+ // means the question was not answered, and an unanswered question is not an all-clear.
9775
+ if (res.error !== undefined || res.status === null || (res.status !== 0 && res.status !== 1)) return null;
9776
+ return new Set((res.stdout ?? '').split('\0').filter((l: string) => l !== ''));
9777
+ }
9778
+
9779
+ function cmdProvenanceCheck(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
9780
+ const repoRoot = resolve(options.get('project') ?? cwd);
9781
+ const json = flags.has('json');
9782
+ const manifestPath = (options.get('manifest') ?? '').trim();
9783
+ if (manifestPath === '') {
9784
+ write('dz provenance-check: --manifest <sources.json> is required');
9785
+ return 2;
9786
+ }
9787
+
9788
+ let manifest: ReturnType<typeof parseSourceManifest> = null;
9789
+ try {
9790
+ // The manifest path is the USER's, so it resolves against the CWD they typed it in — not
9791
+ // against --project, which names the repository the sources belong to. Resolving it against
9792
+ // the repo root made `--manifest ../sources.json --project ..` read a file outside the tree
9793
+ // and report NOT ESTABLISHED for a manifest that was right there.
9794
+ manifest = parseSourceManifest(readFileSync(resolve(cwd, manifestPath), 'utf-8'));
9795
+ } catch {
9796
+ manifest = null;
9797
+ }
9798
+
9799
+ // The public-records list is read from a GIT-TRACKED, COMMITTED file, so declaring a record public
9800
+ // is an act that shows up in a diff someone reviewed. Round 2 of the cross-family review found the
9801
+ // first version calling the file "tracked" and never checking it: an untracked file the drafting
9802
+ // process wrote itself cleared any record it liked, and the whole review argument collapsed.
9803
+ //
9804
+ // Both halves are load-bearing. TRACKED alone is not enough — a tracked file with uncommitted
9805
+ // edits has been through no review either.
9806
+ const publicRecords = new Set<string>();
9807
+ let listUntrusted: string | null = null;
9808
+ if (existsSync(join(repoRoot, PROVENANCE_PUBLIC_REL))) {
9809
+ // Read the COMMITTED blob, never the working copy. Round 5 of the cross-family review found the
9810
+ // list guarded by `git status`, which `--assume-unchanged` blinds — the same bypass already
9811
+ // closed for cited paths, left open one file away. Reading `HEAD:<path>` removes the question
9812
+ // instead of answering it: the gate sees exactly what was reviewed, whatever sits on disk.
9813
+ const show = spawnSync('git', ['-C', repoRoot, 'show', `HEAD:${PROVENANCE_PUBLIC_REL}`], { encoding: 'utf-8', maxBuffer: 8 * 1024 * 1024 });
9814
+ if (show.error !== undefined || show.status !== 0) {
9815
+ listUntrusted = `${PROVENANCE_PUBLIC_REL} is not committed, so nothing in it was ever reviewed — a record cannot be cleared by a list the drafting process could have written itself`;
9816
+ } else {
9817
+ try {
9818
+ const raw = JSON.parse(show.stdout ?? '') as { records?: unknown };
9819
+ if (Array.isArray(raw.records)) for (const r of raw.records) if (typeof r === 'string') publicRecords.add(r);
9820
+ } catch {
9821
+ listUntrusted = `the committed ${PROVENANCE_PUBLIC_REL} could not be parsed`;
9822
+ }
9823
+ }
9824
+ }
9825
+ // A list that exists but cannot be trusted is NOT the same as no list: the operator plainly meant
9826
+ // to use it. Silently emptying it would refuse records for a reason that names the wrong problem.
9827
+ if (listUntrusted !== null) {
9828
+ const dead = { outcome: 'not-established' as const, exit: 3 as const, claims: [], reason: listUntrusted };
9829
+ if (json) write(JSON.stringify(dead));
9830
+ else for (const line of renderSourceProvenance(dead)) write(line);
9831
+ return 3;
9832
+ }
9833
+
9834
+ const resolved = new Map<string, string | null>();
9835
+ const toAsk: string[] = [];
9836
+ for (const c of manifest?.claims ?? []) {
9837
+ if (c.kind !== 'path' || typeof c.source !== 'string' || c.source.trim() === '') continue;
9838
+ const src = c.source.trim();
9839
+ if (resolved.has(src)) continue;
9840
+ const real = provenanceResolve(repoRoot, src);
9841
+ resolved.set(src, real);
9842
+ if (real !== null) toAsk.push(real); // out-of-repo paths NEVER reach the batch call
9843
+ }
9844
+
9845
+ // Three questions, not one: ignored (refused by the owner), tracked (reviewed at all), and clean
9846
+ // (reviewed in its CURRENT state). A file the drafting process wrote a second ago answers "no" to
9847
+ // the first and would have been cleared by a gate that only asked it.
9848
+ const tracked = new Set<string>();
9849
+ const dirty = new Set<string>();
9850
+ if (toAsk.length > 0) {
9851
+ const ls = spawnSync('git', ['-C', repoRoot, 'ls-files', '-z', '--', ...toAsk], { encoding: 'utf-8', maxBuffer: 16 * 1024 * 1024 });
9852
+ if (ls.error === undefined && ls.status === 0) {
9853
+ for (const rel of (ls.stdout ?? '').split('\0')) if (rel !== '') tracked.add(resolve(repoRoot, rel));
9854
+ }
9855
+ // "Clean" is decided by CONTENT, not by `git status`. MEASURED 2026-08-22: after
9856
+ // `git update-index --assume-unchanged cited.md`, replacing the file's contents outright leaves
9857
+ // `git status --porcelain` EMPTY, so a status-based check clears a file whose bytes no longer
9858
+ // match anything anyone reviewed (cross-family review round 4). Comparing the blob hash on disk
9859
+ // against the one in HEAD sees the swap; it also subsumes staged-but-uncommitted.
9860
+ // `--no-filters` is not a detail. A `.gitattributes` clean filter runs on hash-object, so a
9861
+ // filter that strips content makes a modified file hash IDENTICAL to its committed blob —
9862
+ // MEASURED 2026-08-22: with `filter.strip.clean = head -c 7`, appending confidential text left
9863
+ // the hash unchanged (round 6). We compare the bytes on disk, not the bytes git would store.
9864
+ const onDisk = spawnSync('git', ['-C', repoRoot, 'hash-object', '--no-filters', '--', ...toAsk], { encoding: 'utf-8', maxBuffer: 16 * 1024 * 1024 });
9865
+ const inHead = spawnSync('git', ['-C', repoRoot, 'ls-tree', '-z', 'HEAD', '--', ...toAsk], { encoding: 'utf-8', maxBuffer: 16 * 1024 * 1024 });
9866
+ if (onDisk.error !== undefined || onDisk.status !== 0 || inHead.error !== undefined || inHead.status !== 0) {
9867
+ // Cannot compare ⇒ cannot clear. Every candidate is treated as unreviewed rather than clean.
9868
+ for (const p of toAsk) dirty.add(p);
9869
+ } else {
9870
+ const diskHashes = (onDisk.stdout ?? '').split('\n').map((l: string) => l.trim()).filter((l: string) => l !== '');
9871
+ const headHash = new Map<string, string>();
9872
+ for (const rec of (inHead.stdout ?? '').split('\0')) {
9873
+ if (rec === '') continue;
9874
+ const tab = rec.indexOf('\t');
9875
+ if (tab < 0) continue;
9876
+ const parts = rec.slice(0, tab).split(/\s+/);
9877
+ headHash.set(resolve(repoRoot, rec.slice(tab + 1)), parts[2] ?? '');
9878
+ }
9879
+ toAsk.forEach((abs, i) => {
9880
+ const committed = headHash.get(abs);
9881
+ if (committed === undefined || committed !== diskHashes[i]) dirty.add(abs);
9882
+ });
9883
+ }
9884
+ }
9885
+
9886
+ const decision = decideSourceProvenance(manifest, {
9887
+ ignoredPaths: provenanceIgnored(repoRoot, toAsk),
9888
+ publicRecords,
9889
+ resolved,
9890
+ trackedPaths: tracked,
9891
+ dirtyPaths: dirty,
9892
+ });
9893
+
9894
+ if (json) write(JSON.stringify(decision));
9895
+ else for (const line of renderSourceProvenance(decision)) write(line);
9896
+ return decision.exit;
9897
+ }
9898
+
9899
+ // ── `dz name-check` (feature name-check) ─────────────────────────────────────
9900
+ //
9901
+ // Is this name free, before a line of code is written? Twice in one day a collision broke the build
9902
+ // outright — `dz retro` was already a command, `decideProvenance` already an export — and both were
9903
+ // answerable in advance. The decisions are pure and live in harness-core/src/name-check.ts; this half
9904
+ // scans the workspace SOURCE, never `dist` (ADR-001: a stale build answers "free" confidently).
9905
+
9906
+ function nameCheckScan(repoRoot: string): NameFacts {
9907
+ const pkgsRoot = join(repoRoot, 'packages', '@dzhechkov');
9908
+ if (!existsSync(pkgsRoot)) return { commands: new Set(), modules: new Map(), exports: new Map(), scanFailed: true };
9909
+ const commands = new Set<string>();
9910
+ const modules = new Map<string, string>();
9911
+ const exportsFound = new Map<string, string>();
9912
+ let files = 0;
9913
+ let packages = 0;
9914
+ try {
9915
+ for (const pkg of readdirSync(pkgsRoot, { withFileTypes: true })) {
9916
+ if (!pkg.isDirectory()) continue;
9917
+ const srcDir = join(pkgsRoot, pkg.name, 'src');
9918
+ if (!existsSync(srcDir)) continue;
9919
+ packages++;
9920
+ for (const f of readdirSync(srcDir, { withFileTypes: true })) {
9921
+ if (!f.isFile() || !f.name.endsWith('.ts') || f.name.endsWith('.d.ts')) continue;
9922
+ const rel = `${pkg.name}/src/${f.name}`;
9923
+ const base = f.name.replace(/\.ts$/, '');
9924
+ if (!modules.has(base)) modules.set(base, rel);
9925
+ const text = readFileSync(join(srcDir, f.name), 'utf-8');
9926
+ files++;
9927
+ // Prefer the file that DECLARES a name over an `index.ts` that merely re-exports it: the
9928
+ // author needs to know where to look, and "it is in index.ts" points at the wiring, not the
9929
+ // owner. First writer wins otherwise, so a barrel scanned first would hide every source.
9930
+ for (const n of exportedNamesIn(text)) {
9931
+ const known = exportsFound.get(n);
9932
+ if (known === undefined || (known.endsWith('/index.ts') && f.name !== 'index.ts')) exportsFound.set(n, rel);
9933
+ }
9934
+ // Command names come from the dispatcher AND from the help block: a name that dispatches but
9935
+ // is undocumented is still taken, and so is the reverse.
9936
+ if (f.name === 'cli.ts') {
9937
+ for (const c of dispatchedCommandsIn(text)) commands.add(c);
9938
+ const help = /^\s{2}dz ([a-z][a-z0-9-]*)/gm;
9939
+ for (let m = help.exec(text); m !== null; m = help.exec(text)) if (m[1] !== undefined) commands.add(m[1]);
9940
+ }
9941
+ }
9942
+ }
9943
+ } catch {
9944
+ return { commands: new Set(), modules: new Map(), exports: new Map(), scanFailed: true };
9945
+ }
9946
+ // A scan that read nothing is not a scan. Reporting "free" off an empty sweep is the same defect
9947
+ // as a gate that passes because it never ran. What the sweep SAW travels with the facts, so the
9948
+ // operator can see whether it looked at a workspace or at a directory of the right shape.
9949
+ if (files === 0) return { commands: new Set(), modules: new Map(), exports: new Map(), scanFailed: true };
9950
+ return { commands, modules, exports: exportsFound, scanned: { packages, files, exports: exportsFound.size, commands: commands.size } };
9951
+ }
9952
+
9953
+ function cmdNameCheck(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
9954
+ const repoRoot = resolve(options.get('project') ?? cwd);
9955
+ const json = flags.has('json');
9956
+ const queries: NameQuery[] = [];
9957
+ const push = (kind: NameQuery['kind'], raw: string | undefined): void => {
9958
+ for (const n of (raw ?? '').split(',').map((s) => s.trim()).filter((s) => s !== '')) queries.push({ kind, name: n });
9959
+ };
9960
+ push('command', options.get('command'));
9961
+ push('module', options.get('module'));
9962
+ push('export', options.get('export'));
9963
+
9964
+ const facts = nameCheckScan(repoRoot);
9965
+ const decision = decideNameCheck(queries, facts);
9966
+ if (json) write(JSON.stringify(decision));
9967
+ else for (const line of renderNameCheck(decision, facts.scanned)) write(line);
9968
+ return decision.exit;
9969
+ }
9970
+
9971
+ function cmdRecap(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
9972
+ const repo = resolve(options.get('project') ?? cwd);
9973
+ const json = flags.has('json');
9974
+
9975
+ // Every horizon is RECOGNISED, including the refused ones — swallowing `--year` silently would be
9976
+ // the same defect as a gate that passes because it never ran.
9977
+ const asked = (['day', 'week', 'month', ...REFUSED_HORIZONS] as const).filter((h) => flags.has(h));
9978
+ if (asked.length > 1) { write(`dz recap: pick ONE horizon, not ${asked.length}`); return 2; }
9979
+ const requested = asked[0] ?? 'week';
9980
+
9981
+ const at = (options.get('at') ?? new Date().toISOString()).trim();
9982
+ if (!/^\d{4}-\d{2}-\d{2}/.test(at)) { write(`dz recap: --at must be an ISO date (YYYY-MM-DD), got ${JSON.stringify(at)}`); return 2; }
9983
+
9984
+ const deliveries = recapDeliveries(repo, write);
9985
+ const guard = recapGuard(repo);
9986
+ const publishes = recapPublishes(repo);
9987
+ const starts = [deliveries.facts?.dataStart, guard?.dataStart, publishes?.dataStart]
9988
+ .filter((d): d is string => typeof d === 'string').sort();
9989
+ const spanDays = starts.length === 0 ? 0
9990
+ : Math.max(0, Math.round((Date.parse(`${at.slice(0, 10)}T00:00:00Z`) - Date.parse(`${starts[0] as string}T00:00:00Z`)) / 86_400_000));
9991
+
9992
+ const decision = decideHorizon({ requested, spanDays });
9993
+ if (decision.action === 'refuse') {
9994
+ if (json) write(JSON.stringify({ ok: false, requested, spanDays, reason: decision.reason }));
9995
+ else write(`dz recap: REFUSED — ${decision.reason}`);
9996
+ return 2;
9997
+ }
9998
+
9999
+ const window = recapWindow(requested as RecapHorizon, at);
10000
+ const report = buildRecap({
10001
+ window, spanDays,
10002
+ deliveries: deliveries.facts, publishes, guard,
10003
+ reuse: recapReuse(repo, window),
10004
+ uncommittedSlugs: deliveries.uncommitted.sort(),
10005
+ });
10006
+ if (json) write(JSON.stringify(report));
10007
+ else for (const line of renderRecap(report)) write(line);
10008
+ return 0;
10009
+ }
10010
+
9480
10011
  function cmdFeatureAdrRecord(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
9481
10012
  const json = flags.has('json');
9482
10013
  const kind = (options.get('kind') ?? '').trim() as RecordKind;
@@ -9574,15 +10105,14 @@ function cmdAmendmentCheck(options: Map<string, string>, flags: Set<string>, cwd
9574
10105
  const slug = basename(featureDir);
9575
10106
  const ideation = readOr(join(featureDir, '03.5_ideation_report.md'));
9576
10107
  const plan = readOr(join(featureDir, '06_implementation_plan.md'));
9577
- if (ideation === null) {
9578
- return {
9579
- slug,
9580
- resolutions: [],
9581
- decision: decideAmendmentOutcome({ sectionPresent: false, rows: [], resolutions: [], planSaysNone: false }),
9582
- };
9583
- }
9584
- const sectionPresent = amendmentSection(ideation) !== null;
9585
- const ideationRows = parseAmendments(ideation);
10108
+ // A missing ideation report must NOT stop the plan from being read. It did: an S/M feature that
10109
+ // legitimately skips Step 3.5 got `skip — no Amendments section` while its plan carried a
10110
+ // complete section, and the same split produced verdicts that contradicted their own counts
10111
+ // (rows parsed from the PLAN, `sectionPresent` computed from the IDEATION report). MEASURED
10112
+ // 2026-08-24 on features/name-check: resolved 0 with 3 rows sitting in the plan.
10113
+ const sectionPresent = (ideation !== null && amendmentSection(ideation) !== null)
10114
+ || (plan !== null && amendmentSection(plan) !== null);
10115
+ const ideationRows = ideation === null ? [] : parseAmendments(ideation);
9586
10116
  const planRows = plan === null ? [] : parseAmendments(plan);
9587
10117
  // The PLAN is authoritative when it carries rows: Step 6 owes "carry AM-N into the plan
9588
10118
  // verbatim", and the ideation report is the historical record — rewriting its rows to match
@@ -12337,6 +12867,16 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
12337
12867
  };
12338
12868
  const { command, options, optionLists, flags } = parseArgs(argv);
12339
12869
 
12870
+ // An unrecognised `--flag` must not pass in silence. MEASURED 2026-08-24: `dz recall "x" --breif
12871
+ // --limit 2` printed the full ordinary output and exited 0, so a typo read as "the mode worked".
12872
+ // It WARNS rather than refuses, and the reason is measured, not cautious: 53 of the names this CLI
12873
+ // reads appear nowhere in help, and static extraction over the dispatch table lost `--week` from
12874
+ // `dz recap` — a refusal built on either list would reject working commands, which is a worse
12875
+ // failure than the one being fixed. Goes to STDERR so a `--json` consumer's stdout stays clean.
12876
+ for (const notice of unknownFlagNotice([...flags, ...options.keys()].filter((k) => !k.startsWith('_positional_')), KNOWN_CLI_FLAGS)) {
12877
+ writeErr(notice.line);
12878
+ }
12879
+
12340
12880
  // ── `dz --version` / `dz -v` / `dz version` — PRE-DISPATCH, before the help branch ──
12341
12881
  //
12342
12882
  // Until now `dz --version` printed the whole USAGE manual and exited 0 (MEASURED 2026-08-17,
@@ -12487,6 +13027,12 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
12487
13027
  return cmdEpochReplay(options, flags, cwd, write);
12488
13028
  case 'score':
12489
13029
  return cmdScore(options, flags, cwd, write);
13030
+ case 'recap':
13031
+ return cmdRecap(options, flags, cwd, write);
13032
+ case 'name-check':
13033
+ return cmdNameCheck(options, flags, cwd, write);
13034
+ case 'provenance-check':
13035
+ return cmdProvenanceCheck(options, flags, cwd, write);
12490
13036
  case 'feature-adr-record':
12491
13037
  return cmdFeatureAdrRecord(options, flags, cwd, write);
12492
13038
  case 'amendment-check':