@dzhechkov/harness-cli 0.7.0 → 0.7.3

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,7 @@
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 { execFileSync, execSync, spawn, spawnSync, type ChildProcess } from 'node:child_process';
11
11
  import { createHash, randomBytes } from 'node:crypto';
12
12
  import { homedir, tmpdir } from 'node:os';
13
13
  import { createRequire } from 'node:module';
@@ -311,6 +311,32 @@ import {
311
311
  type WorkOrder,
312
312
  type EpochOutcome,
313
313
  scoreRun,
314
+ readQeGrade,
315
+ recapWindow,
316
+ decideHorizon,
317
+ withinWindow,
318
+ buildRecap,
319
+ renderRecap,
320
+ parseSourceManifest,
321
+ mirrorWriterExplanation,
322
+ appendRecallUsage,
323
+ closenessLine,
324
+ anyAboveFloor,
325
+ decideNameCheck,
326
+ renderNameCheck,
327
+ exportedNamesIn,
328
+ dispatchedCommandsIn,
329
+ type NameFacts,
330
+ type NameQuery,
331
+ decideSourceProvenance,
332
+ renderSourceProvenance,
333
+ REFUSED_HORIZONS,
334
+ type RecapHorizon,
335
+ type RecapWindow,
336
+ type Delivery,
337
+ type Publish,
338
+ type GuardRun,
339
+ type ReuseFacts,
314
340
  renderScorecard,
315
341
  renderCompoundingReport,
316
342
  readReinforcementState,
@@ -466,6 +492,9 @@ Usage:
466
492
  dz epoch-replay --judge <filled-work-order.json> [--out <file>] (blind judge prompts from the filled plans)
467
493
  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
494
  dz score --slug <feature> [--project <dir>] [--json] (process scorecard for ONE feature-adr run, from its artifacts: ADR confirmation, discrimination, cross-model QE grade, live verification, README-first, learning loop, amendments — descriptive-only, a low score exits 0)
495
+ 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)
496
+ dz name-check [--command <n>] [--module <basename>] [--export <a,b>] [--project <dir>] [--json] (is this name free, BEFORE a line of code? Scans workspace SOURCE — never dist, because a stale build answers 'free' confidently. Checks a dz command name against the dispatcher AND the help block, a module basename against every package's src/, and exported identifiers against every declaration in the workspace. exit 0 all free / 1 at least one taken, naming where / 2 nothing asked or the scan did not run — an empty sweep is never a clean bill. Honest limit, printed on the passing path: it reads declarations, so a re-export under a different name stays the build's job)
497
+ 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
498
  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
499
  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
500
  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 +3487,47 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
3458
3487
  // might treat as a break — not just CR/LF. Round 4 forged the domain-boost note
3459
3488
  // twice over: once through `domain`, which was rendered raw, and once through a
3460
3489
  // 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}`);
3490
+ // Closeness rides INSIDE the backend tag, and only when the semantic leg ran — the engine-less
3491
+ // output stays byte-identical, which a pinned test asserts. Two axes, never merged: the leading
3492
+ // number is the lesson's own reward ("what did this earn"), `sim` is closeness ("is it about
3493
+ // what you asked"). The ▲/▽ marker is the floor comparison done for the reader, against the
3494
+ // same measured per-language floors the recall hook already trusts.
3495
+ const simTag = vectorOn ? ` ${closenessLine(h.similarity, shownQuery)}` : '';
3496
+ const backendAndSim = vectorOn ? ` ⟨${h.backend}${simTag}⟩` : '';
3497
+ // 160, not 80: at 80 characters the evidence a reader needs to judge relevance sits in the
3498
+ // hidden remainder, and the cosine then appears to describe the visible fragment rather than
3499
+ // the whole lesson. `--full` prints it all, still on one line.
3500
+ const width = flags.has('full') ? Number.POSITIVE_INFINITY : 160;
3501
+ const oneLined = oneLine(h.pattern.pattern);
3502
+ const shown = width === Number.POSITIVE_INFINITY ? oneLined : oneLined.slice(0, width);
3503
+ write(` [${h.pattern.reward.toFixed(2)}] (${oneLine(h.pattern.domain)})${backendAndSim}${qTag} ${shown}`);
3504
+ }
3505
+ // The apply leg records itself. `dz recall` wrote NOTHING to the usage log — MEASURED 2026-08-24,
3506
+ // 1106 rows before the call and 1106 after — so "how many lessons were recalled" was underivable,
3507
+ // and the pipeline banner asserted a hardcoded `--recalled 3` at three call sites instead.
3508
+ //
3509
+ // Only hits with a MEASURED cosine are recorded. The log's `score` is defined as cosine relevance
3510
+ // and its validator requires a finite number; writing an RRF rank there would mix two scales in
3511
+ // one field, which is the exact lie the closeness work just removed from the display. The gap is
3512
+ // printed rather than hidden, so an under-count can never read as "only these were read".
3513
+ if (vectorOn && hits.length > 0) {
3514
+ const measured = hits.filter((h) => typeof h.similarity === 'number' && Number.isFinite(h.similarity));
3515
+ if (measured.length > 0) {
3516
+ appendRecallUsage({
3517
+ projectRoot: projectRoot,
3518
+ query: shownQuery,
3519
+ hits: measured.map((h) => ({ dzId: patternRecordId(h.pattern), score: h.similarity as number })),
3520
+ });
3521
+ }
3522
+ if (measured.length < hits.length) {
3523
+ 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`);
3524
+ }
3525
+ }
3526
+ if (vectorOn && hits.length > 0 && !anyAboveFloor(hits.map((h) => h.similarity), shownQuery)) {
3527
+ // Said ONCE, in words, instead of leaving the reader to compare every number themselves. This
3528
+ // is the difference between "a strong match" and "the best of a weak field", and without it a
3529
+ // list of five ▽ rows reads exactly like a list of five answers.
3530
+ write(` ℹ nothing here clears the measured similarity floor for this query — this is the best of a weak field, not a match`);
3463
3531
  }
3464
3532
  if (sawQuarantined) {
3465
3533
  // The loop stays VISIBLE (ADR D2): a quarantined hit is shown, marked, and explained.
@@ -3592,7 +3660,11 @@ async function cmdVector(options: Map<string, string>, flags: Set<string>, cwd:
3592
3660
  // `pending: 0` used to stand alone, and it reads as "no debt" when it actually means "no queue
3593
3661
  // was ever opened" — an unconfigured project printed the same line as a fully-mirrored store
3594
3662
  // (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)'}`);
3663
+ // The REASON comes from the same read that decided the state. It used to be one hardcoded
3664
+ // sentence naming a single cause, so a project that set `memory.vector.engine: "off"` was
3665
+ // told it lacked `memory.backend=agentdb` — a diagnosis pointing at something that was not
3666
+ // broken (MEASURED 2026-08-24).
3667
+ write(` Mirror writer: ${st.mirrorWriterEnabled ? 'ON' : 'OFF'} (${mirrorWriterExplanation(st.mirrorWriterState)})`);
3596
3668
  // "not in the mirror" is ALL the set difference proves — a vector written and later deleted is
3597
3669
  // indistinguishable from one never offered, so the label must not claim "never queued".
3598
3670
  // `undefined` has two causes and they are different advice, so they are printed differently.
@@ -5192,17 +5264,40 @@ function cmdPublish(options: Map<string, string>, flags: Set<string>, cwd: strin
5192
5264
  const manifestPath = join(pk.dir, MANIFEST_NAME);
5193
5265
  const manifestPresent = existsSync(manifestPath);
5194
5266
  let verifyOk = false;
5267
+ let artifactUnavailable = false;
5195
5268
  if (trustRootPresent && manifestPresent) {
5269
+ // Verify the ARTIFACT, through the SAME extraction `dz sign` uses. This used to walk the
5270
+ // source TREE narrowed to a shipped-path list — a different object by construction, because
5271
+ // npm synthesises a LICENSE into the pack of a package whose tree has none and pnpm rewrites
5272
+ // package.json at pack time. So the gate named two failures that were both true about the
5273
+ // tree and both wrong about what ships, for EVERY package, and a fail-closed gate no input
5274
+ // can satisfy is a gate that gets routed around — it was: the 0.7.0 release went out through
5275
+ // `pnpm publish` and skipped re-signing (ADR-001, features/publish-gate-verifies-the-tarball).
5276
+ let cleanupGate: (() => void) | null = null;
5196
5277
  try {
5197
5278
  const signed = JSON.parse(readFileSync(manifestPath, 'utf8'));
5198
- let shippedForGate: string[] | undefined;
5199
- try { shippedForGate = npmPackedPaths(pk.dir); } catch { shippedForGate = undefined; }
5200
- verifyOk = verifyManifest(pk.dir, signed, readFileSync(trustRoot, 'utf8'), shippedForGate).ok;
5279
+ let extracted: { dir: string; cleanup: () => void } | undefined;
5280
+ try {
5281
+ extracted = extractPublishTarball(pk.dir);
5282
+ cleanupGate = extracted.cleanup;
5283
+ } catch (err) {
5284
+ // Cross-family review (codex `gpt-5.6-sol`, 2026-08-22): falling back to the working
5285
+ // TREE here fails the gate OPEN. The gate's whole claim is "what ships matches the
5286
+ // signature"; with no artifact, nothing was compared, and reporting a pass would be a
5287
+ // claim about an object that was never built. Say why, and block.
5288
+ write(`dz publish: could not pack ${pk.name} (${(err as Error).message.split('\n')[0]}) — the artifact was never built, so its signature was not checked`);
5289
+ artifactUnavailable = true;
5290
+ }
5291
+ verifyOk =
5292
+ extracted !== undefined &&
5293
+ verifyManifest(extracted.dir, signed, readFileSync(trustRoot, 'utf8')).ok;
5201
5294
  } catch {
5202
5295
  verifyOk = false;
5296
+ } finally {
5297
+ cleanupGate?.();
5203
5298
  }
5204
5299
  }
5205
- const decision = decidePublishGate({ trustRootPresent, manifestPresent, verifyOk, requireSigning });
5300
+ const decision = decidePublishGate({ trustRootPresent, manifestPresent, verifyOk, requireSigning, artifactUnavailable });
5206
5301
  if (decision.action === 'block') {
5207
5302
  write(`dz publish: BLOCKED ${pk.name} — ${decision.reason}`);
5208
5303
  blocked++;
@@ -5232,16 +5327,31 @@ function cmdPublish(options: Map<string, string>, flags: Set<string>, cwd: strin
5232
5327
  // Verify the OUTCOME against the trust root a CONSUMER would use — an existing key may be the
5233
5328
  // WRONG key, and enumerating that state is a losing game (round-1 review). The pack NAME travels
5234
5329
  // with the verdict so a pass about a different artifact cannot be mistaken for this one.
5330
+ // The identity must be the one the PUBLISHER uses — the npm package name — or the check
5331
+ // compares two vocabularies and can never agree. It did: `basename(packDir)` is `memory`
5332
+ // where the publisher says `@dzhechkov/memory`, so every scoped package (all of them here)
5333
+ // was refused as "a pass about another artifact", and the release routed around the gate
5334
+ // instead. MEASURED 2026-08-22 by running `dz publish --filter memory --yes`.
5235
5335
  const trustRoot = resolve(cwd, TRUST_ROOT_REL);
5236
- if (!existsSync(trustRoot)) return { ok: false, trustRootPresent: false, pack: basename(packDir) };
5336
+ const identity = packNpmName(packDir);
5337
+ const id = identity === undefined ? {} : { pack: identity };
5338
+ if (!existsSync(trustRoot)) return { ok: false, trustRootPresent: false, ...id };
5339
+ // Verify the SAME OBJECT `reSign` hashed: the extracted tarball. Walking the source tree here
5340
+ // compares a manifest built from the artifact against files npm rewrites at pack time, so it
5341
+ // reported "does not verify — most likely the WRONG signing key" about a correctly signed pack
5342
+ // (MEASURED 2026-08-22, `dz publish --filter memory --yes`). That message sends the operator
5343
+ // hunting for a key problem that does not exist; the object was simply the wrong one.
5344
+ let cleanup: (() => void) | null = null;
5237
5345
  try {
5238
5346
  const signed = JSON.parse(readFileSync(join(packDir, MANIFEST_NAME), 'utf8')) as never;
5239
- let shipped: string[] | undefined;
5240
- try { shipped = npmPackedPaths(packDir); } catch { shipped = undefined; }
5241
- const res = verifyManifest(packDir, signed, readFileSync(trustRoot, 'utf8'), shipped);
5242
- return { ok: res.ok, trustRootPresent: true, pack: basename(packDir) };
5347
+ const extracted = extractPublishTarball(packDir);
5348
+ cleanup = extracted.cleanup;
5349
+ const res = verifyManifest(extracted.dir, signed, readFileSync(trustRoot, 'utf8'));
5350
+ return { ok: res.ok, trustRootPresent: true, ...id };
5243
5351
  } catch {
5244
- return { ok: false, trustRootPresent: true, pack: basename(packDir) };
5352
+ return { ok: false, trustRootPresent: true, ...id };
5353
+ } finally {
5354
+ cleanup?.();
5245
5355
  }
5246
5356
  },
5247
5357
  reSign: (packDir: string, keyPath: string): void => {
@@ -9374,6 +9484,19 @@ function scanOneReqeRoot(
9374
9484
  * authority on what `files[]` ships; reimplementing its globbing would put a second, divergent answer
9375
9485
  * next to the real one — which is the class of defect this whole change exists to remove.
9376
9486
  */
9487
+ /**
9488
+ * The npm name of a pack on disk — the identity a publisher, a registry and a consumer all use.
9489
+ * `undefined` when it cannot be read, so an unestablished identity is never asserted as a match.
9490
+ */
9491
+ function packNpmName(packDir: string): string | undefined {
9492
+ try {
9493
+ const name = (JSON.parse(readFileSync(join(packDir, 'package.json'), 'utf8')) as { name?: unknown }).name;
9494
+ return typeof name === 'string' && name !== '' ? name : undefined;
9495
+ } catch {
9496
+ return undefined;
9497
+ }
9498
+ }
9499
+
9377
9500
  function npmPackedPaths(packDir: string): string[] {
9378
9501
  // `pnpm`, not `npm`: the PUBLISHER is `pnpm publish` (see `publishArgv`), and the two packers do not
9379
9502
  // agree. MEASURED 2026-08-21 on `skills-news`: `npm pack` emits a 1051-byte package.json identical
@@ -9399,6 +9522,17 @@ function npmPackedPaths(packDir: string): string[] {
9399
9522
  */
9400
9523
  function extractPublishTarball(packDir: string): { dir: string; cleanup: () => void } {
9401
9524
  const tmp = mkdtempSync(join(tmpdir(), 'dz-sign-pack-'));
9525
+ try {
9526
+ return extractIntoTempDir(packDir, tmp);
9527
+ } catch (err) {
9528
+ // The caller never receives a cleanup for a throw, so the directory this function created must
9529
+ // be removed HERE or it leaks once per failed pack (cross-family review, 2026-08-22).
9530
+ try { rmSync(tmp, { recursive: true, force: true }); } catch { /* best-effort */ }
9531
+ throw err;
9532
+ }
9533
+ }
9534
+
9535
+ function extractIntoTempDir(packDir: string, tmp: string): { dir: string; cleanup: () => void } {
9402
9536
  const out = execFileSync('pnpm', ['pack', '--pack-destination', tmp, '--json'], {
9403
9537
  cwd: packDir,
9404
9538
  encoding: 'utf-8',
@@ -9415,6 +9549,463 @@ function extractPublishTarball(packDir: string): { dir: string; cleanup: () => v
9415
9549
  return { dir: join(tmp, 'package'), cleanup: (): void => { try { rmSync(tmp, { recursive: true, force: true }); } catch { /* best-effort */ } } };
9416
9550
  }
9417
9551
 
9552
+ // ── `dz recap` (feature dz-recap) ────────────────────────────────────────────
9553
+ //
9554
+ // What was done over a day, a week or a month — and a loud refusal for anything longer, because the
9555
+ // data does not reach. All the pure decisions live in harness-core/src/recap.ts; this half only
9556
+ // gathers facts, and every gathering step below carries the measurement that shaped it (see
9557
+ // features/dz-recap/03.5_ideation_report.md).
9558
+
9559
+ /** Feature-dir creation dates in ONE git pass. MEASURED: 0.099s here against 10.171s per-dir. */
9560
+ function recapGitCreations(repo: string): { dates: Map<string, string>; ok: boolean } {
9561
+ const dates = new Map<string, string>();
9562
+ let out: string;
9563
+ try {
9564
+ out = execFileSync('git', ['log', '--diff-filter=A', '--name-only', '--format=%aI', '--', 'features/'], {
9565
+ cwd: repo, encoding: 'utf-8', maxBuffer: 64 * 1024 * 1024,
9566
+ });
9567
+ } catch {
9568
+ return { dates, ok: false };
9569
+ }
9570
+ let commitIso = '';
9571
+ for (const raw of out.split('\n')) {
9572
+ const line = raw.trim();
9573
+ if (line === '') continue;
9574
+ if (!line.startsWith('features/')) { commitIso = line; continue; }
9575
+ if (commitIso === '') continue;
9576
+ const parts = line.split('/');
9577
+ // `features/` also holds loose .md files committed directly into it — 5 of them, measured. A
9578
+ // naive parts[1] would file each of those as a slug.
9579
+ if (parts.length < 3) continue;
9580
+ // git walks newest to oldest, so the LAST write per slug is its creation. Taking the min of the
9581
+ // date STRINGS is wrong when commits carry different UTC offsets (reproduced on alpha-to-rc:
9582
+ // string-min picked 07:06:49+00:00 over 08:00:40+03:00, which is 05:00:40Z and earlier).
9583
+ dates.set(parts[1] as string, commitIso);
9584
+ }
9585
+ return { dates, ok: true };
9586
+ }
9587
+
9588
+ function recapDeliveries(repo: string, write: Write): { facts: { dataStart: string | null; items: Delivery[] } | null; uncommitted: string[] } {
9589
+ const featuresDir = join(repo, 'features');
9590
+ if (!existsSync(featuresDir)) return { facts: null, uncommitted: [] };
9591
+ const { dates, ok } = recapGitCreations(repo);
9592
+ if (!ok) {
9593
+ write('dz recap: git log unavailable — the deliveries section cannot be read');
9594
+ return { facts: null, uncommitted: [] };
9595
+ }
9596
+ // Scoped to the TOP-LEVEL features/ only. Six worktrees are live in this repo and one carries its
9597
+ // own diverged .dz store; a recursive walk would merge two chronologies.
9598
+ const onDisk = readdirSync(featuresDir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
9599
+ const items: Delivery[] = [];
9600
+ const uncommitted: string[] = [];
9601
+ for (const slug of onDisk) {
9602
+ const created = dates.get(slug);
9603
+ if (created === undefined) { uncommitted.push(slug); continue; }
9604
+ const reportPath = join(featuresDir, slug, '08_qe_report.md');
9605
+ if (!existsSync(reportPath)) { items.push({ slug, createdIso: created, gradeStatus: 'no-report', grade: null }); continue; }
9606
+ let reading: { status: 'unique' | 'ambiguous' | 'none'; grade: string | null };
9607
+ try { reading = readQeGrade(readFileSync(reportPath, 'utf-8')); } catch { reading = { status: 'none', grade: null }; }
9608
+ // `Delivery` is a discriminated union: `unique` MUST carry a grade, everything else MUST carry
9609
+ // null. Narrowing here is what keeps "graded" and "we have a grade" the same statement.
9610
+ items.push(reading.status === 'unique' && typeof reading.grade === 'string'
9611
+ ? { slug, createdIso: created, gradeStatus: 'unique', grade: reading.grade }
9612
+ : { slug, createdIso: created, gradeStatus: reading.status === 'unique' ? 'none' : reading.status, grade: null });
9613
+ }
9614
+ const starts = [...dates.values()].sort();
9615
+ return { facts: { dataStart: starts.length > 0 ? (starts[0] as string).slice(0, 10) : null, items }, uncommitted };
9616
+ }
9617
+
9618
+ /** Read a JSONL store defensively: BOTH stores grew fields over time, with no schema marker. */
9619
+ function recapReadJsonl(path: string): Record<string, unknown>[] | null {
9620
+ if (!existsSync(path)) return null;
9621
+ try {
9622
+ return readFileSync(path, 'utf-8').split('\n').filter((l) => l.trim() !== '').flatMap((l) => {
9623
+ try { return [JSON.parse(l) as Record<string, unknown>]; } catch { return []; }
9624
+ });
9625
+ } catch {
9626
+ return null;
9627
+ }
9628
+ }
9629
+
9630
+ function recapGuard(repo: string): { dataStart: string | null; items: GuardRun[] } | null {
9631
+ const rows = recapReadJsonl(join(repo, '.dz', 'guard-audit.jsonl'));
9632
+ if (rows === null) return null;
9633
+ const items: GuardRun[] = [];
9634
+ for (const r of rows) {
9635
+ const iso = typeof r['ts'] === 'string' ? r['ts'] : null;
9636
+ if (iso === null) continue;
9637
+ const violations = Array.isArray(r['violations']) ? (r['violations'] as Record<string, unknown>[]) : [];
9638
+ const rules = violations.map((v) => (typeof v['rule'] === 'string' ? v['rule'] : '')).filter((s) => s !== '');
9639
+ items.push({ iso, verdict: typeof r['verdict'] === 'string' ? r['verdict'] : 'unknown', rules });
9640
+ }
9641
+ const sorted = items.map((i) => i.iso).sort();
9642
+ return { dataStart: sorted.length > 0 ? (sorted[0] as string).slice(0, 10) : null, items };
9643
+ }
9644
+
9645
+ /** Probe rows that join to no lesson. Measured: `teach:zzz` and `teach:probe` are test pollution. */
9646
+ const RECAP_PROBE_IDS: ReadonlySet<string> = new Set(['teach:zzz', 'teach:probe']);
9647
+
9648
+ function recapReuse(repo: string, window: RecapWindow): ReuseFacts | null {
9649
+ const rows = recapReadJsonl(join(repo, '.dz', 'recall-usage.jsonl'));
9650
+ if (rows === null) return null;
9651
+ const recalled = new Set<string>();
9652
+ let eventsInWindow = 0;
9653
+ let earliest: string | null = null;
9654
+ for (const r of rows) {
9655
+ const iso = typeof r['ts'] === 'string' ? r['ts'] : null;
9656
+ const id = typeof r['dzId'] === 'string' ? r['dzId'] : null;
9657
+ if (iso === null || id === null || RECAP_PROBE_IDS.has(id)) continue;
9658
+ if (earliest === null || iso < earliest) earliest = iso;
9659
+ recalled.add(id);
9660
+ if (withinWindow(window, iso)) eventsInWindow++;
9661
+ }
9662
+ let lessonsTotal = 0;
9663
+ try { lessonsTotal = loadStorePatternsSync(repo).length; } catch { lessonsTotal = 0; }
9664
+ return {
9665
+ dataStart: earliest === null ? null : earliest.slice(0, 10),
9666
+ eventsInWindow,
9667
+ lessonsEverRecalled: recalled.size,
9668
+ lessonsTotal,
9669
+ };
9670
+ }
9671
+
9672
+ /** Publishes come from a cache ONLY. 51 packages cost 18.3s over the network — never inside a report. */
9673
+ function recapPublishes(repo: string): { dataStart: string | null; items: Publish[] } | null {
9674
+ const path = join(repo, '.dz', 'recap', 'npm-times.json');
9675
+ if (!existsSync(path)) return null;
9676
+ try {
9677
+ const raw = JSON.parse(readFileSync(path, 'utf-8')) as { packages?: Record<string, { versions?: Record<string, string> }> };
9678
+ const items: Publish[] = [];
9679
+ for (const [pkg, entry] of Object.entries(raw.packages ?? {})) {
9680
+ for (const [version, iso] of Object.entries(entry.versions ?? {})) {
9681
+ if (version === 'created' || version === 'modified') continue;
9682
+ items.push({ pkg, version, iso });
9683
+ }
9684
+ }
9685
+ const sorted = items.map((i) => i.iso).sort();
9686
+ return { dataStart: sorted.length > 0 ? (sorted[0] as string).slice(0, 10) : null, items };
9687
+ } catch {
9688
+ return null;
9689
+ }
9690
+ }
9691
+
9692
+ // ── `dz provenance-check` (feature provenance-gate) ──────────────────────────
9693
+ //
9694
+ // Nothing goes out citing a source that may not leave this machine. The decisions are pure and live
9695
+ // in harness-core/src/provenance.ts; this half runs the oracle, and every step below carries the
9696
+ // measurement that shaped it (features/provenance-gate/03.5_ideation_report.md).
9697
+
9698
+ /** The tracked file that declares which store records may be cited. Tracked ON PURPOSE (ADR-001). */
9699
+ const PROVENANCE_PUBLIC_REL = 'provenance-public.json';
9700
+
9701
+ /**
9702
+ * Resolve one source path, and refuse anything that lands outside the repository.
9703
+ *
9704
+ * `realpathSync` is the point: `git check-ignore` classifies the STRING and never dereferences a
9705
+ * symlink, so `allowed/pointer.md → ../secret/note.md` came back "not ignored" while `cat` printed
9706
+ * the secret (MEASURED 2026-08-22 in a clean temp repo). Classifying the resolved target closes it.
9707
+ */
9708
+ function provenanceResolve(repoRoot: string, source: string): string | null {
9709
+ try {
9710
+ const abs = isAbsolute(source) ? source : join(repoRoot, source);
9711
+ const real = realpathSync(abs);
9712
+ const rootReal = realpathSync(repoRoot);
9713
+ // `startsWith` alone would accept a sibling directory whose name merely extends the root's.
9714
+ if (real !== rootReal && !real.startsWith(rootReal + sep)) return null;
9715
+ return real;
9716
+ } catch {
9717
+ return null;
9718
+ }
9719
+ }
9720
+
9721
+ /**
9722
+ * Ask git which of these paths are ignored — ONE batch call, and `null` when it did not run.
9723
+ *
9724
+ * Two measured hazards, both closed here:
9725
+ * - `git check-ignore` resolves a relative path against the PROCESS CWD, so the same manifest that
9726
+ * blocked from the repo root cleared everything from a subdirectory, with no error and no
9727
+ * non-zero exit. Always `git -C <root>`, and always absolute paths.
9728
+ * - one out-of-repo path anywhere in the batch prints the matches found so far and then dies with
9729
+ * exit 128, dropping every path queued behind it. So callers must filter those out first, and
9730
+ * any exit outside {0,1} is an ORACLE FAILURE for the whole batch, never partial credit.
9731
+ */
9732
+ const PROVENANCE_CANARY = 'dz-provenance-canary';
9733
+
9734
+ /**
9735
+ * Prove the oracle actually answers before believing its silence.
9736
+ *
9737
+ * `git check-ignore` returns exit 1 for "none of these are ignored" — which is indistinguishable
9738
+ * from a `git` on PATH that does nothing and exits 1, and that reading clears EVERY path
9739
+ * (cross-family review, codex `gpt-5.6-sol`, 2026-08-22). So each run first asks a question whose
9740
+ * answer is known: a canary name made ignorable through a private excludes file. A real git says
9741
+ * "ignored"; anything that cannot is not a usable oracle.
9742
+ *
9743
+ * The private excludes file is passed only on THIS call, so it cannot alter a real verdict —
9744
+ * verified by running: with the canary file supplied, `README.md` is still not ignored.
9745
+ */
9746
+ function provenanceOracleAlive(repoRoot: string): boolean {
9747
+ let excludes = '';
9748
+ try {
9749
+ excludes = join(mkdtempSync(join(tmpdir(), 'dz-prov-canary-')), 'excludes');
9750
+ writeFileSync(excludes, `${PROVENANCE_CANARY}\n`);
9751
+ const probe = spawnSync('git', ['-C', repoRoot, '-c', `core.excludesFile=${excludes}`, 'check-ignore', '-q', PROVENANCE_CANARY], { encoding: 'utf-8' });
9752
+ return probe.error === undefined && probe.status === 0;
9753
+ } catch {
9754
+ return false;
9755
+ } finally {
9756
+ try { if (excludes !== '') rmSync(dirname(excludes), { recursive: true, force: true }); } catch { /* best-effort */ }
9757
+ }
9758
+ }
9759
+
9760
+ function provenanceIgnored(repoRoot: string, absPaths: readonly string[]): Set<string> | null {
9761
+ if (!provenanceOracleAlive(repoRoot)) return null;
9762
+ if (absPaths.length === 0) return new Set();
9763
+ // `-z` on BOTH sides. Without it a path containing a NEWLINE splits into two records, and the
9764
+ // gate answers about a path nobody asked about — a filename may legally contain a newline on
9765
+ // Linux, and one was constructed to prove it (cross-family review, 2026-08-22).
9766
+ const res = spawnSync('git', ['-C', repoRoot, 'check-ignore', '-z', '--stdin', '--no-index'], {
9767
+ input: `${absPaths.join('\0')}\0`,
9768
+ encoding: 'utf-8',
9769
+ maxBuffer: 16 * 1024 * 1024,
9770
+ });
9771
+ // 0 = at least one ignored, 1 = none ignored. Anything else (128 fatal, a signal, a missing git)
9772
+ // means the question was not answered, and an unanswered question is not an all-clear.
9773
+ if (res.error !== undefined || res.status === null || (res.status !== 0 && res.status !== 1)) return null;
9774
+ return new Set((res.stdout ?? '').split('\0').filter((l: string) => l !== ''));
9775
+ }
9776
+
9777
+ function cmdProvenanceCheck(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
9778
+ const repoRoot = resolve(options.get('project') ?? cwd);
9779
+ const json = flags.has('json');
9780
+ const manifestPath = (options.get('manifest') ?? '').trim();
9781
+ if (manifestPath === '') {
9782
+ write('dz provenance-check: --manifest <sources.json> is required');
9783
+ return 2;
9784
+ }
9785
+
9786
+ let manifest: ReturnType<typeof parseSourceManifest> = null;
9787
+ try {
9788
+ // The manifest path is the USER's, so it resolves against the CWD they typed it in — not
9789
+ // against --project, which names the repository the sources belong to. Resolving it against
9790
+ // the repo root made `--manifest ../sources.json --project ..` read a file outside the tree
9791
+ // and report NOT ESTABLISHED for a manifest that was right there.
9792
+ manifest = parseSourceManifest(readFileSync(resolve(cwd, manifestPath), 'utf-8'));
9793
+ } catch {
9794
+ manifest = null;
9795
+ }
9796
+
9797
+ // The public-records list is read from a GIT-TRACKED, COMMITTED file, so declaring a record public
9798
+ // is an act that shows up in a diff someone reviewed. Round 2 of the cross-family review found the
9799
+ // first version calling the file "tracked" and never checking it: an untracked file the drafting
9800
+ // process wrote itself cleared any record it liked, and the whole review argument collapsed.
9801
+ //
9802
+ // Both halves are load-bearing. TRACKED alone is not enough — a tracked file with uncommitted
9803
+ // edits has been through no review either.
9804
+ const publicRecords = new Set<string>();
9805
+ let listUntrusted: string | null = null;
9806
+ if (existsSync(join(repoRoot, PROVENANCE_PUBLIC_REL))) {
9807
+ // Read the COMMITTED blob, never the working copy. Round 5 of the cross-family review found the
9808
+ // list guarded by `git status`, which `--assume-unchanged` blinds — the same bypass already
9809
+ // closed for cited paths, left open one file away. Reading `HEAD:<path>` removes the question
9810
+ // instead of answering it: the gate sees exactly what was reviewed, whatever sits on disk.
9811
+ const show = spawnSync('git', ['-C', repoRoot, 'show', `HEAD:${PROVENANCE_PUBLIC_REL}`], { encoding: 'utf-8', maxBuffer: 8 * 1024 * 1024 });
9812
+ if (show.error !== undefined || show.status !== 0) {
9813
+ 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`;
9814
+ } else {
9815
+ try {
9816
+ const raw = JSON.parse(show.stdout ?? '') as { records?: unknown };
9817
+ if (Array.isArray(raw.records)) for (const r of raw.records) if (typeof r === 'string') publicRecords.add(r);
9818
+ } catch {
9819
+ listUntrusted = `the committed ${PROVENANCE_PUBLIC_REL} could not be parsed`;
9820
+ }
9821
+ }
9822
+ }
9823
+ // A list that exists but cannot be trusted is NOT the same as no list: the operator plainly meant
9824
+ // to use it. Silently emptying it would refuse records for a reason that names the wrong problem.
9825
+ if (listUntrusted !== null) {
9826
+ const dead = { outcome: 'not-established' as const, exit: 3 as const, claims: [], reason: listUntrusted };
9827
+ if (json) write(JSON.stringify(dead));
9828
+ else for (const line of renderSourceProvenance(dead)) write(line);
9829
+ return 3;
9830
+ }
9831
+
9832
+ const resolved = new Map<string, string | null>();
9833
+ const toAsk: string[] = [];
9834
+ for (const c of manifest?.claims ?? []) {
9835
+ if (c.kind !== 'path' || typeof c.source !== 'string' || c.source.trim() === '') continue;
9836
+ const src = c.source.trim();
9837
+ if (resolved.has(src)) continue;
9838
+ const real = provenanceResolve(repoRoot, src);
9839
+ resolved.set(src, real);
9840
+ if (real !== null) toAsk.push(real); // out-of-repo paths NEVER reach the batch call
9841
+ }
9842
+
9843
+ // Three questions, not one: ignored (refused by the owner), tracked (reviewed at all), and clean
9844
+ // (reviewed in its CURRENT state). A file the drafting process wrote a second ago answers "no" to
9845
+ // the first and would have been cleared by a gate that only asked it.
9846
+ const tracked = new Set<string>();
9847
+ const dirty = new Set<string>();
9848
+ if (toAsk.length > 0) {
9849
+ const ls = spawnSync('git', ['-C', repoRoot, 'ls-files', '-z', '--', ...toAsk], { encoding: 'utf-8', maxBuffer: 16 * 1024 * 1024 });
9850
+ if (ls.error === undefined && ls.status === 0) {
9851
+ for (const rel of (ls.stdout ?? '').split('\0')) if (rel !== '') tracked.add(resolve(repoRoot, rel));
9852
+ }
9853
+ // "Clean" is decided by CONTENT, not by `git status`. MEASURED 2026-08-22: after
9854
+ // `git update-index --assume-unchanged cited.md`, replacing the file's contents outright leaves
9855
+ // `git status --porcelain` EMPTY, so a status-based check clears a file whose bytes no longer
9856
+ // match anything anyone reviewed (cross-family review round 4). Comparing the blob hash on disk
9857
+ // against the one in HEAD sees the swap; it also subsumes staged-but-uncommitted.
9858
+ // `--no-filters` is not a detail. A `.gitattributes` clean filter runs on hash-object, so a
9859
+ // filter that strips content makes a modified file hash IDENTICAL to its committed blob —
9860
+ // MEASURED 2026-08-22: with `filter.strip.clean = head -c 7`, appending confidential text left
9861
+ // the hash unchanged (round 6). We compare the bytes on disk, not the bytes git would store.
9862
+ const onDisk = spawnSync('git', ['-C', repoRoot, 'hash-object', '--no-filters', '--', ...toAsk], { encoding: 'utf-8', maxBuffer: 16 * 1024 * 1024 });
9863
+ const inHead = spawnSync('git', ['-C', repoRoot, 'ls-tree', '-z', 'HEAD', '--', ...toAsk], { encoding: 'utf-8', maxBuffer: 16 * 1024 * 1024 });
9864
+ if (onDisk.error !== undefined || onDisk.status !== 0 || inHead.error !== undefined || inHead.status !== 0) {
9865
+ // Cannot compare ⇒ cannot clear. Every candidate is treated as unreviewed rather than clean.
9866
+ for (const p of toAsk) dirty.add(p);
9867
+ } else {
9868
+ const diskHashes = (onDisk.stdout ?? '').split('\n').map((l: string) => l.trim()).filter((l: string) => l !== '');
9869
+ const headHash = new Map<string, string>();
9870
+ for (const rec of (inHead.stdout ?? '').split('\0')) {
9871
+ if (rec === '') continue;
9872
+ const tab = rec.indexOf('\t');
9873
+ if (tab < 0) continue;
9874
+ const parts = rec.slice(0, tab).split(/\s+/);
9875
+ headHash.set(resolve(repoRoot, rec.slice(tab + 1)), parts[2] ?? '');
9876
+ }
9877
+ toAsk.forEach((abs, i) => {
9878
+ const committed = headHash.get(abs);
9879
+ if (committed === undefined || committed !== diskHashes[i]) dirty.add(abs);
9880
+ });
9881
+ }
9882
+ }
9883
+
9884
+ const decision = decideSourceProvenance(manifest, {
9885
+ ignoredPaths: provenanceIgnored(repoRoot, toAsk),
9886
+ publicRecords,
9887
+ resolved,
9888
+ trackedPaths: tracked,
9889
+ dirtyPaths: dirty,
9890
+ });
9891
+
9892
+ if (json) write(JSON.stringify(decision));
9893
+ else for (const line of renderSourceProvenance(decision)) write(line);
9894
+ return decision.exit;
9895
+ }
9896
+
9897
+ // ── `dz name-check` (feature name-check) ─────────────────────────────────────
9898
+ //
9899
+ // Is this name free, before a line of code is written? Twice in one day a collision broke the build
9900
+ // outright — `dz retro` was already a command, `decideProvenance` already an export — and both were
9901
+ // answerable in advance. The decisions are pure and live in harness-core/src/name-check.ts; this half
9902
+ // scans the workspace SOURCE, never `dist` (ADR-001: a stale build answers "free" confidently).
9903
+
9904
+ function nameCheckScan(repoRoot: string): NameFacts {
9905
+ const pkgsRoot = join(repoRoot, 'packages', '@dzhechkov');
9906
+ if (!existsSync(pkgsRoot)) return { commands: new Set(), modules: new Map(), exports: new Map(), scanFailed: true };
9907
+ const commands = new Set<string>();
9908
+ const modules = new Map<string, string>();
9909
+ const exportsFound = new Map<string, string>();
9910
+ let files = 0;
9911
+ let packages = 0;
9912
+ try {
9913
+ for (const pkg of readdirSync(pkgsRoot, { withFileTypes: true })) {
9914
+ if (!pkg.isDirectory()) continue;
9915
+ const srcDir = join(pkgsRoot, pkg.name, 'src');
9916
+ if (!existsSync(srcDir)) continue;
9917
+ packages++;
9918
+ for (const f of readdirSync(srcDir, { withFileTypes: true })) {
9919
+ if (!f.isFile() || !f.name.endsWith('.ts') || f.name.endsWith('.d.ts')) continue;
9920
+ const rel = `${pkg.name}/src/${f.name}`;
9921
+ const base = f.name.replace(/\.ts$/, '');
9922
+ if (!modules.has(base)) modules.set(base, rel);
9923
+ const text = readFileSync(join(srcDir, f.name), 'utf-8');
9924
+ files++;
9925
+ // Prefer the file that DECLARES a name over an `index.ts` that merely re-exports it: the
9926
+ // author needs to know where to look, and "it is in index.ts" points at the wiring, not the
9927
+ // owner. First writer wins otherwise, so a barrel scanned first would hide every source.
9928
+ for (const n of exportedNamesIn(text)) {
9929
+ const known = exportsFound.get(n);
9930
+ if (known === undefined || (known.endsWith('/index.ts') && f.name !== 'index.ts')) exportsFound.set(n, rel);
9931
+ }
9932
+ // Command names come from the dispatcher AND from the help block: a name that dispatches but
9933
+ // is undocumented is still taken, and so is the reverse.
9934
+ if (f.name === 'cli.ts') {
9935
+ for (const c of dispatchedCommandsIn(text)) commands.add(c);
9936
+ const help = /^\s{2}dz ([a-z][a-z0-9-]*)/gm;
9937
+ for (let m = help.exec(text); m !== null; m = help.exec(text)) if (m[1] !== undefined) commands.add(m[1]);
9938
+ }
9939
+ }
9940
+ }
9941
+ } catch {
9942
+ return { commands: new Set(), modules: new Map(), exports: new Map(), scanFailed: true };
9943
+ }
9944
+ // A scan that read nothing is not a scan. Reporting "free" off an empty sweep is the same defect
9945
+ // as a gate that passes because it never ran. What the sweep SAW travels with the facts, so the
9946
+ // operator can see whether it looked at a workspace or at a directory of the right shape.
9947
+ if (files === 0) return { commands: new Set(), modules: new Map(), exports: new Map(), scanFailed: true };
9948
+ return { commands, modules, exports: exportsFound, scanned: { packages, files, exports: exportsFound.size, commands: commands.size } };
9949
+ }
9950
+
9951
+ function cmdNameCheck(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
9952
+ const repoRoot = resolve(options.get('project') ?? cwd);
9953
+ const json = flags.has('json');
9954
+ const queries: NameQuery[] = [];
9955
+ const push = (kind: NameQuery['kind'], raw: string | undefined): void => {
9956
+ for (const n of (raw ?? '').split(',').map((s) => s.trim()).filter((s) => s !== '')) queries.push({ kind, name: n });
9957
+ };
9958
+ push('command', options.get('command'));
9959
+ push('module', options.get('module'));
9960
+ push('export', options.get('export'));
9961
+
9962
+ const facts = nameCheckScan(repoRoot);
9963
+ const decision = decideNameCheck(queries, facts);
9964
+ if (json) write(JSON.stringify(decision));
9965
+ else for (const line of renderNameCheck(decision, facts.scanned)) write(line);
9966
+ return decision.exit;
9967
+ }
9968
+
9969
+ function cmdRecap(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
9970
+ const repo = resolve(options.get('project') ?? cwd);
9971
+ const json = flags.has('json');
9972
+
9973
+ // Every horizon is RECOGNISED, including the refused ones — swallowing `--year` silently would be
9974
+ // the same defect as a gate that passes because it never ran.
9975
+ const asked = (['day', 'week', 'month', ...REFUSED_HORIZONS] as const).filter((h) => flags.has(h));
9976
+ if (asked.length > 1) { write(`dz recap: pick ONE horizon, not ${asked.length}`); return 2; }
9977
+ const requested = asked[0] ?? 'week';
9978
+
9979
+ const at = (options.get('at') ?? new Date().toISOString()).trim();
9980
+ 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; }
9981
+
9982
+ const deliveries = recapDeliveries(repo, write);
9983
+ const guard = recapGuard(repo);
9984
+ const publishes = recapPublishes(repo);
9985
+ const starts = [deliveries.facts?.dataStart, guard?.dataStart, publishes?.dataStart]
9986
+ .filter((d): d is string => typeof d === 'string').sort();
9987
+ const spanDays = starts.length === 0 ? 0
9988
+ : 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));
9989
+
9990
+ const decision = decideHorizon({ requested, spanDays });
9991
+ if (decision.action === 'refuse') {
9992
+ if (json) write(JSON.stringify({ ok: false, requested, spanDays, reason: decision.reason }));
9993
+ else write(`dz recap: REFUSED — ${decision.reason}`);
9994
+ return 2;
9995
+ }
9996
+
9997
+ const window = recapWindow(requested as RecapHorizon, at);
9998
+ const report = buildRecap({
9999
+ window, spanDays,
10000
+ deliveries: deliveries.facts, publishes, guard,
10001
+ reuse: recapReuse(repo, window),
10002
+ uncommittedSlugs: deliveries.uncommitted.sort(),
10003
+ });
10004
+ if (json) write(JSON.stringify(report));
10005
+ else for (const line of renderRecap(report)) write(line);
10006
+ return 0;
10007
+ }
10008
+
9418
10009
  function cmdFeatureAdrRecord(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
9419
10010
  const json = flags.has('json');
9420
10011
  const kind = (options.get('kind') ?? '').trim() as RecordKind;
@@ -9512,15 +10103,14 @@ function cmdAmendmentCheck(options: Map<string, string>, flags: Set<string>, cwd
9512
10103
  const slug = basename(featureDir);
9513
10104
  const ideation = readOr(join(featureDir, '03.5_ideation_report.md'));
9514
10105
  const plan = readOr(join(featureDir, '06_implementation_plan.md'));
9515
- if (ideation === null) {
9516
- return {
9517
- slug,
9518
- resolutions: [],
9519
- decision: decideAmendmentOutcome({ sectionPresent: false, rows: [], resolutions: [], planSaysNone: false }),
9520
- };
9521
- }
9522
- const sectionPresent = amendmentSection(ideation) !== null;
9523
- const ideationRows = parseAmendments(ideation);
10106
+ // A missing ideation report must NOT stop the plan from being read. It did: an S/M feature that
10107
+ // legitimately skips Step 3.5 got `skip — no Amendments section` while its plan carried a
10108
+ // complete section, and the same split produced verdicts that contradicted their own counts
10109
+ // (rows parsed from the PLAN, `sectionPresent` computed from the IDEATION report). MEASURED
10110
+ // 2026-08-24 on features/name-check: resolved 0 with 3 rows sitting in the plan.
10111
+ const sectionPresent = (ideation !== null && amendmentSection(ideation) !== null)
10112
+ || (plan !== null && amendmentSection(plan) !== null);
10113
+ const ideationRows = ideation === null ? [] : parseAmendments(ideation);
9524
10114
  const planRows = plan === null ? [] : parseAmendments(plan);
9525
10115
  // The PLAN is authoritative when it carries rows: Step 6 owes "carry AM-N into the plan
9526
10116
  // verbatim", and the ideation report is the historical record — rewriting its rows to match
@@ -12425,6 +13015,12 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
12425
13015
  return cmdEpochReplay(options, flags, cwd, write);
12426
13016
  case 'score':
12427
13017
  return cmdScore(options, flags, cwd, write);
13018
+ case 'recap':
13019
+ return cmdRecap(options, flags, cwd, write);
13020
+ case 'name-check':
13021
+ return cmdNameCheck(options, flags, cwd, write);
13022
+ case 'provenance-check':
13023
+ return cmdProvenanceCheck(options, flags, cwd, write);
12428
13024
  case 'feature-adr-record':
12429
13025
  return cmdFeatureAdrRecord(options, flags, cwd, write);
12430
13026
  case 'amendment-check':