@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/.dz-manifest.json +18 -18
- package/README.md +56 -5
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +665 -31
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/sbom.json +17 -17
- package/src/cli.ts +619 -23
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
|
-
|
|
3462
|
-
|
|
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
|
-
|
|
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
|
|
5199
|
-
try {
|
|
5200
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5240
|
-
|
|
5241
|
-
const res = verifyManifest(
|
|
5242
|
-
return { ok: res.ok, trustRootPresent: true,
|
|
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,
|
|
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
|
-
|
|
9516
|
-
|
|
9517
|
-
|
|
9518
|
-
|
|
9519
|
-
|
|
9520
|
-
|
|
9521
|
-
|
|
9522
|
-
const
|
|
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':
|