@dzhechkov/harness-cli 0.6.0 → 0.7.0
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 +17 -245
- package/README.md +54 -6
- package/dist/cli.d.ts +9 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +689 -27
- package/dist/cli.js.map +1 -1
- package/dist/core-compat.d.ts +1 -1
- package/dist/core-compat.d.ts.map +1 -1
- package/dist/core-compat.js +4 -1
- package/dist/core-compat.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/keys/README.md +44 -5
- package/keys/dz.pub +3 -0
- package/package.json +2 -2
- package/sbom.json +21 -591
- package/src/cli.ts +680 -26
- package/src/core-compat.ts +4 -1
- package/src/index.ts +3 -0
package/dist/cli.js
CHANGED
|
@@ -10,9 +10,9 @@ import { execFileSync, execSync, spawn } from 'node:child_process';
|
|
|
10
10
|
import { createHash, randomBytes } from 'node:crypto';
|
|
11
11
|
import { homedir, tmpdir } from 'node:os';
|
|
12
12
|
import { createRequire } from 'node:module';
|
|
13
|
-
import { createSkill, getSkillInfo, listSkillsDetailed, formatSkillLoadFailures, formatSkillApplyFailures, resolveTargetName, formatTargetProblem, formatTargetAliasNote, TARGET_NAMES_SORTED, runDoctor, runInit, resolvePackageSkillRoots, PACKAGE_SKILL_LAYOUTS, benchmarkSkill, benchmarkSkills, scanMcp, reconcileCapabilities, RECONCILE_BANNER, buildRegistry, discoverSkillPackDirs, checkUpstream, compareSkills, checkAllUpstream, sweepSkillDrift, syncCanonicalSkill, checkUpgrades, discoverPackages, discoverSourcePackages, fetchAllDownloads, filterByCategory, pretrain, recommend, generatePlugin, publishPackages, runSetup, runMigrate, searchRegistry, runSync, runVerify, runInitAgentsMd, runInitGeminiMd, runSyncAgentsPolicy, runSyncCodexHooks, resolveCodexHome, withNamedLockSync,
|
|
13
|
+
import { createSkill, getSkillInfo, listSkillsDetailed, formatSkillLoadFailures, formatSkillApplyFailures, resolveTargetName, formatTargetProblem, formatTargetAliasNote, TARGET_NAMES_SORTED, runDoctor, runInit, resolvePackageSkillRoots, PACKAGE_SKILL_LAYOUTS, benchmarkSkill, benchmarkSkills, scanMcp, reconcileCapabilities, RECONCILE_BANNER, buildRegistry, discoverSkillPackDirs, discoverVerifiablePackDirs, checkUpstream, compareSkills, checkAllUpstream, sweepSkillDrift, syncCanonicalSkill, checkUpgrades, discoverPackages, discoverSourcePackages, fetchAllDownloads, filterByCategory, pretrain, recommend, generatePlugin, publishPackages, runSetup, runMigrate, searchRegistry, runSync, runVerify, runInitAgentsMd, runInitGeminiMd, runSyncAgentsPolicy, runSyncCodexHooks, resolveCodexHome, withNamedLockSync,
|
|
14
14
|
// dz workflow run (feature dz-workflow-run): the pure scheduler + the dispatch adapters.
|
|
15
|
-
TRACE_RUNID_RE, WF_RUN_OWNER_HOST, preflight, runWorkflow, makeClaudePDispatcher, makeCodexExecDispatcher, NamedLockTimeoutError, NamedLockCompromisedError, POLICY_SOURCES, detectPolicyDrift, hasPolicyFence, TARGET_NAMES, buildParityMatrix, downgradeForStaleEvidence, findStaleTranscriptEvidence, TARGET_CAPABILITIES, TARGET_SHORT_LABELS, WORKFLOW_TEMPLATES_RETIRED_MESSAGE, parsePlan, isParseErrors, validatePlan, normalizePlan, planDigest, toTraceProjection, renderPlan, mergeRender, lint, lintExitCode, LOOP_BLOBS, parseTrace, assembleTimeline, runInvariants, deriveAttestation, stampAttestation, corroborate, NOT_WITNESSED, renderTimelineHtml, importEcc, recordPattern, resolveLearningBackend, storeStats, consolidateSessions, pruneNoisePatterns, lessonDeltaReport, removePatternsByIds, snapshotStore, recallHybrid, teachGuard, mirrorPatternsToVector, mirrorEntriesToVector, patternVectorEntry, readMemoryLearningConfig, promotePatterns, quarantineExpiryCandidates, pruneQuarantinePatterns, clearAgentdbQuarantine, vectorMirrorEnabled, vectorTierStatus, resolveVectorEngine, reindexVectorStore, harmonizeVectorStore, importRvfCheckpoint, statuslineData, writeFeatureAdrState, computeUsage, deriveCostLedger, renderCostLedger, verifyCostLedgerReport, writeCostLedgerJsonl, COST_LEDGER_SCOPE, deriveUsageCalibration, normalizeClaudeUsageModelKey, readUsageLimits, parseWeeklyResetAnchor, claimCheck, summarize, queryBookKnowledge, loadStorePatternsSync, patternRecordId, loadStoreRecords, recordToPattern, bundleSkills, brainHome, listBrain, promoteProjectToBrain, updateBrainSource, queryBrain, groundPrompt, expandKu, reindexBrainVectors, buildPrimer, exportBrainSlice, importBrainSlice, registerKusToBrain, RECALL_USAGE_LOG_RELATIVE, RECALL_USAGE_LOG_MAX_BYTES, parseRecallUsageLog, buildRecallUsageReport, EVENT_CHAIN_TAIL_BYTES, EMPTY_LOG_TAIL, readTailInfo, appendChainedLines, verifyEventChainText, buildManifest, buildSbom, resolveTrustRoot, decideVerifyPolicy, generateSigningKeypair, evaluateGuard, resolveRules, auditRecord, guardExitCode, DEFAULT_RULES, parsePnpmLockImporters, scannableStubPath,
|
|
15
|
+
TRACE_RUNID_RE, WF_RUN_OWNER_HOST, preflight, runWorkflow, makeClaudePDispatcher, makeCodexExecDispatcher, NamedLockTimeoutError, NamedLockCompromisedError, POLICY_SOURCES, detectPolicyDrift, hasPolicyFence, TARGET_NAMES, buildParityMatrix, downgradeForStaleEvidence, findStaleTranscriptEvidence, TARGET_CAPABILITIES, TARGET_SHORT_LABELS, WORKFLOW_TEMPLATES_RETIRED_MESSAGE, parsePlan, isParseErrors, validatePlan, normalizePlan, planDigest, toTraceProjection, renderPlan, mergeRender, lint, lintExitCode, LOOP_BLOBS, parseTrace, assembleTimeline, runInvariants, deriveAttestation, stampAttestation, corroborate, NOT_WITNESSED, renderTimelineHtml, importEcc, recordPattern, resolveLearningBackend, storeStats, consolidateSessions, pruneNoisePatterns, lessonDeltaReport, removePatternsByIds, snapshotStore, recallHybrid, teachGuard, mirrorPatternsToVector, mirrorEntriesToVector, patternVectorEntry, readMemoryLearningConfig, promotePatterns, quarantineExpiryCandidates, pruneQuarantinePatterns, clearAgentdbQuarantine, vectorMirrorEnabled, vectorTierStatus, resolveVectorEngine, reindexVectorStore, harmonizeVectorStore, importRvfCheckpoint, statuslineData, writeFeatureAdrState, computeUsage, deriveCostLedger, renderCostLedger, verifyCostLedgerReport, writeCostLedgerJsonl, COST_LEDGER_SCOPE, deriveUsageCalibration, normalizeClaudeUsageModelKey, readUsageLimits, parseWeeklyResetAnchor, claimCheck, summarize, queryBookKnowledge, loadStorePatternsSync, patternRecordId, loadStoreRecords, recordToPattern, bundleSkills, brainHome, listBrain, bookKbPath, promoteProjectToBrain, updateBrainSource, queryBrain, groundPrompt, expandKu, reindexBrainVectors, buildPrimer, exportBrainSlice, importBrainSlice, registerKusToBrain, RECALL_USAGE_LOG_RELATIVE, RECALL_USAGE_LOG_MAX_BYTES, parseRecallUsageLog, buildRecallUsageReport, EVENT_CHAIN_TAIL_BYTES, EMPTY_LOG_TAIL, readTailInfo, appendChainedLines, verifyEventChainText, buildManifest, buildSbom, resolveTrustRoot, decideVerifyPolicy, generateSigningKeypair, evaluateGuard, resolveRules, auditRecord, guardExitCode, DEFAULT_RULES, parsePnpmLockImporters, scannableStubPath,
|
|
16
16
|
// guard-promotion (feature guard-promotion, scout idea #1)
|
|
17
17
|
assembleCandidates, renderPromotionReport, renderPromotionAdr, normalizePromotionState, nextPromotionState, globMatch, promotionAdrRelPath, DEFAULT_WINDOW_DAYS, DEFAULT_PERIODS, MAX_CONTENT_FETCHES, BUILTIN_COVERAGE, decideProvenance, isInsideTree, signManifest, verifyManifest, listSignablePackFiles, assertKeyOutsideTree, decidePublishGate, collectPackageFacts, planReleaseGates, selectAffectedPackages, classifyGateExecutions, buildFailureIssue, buildReleaseNotes, releaseTagName, firstOutputLine, formatPublishError, MANIFEST_NAME, SBOM_NAME, buildArchitectureMap, renderMapHuman, findArchitectureDrift, renderDriftReport, scanWorkspacePackages, loadSubsystemManifest, loadProductVision, checkFeatureAgainstArchitecture, renderArchCheck, planProjectSkills, guidanceForStage, renderInjectionReport, analyzeCorpus, renderRakeReport, renderCriticSection, rakeAsLesson, rakeReward, DEFAULT_RAKE_THRESHOLDS, streamSessionEvents, findLatestTranscript, detectProcessRakes, buildRetro, renderRetro, retroLessonText, PROCESS_SIGNATURES, RETRO_DOMAIN, scanForSetup, buildSetupPlan, scaffoldFromSpec, renderScaffoldPreview, readExistingForScaffold, assembleChallengeContext, buildChallengeBrief, planDiscriminationCheck, classifyDiscrimination, classifyExecutionEvidence, pickAdversaryModel, CHALLENGE_QUESTIONS, loadOutcomes, renderOutcomes, statsForKey, selectAutoCost, recordProvisional, finalizeOutcome, harvestStageOutcomes, recommendModels, planFeed, GRADE_SUCCESS_FLOOR, COST_LADDER, splitScenarios, budgetPlan, selectWinner, proseScopeOk, renderProseDiff, readScenarioIds, DEFAULT_MAX_JUDGE_RUNS, collectDeliveryFacts, planDeliveryCheck, renderDeliveryBrief, classifyDelivery, isUsablePlaneResult, renderDeliveryReview, scanSkillsLayout, declaredPluginSurface, parseInitFacts, verifyRegistration, buildContentProbePrompt, classifyContentProbe, renderContentProbe, findNonRegistrableSkillDirs, assembleCompoundingReport,
|
|
18
18
|
// Cold-vs-warm EPOCH RUNNER (feature epoch-replay) — orchestrates + scores, never calls a model.
|
|
@@ -22,7 +22,7 @@ readBacklogConfig, readIdeas, writeIdeas, ideaId, dedupIdea, readGoalMap, readGo
|
|
|
22
22
|
// qe-bridge (feature qe-bridge-claude, ADR-001): the pure half of the reverse QE bridge.
|
|
23
23
|
KNOWN_CLAUDE, isSafeClaudeId, claudeProbeArgs, claudeReviewArgs, interpretClaudeProbe, modelFamily, buildBridgePrompt, parseBridgeOutput, buildBridgeFailureRecord, buildBridgeSignoffRecord, renderBridgeReport, isSafeSlug, hasUnsafePathChars, hasDotDotSegment, buildReqeBrief, settleReqeDebt, renderReqeList, REQE_SCOPE,
|
|
24
24
|
// Mutation gate (feature ha-mutation-gate) — break each named protection, run the suite, require red.
|
|
25
|
-
parseMutationRegistry, applyMutationToText, countFailingTests, classifyBaseline, classifyRunFailure, classifyMutationOutcome, mutationGateExitCode, summarizeMutationResults, renderMutationReport, TRACE_BUNDLE_LEDGER_PATH, TRACE_BUNDLE_SCHEMA, TRACE_BUNDLE_RUN_META_FILE, buildBundle, serializeBundle, parseBundle, planImport, decideCheckpointWrite, } from '@dzhechkov/harness-core';
|
|
25
|
+
parseMutationRegistry, applyMutationToText, countFailingTests, classifyBaseline, classifyRunFailure, classifyMutationOutcome, mutationGateExitCode, summarizeMutationResults, renderMutationReport, TRACE_BUNDLE_LEDGER_PATH, TRACE_BUNDLE_SCHEMA, TRACE_BUNDLE_RUN_META_FILE, buildBundle, serializeBundle, parseBundle, planImport, decideCheckpointWrite, amendmentSection, planSaysNoAmendments, parseAmendments, resolveAmendments, decideAmendmentOutcome, amendmentVerdictLine, amendmentsMissingFromPlan, AMENDMENT_VACUITY_NOTE, decideSignableSet, signableSetLine, decideRecordWrite, decideReadBack, recordVerdictLine, } from '@dzhechkov/harness-core';
|
|
26
26
|
import { getPreset, PRESET_NAMES } from '@dzhechkov/harness-presets';
|
|
27
27
|
import { scanGitHub, analyzeRepo, generateReport, deepAnalyze, scanAllSources, ScoutMemory } from '@dzhechkov/scout';
|
|
28
28
|
const USAGE = `dz - DZ cross-platform harness CLI
|
|
@@ -62,6 +62,8 @@ Usage:
|
|
|
62
62
|
dz epoch-replay --judge <filled-work-order.json> [--out <file>] (blind judge prompts from the filled plans)
|
|
63
63
|
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)
|
|
64
64
|
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)
|
|
65
|
+
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)
|
|
66
|
+
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)
|
|
65
67
|
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)
|
|
66
68
|
dz reqe [--slug <feature> [--done --report <f>]] [--json] (the re-QE debt ledger: a usage-switched run whose Step-8 QE ran on the coder's OWN family records a debt; list debts, print the cross-family review brief, settle FAIL-CLOSED against a graded report — the settlement lands in 08_qe_report.md)
|
|
67
69
|
dz qe-bridge --family claude --slug <feature> [--coder-family codex|claude] [--model <id>] [--files a,b] [--out <f>] [--timeout <s>] [--allow-same-family] [--json] (the REVERSE QE bridge: run an INDEPENDENT Claude reviewer over a feature's Step-8 artifacts from ANY host — a Codex session included, plain shell, no Claude agent plane needed — and land a PARSED signoff. The reviewer runs ISOLATED: an EMPTY temp cwd plus --safe-mode --strict-mcp-config --tools '' --no-session-persistence, so no CLAUDE.md/skills/plugins/hooks/MCP load, and the verdict is read from the --output-format json RESULT ENVELOPE — text a session customization printed onto the same stdout can never become a signoff. Probes the model before trusting it; sends SCOPED extracts with a loud 200k-char ceiling (never silent truncation); the grade must AGREE across three LAST-anchored channels (terminal marker line, fenced qe-bridge-signoff JSON, the report's own GRADE line) AND the marker must be the FINAL content — empty, gradeless, self-contradicting or miscounted output is one of 18 NAMED failures with an audit record under features/<slug>/.fa-state/qe-bridge/ (runId, resolved executable + binOverride, prompt sha256, channel offsets, requestedOut, reportWritten, retained raw stdout; 0600 files in a 0700 dir), never a clean review. A --coder-family that contradicts the recorded reqe debt is refused. Writes features/<slug>/08b_reqe_report.md, which dz reqe --done settles unchanged. DISCLOSURE: the extracts you scope are sent to the Claude runtime; the bridge cannot classify secrets. DZ_QE_BRIDGE_CLAUDE_BIN is a TEST SEAM, not a flag. exit 0 signoff parsed (ANY grade — it reports, it does not gate) / 1 named failure / 2 usage)
|
|
@@ -2166,6 +2168,13 @@ async function cmdTeach(options, flags, cwd, write) {
|
|
|
2166
2168
|
}
|
|
2167
2169
|
write(`Imported ${imported} pattern(s) from ${fromJson}`);
|
|
2168
2170
|
write(` Skipped ${skipped} (duplicates already in the store, or invalid entries)`);
|
|
2171
|
+
// Carrying a brain to a new machine goes through this path, and the mirror gate is the SAME one
|
|
2172
|
+
// teach uses — so without a config the whole import lands unindexed while `vector status` still
|
|
2173
|
+
// prints `pending: 0`. Say it here, where the user can act on it (FR-6).
|
|
2174
|
+
if (imported > 0 && !flags.has('no-mirror') && !vectorMirrorEnabled(projectRoot)) {
|
|
2175
|
+
write(` ⚠ the vector mirror writer is OFF — these ${imported} pattern(s) are LEXICAL ONLY`);
|
|
2176
|
+
write(` enable it in .dz/config.json (memory.backend=agentdb), then run: dz vector reindex`);
|
|
2177
|
+
}
|
|
2169
2178
|
// Bulk import preserves the DOMAIN of every record, so it can put medical lessons in
|
|
2170
2179
|
// a shared store as silently as a hand-typed teach — and it returned before the
|
|
2171
2180
|
// advice single-teach prints. The same advice, at the same point in the flow: after
|
|
@@ -2579,6 +2588,43 @@ async function cmdRecallForget(options, flags, projectRoot, write) {
|
|
|
2579
2588
|
write(' the vector mirror still holds them — run `dz vector reindex` to resync');
|
|
2580
2589
|
return 0;
|
|
2581
2590
|
}
|
|
2591
|
+
/**
|
|
2592
|
+
* Run `fn` with anything written to STDOUT by code we do not own routed to STDERR instead.
|
|
2593
|
+
*
|
|
2594
|
+
* Used to keep `--json` output parseable: a dependency that greets stdout on first load (currently
|
|
2595
|
+
* transformers.js) would otherwise sit in front of the JSON array. Nothing is swallowed — the text
|
|
2596
|
+
* still reaches the terminal, on the stream diagnostics belong on. Restoration is in `finally`, so a
|
|
2597
|
+
* throwing `fn` cannot leave stdout redirected.
|
|
2598
|
+
*/
|
|
2599
|
+
export async function withForeignStdoutOnStderr(fn) {
|
|
2600
|
+
// Re-entrant: a nested call must not restore stdout when the INNER scope ends, or the outer scope
|
|
2601
|
+
// silently loses its guard. Depth-counted, and only the outermost exit restores (found by
|
|
2602
|
+
// independent review). Backpressure is not proxied — every writer here emits short diagnostic
|
|
2603
|
+
// lines, and returning stderr's own boolean is closer to the truth than inventing one.
|
|
2604
|
+
// Counted for EVERY caller, nested or concurrent. The first version only incremented when it
|
|
2605
|
+
// installed the patch, so an overlapping call that arrived second was not counted — and when the
|
|
2606
|
+
// FIRST finished it restored stdout while the second was still running, leaking exactly what the
|
|
2607
|
+
// guard exists to catch (found by cross-family review). The original `write` is captured once, by
|
|
2608
|
+
// the caller that installs the patch, and restored by the last one to leave.
|
|
2609
|
+
if (stdoutRedirectDepth === 0) {
|
|
2610
|
+
originalStdoutWrite = process.stdout.write;
|
|
2611
|
+
process.stdout.write = ((chunk, ...rest) => process.stderr.write(chunk, ...rest));
|
|
2612
|
+
}
|
|
2613
|
+
stdoutRedirectDepth += 1;
|
|
2614
|
+
try {
|
|
2615
|
+
return await fn();
|
|
2616
|
+
}
|
|
2617
|
+
finally {
|
|
2618
|
+
stdoutRedirectDepth -= 1;
|
|
2619
|
+
// the EXACT original function, not a fresh binding of it
|
|
2620
|
+
if (stdoutRedirectDepth === 0 && originalStdoutWrite !== undefined) {
|
|
2621
|
+
process.stdout.write = originalStdoutWrite;
|
|
2622
|
+
originalStdoutWrite = undefined;
|
|
2623
|
+
}
|
|
2624
|
+
}
|
|
2625
|
+
}
|
|
2626
|
+
let stdoutRedirectDepth = 0;
|
|
2627
|
+
let originalStdoutWrite;
|
|
2582
2628
|
/**
|
|
2583
2629
|
* `dz recall --promote <dzId>[,<dzId>…] [--apply]` — lift quarantine from NAMED records
|
|
2584
2630
|
* (lesson-quarantine FR-6b). Dry-run by default, the --forget symmetry. Also clears the
|
|
@@ -2725,10 +2771,13 @@ async function cmdRecall(options, flags, cwd, write) {
|
|
|
2725
2771
|
return 1;
|
|
2726
2772
|
}
|
|
2727
2773
|
if (flags.has('books')) {
|
|
2728
|
-
|
|
2774
|
+
// Same guard as the hybrid path: book knowledge is a vector search, so it loads the same
|
|
2775
|
+
// embedder and greeted stdout ahead of the JSON array (found by independent review).
|
|
2776
|
+
const runBooks = () => queryBookKnowledge(projectRoot, query, {
|
|
2729
2777
|
limit,
|
|
2730
2778
|
...(bookFilter !== undefined ? { book: bookFilter } : {}),
|
|
2731
2779
|
});
|
|
2780
|
+
const { hits, error } = asJson ? await withForeignStdoutOnStderr(runBooks) : await runBooks();
|
|
2732
2781
|
if (asJson) {
|
|
2733
2782
|
write(JSON.stringify(hits));
|
|
2734
2783
|
return 0;
|
|
@@ -2740,6 +2789,35 @@ async function cmdRecall(options, flags, cwd, write) {
|
|
|
2740
2789
|
const src = h.chapter !== undefined ? ` [${h.book} гл.${h.chapter}${h.pages ? ` с.${h.pages[0]}-${h.pages[1]}` : ''}]` : ` [${h.book}]`;
|
|
2741
2790
|
write(` (${h.type}) ${h.name}${src}`);
|
|
2742
2791
|
}
|
|
2792
|
+
// A zero-hit search must say WHERE it looked, and — only then — whether the other shelf has
|
|
2793
|
+
// anything. `--books` reads THIS PROJECT's store; digitised books are promoted to a machine-wide
|
|
2794
|
+
// brain. MEASURED: the same query gives 3 hits in this repository, 0 in any other directory, and
|
|
2795
|
+
// 2 through `dz brain query` from that same other directory — with no sign that the knowledge
|
|
2796
|
+
// was one command away (features/books-names-the-brain).
|
|
2797
|
+
// A FAILED search is not an empty one. With `error` set, `hits` is empty because the store could
|
|
2798
|
+
// not be read — claiming it was searched, and pointing elsewhere, would turn a fault into a
|
|
2799
|
+
// "nothing here" (found by cross-family review; the error itself is already printed above).
|
|
2800
|
+
if (hits.length === 0 && error === undefined) {
|
|
2801
|
+
write(` searched this project's book store: ${bookKbPath(projectRoot)}`);
|
|
2802
|
+
// Read the brain ONLY here: the happy path must not pay for the empty one. A brain that
|
|
2803
|
+
// cannot be read says NOTHING — an unreadable shelf is not an empty shelf.
|
|
2804
|
+
let sources;
|
|
2805
|
+
try {
|
|
2806
|
+
sources = listBrain();
|
|
2807
|
+
}
|
|
2808
|
+
catch {
|
|
2809
|
+
sources = undefined;
|
|
2810
|
+
}
|
|
2811
|
+
if (sources !== undefined && sources.length > 0) {
|
|
2812
|
+
// Deliberately NOT asserting that `--book <slug>` exists in the brain — nothing here checked
|
|
2813
|
+
// that, and advising a filter that will also miss is the same defect wearing a hat.
|
|
2814
|
+
// POSIX single-quoting: the query is USER text and lands in a command the reader will paste.
|
|
2815
|
+
// Interpolating it into double quotes breaks on a `"` and invites `$(…)`/backticks to be
|
|
2816
|
+
// read by their shell (found by cross-family review).
|
|
2817
|
+
const quoted = `'${query.replace(/'/g, `'\\''`)}'`;
|
|
2818
|
+
write(` the machine-wide brain holds ${sources.length} source(s) — search it with: dz brain query ${quoted}`);
|
|
2819
|
+
}
|
|
2820
|
+
}
|
|
2743
2821
|
return 0;
|
|
2744
2822
|
}
|
|
2745
2823
|
// Hybrid recall (dz-rvf-vector-bridge FR-3): lexical FIRST (unchanged baseline), then a
|
|
@@ -2766,10 +2844,24 @@ async function cmdRecall(options, flags, cwd, write) {
|
|
|
2766
2844
|
const shownQuery = oneLine(query);
|
|
2767
2845
|
const shownDomain = wantedDomain === undefined ? undefined : oneLine(wantedDomain);
|
|
2768
2846
|
const fetchLimit = wantedDomain !== undefined ? Math.min(limit * 3, limit + 20) : limit;
|
|
2769
|
-
|
|
2847
|
+
// `--json` promises MACHINE-READABLE stdout, and transformers.js writes `Transformers.js loaded:
|
|
2848
|
+
// <model>` straight to stdout when the embedding model loads — so the machine mode was unparseable
|
|
2849
|
+
// in exactly the mode that makes it machine-readable (MEASURED 2026-08-22: it broke this project's
|
|
2850
|
+
// own measurement script and produced a false result). The same noise already forced
|
|
2851
|
+
// `.claude/helpers/agentdb-mcp-shim.mjs` to exist for the MCP stdio channel; this is that class,
|
|
2852
|
+
// second occurrence. Foreign stdout is routed to stderr for the duration of the engine call — our
|
|
2853
|
+
// own output is written after it returns.
|
|
2854
|
+
const result = asJson
|
|
2855
|
+
? await withForeignStdoutOnStderr(() => recallHybrid(projectRoot, query, { limit: fetchLimit, mode }))
|
|
2856
|
+
: await recallHybrid(projectRoot, query, { limit: fetchLimit, mode });
|
|
2770
2857
|
if (mode === 'semantic' && result.vectorEngine === 'none') {
|
|
2771
2858
|
// --semantic is an explicit ask — degrading it silently would be dishonest (FR-3).
|
|
2772
|
-
|
|
2859
|
+
const why = result.vectorReason ?? 'no vector engine available — run: dz setup --memory agentdb';
|
|
2860
|
+
// …and under --json the refusal must itself be JSON. This branch wrote PROSE to stdout, so the
|
|
2861
|
+
// one mode that promises machine-readable output broke exactly where the feature is loudest
|
|
2862
|
+
// (found by cross-family review; MEASURED: `--semantic --json` with no engine printed a
|
|
2863
|
+
// sentence). An error the caller cannot parse is not an honest refusal, only a different lie.
|
|
2864
|
+
write(asJson ? JSON.stringify({ error: 'semantic-unavailable', reason: why, hits: [] }) : `dz recall --semantic: ${why}`);
|
|
2773
2865
|
return 1;
|
|
2774
2866
|
}
|
|
2775
2867
|
// Domain-aware re-ranking (health-advisor slice H): `--domain <name>` lifts lessons
|
|
@@ -2777,6 +2869,15 @@ async function cmdRecall(options, flags, cwd, write) {
|
|
|
2777
2869
|
// shared store keeps the cross-domain transfers that make it worth more than two stores.
|
|
2778
2870
|
const boost = wantedDomain !== undefined ? applyDomainBoost(result.hits, wantedDomain) : null;
|
|
2779
2871
|
const hits = (boost ? boost.hits : result.hits).slice(0, limit);
|
|
2872
|
+
// Computed ONCE, honoured by EVERY return path. It used to live only on the text tail, so the two
|
|
2873
|
+
// paths that return earlier — `--json` and the zero-hits branch — still reported success. That
|
|
2874
|
+
// made the contract change invisible to exactly the caller the ADR justifies it by: a script
|
|
2875
|
+
// (MEASURED: text mode exited 1, `--json` exited 0 on the same query).
|
|
2876
|
+
// `vectorError` is EXCLUDED on purpose: an engine that was asked and failed/timed out is the
|
|
2877
|
+
// documented degraded path (exit 0, 05 §2.3) and stays that way. This code is for a tier that had
|
|
2878
|
+
// nothing to give, not for one that broke — conflating them would make the exit status depend on a
|
|
2879
|
+
// timeout and so vary run to run (found by independent review).
|
|
2880
|
+
const semanticUnserved = mode === 'semantic' && result.vectorError === undefined && result.semanticRanked === 0;
|
|
2780
2881
|
// The boost never drops a hit, but the CUT still can: promoting a match into the top
|
|
2781
2882
|
// `limit` pushes the last one out, so a lesson visible WITHOUT --domain can vanish
|
|
2782
2883
|
// WITH it. Cross-model review called this out as a lie by omission — the note said
|
|
@@ -2786,7 +2887,19 @@ async function cmdRecall(options, flags, cwd, write) {
|
|
|
2786
2887
|
if (asJson) {
|
|
2787
2888
|
// Portable contract UNCHANGED (I-7/AC-6): a plain PatternRecord[] — round-trips through
|
|
2788
2889
|
// `dz teach --from-json` regardless of which backend ranked each hit.
|
|
2789
|
-
|
|
2890
|
+
// The RRF relevance that ranked these very records was computed and then dropped, so no
|
|
2891
|
+
// automated consumer could threshold on it (MEASURED: keys were exactly
|
|
2892
|
+
// pattern,type,reward,domain,ts,source). It rides as a COMPANION key: `dz teach --from-json`
|
|
2893
|
+
// ignores unknown keys, so the round-trip is preserved — PROVEN by running, not assumed.
|
|
2894
|
+
// `relevance` is null under `--domain`: the domain boost REORDERS the list, so the RRF score no
|
|
2895
|
+
// longer explains the order shown, and printing it beside a boosted ranking would be a number
|
|
2896
|
+
// that contradicts its own list. Null means "not applicable here", never "zero relevance".
|
|
2897
|
+
// The condition is the BOOST, not `'score' in h`: boosted hits carry a score too, so the first
|
|
2898
|
+
// version emitted the number while its own comment promised null (found by independent review).
|
|
2899
|
+
write(JSON.stringify(hits.map((h) => ({
|
|
2900
|
+
...h.pattern,
|
|
2901
|
+
relevance: boost === null && 'score' in h && typeof h.score === 'number' ? h.score : null,
|
|
2902
|
+
}))));
|
|
2790
2903
|
// The honesty notes go to STDERR here rather than being skipped: the JSON branch
|
|
2791
2904
|
// used to return before them, so a scripted caller was told nothing about a boost
|
|
2792
2905
|
// that had promoted a match and pushed a visible hit past the --limit cut.
|
|
@@ -2796,7 +2909,7 @@ async function cmdRecall(options, flags, cwd, write) {
|
|
|
2796
2909
|
if (cutNoteJson !== '')
|
|
2797
2910
|
process.stderr.write(`${cutNoteJson}\n`);
|
|
2798
2911
|
}
|
|
2799
|
-
return 0;
|
|
2912
|
+
return semanticUnserved ? 1 : 0;
|
|
2800
2913
|
}
|
|
2801
2914
|
if (hits.length === 0) {
|
|
2802
2915
|
write(`dz recall "${shownQuery}"`);
|
|
@@ -2806,11 +2919,29 @@ async function cmdRecall(options, flags, cwd, write) {
|
|
|
2806
2919
|
// boost had been applied and found nothing, or had not run at all.
|
|
2807
2920
|
if (boost !== null && shownDomain !== undefined)
|
|
2808
2921
|
write(renderDomainBoostNote(boost, shownDomain));
|
|
2809
|
-
return 0;
|
|
2810
|
-
}
|
|
2811
|
-
|
|
2922
|
+
return semanticUnserved ? 1 : 0;
|
|
2923
|
+
}
|
|
2924
|
+
// `vectorOn` used to mean "an engine RESOLVED", so the header claimed vector ranking over a store
|
|
2925
|
+
// with zero vectors while each hit's own label honestly read ⟨sqlite⟩ (MEASURED 2026-08-22).
|
|
2926
|
+
// It now means what it says: a vector actually ranked something (ADR-001).
|
|
2927
|
+
const vectorOn = result.semanticRanked > 0 && result.vectorError === undefined && mode !== 'lexical';
|
|
2928
|
+
const engineUp = result.vectorEngine !== 'none' && result.vectorError === undefined;
|
|
2929
|
+
// Whether a tier EXISTS, regardless of whether this query's search succeeded. The advice below
|
|
2930
|
+
// must key on existence: gated on `engineUp`, a timed-out but installed tier was told to install
|
|
2931
|
+
// itself, one line under "vector search degraded" (found by independent review).
|
|
2932
|
+
const engineInstalled = result.vectorEngine !== 'none';
|
|
2812
2933
|
const lexLabel = result.lexicalBackend === 'sqlite' ? 'SQLite FTS5' : 'keyword (JSON)';
|
|
2813
|
-
const ranking = vectorOn
|
|
2934
|
+
const ranking = vectorOn
|
|
2935
|
+
? `${lexLabel} + vector (${result.vectorEngine}) ranking`
|
|
2936
|
+
: engineUp && mode !== 'lexical'
|
|
2937
|
+
// the engine is up and returned nothing usable — name the state and the fix, do not claim a
|
|
2938
|
+
// ranking that did not happen and do not advise installing what is already installed
|
|
2939
|
+
// `semanticCandidates` earns its place here: an engine that returned candidates which were ALL
|
|
2940
|
+
// orphans is a different problem from an engine with nothing in it, and the fix differs too.
|
|
2941
|
+
? result.semanticCandidates > 0
|
|
2942
|
+
? `${lexLabel} only (the semantic tier returned ${result.semanticCandidates} stale id(s) — run: dz consolidate)`
|
|
2943
|
+
: `${lexLabel} only (semantic tier empty — run: dz vector reindex)`
|
|
2944
|
+
: `${lexLabel} ranking (lexical)`;
|
|
2814
2945
|
write(`dz recall "${shownQuery}" — ${hits.length} hit(s), ${ranking}`);
|
|
2815
2946
|
let sawQuarantined = false;
|
|
2816
2947
|
for (const h of hits) {
|
|
@@ -2849,10 +2980,20 @@ async function cmdRecall(options, flags, cwd, write) {
|
|
|
2849
2980
|
// FR-8 hint swap: with an engine the old MCP-only hint is gone; without one, the SAME
|
|
2850
2981
|
// conditional position carries an actionable enablement line instead — the only permitted
|
|
2851
2982
|
// output change on the degraded path.
|
|
2852
|
-
|
|
2983
|
+
// ... and NOT when the tier is already installed: in the repo (539 vectors, a 2.9 MB agentdb.db)
|
|
2984
|
+
// `--no-semantic` advised installing the tier it was deliberately not using (MEASURED 2026-08-22).
|
|
2985
|
+
// ... and NOT when the user explicitly asked for lexical recall. Under `--no-semantic` the result
|
|
2986
|
+
// reports `vectorEngine: 'none'` BY CONSTRUCTION, so this line fired over a tier that was installed
|
|
2987
|
+
// and full — in this repo, 539 vectors and a 2.9 MB agentdb.db, advising the user to install it
|
|
2988
|
+
// (MEASURED 2026-08-22). Someone who passed --no-semantic has opted out; advice is noise there.
|
|
2989
|
+
if (!vectorOn && !engineInstalled && mode !== 'lexical' && existsSync(join(projectRoot, '.dz', 'agentdb.db'))) {
|
|
2853
2990
|
write(` ℹ semantic (vector) recall needs the agentdb vector tier — run: dz setup --memory agentdb`);
|
|
2854
2991
|
}
|
|
2855
|
-
|
|
2992
|
+
// An EXPLICIT --semantic that no vector could serve is not a success. The caller who most needs to
|
|
2993
|
+
// know is the one that cannot read the prose above it (ADR-001; this is a contract change).
|
|
2994
|
+
// Exit 1, the SAME code the documented sibling case uses ("--semantic … exit 1 if no engine"):
|
|
2995
|
+
// an explicit ask that could not be served is one failure class, not two.
|
|
2996
|
+
return semanticUnserved ? 1 : 0;
|
|
2856
2997
|
}
|
|
2857
2998
|
/* ------------------------------------------------------------------ */
|
|
2858
2999
|
/* vector — the semantic-tier observability + export surface (FR-10) */
|
|
@@ -2933,11 +3074,36 @@ async function cmdVector(options, flags, cwd, write) {
|
|
|
2933
3074
|
if (st.embeddingModel !== undefined)
|
|
2934
3075
|
write(` Embedding model: ${st.embeddingModel}`);
|
|
2935
3076
|
write(` Lexical patterns: ${st.lexicalMirrorable} mirrorable (${st.lexicalTotal} total)`);
|
|
2936
|
-
|
|
3077
|
+
// Each line NAMES its scope. `Mirrored vectors` used to count three task types and sit directly
|
|
3078
|
+
// under a one-task-type lexical count, and a reader took the pair at face value: 547 vs 274 read
|
|
3079
|
+
// as half the index orphaned, and a task was filed to prune it. MEASURED: 273 of those were
|
|
3080
|
+
// `dz-backlog` idea ids and there were ZERO orphans (ADR-001, features/mirror-counts-comparable).
|
|
3081
|
+
write(` Mirrored vectors (learned patterns): ${st.mirrored !== undefined ? st.mirrored : 'n/a (no engine)'}`);
|
|
3082
|
+
if (st.mirroredOther !== undefined && st.mirroredOther > 0) {
|
|
3083
|
+
write(` Other dz-owned vectors (backlog ideas): ${st.mirroredOther} — counted separately, not part of the pair above`);
|
|
3084
|
+
}
|
|
3085
|
+
if (st.orphaned !== undefined && st.orphaned > 0) {
|
|
3086
|
+
write(` Orphan vectors (no lexical record): ${st.orphaned} — run: dz vector reindex`);
|
|
3087
|
+
}
|
|
2937
3088
|
write(` Pending mirror queue: ${st.pending}`);
|
|
2938
|
-
|
|
3089
|
+
// `pending: 0` used to stand alone, and it reads as "no debt" when it actually means "no queue
|
|
3090
|
+
// was ever opened" — an unconfigured project printed the same line as a fully-mirrored store
|
|
3091
|
+
// (MEASURED: two projects differing by one config file, 0 vs 1 for the same record).
|
|
3092
|
+
write(` Mirror writer: ${st.mirrorWriterEnabled ? 'ON' : 'OFF (.dz/config.json has no memory.backend=agentdb — teach is NOT queueing)'}`);
|
|
3093
|
+
// "not in the mirror" is ALL the set difference proves — a vector written and later deleted is
|
|
3094
|
+
// indistinguishable from one never offered, so the label must not claim "never queued".
|
|
3095
|
+
// `undefined` has two causes and they are different advice, so they are printed differently.
|
|
3096
|
+
const unknownReason = st.available ? 'unknown (the engine failed to list its ids)' : 'unknown (no engine to ask)';
|
|
3097
|
+
write(` Not in the mirror: ${st.unmirrored !== undefined ? st.unmirrored : unknownReason}`);
|
|
3098
|
+
// The old advice compared `mirrored < lexicalMirrorable`, two counts of different things — so
|
|
3099
|
+
// backlog ideas inflating `mirrored` could SILENCE it while patterns really were missing.
|
|
3100
|
+
// `unmirrored` is a set difference over ids and answers the same question correctly.
|
|
3101
|
+
if (st.unmirrored !== undefined && st.unmirrored > 0) {
|
|
2939
3102
|
write(` ℹ mirror behind the lexical store — run: dz consolidate (backfill)`);
|
|
2940
3103
|
}
|
|
3104
|
+
if (st.unmirrored !== undefined && st.unmirrored > 0) {
|
|
3105
|
+
write(` ℹ ${st.unmirrored} mirrorable pattern(s) are not in the vector mirror — run: dz vector reindex`);
|
|
3106
|
+
}
|
|
2941
3107
|
return 0;
|
|
2942
3108
|
}
|
|
2943
3109
|
if (sub === 'reindex') {
|
|
@@ -4030,7 +4196,10 @@ function verifyInstalledPacks(cwd, explicitPubkey) {
|
|
|
4030
4196
|
repo: existsSync(repoKey) ? repoKey : undefined,
|
|
4031
4197
|
packaged: packagedTrustRootPath(),
|
|
4032
4198
|
});
|
|
4033
|
-
|
|
4199
|
+
// ADR-001: verification asks "which packs carry a signature?", which is NOT the question
|
|
4200
|
+
// `discoverSkillPackDirs` answers. MEASURED 2026-08-21 — the prefix filter left 26 of 52 signed
|
|
4201
|
+
// packs invisible, `keysarium` drifted unnoticed, and the summary line read as coverage.
|
|
4202
|
+
const packs = discoverVerifiablePackDirs(cwd);
|
|
4034
4203
|
// Cross-model review: `--pubkey <pack>/evil.pub` would let the artifact supply its own verifying key
|
|
4035
4204
|
// through the caller. The tool must never verify a pack against a key that lives inside it.
|
|
4036
4205
|
if (trustRoot?.source === 'explicit') {
|
|
@@ -4069,6 +4238,35 @@ function verifyInstalledPacks(cwd, explicitPubkey) {
|
|
|
4069
4238
|
checks.push({ pack, verdict: 'no-trust-root', failures: [] });
|
|
4070
4239
|
continue;
|
|
4071
4240
|
}
|
|
4241
|
+
// A SOURCE tree legitimately holds files the tarball never ships (tests, coverage, CHANGELOG), so
|
|
4242
|
+
// the added-file sweep is meaningless there — scoping it to the manifest's own list disables it.
|
|
4243
|
+
// An INSTALLED pack under node_modules IS the extracted artifact, and there the sweep is the whole
|
|
4244
|
+
// point: it is what catches a file an attacker added. Same function, two honest modes.
|
|
4245
|
+
//
|
|
4246
|
+
// This distinction had to be drawn the moment the SIGNER started covering only the shipped set
|
|
4247
|
+
// (2026-08-21). Leaving it undrawn made every source pack report TAMPERED — the third time in one
|
|
4248
|
+
// day that a scope change on one side was not mirrored on the other.
|
|
4249
|
+
// An ARTIFACT is an extracted tarball, and extracted tarballs live in `node_modules`. Anything
|
|
4250
|
+
// else is a checkout. Deliberately NOT keyed on `cwd`: packs are also discovered from the CLI's
|
|
4251
|
+
// own install location, which is outside the project being checked — keying on cwd made the
|
|
4252
|
+
// repo's own source packs look like artifacts and report TAMPERED from a temp-dir fixture.
|
|
4253
|
+
// Resolve the link FIRST: pnpm links workspace packages into `node_modules`, so a source checkout
|
|
4254
|
+
// is reachable by a path that looks like an artifact. Judging by the given path made the repo's
|
|
4255
|
+
// own packs verify as tarballs and report TAMPERED (measured while wiring this).
|
|
4256
|
+
let realDir = dir;
|
|
4257
|
+
try {
|
|
4258
|
+
realDir = realpathSync(dir);
|
|
4259
|
+
}
|
|
4260
|
+
catch { /* keep the given path */ }
|
|
4261
|
+
const isSourceTree = !realDir.split(sep).includes('node_modules');
|
|
4262
|
+
if (isSourceTree) {
|
|
4263
|
+
// The manifest describes the PUBLISHED TARBALL, and a source checkout is a different object —
|
|
4264
|
+
// `pnpm publish` re-serialises package.json and rewrites `workspace:*`. Hash-verifying a
|
|
4265
|
+
// checkout against it produces a guaranteed false TAMPERED, so this reports a state of its own
|
|
4266
|
+
// instead of an alarm. `dz verify-pack` packs and checks the real artifact.
|
|
4267
|
+
checks.push({ pack, verdict: 'source-tree', failures: [] });
|
|
4268
|
+
continue;
|
|
4269
|
+
}
|
|
4072
4270
|
const res = verifyManifest(dir, signed, keyPem);
|
|
4073
4271
|
checks.push({
|
|
4074
4272
|
pack,
|
|
@@ -4078,6 +4276,30 @@ function verifyInstalledPacks(cwd, explicitPubkey) {
|
|
|
4078
4276
|
}
|
|
4079
4277
|
return { trustRoot, checks };
|
|
4080
4278
|
}
|
|
4279
|
+
/**
|
|
4280
|
+
* The signature verdicts as DATA. The text reporter and the `--json` output both render this, so the
|
|
4281
|
+
* two cannot disagree about what was found — the failure this feature removes is exactly a verdict
|
|
4282
|
+
* that exists in one surface and not the other.
|
|
4283
|
+
*/
|
|
4284
|
+
function collectPackVerification(cwd, explicitPubkey) {
|
|
4285
|
+
let trustRoot = null;
|
|
4286
|
+
let checks = [];
|
|
4287
|
+
try {
|
|
4288
|
+
({ trustRoot, checks } = verifyInstalledPacks(cwd, explicitPubkey));
|
|
4289
|
+
}
|
|
4290
|
+
catch {
|
|
4291
|
+
// A refusal is a result: an empty listing with no trust root, not a crash and not a silent pass.
|
|
4292
|
+
return { trustRoot: null, counts: { verified: 0, unsigned: 0, tampered: 0, 'no-trust-root': 0, 'source-tree': 0 }, packs: [] };
|
|
4293
|
+
}
|
|
4294
|
+
const counts = { verified: 0, unsigned: 0, tampered: 0, 'no-trust-root': 0, 'source-tree': 0 };
|
|
4295
|
+
for (const c of checks)
|
|
4296
|
+
counts[c.verdict]++;
|
|
4297
|
+
return {
|
|
4298
|
+
trustRoot: trustRoot === null ? null : { source: trustRoot.source, path: trustRoot.path },
|
|
4299
|
+
counts,
|
|
4300
|
+
packs: checks.map((c) => ({ pack: c.pack, verdict: c.verdict, failures: [...c.failures] })),
|
|
4301
|
+
};
|
|
4302
|
+
}
|
|
4081
4303
|
/** Print the pack verdicts and return 1 iff the policy says any of them is fatal. */
|
|
4082
4304
|
function reportPackVerification(cwd, explicitPubkey, requireSigning, write) {
|
|
4083
4305
|
let trustRoot;
|
|
@@ -4092,7 +4314,7 @@ function reportPackVerification(cwd, explicitPubkey, requireSigning, write) {
|
|
|
4092
4314
|
}
|
|
4093
4315
|
if (checks.length === 0)
|
|
4094
4316
|
return 0;
|
|
4095
|
-
const counts = { verified: 0, unsigned: 0, tampered: 0, 'no-trust-root': 0 };
|
|
4317
|
+
const counts = { verified: 0, unsigned: 0, tampered: 0, 'no-trust-root': 0, 'source-tree': 0 };
|
|
4096
4318
|
let fatal = 0;
|
|
4097
4319
|
for (const c of checks) {
|
|
4098
4320
|
counts[c.verdict]++;
|
|
@@ -4108,7 +4330,7 @@ function reportPackVerification(cwd, explicitPubkey, requireSigning, write) {
|
|
|
4108
4330
|
}
|
|
4109
4331
|
const root = trustRoot ? `${trustRoot.source} (${trustRoot.path})` : 'none';
|
|
4110
4332
|
write(` signatures: ${counts.verified} verified, ${counts.unsigned} unsigned, ` +
|
|
4111
|
-
`${counts.tampered} TAMPERED, ${counts['no-trust-root']} unverifiable; trust root: ${root}`);
|
|
4333
|
+
`${counts.tampered} TAMPERED, ${counts['no-trust-root']} unverifiable, ${counts['source-tree']} source-tree (not an artifact); trust root: ${root}`);
|
|
4112
4334
|
// A signature proves the bytes are unmodified. It never proves the skill is any good.
|
|
4113
4335
|
return fatal > 0 ? 1 : 0;
|
|
4114
4336
|
}
|
|
@@ -4212,15 +4434,40 @@ function cmdSign(options, flags, cwd, write) {
|
|
|
4212
4434
|
write(`dz sign: private key not found: ${resolve(cwd, key)}`);
|
|
4213
4435
|
return 1;
|
|
4214
4436
|
}
|
|
4215
|
-
|
|
4437
|
+
// The manifest covers what the CONSUMER receives. `dz sign` and the publish-time re-sign MUST use
|
|
4438
|
+
// the same rule, or the two produce different manifests for the same pack — a second, divergent
|
|
4439
|
+
// answer beside the real one, which is the class of defect this change removes.
|
|
4440
|
+
// Hash the EXTRACTED TARBALL, not the working tree. `pnpm publish` re-serialises package.json and
|
|
4441
|
+
// rewrites `workspace:*`, so a hash taken from disk is stale before the tarball exists — MEASURED
|
|
4442
|
+
// 2026-08-21: the published `skills-news` package.json is 1050 bytes where the tree's is 1051, and
|
|
4443
|
+
// that single missing newline made six freshly re-signed packs report TAMPERED to every consumer.
|
|
4444
|
+
// `dz sign` and the publish-time re-sign use this same path, or they produce different manifests for
|
|
4445
|
+
// the same pack.
|
|
4446
|
+
let hashRoot = packDir;
|
|
4447
|
+
let cleanupPack = null;
|
|
4448
|
+
let files = packFiles(packDir);
|
|
4449
|
+
try {
|
|
4450
|
+
const extracted = extractPublishTarball(packDir);
|
|
4451
|
+
hashRoot = extracted.dir;
|
|
4452
|
+
cleanupPack = extracted.cleanup;
|
|
4453
|
+
files = packFiles(hashRoot);
|
|
4454
|
+
write(`dz sign: hashing the packed tarball (${files.length} file(s)) — the bytes a recipient receives`);
|
|
4455
|
+
}
|
|
4456
|
+
catch (err) {
|
|
4457
|
+
// Not an npm package, or no pnpm: sign the tree and SAY SO. A silent fallback would restore the
|
|
4458
|
+
// divergence this change closes.
|
|
4459
|
+
write(`dz sign: could not pack this directory (${err.message.split('\n')[0]}) — signing the working tree instead`);
|
|
4460
|
+
}
|
|
4216
4461
|
if (files.length === 0) {
|
|
4462
|
+
cleanupPack?.();
|
|
4217
4463
|
write('dz sign: the pack contains no files — refusing to sign nothing');
|
|
4218
4464
|
return 1;
|
|
4219
4465
|
}
|
|
4220
|
-
const manifest = buildManifest(
|
|
4466
|
+
const manifest = buildManifest(hashRoot, basename(packDir), files);
|
|
4221
4467
|
const signed = signManifest(manifest, readFileSync(resolve(cwd, key), 'utf8'));
|
|
4222
4468
|
writeFileSync(join(packDir, MANIFEST_NAME), JSON.stringify(signed, null, 2) + '\n');
|
|
4223
4469
|
writeFileSync(join(packDir, SBOM_NAME), JSON.stringify(buildSbom(manifest), null, 2) + '\n');
|
|
4470
|
+
cleanupPack?.();
|
|
4224
4471
|
write(`dz sign: signed ${files.length} file(s) in ${packDir}`);
|
|
4225
4472
|
write(` ${MANIFEST_NAME} + ${SBOM_NAME} written. Ed25519 gives tamper-evidence, never truthfulness.`);
|
|
4226
4473
|
return 0;
|
|
@@ -4251,7 +4498,16 @@ function cmdVerifyPack(options, flags, cwd, write) {
|
|
|
4251
4498
|
write(`dz verify-pack: ${MANIFEST_NAME} is not valid JSON`);
|
|
4252
4499
|
return 1;
|
|
4253
4500
|
}
|
|
4254
|
-
|
|
4501
|
+
// Same rule as the signer: a working-tree file `files[]` excludes was never "added to the pack".
|
|
4502
|
+
// Unscoped when npm cannot answer — an extracted tarball verifies exactly as before.
|
|
4503
|
+
let shippedForVerify;
|
|
4504
|
+
try {
|
|
4505
|
+
shippedForVerify = npmPackedPaths(packDir);
|
|
4506
|
+
}
|
|
4507
|
+
catch {
|
|
4508
|
+
shippedForVerify = undefined;
|
|
4509
|
+
}
|
|
4510
|
+
const res = verifyManifest(packDir, signed, readFileSync(pubPath, 'utf8'), shippedForVerify);
|
|
4255
4511
|
if (res.ok) {
|
|
4256
4512
|
write(`dz verify-pack: OK — ${packDir} matches its signed manifest`);
|
|
4257
4513
|
return 0;
|
|
@@ -4308,7 +4564,7 @@ function cmdPublish(options, flags, cwd, write) {
|
|
|
4308
4564
|
// Reject unknown flags/options so a typo (e.g. `--dry-rum`) can NEVER be
|
|
4309
4565
|
// silently swallowed and flip the command into live-publish mode.
|
|
4310
4566
|
const allowedFlags = new Set(['dry-run', 'no-dry-run', 'yes', 'confirm', 'bump-only', 'help', 'require-signing', 'provenance', 'no-provenance']);
|
|
4311
|
-
const allowedOptions = new Set(['filter', 'claim-check', 'no-guard']);
|
|
4567
|
+
const allowedOptions = new Set(['filter', 'claim-check', 'no-guard', 'sign-key']);
|
|
4312
4568
|
const allowedHelp = ' allowed: --dry-run (default), --yes/--confirm/--no-dry-run (go live), --bump-only, --filter <substr>, --claim-check <off|warn|error>, --no-guard "<reason>" (skip the guard pre-flight; logged)';
|
|
4313
4569
|
for (const flag of flags) {
|
|
4314
4570
|
if (!allowedFlags.has(flag)) {
|
|
@@ -4439,7 +4695,14 @@ function cmdPublish(options, flags, cwd, write) {
|
|
|
4439
4695
|
if (trustRootPresent && manifestPresent) {
|
|
4440
4696
|
try {
|
|
4441
4697
|
const signed = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
4442
|
-
|
|
4698
|
+
let shippedForGate;
|
|
4699
|
+
try {
|
|
4700
|
+
shippedForGate = npmPackedPaths(pk.dir);
|
|
4701
|
+
}
|
|
4702
|
+
catch {
|
|
4703
|
+
shippedForGate = undefined;
|
|
4704
|
+
}
|
|
4705
|
+
verifyOk = verifyManifest(pk.dir, signed, readFileSync(trustRoot, 'utf8'), shippedForGate).ok;
|
|
4443
4706
|
}
|
|
4444
4707
|
catch {
|
|
4445
4708
|
verifyOk = false;
|
|
@@ -4459,7 +4722,82 @@ function cmdPublish(options, flags, cwd, write) {
|
|
|
4459
4722
|
return 1;
|
|
4460
4723
|
}
|
|
4461
4724
|
}
|
|
4462
|
-
|
|
4725
|
+
// A signed pack must be RE-SIGNED after publish's own bump and README sync (feature
|
|
4726
|
+
// `sign-after-bump`): publish mutates the pack, so any earlier signature describes files that no
|
|
4727
|
+
// longer exist. Default to the same path `dz sign --init` writes, so the ordinary operator needs no
|
|
4728
|
+
// new flag; `--sign-key` overrides it.
|
|
4729
|
+
const signKey = (options.get('sign-key') ?? join(homedir(), '.dz', 'keys', 'dz.key')).trim();
|
|
4730
|
+
const report = publishPackages(cwd, {
|
|
4731
|
+
provenance,
|
|
4732
|
+
dryRun,
|
|
4733
|
+
filter,
|
|
4734
|
+
bumpOnly,
|
|
4735
|
+
claimGate: claimCheckOpt,
|
|
4736
|
+
signKey: signKey === '' ? undefined : resolve(cwd, signKey),
|
|
4737
|
+
verifyAfterSign: (packDir) => {
|
|
4738
|
+
// Verify the OUTCOME against the trust root a CONSUMER would use — an existing key may be the
|
|
4739
|
+
// WRONG key, and enumerating that state is a losing game (round-1 review). The pack NAME travels
|
|
4740
|
+
// with the verdict so a pass about a different artifact cannot be mistaken for this one.
|
|
4741
|
+
const trustRoot = resolve(cwd, TRUST_ROOT_REL);
|
|
4742
|
+
if (!existsSync(trustRoot))
|
|
4743
|
+
return { ok: false, trustRootPresent: false, pack: basename(packDir) };
|
|
4744
|
+
try {
|
|
4745
|
+
const signed = JSON.parse(readFileSync(join(packDir, MANIFEST_NAME), 'utf8'));
|
|
4746
|
+
let shipped;
|
|
4747
|
+
try {
|
|
4748
|
+
shipped = npmPackedPaths(packDir);
|
|
4749
|
+
}
|
|
4750
|
+
catch {
|
|
4751
|
+
shipped = undefined;
|
|
4752
|
+
}
|
|
4753
|
+
const res = verifyManifest(packDir, signed, readFileSync(trustRoot, 'utf8'), shipped);
|
|
4754
|
+
return { ok: res.ok, trustRootPresent: true, pack: basename(packDir) };
|
|
4755
|
+
}
|
|
4756
|
+
catch {
|
|
4757
|
+
return { ok: false, trustRootPresent: true, pack: basename(packDir) };
|
|
4758
|
+
}
|
|
4759
|
+
},
|
|
4760
|
+
reSign: (packDir, keyPath) => {
|
|
4761
|
+
// The same three steps `dz sign` performs, including the SBOM — a manifest refreshed without
|
|
4762
|
+
// its SBOM would leave the two describing different trees.
|
|
4763
|
+
//
|
|
4764
|
+
// Path A: the manifest must cover what the CONSUMER receives, not what the author has on disk.
|
|
4765
|
+
// MEASURED 2026-08-21 by a live install of the published 0.6.1 — with the trust root restored,
|
|
4766
|
+
// six packs reported TAMPERED, and only half of that was the version bump. The rest was
|
|
4767
|
+
// `CHANGELOG.md: listed in the manifest but absent`: signed on disk, excluded by `files[]`,
|
|
4768
|
+
// therefore missing for every recipient forever. So the file list comes from `npm pack`, which
|
|
4769
|
+
// is the authority on what ships — we do not reimplement its globbing.
|
|
4770
|
+
// Hash the EXTRACTED TARBALL, not the working tree. `pnpm publish` re-serialises package.json and
|
|
4771
|
+
// rewrites `workspace:*`, so a hash taken from disk is stale before the tarball exists — MEASURED
|
|
4772
|
+
// 2026-08-21: the published `skills-news` package.json is 1050 bytes where the tree's is 1051,
|
|
4773
|
+
// and that one missing newline is what made six freshly re-signed packs report TAMPERED.
|
|
4774
|
+
const { dir: shippedDir, cleanup } = extractPublishTarball(packDir);
|
|
4775
|
+
try {
|
|
4776
|
+
const onDisk = packFiles(shippedDir);
|
|
4777
|
+
const packed = packFiles(shippedDir);
|
|
4778
|
+
const setDecision = decideSignableSet({ signable: onDisk, packed });
|
|
4779
|
+
write(signableSetLine(basename(packDir), setDecision));
|
|
4780
|
+
if (setDecision.publishedButUnsigned.length > 0) {
|
|
4781
|
+
// A shipped file no signature covers is WORSE than an unsigned pack: the badge says verified
|
|
4782
|
+
// while part of the payload is unchecked. Refuse rather than sign a partial claim.
|
|
4783
|
+
throw new Error(`refusing to sign a pack with ${setDecision.publishedButUnsigned.length} SHIPPED BUT UNSIGNED file(s): ${setDecision.publishedButUnsigned.slice(0, 5).join(', ')}`);
|
|
4784
|
+
}
|
|
4785
|
+
const files = [...setDecision.sign];
|
|
4786
|
+
if (files.length === 0)
|
|
4787
|
+
throw new Error(`refusing to sign an empty pack: ${packDir}`);
|
|
4788
|
+
// Hashes come from the extracted tarball; the manifest is WRITTEN to the source dir so the next
|
|
4789
|
+
// pack carries it. The re-pack normalises package.json identically (deterministic — MEASURED by
|
|
4790
|
+
// packing twice and comparing hashes), so the entries still describe what ships.
|
|
4791
|
+
const manifest = buildManifest(shippedDir, basename(packDir), files);
|
|
4792
|
+
const signed = signManifest(manifest, readFileSync(keyPath, 'utf-8'));
|
|
4793
|
+
writeFileSync(join(packDir, MANIFEST_NAME), `${JSON.stringify(signed, null, 2)}\n`);
|
|
4794
|
+
writeFileSync(join(packDir, SBOM_NAME), `${JSON.stringify(buildSbom(manifest), null, 2)}\n`);
|
|
4795
|
+
}
|
|
4796
|
+
finally {
|
|
4797
|
+
cleanup();
|
|
4798
|
+
}
|
|
4799
|
+
},
|
|
4800
|
+
});
|
|
4463
4801
|
write(`\ndz publish${dryRun ? ' --dry-run' : ''}${bumpOnly ? ' --bump-only' : ''}${claimCheckOpt !== 'warn' ? ` --claim-check ${claimCheckOpt}` : ''}`);
|
|
4464
4802
|
write(` Published: ${report.published} Skipped: ${report.skipped} Errors: ${report.errors}\n`);
|
|
4465
4803
|
for (const pkg of report.packages) {
|
|
@@ -5888,8 +6226,14 @@ function cmdDriftCheck(options, flags, cwd, write) {
|
|
|
5888
6226
|
const allowlist = readDriftAllowlist(root);
|
|
5889
6227
|
const r = sweepSkillDrift(root, { scope, allowlist });
|
|
5890
6228
|
if (flags.has('json')) {
|
|
5891
|
-
|
|
5892
|
-
|
|
6229
|
+
// The signature verdicts were printed in the TEXT output and absent from the JSON, so a CI job
|
|
6230
|
+
// parsing `--json` saw `drifted: 0` and concluded all was well while packs were TAMPERED. A gate
|
|
6231
|
+
// silent in the form CI reads is not a gate (MEASURED 2026-08-21: keys were duplicated, drifted,
|
|
6232
|
+
// allowlisted, scope, allowlist — and nothing else).
|
|
6233
|
+
const sig = collectPackVerification(root, options.get('pubkey'));
|
|
6234
|
+
const sigBlocking = sig.packs.filter((c) => decideVerifyPolicy(c.verdict, flags.has('require-signing')).action === 'fail').length;
|
|
6235
|
+
write(JSON.stringify({ ...r, scope, allowlist, signatures: sig }));
|
|
6236
|
+
return r.drifted.length > 0 || sigBlocking > 0 ? 1 : 0;
|
|
5893
6237
|
}
|
|
5894
6238
|
write(`Skills duplicated across ≥2 ${scope === 'packages' ? 'package' : ''} locations: ${r.duplicated}`);
|
|
5895
6239
|
if (r.allowlisted.length > 0) {
|
|
@@ -6102,6 +6446,61 @@ function gatherGuardFacts(op, root, text, storeCap) {
|
|
|
6102
6446
|
facts['readmeFirst'] = [...perPack.entries()].map(([name, e]) => ({ name: '@dzhechkov/' + name, versionBumped: e.pkgJson, readmeChanged: e.readme }));
|
|
6103
6447
|
}
|
|
6104
6448
|
catch { /* not a git repo — rule skips */ }
|
|
6449
|
+
// review-round: the same WORKING-TREE diff, asked a different question — does a package that
|
|
6450
|
+
// bumps its version and changes SOURCE bring a GRADED QE report with it? Scoped to source so a
|
|
6451
|
+
// docs-only republish is never blocked (ADR-001, features/publish-needs-a-review). A throw here
|
|
6452
|
+
// leaves the whole fact undefined, and the rule then reports NOTHING: absence of a report is an
|
|
6453
|
+
// accusation, absence of facts is ignorance, and they must not render the same.
|
|
6454
|
+
try {
|
|
6455
|
+
const status = execSync('git status --porcelain -uall', { cwd: root, encoding: 'utf-8' });
|
|
6456
|
+
const changed = status.split('\n').map((l) => l.slice(3).trim()).filter(Boolean);
|
|
6457
|
+
const perPack = new Map();
|
|
6458
|
+
for (const rel of changed) {
|
|
6459
|
+
const m = rel.match(/^packages\/@dzhechkov\/([^/]+)\/(.+)$/);
|
|
6460
|
+
if (!m || !m[1] || !m[2])
|
|
6461
|
+
continue;
|
|
6462
|
+
const e = perPack.get(m[1]) ?? { versionBumped: false, sourceChanged: false };
|
|
6463
|
+
if (m[2] === 'package.json')
|
|
6464
|
+
e.versionBumped = true;
|
|
6465
|
+
// SOURCE = what ships and can be wrong at runtime. Tests, docs and fixtures are excluded:
|
|
6466
|
+
// a test-only change still ships, but it is not the class the review gate is about, and
|
|
6467
|
+
// widening the scope is what makes a HARD gate get switched off.
|
|
6468
|
+
if (/^(src|lib|bin|skills)\//.test(m[2]) && !/\.(md|json|txt)$/.test(m[2]))
|
|
6469
|
+
e.sourceChanged = true;
|
|
6470
|
+
perPack.set(m[1], e);
|
|
6471
|
+
}
|
|
6472
|
+
const grades = [];
|
|
6473
|
+
for (const rel of changed) {
|
|
6474
|
+
if (!/^features\/[^/]+\/08_qe_report\.md$/.test(rel))
|
|
6475
|
+
continue;
|
|
6476
|
+
let text;
|
|
6477
|
+
try {
|
|
6478
|
+
text = readFileSync(join(root, rel), 'utf-8');
|
|
6479
|
+
}
|
|
6480
|
+
catch {
|
|
6481
|
+
continue;
|
|
6482
|
+
}
|
|
6483
|
+
// The grade the report itself STATES — `grade C`, `GRADE: B`, `**grade: A**`. A report that
|
|
6484
|
+
// never states one is not evidence (AM-2), so nothing is pushed for it.
|
|
6485
|
+
const m = text.match(/\bgrade\s*:?\s*\**\s*([ABCDF])\b/i);
|
|
6486
|
+
if (m && m[1])
|
|
6487
|
+
grades.push({ report: rel, grade: m[1] });
|
|
6488
|
+
}
|
|
6489
|
+
const cfgMin = loadGuardConfig(root).reviewRound?.minGrade;
|
|
6490
|
+
const minGrade = typeof cfgMin === 'string' ? cfgMin : undefined;
|
|
6491
|
+
facts['reviewRound'] = {
|
|
6492
|
+
packages: [...perPack.entries()].map(([name, e]) => ({ name: '@dzhechkov/' + name, ...e })),
|
|
6493
|
+
grades,
|
|
6494
|
+
gathered: true,
|
|
6495
|
+
...(minGrade !== undefined ? { minGrade } : {}),
|
|
6496
|
+
};
|
|
6497
|
+
}
|
|
6498
|
+
catch {
|
|
6499
|
+
// TRIED and could not read the tree. Say so on the record rather than passing silently: a HARD
|
|
6500
|
+
// gate that is quiet about ungathered evidence cannot be told from one that checked (raised by
|
|
6501
|
+
// cross-family review). It still does not BLOCK — ignorance is not an accusation.
|
|
6502
|
+
facts['reviewRound'] = { packages: [], grades: [], gathered: false };
|
|
6503
|
+
}
|
|
6105
6504
|
// skills-registrable: every skill dir in a skill pack must carry a depth-1 SKILL.md, or it ships
|
|
6106
6505
|
// registering nowhere (the health-advisor 1.2.0 class). Pure-toolkit packages yield nothing.
|
|
6107
6506
|
try {
|
|
@@ -8634,6 +9033,265 @@ function scanOneReqeRoot(featuresDir, keyMatchesDir, out, addMalformed) {
|
|
|
8634
9033
|
* It measures `--artifact` paths on disk itself. A caller cannot assert presence; it can only name
|
|
8635
9034
|
* what must be there.
|
|
8636
9035
|
*/
|
|
9036
|
+
/**
|
|
9037
|
+
* `dz amendment-check` — the deterministic half of the Step-8 amendment gate (ADR-001).
|
|
9038
|
+
*
|
|
9039
|
+
* The gate used to be prompt text asking the QE agent to confirm every `AM-N` row names a real test.
|
|
9040
|
+
* That is layer 4 on the cost-of-detection ladder, and `features/qe-scoped-review` shipped with five
|
|
9041
|
+
* dangling ids and a plan recording `## Amendments: None`. This command owns I/O and the exit code;
|
|
9042
|
+
* `harness-core/src/amendment-trace.ts` owns the grammar and the rules.
|
|
9043
|
+
*/
|
|
9044
|
+
/**
|
|
9045
|
+
* `dz feature-adr-record` — the witnessed writer for the run-cost ledger and training pairs
|
|
9046
|
+
* (ADR-001 … ADR-003). The subagent stops being a COURIER handed a shell pipeline and becomes a
|
|
9047
|
+
* CALLER handed arguments: this command owns the paths, the refusal, the append, the READ-BACK and
|
|
9048
|
+
* the exit code. A courier can neither refuse nor verify, which is how four workflow runs finished
|
|
9049
|
+
* with no cost row at all.
|
|
9050
|
+
*/
|
|
9051
|
+
/**
|
|
9052
|
+
* The file list of the tarball `npm pack` would produce, `package/` prefix stripped. npm is the
|
|
9053
|
+
* authority on what `files[]` ships; reimplementing its globbing would put a second, divergent answer
|
|
9054
|
+
* next to the real one — which is the class of defect this whole change exists to remove.
|
|
9055
|
+
*/
|
|
9056
|
+
function npmPackedPaths(packDir) {
|
|
9057
|
+
// `pnpm`, not `npm`: the PUBLISHER is `pnpm publish` (see `publishArgv`), and the two packers do not
|
|
9058
|
+
// agree. MEASURED 2026-08-21 on `skills-news`: `npm pack` emits a 1051-byte package.json identical
|
|
9059
|
+
// to the working tree, `pnpm pack` emits 1050 — pnpm re-serialises it (dropping the trailing
|
|
9060
|
+
// newline, and expanding `workspace:*`). Asking one tool what ships while a different tool ships it
|
|
9061
|
+
// is how a signature ends up describing a file nobody receives.
|
|
9062
|
+
const out = execFileSync('pnpm', ['pack', '--pack-destination', mkdtempSync(join(tmpdir(), 'dz-pack-probe-')), '--json'], {
|
|
9063
|
+
cwd: packDir,
|
|
9064
|
+
encoding: 'utf-8',
|
|
9065
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
9066
|
+
});
|
|
9067
|
+
const parsed = JSON.parse(out);
|
|
9068
|
+
const entry = Array.isArray(parsed) ? parsed[0] : parsed;
|
|
9069
|
+
const files = entry?.files ?? [];
|
|
9070
|
+
return files.map((f) => f.path.replace(/^package\//, '')).sort();
|
|
9071
|
+
}
|
|
9072
|
+
/**
|
|
9073
|
+
* Pack the package with the SAME tool that publishes it, extract the tarball, and return the directory
|
|
9074
|
+
* holding its contents. Hashing THAT is the only way a manifest can describe what a recipient gets:
|
|
9075
|
+
* `pnpm publish` re-serialises package.json and rewrites `workspace:*`, so any hash taken from the
|
|
9076
|
+
* working tree is stale before the tarball exists.
|
|
9077
|
+
*/
|
|
9078
|
+
function extractPublishTarball(packDir) {
|
|
9079
|
+
const tmp = mkdtempSync(join(tmpdir(), 'dz-sign-pack-'));
|
|
9080
|
+
const out = execFileSync('pnpm', ['pack', '--pack-destination', tmp, '--json'], {
|
|
9081
|
+
cwd: packDir,
|
|
9082
|
+
encoding: 'utf-8',
|
|
9083
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
9084
|
+
});
|
|
9085
|
+
const parsed = JSON.parse(out);
|
|
9086
|
+
const entry = Array.isArray(parsed) ? parsed[0] : parsed;
|
|
9087
|
+
const tgz = entry?.filename;
|
|
9088
|
+
if (tgz === undefined)
|
|
9089
|
+
throw new Error(`pnpm pack did not name a tarball for ${packDir}`);
|
|
9090
|
+
// pnpm reports an ABSOLUTE filename (it already contains --pack-destination); joining again would
|
|
9091
|
+
// double the directory. npm reports a bare name. Accept both rather than assuming either.
|
|
9092
|
+
const tgzPath = isAbsolute(tgz) ? tgz : join(tmp, tgz);
|
|
9093
|
+
execFileSync('tar', ['-xzf', tgzPath, '-C', tmp]);
|
|
9094
|
+
return { dir: join(tmp, 'package'), cleanup: () => { try {
|
|
9095
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
9096
|
+
}
|
|
9097
|
+
catch { /* best-effort */ } } };
|
|
9098
|
+
}
|
|
9099
|
+
function cmdFeatureAdrRecord(options, flags, cwd, write) {
|
|
9100
|
+
const json = flags.has('json');
|
|
9101
|
+
const kind = (options.get('kind') ?? '').trim();
|
|
9102
|
+
const stage = (options.get('stage') ?? '').trim();
|
|
9103
|
+
const slug = (options.get('slug') ?? '').trim();
|
|
9104
|
+
const payloadRaw = options.get('row') ?? options.get('pair') ?? '';
|
|
9105
|
+
const emit = (d, extra = {}) => {
|
|
9106
|
+
if (json) {
|
|
9107
|
+
write(JSON.stringify({ ok: d.exit === 0, kind, stage, verdict: d.verdict, exit: d.exit, reason: d.reason, blocking: d.blocking, ...extra }));
|
|
9108
|
+
}
|
|
9109
|
+
else {
|
|
9110
|
+
write(recordVerdictLine(kind === 'ledger' || kind === 'training-pair' ? kind : 'ledger', stage, d));
|
|
9111
|
+
}
|
|
9112
|
+
return d.exit;
|
|
9113
|
+
};
|
|
9114
|
+
if (kind !== 'ledger' && kind !== 'training-pair') {
|
|
9115
|
+
write('dz feature-adr-record: --kind must be ledger or training-pair');
|
|
9116
|
+
return 2;
|
|
9117
|
+
}
|
|
9118
|
+
if (payloadRaw === '') {
|
|
9119
|
+
write('dz feature-adr-record: --row (ledger) or --pair (training-pair) is required');
|
|
9120
|
+
return 2;
|
|
9121
|
+
}
|
|
9122
|
+
const repo = (options.get('project') ?? cwd).trim() || cwd;
|
|
9123
|
+
const target = kind === 'ledger'
|
|
9124
|
+
? join(repo, '.dz', 'feature-adr', 'run-cost-ledger.jsonl')
|
|
9125
|
+
: join(repo, '.dz', 'fa-training', slug === '' ? 'unknown' : slug, `${stage.replace(/[^\w.-]/g, '_')}.jsonl`);
|
|
9126
|
+
const markDir = join(repo, '.dz', 'fa-training', '.backfill-marks');
|
|
9127
|
+
const markName = (options.get('mark') ?? '').trim();
|
|
9128
|
+
const markPath = markName === '' ? null : join(markDir, markName.replace(/[^\w.-]/g, '_'));
|
|
9129
|
+
const decision = decideRecordWrite({
|
|
9130
|
+
kind,
|
|
9131
|
+
payloadRaw,
|
|
9132
|
+
stage,
|
|
9133
|
+
stageProducedResult: flags.has('no-result') ? false : true,
|
|
9134
|
+
markExists: markPath !== null && existsSync(markPath),
|
|
9135
|
+
targetExists: existsSync(target),
|
|
9136
|
+
targetHasPair: flags.has('once') && existsSync(target),
|
|
9137
|
+
timestamp: new Date().toISOString(),
|
|
9138
|
+
});
|
|
9139
|
+
if (decision.line === null)
|
|
9140
|
+
return emit(decision);
|
|
9141
|
+
// MEASURE, never assume: an unwritable target is a LOUD refusal, because a swallowed mkdir failure
|
|
9142
|
+
// is exactly how a write becomes a silent no-op (acid case A4).
|
|
9143
|
+
try {
|
|
9144
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
9145
|
+
if (markPath !== null) {
|
|
9146
|
+
mkdirSync(markDir, { recursive: true });
|
|
9147
|
+
mkdirSync(markPath);
|
|
9148
|
+
}
|
|
9149
|
+
}
|
|
9150
|
+
catch (err) {
|
|
9151
|
+
const code = err.code;
|
|
9152
|
+
if (markPath !== null && code === 'EEXIST') {
|
|
9153
|
+
// A stale mark (target absent) was already decided as writable above; re-taking it is a no-op,
|
|
9154
|
+
// not a duplicate. Only a mark WITH its target means another run got here first.
|
|
9155
|
+
if (decision.staleMark !== true) {
|
|
9156
|
+
return emit({ verdict: 'duplicate', exit: 0, reason: 'a mark for this record already exists — another run captured it first', blocking: false, line: null });
|
|
9157
|
+
}
|
|
9158
|
+
}
|
|
9159
|
+
return emit({ verdict: 'refused', exit: 2, reason: `the target could not be prepared: ${err.message}`, blocking: false, line: null });
|
|
9160
|
+
}
|
|
9161
|
+
try {
|
|
9162
|
+
appendFileSync(target, `${decision.line}\n`, 'utf-8');
|
|
9163
|
+
}
|
|
9164
|
+
catch (err) {
|
|
9165
|
+
return emit({ verdict: 'refused', exit: 2, reason: `the append failed: ${err.message}`, blocking: false, line: null });
|
|
9166
|
+
}
|
|
9167
|
+
// ADR-002: the write is verified against the disk, never inferred from the absence of an error.
|
|
9168
|
+
let lastLine = null;
|
|
9169
|
+
try {
|
|
9170
|
+
const body = readFileSync(target, 'utf-8');
|
|
9171
|
+
const lines = body.split('\n').filter((l) => l !== '');
|
|
9172
|
+
lastLine = lines.length > 0 ? lines[lines.length - 1] : null;
|
|
9173
|
+
}
|
|
9174
|
+
catch {
|
|
9175
|
+
lastLine = null;
|
|
9176
|
+
}
|
|
9177
|
+
return emit(decideReadBack(decision.line, lastLine), { target });
|
|
9178
|
+
}
|
|
9179
|
+
function cmdAmendmentCheck(options, flags, cwd, write) {
|
|
9180
|
+
const json = flags.has('json');
|
|
9181
|
+
const readOr = (abs) => {
|
|
9182
|
+
try {
|
|
9183
|
+
return readFileSync(abs, 'utf-8');
|
|
9184
|
+
}
|
|
9185
|
+
catch {
|
|
9186
|
+
return null;
|
|
9187
|
+
}
|
|
9188
|
+
};
|
|
9189
|
+
const checkOne = (featureDir) => {
|
|
9190
|
+
const slug = basename(featureDir);
|
|
9191
|
+
const ideation = readOr(join(featureDir, '03.5_ideation_report.md'));
|
|
9192
|
+
const plan = readOr(join(featureDir, '06_implementation_plan.md'));
|
|
9193
|
+
if (ideation === null) {
|
|
9194
|
+
return {
|
|
9195
|
+
slug,
|
|
9196
|
+
resolutions: [],
|
|
9197
|
+
decision: decideAmendmentOutcome({ sectionPresent: false, rows: [], resolutions: [], planSaysNone: false }),
|
|
9198
|
+
};
|
|
9199
|
+
}
|
|
9200
|
+
const sectionPresent = amendmentSection(ideation) !== null;
|
|
9201
|
+
const ideationRows = parseAmendments(ideation);
|
|
9202
|
+
const planRows = plan === null ? [] : parseAmendments(plan);
|
|
9203
|
+
// The PLAN is authoritative when it carries rows: Step 6 owes "carry AM-N into the plan
|
|
9204
|
+
// verbatim", and the ideation report is the historical record — rewriting its rows to match
|
|
9205
|
+
// tests named later would close the trail by falsifying it. Coverage keeps that honest below.
|
|
9206
|
+
const rows = planRows.length > 0 ? planRows : ideationRows;
|
|
9207
|
+
const missingFromPlan = planRows.length > 0 ? amendmentsMissingFromPlan(ideationRows, planRows) : [];
|
|
9208
|
+
// Paths in an amendment row are repo-relative, so they resolve against the repo root — not
|
|
9209
|
+
// against the feature directory, and not against wherever the caller happened to stand.
|
|
9210
|
+
const resolutions = resolveAmendments(rows, { readFile: (rel) => readOr(resolve(cwd, rel)) });
|
|
9211
|
+
const decision = decideAmendmentOutcome({
|
|
9212
|
+
sectionPresent,
|
|
9213
|
+
rows,
|
|
9214
|
+
resolutions,
|
|
9215
|
+
planSaysNone: plan !== null && planSaysNoAmendments(plan),
|
|
9216
|
+
missingFromPlan,
|
|
9217
|
+
});
|
|
9218
|
+
return { slug, decision, resolutions };
|
|
9219
|
+
};
|
|
9220
|
+
// --all is a CENSUS, not a gate (ADR-003): a gate that is red on arrival over 21 historical
|
|
9221
|
+
// features gets disabled, and then the mechanism is gone along with the debt it was to surface.
|
|
9222
|
+
if (flags.has('all')) {
|
|
9223
|
+
const featuresDir = join(cwd, 'features');
|
|
9224
|
+
let slugs = [];
|
|
9225
|
+
try {
|
|
9226
|
+
slugs = readdirSync(featuresDir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name).sort();
|
|
9227
|
+
}
|
|
9228
|
+
catch {
|
|
9229
|
+
if (json)
|
|
9230
|
+
write(JSON.stringify({ ok: true, mode: 'all', features: [], note: 'no features/ directory' }));
|
|
9231
|
+
else
|
|
9232
|
+
write('amendment traceability census: no features/ directory');
|
|
9233
|
+
return 0;
|
|
9234
|
+
}
|
|
9235
|
+
const rowsOut = [];
|
|
9236
|
+
for (const slug of slugs) {
|
|
9237
|
+
try {
|
|
9238
|
+
const r = checkOne(join(featuresDir, slug));
|
|
9239
|
+
rowsOut.push({ slug, outcome: r.decision.outcome, counts: r.decision.counts, reasons: r.decision.reasons.length });
|
|
9240
|
+
}
|
|
9241
|
+
catch (err) {
|
|
9242
|
+
// AM-6: one unreadable feature becomes its own row. Dropping it silently would make the
|
|
9243
|
+
// census read like coverage it does not have.
|
|
9244
|
+
rowsOut.push({ slug, outcome: 'read-error', error: err.message });
|
|
9245
|
+
}
|
|
9246
|
+
}
|
|
9247
|
+
if (json) {
|
|
9248
|
+
write(JSON.stringify({ ok: true, mode: 'all', note: AMENDMENT_VACUITY_NOTE, features: rowsOut }));
|
|
9249
|
+
}
|
|
9250
|
+
else {
|
|
9251
|
+
const tally = new Map();
|
|
9252
|
+
for (const r of rowsOut)
|
|
9253
|
+
tally.set(String(r['outcome']), (tally.get(String(r['outcome'])) ?? 0) + 1);
|
|
9254
|
+
for (const r of rowsOut) {
|
|
9255
|
+
if (r['outcome'] !== 'pass' && r['outcome'] !== 'skip')
|
|
9256
|
+
write(` [${String(r['outcome'])}] ${String(r['slug'])}`);
|
|
9257
|
+
}
|
|
9258
|
+
write(`amendment traceability census over ${rowsOut.length} feature(s): ${[...tally].map(([k, v]) => `${v} ${k}`).join(', ')}`);
|
|
9259
|
+
write(AMENDMENT_VACUITY_NOTE);
|
|
9260
|
+
}
|
|
9261
|
+
return 0;
|
|
9262
|
+
}
|
|
9263
|
+
const explicitDir = (options.get('feature-dir') ?? '').trim();
|
|
9264
|
+
const slug = (options.get('slug') ?? '').trim();
|
|
9265
|
+
if (explicitDir === '' && slug === '') {
|
|
9266
|
+
write('dz amendment-check: one of --slug / --feature-dir is required (or --all for a census)');
|
|
9267
|
+
return 2;
|
|
9268
|
+
}
|
|
9269
|
+
const featureDir = explicitDir !== '' ? resolve(cwd, explicitDir) : join(cwd, 'features', slug);
|
|
9270
|
+
const { decision, resolutions } = checkOne(featureDir);
|
|
9271
|
+
if (json) {
|
|
9272
|
+
write(JSON.stringify({
|
|
9273
|
+
ok: decision.outcome === 'pass' || decision.outcome === 'skip',
|
|
9274
|
+
slug: basename(featureDir),
|
|
9275
|
+
outcome: decision.outcome,
|
|
9276
|
+
exit: decision.exit,
|
|
9277
|
+
counts: decision.counts,
|
|
9278
|
+
reasons: decision.reasons,
|
|
9279
|
+
rows: resolutions,
|
|
9280
|
+
note: AMENDMENT_VACUITY_NOTE,
|
|
9281
|
+
}));
|
|
9282
|
+
}
|
|
9283
|
+
else {
|
|
9284
|
+
for (const r of resolutions) {
|
|
9285
|
+
if (r.verdict !== 'resolved')
|
|
9286
|
+
write(` [${r.verdict}] ${r.id}${r.testId === null ? '' : ` \`${r.testId}\``} — ${r.detail}`);
|
|
9287
|
+
}
|
|
9288
|
+
write(AMENDMENT_VACUITY_NOTE);
|
|
9289
|
+
// The verdict is the LAST line, in the K2 gate's own shape, so a caller that reads the tail of
|
|
9290
|
+
// the output reads a verdict rather than a finding.
|
|
9291
|
+
write(amendmentVerdictLine(decision));
|
|
9292
|
+
}
|
|
9293
|
+
return decision.exit;
|
|
9294
|
+
}
|
|
8637
9295
|
function cmdFeatureAdrCheckpoint(options, flags, cwd, write) {
|
|
8638
9296
|
const json = flags.has('json');
|
|
8639
9297
|
const emit = (payload, human, code) => {
|
|
@@ -11513,6 +12171,10 @@ export async function runCli(argv, io = {}) {
|
|
|
11513
12171
|
return cmdEpochReplay(options, flags, cwd, write);
|
|
11514
12172
|
case 'score':
|
|
11515
12173
|
return cmdScore(options, flags, cwd, write);
|
|
12174
|
+
case 'feature-adr-record':
|
|
12175
|
+
return cmdFeatureAdrRecord(options, flags, cwd, write);
|
|
12176
|
+
case 'amendment-check':
|
|
12177
|
+
return cmdAmendmentCheck(options, flags, cwd, write);
|
|
11516
12178
|
case 'feature-adr-checkpoint':
|
|
11517
12179
|
return cmdFeatureAdrCheckpoint(options, flags, cwd, write);
|
|
11518
12180
|
case 'reqe':
|