@dzhechkov/harness-cli 0.3.262 → 0.4.2

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/dist/cli.js CHANGED
@@ -3,19 +3,22 @@
3
3
  *
4
4
  * @packageDocumentation
5
5
  */
6
- import { appendFileSync, chmodSync, closeSync, existsSync, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, readlinkSync, realpathSync, renameSync, rmdirSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
7
- import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
6
+ 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';
7
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import { execFileSync, execSync, spawn } from 'node:child_process';
10
+ import { createHash } from 'node:crypto';
10
11
  import { homedir, tmpdir } from 'node:os';
11
12
  import { createRequire } from 'node:module';
12
- import { createSkill, getSkillInfo, getWorkflow, isTargetName, listSkills, runDoctor, runInit, 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, TARGET_NAMES, buildParityMatrix, TARGET_CAPABILITIES, TARGET_SHORT_LABELS, WORKFLOW_NAMES, 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, 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,
13
+ import { createSkill, getSkillInfo, isTargetName, listSkills, 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, TARGET_NAMES, buildParityMatrix, TARGET_CAPABILITIES, TARGET_SHORT_LABELS, WORKFLOW_TEMPLATES_RETIRED_MESSAGE, parsePlan, isParseErrors, validatePlan, normalizePlan, planDigest, toTraceProjection, renderPlan, mergeRender, lint, lintExitCode, LOOP_BLOBS, parseTrace, assembleTimeline, runInvariants, 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, 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,
13
14
  // guard-promotion (feature guard-promotion, scout idea #1)
14
15
  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, pickAdversaryModel, CHALLENGE_QUESTIONS, loadOutcomes, renderOutcomes, statsForKey, selectAutoCost, recordProvisional, finalizeOutcome, COST_LADDER, splitScenarios, budgetPlan, selectWinner, proseScopeOk, renderProseDiff, readScenarioIds, DEFAULT_MAX_JUDGE_RUNS, collectDeliveryFacts, planDeliveryCheck, renderDeliveryBrief, classifyDelivery, isUsablePlaneResult, renderDeliveryReview, scanSkillsLayout, parseInitFacts, verifyRegistration, buildContentProbePrompt, classifyContentProbe, renderContentProbe, findNonRegistrableSkillDirs, assembleCompoundingReport,
15
16
  // Cold-vs-warm EPOCH RUNNER (feature epoch-replay) — orchestrates + scores, never calls a model.
16
17
  replayableInstances, buildWorkOrder, buildJudgePrompts, unblindJudgments, verifyWorkOrder, isValidMargin, DIGEST_HONEST_SCOPE, scoreEpochReplay, generateMockOutcomes, renderEpochReplayResult, renderWorkOrderSummary, renderJudgePromptsSummary, WORK_ORDER_KIND, DEFAULT_MOCK_N, DEFAULT_MOCK_SEED, scoreRun, renderScorecard, renderCompoundingReport, readReinforcementState, readQuarantineState, registrationExitCode, renderRegistrationReport,
17
18
  // Smart Backlog (feature smart-backlog) — goal-directed idea pipeline over the Brain vector engine.
18
- readBacklogConfig, readIdeas, writeIdeas, ideaId, dedupIdea, readGoalMap, readGoalMapDetailed, parseEffort, ensureBacklogGitignored, isSafeId, alignIdea, mirrorIdeaVector, snapshotIdeas, spinRoulette, rankRoulette, seededRng, eligibleIdeas, stageEnrichment, buildJiraDraft, resolveJiraAdapter, makeBacklogIO, harmonizeBacklog, BACKLOG_BACKENDS, applyDomainBoost, countDisplacedByCut, renderDomainBoostNote, renderDomainCutNote, parseReqeDebt, buildReqeBrief, settleReqeDebt, renderReqeList, REQE_SCOPE, } from '@dzhechkov/harness-core';
19
+ readBacklogConfig, readIdeas, writeIdeas, ideaId, dedupIdea, readGoalMap, readGoalMapDetailed, parseEffort, ensureBacklogGitignored, isSafeId, alignIdea, mirrorIdeaVector, ensureBacklogEmbedForm, readBacklogEmbedFormVersion, recordAbsorption, DEDUP_EMBED_FORM_VERSION, snapshotIdeas, spinRoulette, rankRoulette, seededRng, eligibleIdeas, stageEnrichment, buildJiraDraft, resolveJiraAdapter, makeBacklogIO, harmonizeBacklog, transitionIdeas, BACKLOG_BACKENDS, applyDomainBoost, DZ_OWNED_TASK_TYPES, applyExportHoldout, DEFAULT_HELD_OUT_DOMAINS, canonicalDomainKey, readAgentdbRowsByTaskType, heldOutAfterOptIn, renderHoldoutNote, renderSharedStoreAdvice, decideVectorExport, countDisplacedByCut, renderDomainBoostNote, renderDomainCutNote, parseReqeDebt, buildReqeBrief, settleReqeDebt, renderReqeList, REQE_SCOPE,
20
+ // Mutation gate (feature ha-mutation-gate) — break each named protection, run the suite, require red.
21
+ parseMutationRegistry, applyMutationToText, countFailingTests, classifyBaseline, classifyRunFailure, classifyMutationOutcome, mutationGateExitCode, summarizeMutationResults, renderMutationReport, } from '@dzhechkov/harness-core';
19
22
  import { getPreset, PRESET_NAMES } from '@dzhechkov/harness-presets';
20
23
  import { scanGitHub, analyzeRepo, generateReport, deepAnalyze, scanAllSources, ScoutMemory } from '@dzhechkov/scout';
21
24
  const USAGE = `dz - DZ cross-platform harness CLI
@@ -30,7 +33,12 @@ Usage:
30
33
  dz migrate [--project <dir>]
31
34
  dz create-skill --name <id> [--description <text>] [--skills-dir <dir>] [--tier <1-3>] [--with-references] [--no-evals] [--bto]
32
35
  dz scout [--topics <list>] [--since <date>] [--deep] [--output <file>] [--diff] [--report]
33
- dz workflow <task> [--dry-run]
36
+ dz workflow init --name <n> [--pattern pipeline|barrier|fanout|gate] [--o <plan.json>] (scaffold a loop-plan/1 plan)
37
+ dz workflow validate <plan.json> [--json] (schema + INV-1..8 checks; CI-runnable, non-zero on failure)
38
+ dz workflow render <plan.json> --o <script.js> [--check] [--force] (plan → region-delimited loop script; USER regions preserved)
39
+ dz workflow blobs [--check] (list/self-check the subsystem blob registry)
40
+ dz workflow-lint <script.js> [--plan <plan.json>] [--require-plan|--legacy] [--json] (layer-1 gate; exit 0/1/3 — inconclusive is never a pass)
41
+ dz workflow-trace <runDir|--slug <s>|--run <id>> [--invariants <plan.json>] [--html <out.html>] [--json] (timeline + SEQ invariant runner)
34
42
  dz install <npm-pkg> [--target <name>] [--project <dir>] [--force]
35
43
  dz bundle [--preset <name> | --select id,id,...] [--out <dir>] [--skills-dir <dir>] [--force] (portable self-contained skill bundles for a generic/LangGraph consumer)
36
44
  dz doctor [--project <dir>] [--pubkey <path>] [--require-signing] (health + signature check of installed packs)
@@ -49,11 +57,15 @@ Usage:
49
57
  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)
50
58
  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)
51
59
  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)
60
+ dz mutation-gate [--package <dir>] [--registry <file>] [--test-cmd "<cmd>"] [--only <id[,id]>] [--timeout <ms>] [--rebaseline per-entry|final] [--keep-scratch] [--json] (prove each NAMED protection has a test that DISCRIMINATES: copy the package to a scratch dir, verify the baseline suite is green, apply each registry mutation, run the suite, REQUIRE red, restore. The red must be BEHAVIOURAL: a mutation that no longer parses is MUTATION_UNPARSEABLE; a red run whose OWN output reports a test FILE failing to load (node --test file-level not-ok with exitCode, vitest Failed Suites) is MUTATION_LOAD_FATAL — the signal comes from the same run as the failing count, never from a separate isolated import; red output whose shape matches no known runner is INCONCLUSIVE (a runner-coverage gap, loud, never PROVEN); a count far above the entry's bound is OVER_FAILING; a restored tree that does not reproduce green makes the entry INCONCLUSIVE (flaky). Mutation writes are realpath-contained to the scratch copy: a symlink escape or a node_modules/ target is refused (exit 2), the real tree is never written. A mutation that does not apply, a green suite, or an inconclusive run is a FAILURE — never a skip. exit 0 all proven / 1 gate failed / 2 setup error)
52
61
  dz backlog add "<idea>" [--effort 1-5] [--proposal <text>] [--dry-run] [--project <dir>] [--json] (capture an idea: semantic dedup against existing ideas via the Brain vector engine (DUPLICATE>=0.92 merges, RELATED links, NEW creates) + GoalMap alignment; --dry-run classifies without writing)
53
62
  dz backlog list [--status <s>] [--goal <id>] [--project <dir>] [--json] (list captured ideas, filterable by status/goal)
54
63
  dz backlog show <id> [--project <dir>] [--json] (full record for one idea)
55
64
  dz backlog goals [--validate] [--project <dir>] [--json] (list/validate the compass at .dz/backlog/goals.json)
56
- dz backlog roulette [--pick <N>] [--seed <n>] [--commit] [--project <dir>] [--json] (WEIGHTED draw over eligible ideas: alignment^alpha * recencyDecay * 1/effort, seeded; --pick N = ranked shortlist; --commit flips the pick to in-progress (the only mutation))
65
+ dz backlog roulette [--pick <N>] [--seed <n>] [--commit] [--project <dir>] [--json] (WEIGHTED draw over eligible ideas: alignment^alpha * recencyDecay * 1/effort, seeded; --pick N = ranked shortlist; --commit flips the pick to in-progress)
66
+ dz backlog ship <id> [<id>…] [--reason <t>] [--dry-run] [--project <dir>] [--json] (mark work DONE: new|enriched|in-progress → shipped, removing it from the roulette pool — run it after finishing a task; short id prefixes ok, ambiguous = loud error)
67
+ dz backlog drop <id> [<id>…] [--reason <t>] [--dry-run] [--project <dir>] [--json] (retire an idea: new|enriched|in-progress → dropped)
68
+ dz backlog reopen <id> [<id>…] [--reason <t>] [--dry-run] [--project <dir>] [--json] (back to the pool: shipped|dropped|in-progress → new)
57
69
  dz backlog enrich <id> [--project <dir>] [--json] (stage the idea2prd input scaffold in features/<slug>/ and hand off to the idea2prd-manual skill — the CLI never fabricates a PRD)
58
70
  dz backlog jira <id> [--project <dir>] [--json] (draft a Jira issue via the configurable adapter (backlog.jira.adapter: jira-mcp|copilot-mcp|none); none writes an auditable jira-outbox/<id>.json stub)
59
71
  dz backlog harmonize [--apply] [--threshold <0-1>] [--project <dir>] [--json] (batch semantic dedup of the backlog ideas; --dry-run default, --apply snapshots first)
@@ -78,7 +90,7 @@ Usage:
78
90
  dz brain expand <kuId> [--source <slug>] [--json] (full-content lookup for a citation kuId; --json emits the full KU object)
79
91
  dz brain init [--project <dir>] [--k <N>] (wire the grounding hook into .claude/settings.json — opt-in)
80
92
  dz statusline [--json] [--install] [--project <dir>] (live self-learning panel for Claude Code's status bar; reads the CC JSON payload from STDIN)
81
- dz statusline --fa-record --slug <s> --step "<label>" [--recalled <n>] [--stored <n>] [--mode <m>] (feature-adr: record live per-run learning state → 📐 panel segment)
93
+ dz statusline --fa-record --slug <s> --step "<label>" [--kind <feature-adr|loop>] [--recalled <n>] [--stored <n>] [--mode <m>] (feature-adr: record live per-run learning state → 📐 panel segment)
82
94
  dz usage [--json] [--project <dir>] | dz usage --calibrate --session <pct> --weekly <pct> [--model fable=<pct>] [--project <dir>] (ESTIMATE Claude usage from fixed reset windows; optional per-model weekly binding; exit 0 ALWAYS; pct=null when limits unconfigured)
83
95
  dz usage --by-stage [--run <runId> | --slug <slug>] [--epsilon <0..1>] [--write <file.jsonl>] [--json] (per-stage cost ledger for ONE feature-adr run + the reconciliation invariant: accounted + unaccounted = run total; verdict BALANCED | DEFECT | INSUFFICIENT_DATA; local transcript ESTIMATES — catches ATTRIBUTION errors, not pricing errors)
84
96
  dz claim-check [paths...] [--json] [--fail-on high|medium|none] [--project <dir>] (enforce the Integrity Rule: flag untagged/overstated accuracy claims; default scan = root README.md + every discovered package's README.md + features/*/08_qe_report.md + docs/**/*.md (historical feature artifacts are NOT scanned — pass paths explicitly); exit 1 only at/above --fail-on, default high)
@@ -103,7 +115,7 @@ Usage:
103
115
  dz import-ecc [--local-path <dir>] [--select id,id,...] [--limit N] [--output <dir>] [--force]
104
116
  dz help
105
117
 
106
- Workflows: coverage-lift, mutation-kill, canonicalize, security-audit
118
+ Workflows: author loop-plan/1 plans with dz workflow init/validate/render; gate them with dz workflow-lint; read runs with dz workflow-trace (the ADR-005 templates are retired)
107
119
 
108
120
  Targets: ${TARGET_NAMES.join(', ')}
109
121
  Presets: ${PRESET_NAMES.join(', ')}`;
@@ -495,27 +507,322 @@ async function cmdScout(options, flags, cwd, write) {
495
507
  return 1;
496
508
  }
497
509
  }
510
+ /** Scaffold plans for `dz workflow init` — one per pattern (each validates + renders lint-clean:
511
+ * the ADR-002 property "the generator cannot emit a script its own lint rejects" is enforced by
512
+ * workflow-init-lint-clean.test.ts, not convention). */
513
+ function workflowInitPlan(name, pattern) {
514
+ const base = {
515
+ schema: 'loop-plan/1',
516
+ name,
517
+ description: `TODO: describe the ${name} loop`, // no-stubs: workflow-init scaffold sentinel the author replaces (deliberate authoring cue, not unfinished code)
518
+ whenToUse: `TODO: when to invoke ${name}`, // no-stubs: workflow-init scaffold sentinel the author replaces (deliberate authoring cue, not unfinished code)
519
+ checkpointing: { enabled: false },
520
+ subsystems: { checkpoints: false, trainingPairs: false, usageAdaptive: false, challengePanel: false, codexDispatch: false },
521
+ trace: { emit: true },
522
+ };
523
+ if (pattern === 'pipeline') {
524
+ return {
525
+ ...base,
526
+ steps: [
527
+ { stepId: 'fan', kind: 'fanout', phase: 'Work', concurrency: 'pipeline', budget: { maxAgents: 8 } },
528
+ { stepId: 'a', kind: 'agent', phase: 'Work', prompt: 'TODO: stage A per item', budget: { maxAgents: 4 } }, // no-stubs: workflow-init scaffold sentinel the author replaces (deliberate authoring cue, not unfinished code)
529
+ { stepId: 'b', kind: 'agent', phase: 'Work', prompt: 'TODO: stage B per item', budget: { maxAgents: 4 } }, // no-stubs: workflow-init scaffold sentinel the author replaces (deliberate authoring cue, not unfinished code)
530
+ { stepId: 'jn', kind: 'join', phase: 'Work', deps: ['fan'] },
531
+ ],
532
+ fanouts: [{ stage: 'fan', registry: ['item1', 'item2', 'item3'], maxFanout: 3, chain: ['a', 'b'] }],
533
+ joins: [{ stage: 'jn', forStage: 'fan', joinPolicy: 'all-activated', onInvalid: 'named-failure' }],
534
+ };
535
+ }
536
+ if (pattern === 'fanout' || pattern === 'barrier') {
537
+ return {
538
+ ...base,
539
+ steps: [
540
+ { stepId: 'fan', kind: 'fanout', phase: 'Lanes', concurrency: 'barrier', budget: { maxAgents: 6 } },
541
+ { stepId: 'lane', kind: 'agent', phase: 'Lanes', prompt: 'TODO: one lane', budget: { maxAgents: 6 } }, // no-stubs: workflow-init scaffold sentinel the author replaces (deliberate authoring cue, not unfinished code)
542
+ { stepId: 'jn', kind: 'join', phase: 'Lanes', deps: ['fan'] },
543
+ // the consumer hangs off the BARRIER (jn), never the fork — barrier-postdominates teaches this
544
+ { stepId: 'synthesize', kind: 'agent', phase: 'Synthesize', deps: ['jn'], prompt: 'TODO: synthesize across lanes', budget: { maxAgents: 1 } }, // no-stubs: workflow-init scaffold sentinel the author replaces (deliberate authoring cue, not unfinished code)
545
+ ],
546
+ fanouts: [{ stage: 'fan', registry: ['lane1', 'lane2', 'lane3'], maxFanout: 3, chain: ['lane'] }],
547
+ joins: [{ stage: 'jn', forStage: 'fan', joinPolicy: 'all-activated', onInvalid: 'named-failure' }],
548
+ };
549
+ }
550
+ if (pattern === 'gate') {
551
+ return {
552
+ ...base,
553
+ steps: [
554
+ { stepId: 'work', kind: 'agent', phase: 'Work', prompt: 'TODO: produce the artifact', budget: { maxAgents: 2 } }, // no-stubs: workflow-init scaffold sentinel the author replaces (deliberate authoring cue, not unfinished code)
555
+ { stepId: 'gate', kind: 'gate', phase: 'Gate', deps: ['work'], prompt: 'TODO: gate check (parse the verdict, never synthesize one)', budget: { maxAgents: 1 } }, // no-stubs: workflow-init scaffold sentinel the author replaces (deliberate authoring cue, not unfinished code)
556
+ ],
557
+ gates: [{ stepId: 'gate', kind: 'parse-verdict', failRoute: 'work', maxRedos: 1 }],
558
+ };
559
+ }
560
+ // minimal default: one agent step
561
+ return {
562
+ ...base,
563
+ steps: [{ stepId: 'main', kind: 'agent', phase: 'Work', prompt: 'TODO: the one step', budget: { maxAgents: 1 } }], // no-stubs: workflow-init scaffold sentinel the author replaces (deliberate authoring cue, not unfinished code)
564
+ };
565
+ }
566
+ /**
567
+ * `dz workflow` — the loop-designer authoring verbs (ADR-002; the ADR-005 template emitter is
568
+ * RETIRED — AM-6). Subverbs: init | validate | render | blobs. Any other invocation (including
569
+ * every legacy `--task <name>` / `--name <name>` / bare positional template spelling) prints the
570
+ * pinned retirement message and exits 1 — no legacy format is silently reachable.
571
+ */
498
572
  function cmdWorkflow(options, flags, cwd, write) {
499
- const taskName = options.get('task') ?? options.get('name') ?? '';
500
- if (taskName === '' || !WORKFLOW_NAMES.includes(taskName)) {
501
- write(`dz workflow: specify a task — one of: ${WORKFLOW_NAMES.join(', ')}`);
573
+ const sub = options.get('_positional_0') ?? '';
574
+ const legacyTask = options.get('task') ?? options.get('name');
575
+ if (sub === 'init') {
576
+ const name = options.get('name') ?? options.get('_positional_1') ?? 'my-loop';
577
+ const pattern = options.get('pattern') ?? 'pipeline';
578
+ const outPath = resolve(cwd, options.get('o') ?? options.get('out') ?? `${name}.plan.json`);
579
+ const planObj = workflowInitPlan(name, pattern);
580
+ const parsed = parsePlan(planObj);
581
+ if (isParseErrors(parsed)) {
582
+ write(`dz workflow init: internal scaffold error — ${parsed.map((e) => e.message).join('; ')}`);
583
+ return 1;
584
+ }
585
+ const diags = validatePlan(parsed);
586
+ if (diags.length > 0) {
587
+ write(`dz workflow init: internal scaffold failed validation — ${diags.map((d) => `${d.invariant} ${d.message}`).join('; ')}`);
588
+ return 1;
589
+ }
590
+ writeFileSync(outPath, JSON.stringify(normalizePlan(parsed), null, 2) + '\n');
591
+ write(`dz workflow init: wrote ${outPath} (pattern: ${pattern})`);
592
+ write('Next: edit the TODO prompts, then `dz workflow validate` + `dz workflow render`.'); // no-stubs: workflow-init scaffold sentinel the author replaces (deliberate authoring cue, not unfinished code)
593
+ return 0;
594
+ }
595
+ if (sub === 'validate') {
596
+ const planPath = options.get('_positional_1') ?? options.get('plan') ?? '';
597
+ if (planPath === '') {
598
+ write('dz workflow validate: usage — dz workflow validate <plan.json> [--json]');
599
+ return 1;
600
+ }
601
+ const abs = resolve(cwd, planPath);
602
+ if (!existsSync(abs)) {
603
+ write(`dz workflow validate: no such plan file: ${abs}`);
604
+ return 1;
605
+ }
606
+ let raw;
607
+ try {
608
+ raw = JSON.parse(readFileSync(abs, 'utf8'));
609
+ }
610
+ catch (e) {
611
+ write(`dz workflow validate: unparseable JSON — ${e instanceof Error ? e.message : String(e)}`);
612
+ return 1;
613
+ }
614
+ const parsed = parsePlan(raw);
615
+ if (isParseErrors(parsed)) {
616
+ if (flags.has('json'))
617
+ write(JSON.stringify({ ok: false, parseErrors: parsed }, null, 2));
618
+ else
619
+ for (const e of parsed)
620
+ write(`PARSE ${e.path}: ${e.message}`);
621
+ return 1;
622
+ }
623
+ const diags = validatePlan(parsed);
624
+ if (flags.has('json')) {
625
+ write(JSON.stringify({ ok: diags.length === 0, digest: planDigest(parsed), diagnostics: diags }, null, 2));
626
+ }
627
+ else {
628
+ for (const d of diags)
629
+ write(`${d.invariant} ${d.path}: ${d.message}`);
630
+ write(diags.length === 0 ? `dz workflow validate: OK (digest sha256:${planDigest(parsed).slice(0, 16)}…)` : `dz workflow validate: ${diags.length} invariant violation(s)`);
631
+ }
632
+ return diags.length === 0 ? 0 : 1;
633
+ }
634
+ if (sub === 'render') {
635
+ const planPath = options.get('_positional_1') ?? options.get('plan') ?? '';
636
+ const outPath = options.get('o') ?? options.get('out') ?? '';
637
+ if (planPath === '' || outPath === '') {
638
+ write('dz workflow render: usage — dz workflow render <plan.json> -o <script.js> [--check] [--force]');
639
+ return 1;
640
+ }
641
+ const absPlan = resolve(cwd, planPath);
642
+ if (!existsSync(absPlan)) {
643
+ write(`dz workflow render: no such plan file: ${absPlan}`);
644
+ return 1;
645
+ }
646
+ const parsed = parsePlan(JSON.parse(readFileSync(absPlan, 'utf8')));
647
+ if (isParseErrors(parsed)) {
648
+ for (const e of parsed)
649
+ write(`PARSE ${e.path}: ${e.message}`);
650
+ return 1;
651
+ }
652
+ const diags = validatePlan(parsed);
653
+ if (diags.length > 0) {
654
+ for (const d of diags)
655
+ write(`${d.invariant} ${d.path}: ${d.message}`);
656
+ write('dz workflow render: refusing to render an invalid plan');
657
+ return 1;
658
+ }
659
+ const rendered = renderPlan(parsed);
660
+ const absOut = resolve(cwd, outPath);
661
+ const sidecar = absOut.replace(/\.js$/, '') + '.plan.json';
662
+ const prev = existsSync(absOut) ? readFileSync(absOut, 'utf8') : '';
663
+ const merged = prev === '' ? { text: rendered.text, conflicts: [], refused: false } : mergeRender(prev, rendered, { force: flags.has('force') });
664
+ if (flags.has('check')) {
665
+ const same = prev === merged.text && !merged.refused;
666
+ write(same ? 'dz workflow render --check: up to date' : 'dz workflow render --check: DRIFT — a fresh render differs from the file on disk');
667
+ return same ? 0 : 1;
668
+ }
669
+ if (merged.refused) {
670
+ const proposed = absOut + '.proposed.js';
671
+ writeFileSync(proposed, merged.proposedText ?? rendered.text);
672
+ write(`dz workflow render: ${absOut} carries NO region markers (hand-written loop) — REFUSING to overwrite.`);
673
+ write(`Proposed render written to ${proposed}; re-run with --force to replace the target.`);
674
+ return 1;
675
+ }
676
+ // sidecar plan FIRST, independently of the script (FR-4.1 — the oracle diffs against it)
677
+ writeFileSync(sidecar, rendered.planJson);
678
+ writeFileSync(absOut, merged.text);
679
+ for (const c of merged.conflicts)
680
+ write(`CONFLICT step ${c.stepId}: ${c.reason}`);
681
+ write(`dz workflow render: wrote ${sidecar} then ${absOut} (exec-fp sha256:${rendered.execFingerprint.slice(0, 16)}…, blobs: ${rendered.manifest.blobs.map((b) => b.name).join(', ') || 'none'})`);
682
+ write('Gate it: dz workflow-lint ' + outPath + ' --plan ' + sidecar + ' --require-plan');
683
+ return merged.conflicts.length > 0 ? 1 : 0;
684
+ }
685
+ if (sub === 'blobs') {
686
+ let bad = 0;
687
+ for (const b of Object.values(LOOP_BLOBS)) {
688
+ const actual = createHash('sha256').update(b.code, 'utf8').digest('hex');
689
+ const ok = actual === b.contentHash;
690
+ if (!ok)
691
+ bad++;
692
+ write(`${b.name}@${b.version} sha256:${b.contentHash.slice(0, 16)}… ${ok ? 'OK' : 'HASH MISMATCH (loop-blobs.generated.ts was hand-edited — regenerate: node scripts/gen-loop-blobs.mjs)'} (requires: [${b.requires.join(', ')}])`);
693
+ }
694
+ if (flags.has('check')) {
695
+ write(bad === 0 ? 'dz workflow blobs --check: registry self-consistent (authoritative canon-vs-committed diff runs in CI via loop-blobs-regen.test.ts / scripts/gen-loop-blobs.mjs --check)' : `dz workflow blobs --check: ${bad} blob(s) inconsistent`);
696
+ return bad === 0 ? 0 : 1;
697
+ }
698
+ return 0;
699
+ }
700
+ // Everything else — the retired ADR-005 surface, BOTH spellings (bare positional task name and
701
+ // --task/--name) — prints the pinned shim message. AM-6: never silently reachable.
702
+ void legacyTask;
703
+ void flags;
704
+ write(WORKFLOW_TEMPLATES_RETIRED_MESSAGE);
705
+ return 1;
706
+ }
707
+ /** `dz workflow-lint` — layer-1 gate; exit 0/1/3 (pass/fail/inconclusive — INV-13: inconclusive is
708
+ * never a pass; the exit convention mirrors consult-gate). */
709
+ function cmdWorkflowLint(options, flags, cwd, write) {
710
+ const scriptPath = options.get('_positional_0') ?? '';
711
+ if (scriptPath === '') {
712
+ write('dz workflow-lint: usage — dz workflow-lint <script.js> [--plan <plan.json>] [--require-plan|--legacy] [--json]');
502
713
  return 1;
503
714
  }
504
- const wf = getWorkflow(taskName);
505
- if (wf === undefined) {
506
- write(`dz workflow: unknown task ${JSON.stringify(taskName)}`);
715
+ const absScript = resolve(cwd, scriptPath);
716
+ if (!existsSync(absScript)) {
717
+ write(`dz workflow-lint: no such script: ${absScript}`);
507
718
  return 1;
508
719
  }
509
- const projectRoot = resolve(cwd, options.get('project') ?? '.');
510
- const script = wf.generate({ projectRoot, dryRun: flags.has('dry-run') });
511
- write(`// Dynamic Workflow: ${wf.name}`);
512
- write(`// ${wf.description}`);
513
- write('');
514
- write(script);
515
- write('');
516
- write(`// To execute: paste the above into Claude Code with Opus 4.8+ and "ultracode" enabled.`);
517
- write(`// Or save to a .js file and run: claude "Execute this workflow" < workflow.js`);
518
- return 0;
720
+ const scriptText = readFileSync(absScript, 'utf8');
721
+ const planPath = options.get('plan');
722
+ let plan = null;
723
+ let digestValue = null;
724
+ if (planPath !== undefined) {
725
+ const absPlan = resolve(cwd, planPath);
726
+ if (!existsSync(absPlan)) {
727
+ write(`dz workflow-lint: no such plan: ${absPlan}`);
728
+ return 1;
729
+ }
730
+ const parsed = parsePlan(JSON.parse(readFileSync(absPlan, 'utf8')));
731
+ if (isParseErrors(parsed)) {
732
+ for (const e of parsed)
733
+ write(`PARSE ${e.path}: ${e.message}`);
734
+ return 1;
735
+ }
736
+ plan = parsed;
737
+ digestValue = planDigest(parsed);
738
+ }
739
+ const mode = flags.has('require-plan') ? 'require-plan' : flags.has('legacy') ? 'legacy' : 'default';
740
+ const run = lint(scriptText, { plan, planDigestValue: digestValue, blobRegistry: LOOP_BLOBS, mode });
741
+ if (flags.has('json')) {
742
+ write(JSON.stringify(run, null, 2));
743
+ }
744
+ else {
745
+ for (const f of run.findings) {
746
+ write(`${f.severity.toUpperCase().padEnd(12)} ${f.rule}: ${f.message}${f.anchor ? ` [anchor: ${f.anchor}]` : ''}`);
747
+ }
748
+ const counts = { fail: 0, warn: 0, inconclusive: 0 };
749
+ for (const f of run.findings)
750
+ if (f.severity in counts)
751
+ counts[f.severity]++;
752
+ write(`dz workflow-lint: ${run.verdict.toUpperCase()} (mode=${run.mode}; ${counts.fail} fail, ${counts.warn} warn, ${counts.inconclusive} inconclusive over ${Object.keys(run.rules).length} rules)`);
753
+ if (run.verdict === 'inconclusive')
754
+ write('inconclusive is NOT a pass (exit 3) — bind a plan (--plan … --require-plan) or acknowledge a legacy script with --legacy');
755
+ }
756
+ return lintExitCode(run);
757
+ }
758
+ /** `dz workflow-trace` — timeline + invariant runner over a run's trace.jsonl. Scope is CAPPED
759
+ * (AM-8): <runDir|--slug|--run>, --invariants, --html, --json. NO watch/filter/compare/search/
760
+ * retention/access-control — adding one needs an ADR amendment (the surface test pins this). */
761
+ function cmdWorkflowTrace(options, flags, cwd, write) {
762
+ const runDirArg = options.get('_positional_0');
763
+ const slug = options.get('slug');
764
+ const runId = options.get('run');
765
+ let runDir;
766
+ if (runDirArg !== undefined && runDirArg !== '')
767
+ runDir = resolve(cwd, runDirArg);
768
+ else if (slug !== undefined)
769
+ runDir = resolve(cwd, 'features', slug);
770
+ else if (runId !== undefined)
771
+ runDir = resolve(cwd, '.dz', 'loop-trace', runId);
772
+ else {
773
+ write('dz workflow-trace: usage — dz workflow-trace <runDir|--slug <s>|--run <id>> [--invariants <plan.json>] [--html <out.html>] [--json]');
774
+ return 1;
775
+ }
776
+ const traceFile = join(runDir, 'trace.jsonl');
777
+ if (!existsSync(traceFile)) {
778
+ write(`dz workflow-trace: no trace.jsonl under ${runDir} (the loop writes its own trace only when the plan sets trace.emit: true)`);
779
+ return 1;
780
+ }
781
+ const traceText = readFileSync(traceFile, 'utf8');
782
+ const ckptFile = join(runDir, '.fa-state', 'checkpoints.jsonl');
783
+ const ledgerFile = resolve(cwd, '.dz', 'feature-adr', 'run-cost-ledger.jsonl');
784
+ const journalFile = join(runDir, 'journal.jsonl');
785
+ const timeline = assembleTimeline({
786
+ trace: traceText,
787
+ checkpoints: existsSync(ckptFile) ? readFileSync(ckptFile, 'utf8') : null,
788
+ ledger: existsSync(ledgerFile) ? readFileSync(ledgerFile, 'utf8') : null,
789
+ journal: existsSync(journalFile) ? readFileSync(journalFile, 'utf8') : null,
790
+ });
791
+ let verdicts = [];
792
+ let projection = null;
793
+ const invariantsPlan = options.get('invariants');
794
+ if (invariantsPlan !== undefined) {
795
+ const absPlan = resolve(cwd, invariantsPlan);
796
+ if (!existsSync(absPlan)) {
797
+ write(`dz workflow-trace: no such plan: ${absPlan}`);
798
+ return 1;
799
+ }
800
+ const parsed = parsePlan(JSON.parse(readFileSync(absPlan, 'utf8')));
801
+ if (isParseErrors(parsed)) {
802
+ for (const e of parsed)
803
+ write(`PARSE ${e.path}: ${e.message}`);
804
+ return 1;
805
+ }
806
+ projection = toTraceProjection(parsed);
807
+ verdicts = runInvariants(projection, parseTrace(traceText));
808
+ }
809
+ const htmlOut = options.get('html');
810
+ if (htmlOut !== undefined) {
811
+ const absHtml = resolve(cwd, htmlOut);
812
+ writeFileSync(absHtml, renderTimelineHtml(timeline, projection, verdicts));
813
+ write(`dz workflow-trace: wrote ${absHtml} (self-contained: mermaid topology + HTML waterfall)`);
814
+ }
815
+ if (flags.has('json')) {
816
+ write(JSON.stringify({ timeline, verdicts }, null, 2));
817
+ return verdicts.some((v) => v.status === 'fail') ? 1 : 0;
818
+ }
819
+ write(`run ${timeline.runId ?? '(unknown)'}${timeline.incomplete ? ' — INCOMPLETE (no run.closed; unflushed tail may be lost)' : ''}; sources: ${timeline.sources.join(', ')}`);
820
+ for (const r of timeline.rows.filter((x) => x.kind === 'trace')) {
821
+ write(` ${String(r.seq).padStart(5)} ${r.label} ${r.detail}${r.wallTime ? ` [wall ${r.wallTime} — diagnostic only]` : ''}`);
822
+ }
823
+ for (const v of verdicts)
824
+ write(`INVARIANT ${v.status.toUpperCase().padEnd(12)} ${v.id}: ${v.message}`);
825
+ return verdicts.some((v) => v.status === 'fail') ? 1 : 0;
519
826
  }
520
827
  function cmdMigrate(options, cwd, write) {
521
828
  const projectRoot = resolve(cwd, options.get('project') ?? '.');
@@ -659,7 +966,7 @@ function cmdBundle(options, flags, cwd, write) {
659
966
  }
660
967
  return 0;
661
968
  }
662
- async function cmdInstall(options, flags, cwd, write) {
969
+ async function cmdInstall(options, flags, cwd, write, installRunner) {
663
970
  const pkg = options.get('_positional_0');
664
971
  if (!pkg) {
665
972
  write('dz install: package name required (e.g., dz install @dzhechkov/skills-devops)');
@@ -671,10 +978,14 @@ async function cmdInstall(options, flags, cwd, write) {
671
978
  return 1;
672
979
  }
673
980
  const projectRoot = resolve(cwd, options.get('project') ?? '.');
674
- // Step 1: npm install the package
981
+ // Step 1: npm install the package (installRunner is the CliIo test seam — unset in production)
675
982
  write(`Installing ${pkg}...`);
983
+ const installCmd = `npm install ${pkg} --save-dev --no-fund --no-audit`;
676
984
  try {
677
- execSync(`npm install ${pkg} --save-dev --no-fund --no-audit`, { cwd: projectRoot, stdio: 'pipe', encoding: 'utf-8' });
985
+ if (installRunner)
986
+ installRunner(installCmd, projectRoot);
987
+ else
988
+ execSync(installCmd, { cwd: projectRoot, stdio: 'pipe', encoding: 'utf-8' });
678
989
  }
679
990
  catch (err) {
680
991
  write(`dz install: npm install failed — ${err instanceof Error ? err.message : String(err)}`);
@@ -686,26 +997,36 @@ async function cmdInstall(options, flags, cwd, write) {
686
997
  write(`dz install: package not found at ${pkgDir}`);
687
998
  return 1;
688
999
  }
689
- // Discover skills look for dirs containing SKILL.md
690
- const entries = readdirSync(pkgDir, { withFileTypes: true })
691
- .filter((e) => e.isDirectory() && existsSync(join(pkgDir, e.name, 'SKILL.md')));
692
- if (entries.length === 0) {
693
- write(`dz install: no SKILL.md files found in ${pkg}. Package may not contain agentskills.io skills.`);
694
- return 0;
1000
+ // Resolve the package's skills root across the known layouts (flat / npx-template /
1001
+ // skills-dir) feature dz-install-npx-init. Total resolution failure is an ERROR
1002
+ // (exit 1), not the old advisory exit 0: a user asking to install skills that were
1003
+ // not installed must not see success.
1004
+ const roots = resolvePackageSkillRoots(pkgDir);
1005
+ if (roots.length === 0) {
1006
+ write(`dz install: no SKILL.md found in ${pkg}. Probed: ${PACKAGE_SKILL_LAYOUTS.map((l) => l.rel).join(', ')}.`);
1007
+ write(`If this package installs itself, try: npx -y ${pkg} init`);
1008
+ return 1;
695
1009
  }
696
- // Step 3: Use dz init with the package as skills source
1010
+ const root = roots[0];
1011
+ // Step 3: Use dz init with the resolved skills root as source
697
1012
  const report = await runInit({
698
1013
  target: targetOpt,
699
- skillsDir: pkgDir,
1014
+ skillsDir: root.dir,
700
1015
  projectRoot,
701
1016
  force: flags.has('force'),
702
1017
  });
703
1018
  const totalWritten = report.skills.reduce((sum, s) => sum + s.written.length, 0);
704
1019
  const totalSkipped = report.skills.reduce((sum, s) => sum + s.skipped.length, 0);
705
- write(`dz install ${pkg}: ${report.skills.length} skill(s), ${totalWritten} file(s) written, ${totalSkipped} skipped`);
1020
+ // Non-flat resolutions are tagged so a mis-resolution is legible in a user's paste;
1021
+ // flat output stays byte-identical to the pre-feature behavior (NFR-1 / test T5.6).
1022
+ const layoutTag = root.layout === 'flat' ? '' : ` [layout: ${root.layout}]`;
1023
+ write(`dz install ${pkg}: ${report.skills.length} skill(s), ${totalWritten} file(s) written, ${totalSkipped} skipped${layoutTag}`);
706
1024
  for (const skill of report.skills) {
707
1025
  write(` ${skill.id}: ${skill.written.length} written, ${skill.skipped.length} skipped`);
708
1026
  }
1027
+ if (root.layout === 'npx-template' && root.hasCompanionAssets) {
1028
+ write(` note: ${pkg} also ships commands/hooks/agents — \`npx -y ${pkg} init\` installs the full kit.`);
1029
+ }
709
1030
  return 0;
710
1031
  }
711
1032
  function cmdCompose(options, cwd, write) {
@@ -891,11 +1212,13 @@ function cmdStatuslineInstall(options, cwd, write) {
891
1212
  /**
892
1213
  * `dz statusline --fa-record` — the entry point the `/feature-adr` pipeline calls at Steps 0/8/9 to
893
1214
  * record its LIVE learning state (Pattern memory loop: pool / recalled / stored). Unlike the render
894
- * path this WRITES `.dz/feature-adr/learning-state.json` (via harness-core's best-effort
895
- * `writeFeatureAdrState`, which itself computes `pool` from the learned-pattern count).
1215
+ * path this WRITES a per-slug `.dz/feature-adr/learning-state/*.json` slot (via harness-core's
1216
+ * best-effort `writeFeatureAdrState`, which itself computes `pool` from the learned-pattern count).
896
1217
  *
897
1218
  * GUARD (feature-adr flag discipline): `--slug` and `--step` are required; a missing one exits 1
898
1219
  * with a copy-paste example. `--recalled`/`--stored` default to 0 and reject non-numeric input.
1220
+ * `--kind <feature-adr|loop>` identifies the producer, defaults to `feature-adr`, and rejects any
1221
+ * other value rather than silently weakening panel arbitration.
899
1222
  */
900
1223
  function cmdStatuslineFaRecord(options, cwd, write) {
901
1224
  const slug = (options.get('slug') ?? '').trim();
@@ -927,10 +1250,16 @@ function cmdStatuslineFaRecord(options, cwd, write) {
927
1250
  const reinforced = parseCount('reinforced');
928
1251
  if (reinforced === undefined)
929
1252
  return 1;
1253
+ const kindRaw = options.get('kind') ?? 'feature-adr';
1254
+ if (kindRaw !== 'feature-adr' && kindRaw !== 'loop') {
1255
+ write(`dz statusline --fa-record: --kind must be feature-adr or loop (got "${kindRaw}")`);
1256
+ write(' Example: dz statusline --fa-record --slug add-user-auth --step "Step 0" --kind feature-adr --recalled 5 --stored 2');
1257
+ return 1;
1258
+ }
930
1259
  const mode = options.get('mode');
931
1260
  const projectRoot = resolve(cwd, options.get('project') ?? '.');
932
1261
  const state = writeFeatureAdrState(projectRoot, {
933
- slug, step, recalled, stored,
1262
+ kind: kindRaw, slug, step, recalled, stored,
934
1263
  ...(reinforced > 0 ? { reinforced } : {}),
935
1264
  ...(mode !== undefined && mode.trim() !== '' ? { mode: mode.trim() } : {}),
936
1265
  });
@@ -973,7 +1302,13 @@ function cmdStatusline(options, flags, cwd, write, readStdin) {
973
1302
  // Live /feature-adr run in flight → PREPEND the pipeline learning segment to the base dz line.
974
1303
  const fa = data.featureAdr;
975
1304
  if (fa !== undefined) {
976
- line = `📐 feature-adr ${fa.step} · 🎓 ${fa.pool} pool · ↑${fa.recalled} used · +${fa.stored} new · ↻${fa.reinforced ?? 0} reinforced · ${line}`;
1305
+ // The producer marker reached data and arbitration in a previous round but not this label, so the bar asserted a pipeline that was not running.
1306
+ if (fa.kind === 'loop') {
1307
+ line = `🔁 loop ${fa.step} · ${line}`;
1308
+ }
1309
+ else {
1310
+ line = `📐 feature-adr ${fa.step} · 🎓 ${fa.pool} pool · ↑${fa.recalled} used · +${fa.stored} new · ↻${fa.reinforced ?? 0} reinforced · ${line}`;
1311
+ }
977
1312
  }
978
1313
  write(line);
979
1314
  return 0;
@@ -1367,6 +1702,23 @@ async function cmdTeach(options, flags, cwd, write) {
1367
1702
  }
1368
1703
  write(`Imported ${imported} pattern(s) from ${fromJson}`);
1369
1704
  write(` Skipped ${skipped} (duplicates already in the store, or invalid entries)`);
1705
+ // Bulk import preserves the DOMAIN of every record, so it can put medical lessons in
1706
+ // a shared store as silently as a hand-typed teach — and it returned before the
1707
+ // advice single-teach prints. The same advice, at the same point in the flow: after
1708
+ // the write, naming the choice, blocking nothing.
1709
+ let resolvedImportRoot = projectRoot;
1710
+ try {
1711
+ resolvedImportRoot = realpathSync(projectRoot);
1712
+ }
1713
+ catch { /* unresolvable is not the brain */ }
1714
+ const importedMedical = importedRecs.filter((r) => DEFAULT_HELD_OUT_DOMAINS.map(canonicalDomainKey).includes(canonicalDomainKey(r.domain)));
1715
+ if (importedMedical.length > 0) {
1716
+ const advice = renderSharedStoreAdvice(importedMedical[0]?.domain, resolvedImportRoot);
1717
+ if (advice !== '') {
1718
+ write(` ⚠ ${importedMedical.length} of the imported lesson(s) carry a medical domain.`);
1719
+ write(advice);
1720
+ }
1721
+ }
1370
1722
  write(` Backend: memory (@dzhechkov/memory) Total now: ${loadStorePatternsSync(projectRoot).length}`);
1371
1723
  // ONE batched mirror call through the same seam as single-teach (QR-6 — no bespoke path).
1372
1724
  await emitMirror(projectRoot, importedRecs, 'dz-teach-import');
@@ -1459,6 +1811,20 @@ async function cmdTeach(options, flags, cwd, write) {
1459
1811
  write(`Learned: "${pattern.slice(0, 60)}${pattern.length > 60 ? '...' : ''}"`);
1460
1812
  write(` Domain: ${domain} Reward: ${reward} Backend: memory (@dzhechkov/memory)`);
1461
1813
  write(` Total patterns: ${count}`);
1814
+ // ADVICE, not a gate. Someone putting medical lessons in a shared store owns both
1815
+ // directories and this binary; refusing would be defending a user against themselves,
1816
+ // which this design does not attempt. Making the choice INFORMED is the part that is
1817
+ // ours to do — the write has already happened when this prints.
1818
+ // RESOLVE before deciding. A symlink named `.health-brain` pointing at a shared project
1819
+ // silenced this advice in exactly the case it exists for.
1820
+ let resolvedRoot = projectRoot;
1821
+ try {
1822
+ resolvedRoot = realpathSync(projectRoot);
1823
+ }
1824
+ catch { /* a path we cannot resolve is not the brain */ }
1825
+ const sharedAdvice = renderSharedStoreAdvice(domain, resolvedRoot);
1826
+ if (sharedAdvice !== '')
1827
+ write(sharedAdvice);
1462
1828
  if (quarantineOn) {
1463
1829
  write(' ⚠ quarantined: excluded from auto-inject, damped in recall — promote by confirming it (dz teach --reinforce "<text>") or dz recall --promote <dzId> --apply');
1464
1830
  }
@@ -1626,13 +1992,42 @@ function fmtUsageRow(row) {
1626
1992
  return `${row.reads}×${last}${score} ${row.dzId}${domain} ${row.pattern.slice(0, 100)}`;
1627
1993
  }
1628
1994
  function cmdRecallUsage(options, flags, projectRoot, write) {
1629
- const report = readRecallUsageReport(projectRoot);
1995
+ const raw = readRecallUsageReport(projectRoot);
1996
+ // The hold-out applies to EVERY surface that hands out lesson TEXT, not only to the
1997
+ // one labelled "export". `--usage --json` prints whole lessons in its `top` and
1998
+ // `neverRead` lists, so it is an export by behaviour whatever it is called — a
1999
+ // self-audit before review found health-research lesson text sitting in both. A
2000
+ // guarantee that covers only the surface you were thinking about is not a guarantee.
2001
+ const heldOut = heldOutAfterOptIn(options.get('include-domain'));
2002
+ // EVERY list of records, not the two that were obvious. A first pass held out `top`
2003
+ // and `neverRead` and a health lesson still came out — in `all`, a third list further
2004
+ // down the same object. Enumerating the lists you remembered is how the surface leaks.
2005
+ const top = applyExportHoldout(raw.top, heldOut);
2006
+ const neverRead = applyExportHoldout(raw.neverRead, heldOut);
2007
+ // `unknown` is deliberately NOT held out: RecallUsageStat carries counters and
2008
+ // timestamps only — no domain, no lesson text — so there is nothing in it to withhold.
2009
+ // Filtering it would be theatre, and theatre in a privacy control is worse than a gap
2010
+ // because it looks like coverage.
2011
+ const every = applyExportHoldout(raw.all ?? [], heldOut);
2012
+ const withheld = top.withheld.length + neverRead.withheld.length + every.withheld.length;
2013
+ const report = {
2014
+ ...raw,
2015
+ top: top.exported,
2016
+ neverRead: neverRead.exported,
2017
+ ...(raw.all === undefined ? {} : { all: every.exported }),
2018
+ };
2019
+ const holdoutNote = renderHoldoutNote([top, neverRead, every]
2020
+ .reduce((a, b) => (b.withheld.length > a.withheld.length ? b : a)));
1630
2021
  if (flags.has('json')) {
1631
2022
  write(JSON.stringify({
1632
2023
  ...report,
1633
2024
  log: join(projectRoot, RECALL_USAGE_LOG_RELATIVE),
1634
2025
  retention: { maxBytes: RECALL_USAGE_LOG_MAX_BYTES },
2026
+ withheld,
2027
+ withheldDomains: [...new Set([...top.domains, ...neverRead.domains, ...every.domains])].sort(),
1635
2028
  }));
2029
+ if (withheld > 0 && holdoutNote !== '')
2030
+ process.stderr.write(`${holdoutNote}\n`);
1636
2031
  return 0;
1637
2032
  }
1638
2033
  const displayLimit = Math.max(1, parseInt(options.get('limit') ?? '20', 10) || 20);
@@ -1662,6 +2057,9 @@ function cmdRecallUsage(options, flags, projectRoot, write) {
1662
2057
  if (report.neverRead.length > displayLimit)
1663
2058
  write(` ... ${report.neverRead.length - displayLimit} more (use --json for the full list)`);
1664
2059
  }
2060
+ if (withheld > 0 && holdoutNote !== '') {
2061
+ write(holdoutNote);
2062
+ }
1665
2063
  return 0;
1666
2064
  }
1667
2065
  /**
@@ -1768,15 +2166,55 @@ async function cmdRecall(options, flags, cwd, write) {
1768
2166
  // --all: dump the entire learned store (backend-agnostic, via loadStorePatternsSync).
1769
2167
  // With --json this is the portable export the agentdb-memory MCP bridge consumes.
1770
2168
  if (all) {
1771
- const patterns = loadStorePatternsSync(projectRoot);
2169
+ // ISOLATION (health-advisor slice H, ADR-003). This command is THE portable sharing
2170
+ // form — it is documented as such and the agentdb bridge consumes it — so it is the
2171
+ // realistic path by which a learned store leaves this machine. `health-research`
2172
+ // carries lessons drawn from one person's investigations and is held back unless the
2173
+ // caller names it. This replaced a text-inspecting privacy guard that seven rounds of
2174
+ // review could not make correct: a tag set by the writer is decidable, prose is not.
2175
+ const allPatterns = loadStorePatternsSync(projectRoot);
2176
+ const holdout = applyExportHoldout(allPatterns, heldOutAfterOptIn(options.get('include-domain')));
2177
+ const patterns = holdout.exported;
2178
+ const holdoutNote = renderHoldoutNote(holdout);
2179
+ // The opt-in is honoured without argument — and named out loud. A flag that silently
2180
+ // includes medical lessons in a portable export is a flag whose consequence the user
2181
+ // has to remember; one that says what it just handed over is a flag they can act on.
2182
+ const optedIn = options.get('include-domain');
2183
+ if (optedIn !== undefined && optedIn.trim() !== '') {
2184
+ // Count against the SPLIT list, the same way heldOutAfterOptIn parses it. Comparing
2185
+ // each record to the whole unsplit string reported `0 of 2` for a comma-separated
2186
+ // opt-in that had just exported a medical lesson — a warning that undercounts the
2187
+ // thing it warns about is worse than none.
2188
+ const optedKeys = new Set(optedIn.split(',').map((d) => canonicalDomainKey(d)).filter((d) => d !== ''));
2189
+ const medicalKeys = new Set(DEFAULT_HELD_OUT_DOMAINS.map(canonicalDomainKey));
2190
+ const releasedKeys = [...optedKeys].filter((k) => medicalKeys.has(k));
2191
+ const releasedCount = allPatterns.filter((p) => releasedKeys.includes(canonicalDomainKey(p.domain))).length;
2192
+ if (releasedKeys.length > 0) {
2193
+ process.stderr.write(` ⚠ --include-domain ${releasedKeys.join(',')}: this export CONTAINS lessons from a medical `
2194
+ + `domain (${releasedCount} of ${allPatterns.length}). They travel with the file wherever it `
2195
+ + `goes. Nothing was blocked — this is your call.\n`);
2196
+ }
2197
+ }
1772
2198
  if (flags.has('stats')) {
1773
- const stats = storeStats(projectRoot);
2199
+ // `stats` is computed from the UNFILTERED store, and `topUses` carries whole
2200
+ // lesson text. Filtering `patterns` two lines above and serialising this untouched
2201
+ // exported exactly what had just been withheld — found by review immediately after
2202
+ // I had written the lesson "cover every surface", in the same function. The
2203
+ // per-domain histogram goes too: it names the domain and its size.
2204
+ const rawStats = storeStats(projectRoot);
2205
+ const topHoldout = applyExportHoldout(rawStats.topUses, heldOutAfterOptIn(options.get('include-domain')));
2206
+ const perDomain = Object.fromEntries(Object.entries(rawStats.perDomain).filter(([d]) => !holdout.domains.includes(canonicalDomainKey(d))));
2207
+ const stats = { ...rawStats, topUses: topHoldout.exported, perDomain };
1774
2208
  const backendStats = resolveLearningBackend(projectRoot).getStats();
1775
2209
  if (asJson) {
1776
- write(JSON.stringify({ patterns, stats, learning: backendStats }));
2210
+ write(JSON.stringify({ patterns, stats, learning: backendStats, withheld: holdout.withheld.length, withheldDomains: holdout.domains }));
2211
+ if (holdoutNote !== '')
2212
+ process.stderr.write(`${holdoutNote}\n`);
1777
2213
  return 0;
1778
2214
  }
1779
2215
  write(`dz recall --all --stats — ${patterns.length} learned pattern(s)`);
2216
+ if (holdoutNote !== '')
2217
+ write(holdoutNote);
1780
2218
  write(` backend: ${backendStats.backend}${backendStats.advisory !== undefined ? ` (${backendStats.advisory})` : ''}`);
1781
2219
  write(` domains: ${Object.entries(stats.perDomain).map(([k, v]) => `${k}=${v}`).join(', ') || 'none'}`);
1782
2220
  write(` exact-dup groups: ${stats.exactDupGroups}`);
@@ -1788,12 +2226,19 @@ async function cmdRecall(options, flags, cwd, write) {
1788
2226
  return 0;
1789
2227
  }
1790
2228
  if (asJson) {
2229
+ // The note goes to STDERR so the JSON on stdout stays machine-parsable — but it is
2230
+ // still SAID. A silent hold-out would let the reader believe they exported the whole
2231
+ // store, and would make a broken hold-out look exactly like an empty domain.
1791
2232
  write(JSON.stringify(patterns));
2233
+ if (holdoutNote !== '')
2234
+ process.stderr.write(`${holdoutNote}\n`);
1792
2235
  }
1793
2236
  else {
1794
2237
  write(`dz recall --all — ${patterns.length} learned pattern(s)`);
1795
2238
  for (const p of patterns)
1796
2239
  write(` [${p.reward.toFixed(2)}] (${p.domain}) ${p.pattern.slice(0, 80)}`);
2240
+ if (holdoutNote !== '')
2241
+ write(holdoutNote);
1797
2242
  }
1798
2243
  return 0;
1799
2244
  }
@@ -1878,6 +2323,15 @@ async function cmdRecall(options, flags, cwd, write) {
1878
2323
  // Portable contract UNCHANGED (I-7/AC-6): a plain PatternRecord[] — round-trips through
1879
2324
  // `dz teach --from-json` regardless of which backend ranked each hit.
1880
2325
  write(JSON.stringify(hits.map((h) => h.pattern)));
2326
+ // The honesty notes go to STDERR here rather than being skipped: the JSON branch
2327
+ // used to return before them, so a scripted caller was told nothing about a boost
2328
+ // that had promoted a match and pushed a visible hit past the --limit cut.
2329
+ if (boost !== null && shownDomain !== undefined) {
2330
+ process.stderr.write(`${renderDomainBoostNote(boost, shownDomain)}\n`);
2331
+ const cutNoteJson = renderDomainCutNote(displaced, limit);
2332
+ if (cutNoteJson !== '')
2333
+ process.stderr.write(`${cutNoteJson}\n`);
2334
+ }
1881
2335
  return 0;
1882
2336
  }
1883
2337
  if (hits.length === 0) {
@@ -2061,6 +2515,29 @@ async function cmdVector(options, flags, cwd, write) {
2061
2515
  write(' Patterns themselves stay portable via: dz recall --all --json (import with: dz teach --from-json)');
2062
2516
  return 0;
2063
2517
  }
2518
+ // The vector checkpoint carries EMBEDDINGS of the lesson text, and the RVF adapter
2519
+ // copies the whole store — there is no per-record filter to apply. So this path
2520
+ // fails CLOSED: if the store holds a held-out domain, the export is refused unless
2521
+ // the caller names that domain. An embedding is not plaintext, but ADR-003's
2522
+ // question is "does patient data leave this machine?", and a checkpoint that
2523
+ // silently carried it would answer yes while the documentation said no.
2524
+ // JUDGE THE FILE THIS COMMAND EXPORTS. The decision itself lives in harness-core as
2525
+ // a pure function WITH TESTS: the RVF engine is opt-in and absent on most machines,
2526
+ // so this branch could not be exercised in a normal checkout — reasoning about a
2527
+ // safety check I could not run is exactly what this project calls unverified.
2528
+ const optIn = options.get('include-domain');
2529
+ const vecHoldout = applyExportHoldout(loadStorePatternsSync(projectRoot).map((p) => ({ domain: p.domain })), heldOutAfterOptIn(optIn));
2530
+ const decision = decideVectorExport({
2531
+ rvfExists: existsSync(join(projectRoot, '.dz', 'memory', 'patterns.rvf')),
2532
+ heldOutLexicalCount: vecHoldout.withheld.length,
2533
+ heldOutDomains: vecHoldout.domains,
2534
+ optedIn: optIn,
2535
+ });
2536
+ if (!decision.allow) {
2537
+ write(`dz vector export: REFUSED — ${decision.reason}.`);
2538
+ write(` A forgotten lesson's embedding outlives its lexical record. To export anyway, naming what travels: dz vector export ${dest} --include-domain ${decision.optInHint}`);
2539
+ return 1;
2540
+ }
2064
2541
  const r = await exporter(resolve(cwd, dest));
2065
2542
  if (r.error !== undefined) {
2066
2543
  write(`dz vector export: ${r.error}`);
@@ -3013,7 +3490,7 @@ async function cmdAutoCanonicalize(options, cwd, write) {
3013
3490
  const skillName = parts[parts.length - 2] ?? parts[0] ?? 'unknown';
3014
3491
  write(` ${skillName.padEnd(30)} ${path}`);
3015
3492
  }
3016
- write(`\nTo canonicalize, run for each skill:`);
3493
+ write(`\nTo bring each skill into the canonical tree, run:`);
3017
3494
  const packDir = resolve(cwd, pack);
3018
3495
  for (const path of skillMds) {
3019
3496
  const parts = path.split('/');
@@ -3384,6 +3861,8 @@ function cmdPublish(options, flags, cwd, write) {
3384
3861
  write(`dz publish: ⚠ guard warn — ${v.rule}: ${v.detail}`);
3385
3862
  else
3386
3863
  write('dz publish: ✓ guard pre-flight passed');
3864
+ for (const n of guardResult.notes ?? [])
3865
+ write(`dz publish: ℹ guard note — ${n}`); // FN-7: on the record, never blocking
3387
3866
  }
3388
3867
  // ADR-001 (publish-provenance): decide BEFORE any work — flag validation, then a pre-flight that
3389
3868
  // refuses `--provenance` where no OIDC token can be minted. `off` is an escape hatch that names itself.
@@ -4584,7 +5063,13 @@ function cmdDriftCheck(options, flags, cwd, write) {
4584
5063
  return driftExit || sigFatal;
4585
5064
  }
4586
5065
  const DEFAULT_STORE_CAP = 5000;
4587
- /** Read the optional `.dz/guard.json` — `{ rules?: [...], storeCap?: number }`. Missing/broken ⇒ defaults. */
5066
+ /**
5067
+ * Ceiling on how many changed files the no-stubs scan reads per evaluation. Deterministic (the file
5068
+ * list is `git status` order) and fail-open: files beyond it simply have no gathered contents, so
5069
+ * the rule reports nothing for them — a pre-flight must never become a filesystem sweep.
5070
+ */
5071
+ const MAX_STUB_SCAN_FILES = 400;
5072
+ /** Read the optional `.dz/guard.json` — `{ rules?: [...], storeCap?: number, stubWaivers?: [...] }`. Missing/broken ⇒ defaults. */
4588
5073
  function loadGuardConfig(root) {
4589
5074
  const p = join(root, '.dz', 'guard.json');
4590
5075
  if (!existsSync(p))
@@ -4667,6 +5152,34 @@ function gatherGuardFacts(op, root, text, storeCap) {
4667
5152
  packages.push({ name: m.name ?? '(unnamed)', deps });
4668
5153
  }
4669
5154
  facts['packages'] = packages;
5155
+ // licence-hold (ADR-001, hermes-claude-adaptation): for each pack DECLARING a hold via a
5156
+ // `licenseHold` field, hand the raw evidence to the pure checker. Best-effort: an unreadable
5157
+ // LICENSE reads as absent (null), which the checker treats as a violation for a publishable
5158
+ // pack — the fail direction that protects the hold, never the publish.
5159
+ try {
5160
+ const holds = [];
5161
+ for (const { dir, m } of located) {
5162
+ if (!m || m.licenseHold === undefined || m.licenseHold === null)
5163
+ continue;
5164
+ const read = (rel) => {
5165
+ try {
5166
+ return readFileSync(join(root, dir, rel), 'utf8');
5167
+ }
5168
+ catch {
5169
+ return null;
5170
+ }
5171
+ };
5172
+ holds.push({
5173
+ name: m.name ?? dir,
5174
+ privateFlag: m.private === true,
5175
+ licenseText: read('LICENSE'),
5176
+ noticesText: read('THIRD_PARTY_NOTICES.md') ?? read('THIRD_PARTY_NOTICES'),
5177
+ licenseField: typeof m.license === 'string' ? m.license : null,
5178
+ });
5179
+ }
5180
+ facts['licenceHold'] = holds;
5181
+ }
5182
+ catch { /* unreadable tree — the rule reports nothing rather than inventing a violation */ }
4670
5183
  try {
4671
5184
  facts['drift'] = sweepSkillDrift(root, { scope: 'packages', allowlist: readDriftAllowlist(root) }).drifted.map((d) => d.name);
4672
5185
  }
@@ -4733,11 +5246,26 @@ function gatherGuardFacts(op, root, text, storeCap) {
4733
5246
  catch {
4734
5247
  facts['lockfile'] = { parsed: false }; /* no lockfile (not a pnpm workspace) — rule stays silent */
4735
5248
  }
4736
- // change: the working-tree diff, for PROMOTED (template) rules. Without this fact a rule written
4737
- // by `dz guard promote --apply` would be INERT present in the config and enforcing nothing.
4738
- // Contents are read only for the globs an active `format-match` rule actually asks about.
5249
+ // change: the working-tree diff the notion of "changed" shared by PROMOTED (template) rules
5250
+ // and the no-stubs rule. (readme-first gathers its OWN pathspec-scoped porcelain call above and
5251
+ // does not read this fact — so the -uall widening below does not alter readme-first at all;
5252
+ // its collapsed-untracked-dir blind spot for a brand-new package dir is a separate, documented
5253
+ // limit of THAT gatherer.) Without this fact a rule written by
5254
+ // `dz guard promote --apply` would be INERT — present in the config and enforcing nothing.
5255
+ // Contents are read for the globs an active `format-match` rule asks about PLUS the changed
5256
+ // files the no-stubs scan reads (its explicit extension allowlist, capped: a pathological
5257
+ // change-set must not turn a pre-flight into a filesystem sweep — beyond the cap the rule
5258
+ // simply sees no contents for the excess files, the standing fail-open contract).
4739
5259
  try {
4740
- const status = execSync('git status --porcelain', { cwd: root, encoding: 'utf-8' });
5260
+ // -uall (FN-1): without it, a brand-new DIRECTORY reports as one collapsed `?? newdir/` line
5261
+ // and every file INSIDE it is invisible to the change fact — and a fresh module directory is
5262
+ // the most stub-prone artifact there is (REPRODUCED: newdir/stub.ts with a live marker ⇒
5263
+ // PASS/0 findings). -uall lists the individual files; `.gitignore` semantics are unchanged
5264
+ // (git status never lists ignored paths, -uall or not — tested live). maxBuffer is raised
5265
+ // (default 1MB) because -uall can expand a huge untracked tree into a long listing; KNOWN
5266
+ // LIMIT: a listing beyond even this bound throws, the catch below drops the whole `change`
5267
+ // fact, and no-stubs + every template rule go silently fail-open together for that run.
5268
+ const status = execSync('git status --porcelain -uall', { cwd: root, encoding: 'utf-8', maxBuffer: 32 * 1024 * 1024 });
4741
5269
  const files = status
4742
5270
  .split('\n')
4743
5271
  .map((l) => l.slice(3).trim())
@@ -4746,34 +5274,58 @@ function gatherGuardFacts(op, root, text, storeCap) {
4746
5274
  const formatGlobs = (Array.isArray(loadGuardConfig(root).rules) ? loadGuardConfig(root).rules : [])
4747
5275
  .filter((r) => r?.template === 'format-match' && typeof r?.params?.file === 'string')
4748
5276
  .map((r) => r.params.file);
5277
+ const stubScannable = files.filter((f) => scannableStubPath(f));
5278
+ const stubWanted = new Set(stubScannable.slice(0, MAX_STUB_SCAN_FILES));
5279
+ // FN-7: fail-open must not be fail-SILENT. Count every stub-scannable changed file whose
5280
+ // contents we do NOT gather (beyond the cap here; deleted/non-regular/oversize/read-error
5281
+ // below) — the no-stubs rule surfaces the count as ONE aggregate note, never a violation.
5282
+ let stubSkipped = stubScannable.length - stubWanted.size;
4749
5283
  const contents = {};
4750
- if (formatGlobs.length > 0) {
5284
+ if (formatGlobs.length > 0 || stubWanted.size > 0) {
4751
5285
  for (const f of files) {
4752
- if (!formatGlobs.some((g) => globMatch(g, f)))
5286
+ const wanted = stubWanted.has(f);
5287
+ if (!wanted && !formatGlobs.some((g) => globMatch(g, f)))
4753
5288
  continue;
4754
5289
  const abs = resolve(root, f);
4755
5290
  // Containment: a `git status` path is repo-relative, but `..` in one must never let the
4756
5291
  // LIVE reader step outside the repo the HISTORICAL reader is confined to.
4757
- if (abs !== root && !abs.startsWith(root + sep))
5292
+ if (abs !== root && !abs.startsWith(root + sep)) {
5293
+ if (wanted)
5294
+ stubSkipped++;
4758
5295
  continue;
5296
+ }
4759
5297
  try {
4760
5298
  // lstat, NOT stat (Codex QE MED-3). `git show <sha>:<path>` yields the SYMLINK TARGET
4761
5299
  // TEXT, never the file it points at, so a live reader that follows links answers a
4762
5300
  // different question than the replay — and `/dev/zero` behind a symlink hangs the read.
4763
5301
  // Skipping non-regular files restores replay/live equivalence and closes the DoS.
4764
5302
  const st = lstatSync(abs);
4765
- if (!st.isFile())
5303
+ if (!st.isFile()) {
5304
+ if (wanted)
5305
+ stubSkipped++;
4766
5306
  continue;
4767
- if (st.size > MAX_CONTENT_BYTES)
4768
- continue; // too large to be a spec file — undecidable, never guessed
5307
+ }
5308
+ if (st.size > MAX_CONTENT_BYTES) {
5309
+ if (wanted)
5310
+ stubSkipped++;
5311
+ continue;
5312
+ } // too large to be a spec file — undecidable, never guessed
4769
5313
  contents[f] = readFileSync(abs, 'utf8');
4770
5314
  }
4771
- catch { /* deleted — leave it undecidable, never guess */ }
5315
+ catch {
5316
+ if (wanted)
5317
+ stubSkipped++; /* deleted — leave it undecidable, never guess */
5318
+ }
4772
5319
  }
4773
5320
  }
4774
- facts['change'] = { files, ...(Object.keys(contents).length > 0 ? { contents } : {}) };
5321
+ facts['change'] = { files, ...(Object.keys(contents).length > 0 ? { contents } : {}), ...(stubSkipped > 0 ? { stubSkipped } : {}) };
4775
5322
  }
4776
5323
  catch { /* not a git repo — every template rule stays silent (fail-open) */ }
5324
+ // no-stubs config waivers: `.dz/guard.json` `stubWaivers: [{path, reason}]` — path-keyed, reason
5325
+ // MANDATORY (the feature-adr-setup --guards shape; the pure checker refuses a reasonless entry).
5326
+ const stubWaivers = loadGuardConfig(root).stubWaivers;
5327
+ if (Array.isArray(stubWaivers))
5328
+ facts['stubWaivers'] = stubWaivers;
4777
5329
  }
4778
5330
  if (op === 'consolidate') {
4779
5331
  try {
@@ -5247,6 +5799,10 @@ function cmdGuard(options, flags, cwd, write) {
5247
5799
  const scaffold = {
5248
5800
  storeCap: DEFAULT_STORE_CAP,
5249
5801
  rules: DEFAULT_RULES.map((r) => ({ id: r.id, severity: r.severity, enabled: true, description: r.description })),
5802
+ // The scaffold QUOTES the stub marker names in the no-stubs rule description, and this file is
5803
+ // itself a scannable changed file the moment it is written — so it carries its own reasoned
5804
+ // waiver (explicit and justified, never a silent path skip).
5805
+ stubWaivers: [{ path: '.dz/guard.json', reason: 'the guard config quotes the stub marker names in the no-stubs rule description' }],
5250
5806
  };
5251
5807
  mkdirSync(dirname(p), { recursive: true });
5252
5808
  writeFileSync(p, JSON.stringify(scaffold, null, 2) + '\n');
@@ -5304,6 +5860,8 @@ function cmdGuard(options, flags, cwd, write) {
5304
5860
  write(`dz guard (${op}): ${glyph} ${result.verdict.toUpperCase()} [checked: ${result.checked.join(', ') || 'no rules for this op'}]`);
5305
5861
  for (const v of result.violations)
5306
5862
  write(` [${v.severity === 'hard' ? 'BLOCK' : 'warn'}] ${v.rule}: ${v.detail}`);
5863
+ for (const n of result.notes ?? [])
5864
+ write(` [note] ${n}`); // information, never a verdict input (FN-7)
5307
5865
  if (result.verdict === 'block' && forced)
5308
5866
  write(` → forced through: ${force} (logged to .dz/guard-audit.jsonl)`);
5309
5867
  else if (result.verdict === 'block')
@@ -5980,6 +6538,379 @@ function cmdDiscriminationCheck(options, flags, cwd, write) {
5980
6538
  function nameFor(t, outcome) {
5981
6539
  return t.name !== undefined ? { file: t.file, name: t.name, outcome } : { file: t.file, outcome };
5982
6540
  }
6541
+ /**
6542
+ * `dz mutation-gate` — the mutation gate (feature ha-mutation-gate, SPEC at
6543
+ * features/ha-mutation-gate/SPEC.md). A green test proves the code works; it does NOT prove the
6544
+ * test would notice the protection being DELETED. For each entry in a declarative registry this
6545
+ * gate applies the entry's exact {find, replace} mutation to a SCRATCH COPY of the package, runs
6546
+ * the package's test command there, and REQUIRES a non-zero exit (red). All logic is in
6547
+ * harness-core's pure engine (mutation-gate.ts); this is the I/O executor.
6548
+ *
6549
+ * --package <dir> the package to gate (default: cwd; must contain package.json)
6550
+ * --registry <file> registry path (default: <pkg>/test/mutation-registry.json, then
6551
+ * <pkg>/mutation-registry.json)
6552
+ * --test-cmd '<cmd>' suite command run in the scratch copy (default: registry.testCommand,
6553
+ * then `npm test`)
6554
+ * --only <id[,id]> run a subset of entries (unknown id = usage error, never a silent skip)
6555
+ * --timeout <ms> per-suite-run timeout (default 300000). A timed-out run is INCONCLUSIVE —
6556
+ * a FAILURE, never a pass.
6557
+ * --rebaseline <m> route-b guard mode: 'per-entry' (default — every red entry re-runs the
6558
+ * suite on the restored tree; not green ⇒ that entry is INCONCLUSIVE) or
6559
+ * 'final' (one re-run at the end; not green ⇒ every red-based verdict is
6560
+ * downgraded). The gate's PROVEN now means the redness was ATTRIBUTABLE.
6561
+ * --keep-scratch keep the scratch copy for inspection (default: removed in a finally)
6562
+ * --json machine contract {packageDir, registryPath, testCommand, rebaselineMode,
6563
+ * baseline, results, summary, warnings, exitCode}
6564
+ *
6565
+ * The four rules (SPEC §"Four rules") and where each is enforced:
6566
+ * 1. does-not-apply = FAILURE → core classifyMutationOutcome (occurrences !== 1 ⇒ NOT_APPLIED);
6567
+ * 2. green suite = FAILURE → core (exit 0 ⇒ UNDEFENDED, names the property);
6568
+ * 3. never mutate the working tree → HERE: every write targets the scratch copy under tmpdir();
6569
+ * the repo tree is opened read-only, and a crashed run leaves at worst a stale tmp dir;
6570
+ * 4. the gate's own discrimination proof → harness-cli/test/fixtures/mutation-gate-undefended
6571
+ * (the gate MUST fail on it; asserted by test/mutation-gate-cli.test.ts).
6572
+ *
6573
+ * Exit codes: 0 every entry PROVEN · 1 the gate ran and failed (undefended / not-applied /
6574
+ * below-min / unparseable / load-fatal / over-failing / inconclusive entry) · 2 usage or setup
6575
+ * error (missing registry, red BASELINE — a red unmutated copy proves nothing and must not be
6576
+ * read as a mutation result — or an entry whose file RESOLVES outside the scratch copy: a
6577
+ * symlink escape is refused before anything is written, SPEC rule 3).
6578
+ */
6579
+ /**
6580
+ * Route-a guard for `dz mutation-gate`: parse-check a MUTATED file as its own language BEFORE the
6581
+ * suite runs. A registry mutation must delete the protection while keeping the file loadable — a
6582
+ * file that no longer parses kills the whole suite (or its import chain), and that STRUCTURAL
6583
+ * redness says nothing about the named protection. Returns `{error}` when a parser ran and the
6584
+ * text does not parse; `{skipped}` (reported loudly, never silently) when no parser is available.
6585
+ */
6586
+ function parseCheckMutatedFile(absFile, text) {
6587
+ const ext = extname(absFile).toLowerCase();
6588
+ try {
6589
+ if (ext === '.ts' || ext === '.tsx' || ext === '.mts' || ext === '.cts') {
6590
+ let ts = null;
6591
+ for (const from of [absFile, import.meta.url]) {
6592
+ try {
6593
+ ts = createRequire(from)('typescript');
6594
+ break;
6595
+ }
6596
+ catch { /* try the next resolution root */ }
6597
+ }
6598
+ if (ts === null)
6599
+ return { skipped: 'no TypeScript parser resolvable (typescript installed neither near the package nor near the CLI)' };
6600
+ const out = ts.transpileModule(text, { reportDiagnostics: true, compilerOptions: { target: ts.ScriptTarget.Latest } });
6601
+ const first = (out.diagnostics ?? []).find((d) => d.category === ts.DiagnosticCategory.Error);
6602
+ if (first === undefined)
6603
+ return {};
6604
+ return { error: `TS${first.code}: ${ts.flattenDiagnosticMessageText(first.messageText, ' ')}` };
6605
+ }
6606
+ if (ext === '.json') {
6607
+ try {
6608
+ JSON.parse(text);
6609
+ return {};
6610
+ }
6611
+ catch (e) {
6612
+ return { error: String(e.message).slice(0, 200) };
6613
+ }
6614
+ }
6615
+ if (ext === '.js' || ext === '.cjs' || ext === '.mjs' || ext === '') {
6616
+ try {
6617
+ // `node --check` on the file IN PLACE, so the nearest package.json decides the module goal.
6618
+ execFileSync(process.execPath, ['--check', absFile], { stdio: 'pipe' });
6619
+ return {};
6620
+ }
6621
+ catch (e) {
6622
+ const err = e;
6623
+ const stderrLines = String(err.stderr ?? '').split('\n').map((l) => l.trim()).filter((l) => l !== '');
6624
+ // prefer the actual `SyntaxError: …` line over node's trailing version footer.
6625
+ const msg = [...stderrLines].reverse().find((l) => l.includes('Error')) ?? stderrLines.at(-1) ?? 'node --check failed';
6626
+ return { error: msg.slice(0, 200) };
6627
+ }
6628
+ }
6629
+ return { skipped: `no parser for '${ext}' files — parse-check unavailable` };
6630
+ }
6631
+ catch (e) {
6632
+ return { skipped: `parse-check errored: ${String(e.message).slice(0, 120)}` };
6633
+ }
6634
+ }
6635
+ function cmdMutationGate(options, flags, cwd, write) {
6636
+ const json = flags.has('json');
6637
+ const fail = (what) => {
6638
+ write(json ? JSON.stringify({ error: what, exitCode: 2 }) : `dz mutation-gate: ${what}`);
6639
+ return 2;
6640
+ };
6641
+ const pkgDir = resolve(cwd, options.get('package') ?? '.');
6642
+ if (!existsSync(join(pkgDir, 'package.json'))) {
6643
+ return fail(`no package.json at ${pkgDir} — pass --package <dir>`);
6644
+ }
6645
+ const registryOpt = options.get('registry');
6646
+ const registryPath = registryOpt !== undefined
6647
+ ? resolve(cwd, registryOpt)
6648
+ : [join(pkgDir, 'test', 'mutation-registry.json'), join(pkgDir, 'mutation-registry.json')].find((p) => existsSync(p));
6649
+ if (registryPath === undefined || !existsSync(registryPath)) {
6650
+ return fail(`no mutation registry found (looked for test/mutation-registry.json and mutation-registry.json under ${pkgDir}) — pass --registry <file>`);
6651
+ }
6652
+ const parsed = parseMutationRegistry(readFileSync(registryPath, 'utf-8'));
6653
+ if (parsed.registry === null) {
6654
+ return fail(`registry ${registryPath} is invalid:\n - ${parsed.errors.join('\n - ')}`);
6655
+ }
6656
+ let entries = parsed.registry.entries;
6657
+ const only = options.get('only');
6658
+ if (only !== undefined) {
6659
+ const ids = only.split(',').map((s) => s.trim()).filter(Boolean);
6660
+ const known = new Set(entries.map((e) => e.id));
6661
+ const unknown = ids.filter((id) => !known.has(id));
6662
+ if (unknown.length > 0)
6663
+ return fail(`--only names unknown entry id(s): ${unknown.join(', ')}`);
6664
+ entries = entries.filter((e) => ids.includes(e.id));
6665
+ }
6666
+ const testCmdRaw = options.get('test-cmd') ?? parsed.registry.testCommand ?? 'npm test';
6667
+ if (/[\0\n\r]/.test(testCmdRaw))
6668
+ return fail('--test-cmd may not contain NUL or newline characters');
6669
+ const testCmd = testCmdRaw;
6670
+ const timeoutOpt = Number(options.get('timeout') ?? '300000');
6671
+ const timeout = Number.isFinite(timeoutOpt) && timeoutOpt > 0 ? timeoutOpt : 300000;
6672
+ // Route-b guard mode: `per-entry` (default, strongest — each red entry re-baselines the restored
6673
+ // tree, so a flaky neighbour flips THAT entry to INCONCLUSIVE) or `final` (cheap — one re-run at
6674
+ // the end; if it is not green, every red-based verdict of the run is downgraded, because any of
6675
+ // them may have been the flake). MEASURED on the 18-entry health-advisor registry (~15s/suite
6676
+ // run): per-entry ≈ 37 runs, final ≈ 20 runs vs 19 pre-fix. An unknown mode is a usage error.
6677
+ const rebaselineMode = options.get('rebaseline') ?? 'per-entry';
6678
+ if (rebaselineMode !== 'per-entry' && rebaselineMode !== 'final') {
6679
+ return fail(`--rebaseline must be 'per-entry' or 'final', got '${rebaselineMode}'`);
6680
+ }
6681
+ // Rule 3 — NEVER mutate the working tree: the package is copied into a scratch dir we own and
6682
+ // mutated THERE. The copy must actually be RUNNABLE (SPEC rule 3's note), which took three
6683
+ // measured layers on the seed package:
6684
+ // • the package's own node_modules is symlinked back (absolute), so deps + .bin resolve;
6685
+ // • the copy lives inside a SHADOW of the package's repo — every ancestor level mirrors the
6686
+ // real one with SYMLINKED siblings (root node_modules for hoisted deps, sibling packages
6687
+ // for repo-relative test paths like `../../harness-core/dist`); only the package under test
6688
+ // is a real, mutable copy (MEASURED: without this, 30 health-advisor tests failed at
6689
+ // baseline on ERR_MODULE_NOT_FOUND / a missing sibling dist);
6690
+ // • the copy is `git init`-ed and committed, because hygiene tests take `git status` before
6691
+ // and after the run — they compare before WITH after, so a pre-mutation commit keeps them
6692
+ // discriminating (MEASURED: without it, 2 tests failed at baseline on "not a git repository").
6693
+ const scratchParent = mkdtempSync(join(tmpdir(), 'dz-mutgate-'));
6694
+ let gitTop = null;
6695
+ try {
6696
+ gitTop = execSync('git rev-parse --show-toplevel', { cwd: pkgDir, stdio: 'pipe', encoding: 'utf-8' }).trim() || null;
6697
+ }
6698
+ catch { /* not in a git repo */ }
6699
+ let copyDir = join(scratchParent, 'pkg');
6700
+ const results = [];
6701
+ const observations = [];
6702
+ const warnings = [];
6703
+ let baseline;
6704
+ try {
6705
+ if (gitTop !== null && gitTop !== pkgDir && resolve(pkgDir).startsWith(resolve(gitTop) + sep)) {
6706
+ // shadow tree: mirror <gitTop>/…/<pkg> under scratch, symlinking every sibling entry.
6707
+ let realCursor = gitTop;
6708
+ let shadowCursor = join(scratchParent, 'root');
6709
+ mkdirSync(shadowCursor, { recursive: true });
6710
+ const segs = relative(gitTop, pkgDir).split(sep);
6711
+ segs.forEach((seg, i) => {
6712
+ for (const entry of readdirSync(realCursor)) {
6713
+ if (entry === seg || entry === '.git')
6714
+ continue;
6715
+ try {
6716
+ symlinkSync(join(realCursor, entry), join(shadowCursor, entry));
6717
+ }
6718
+ catch { /* best effort */ }
6719
+ }
6720
+ realCursor = join(realCursor, seg);
6721
+ shadowCursor = join(shadowCursor, seg);
6722
+ if (i < segs.length - 1)
6723
+ mkdirSync(shadowCursor, { recursive: true });
6724
+ });
6725
+ copyDir = shadowCursor;
6726
+ }
6727
+ cpSync(pkgDir, copyDir, {
6728
+ recursive: true,
6729
+ filter: (src) => {
6730
+ const rel = relative(pkgDir, src);
6731
+ return rel === '' || !rel.split(sep).some((seg) => seg === 'node_modules' || seg === '.git');
6732
+ },
6733
+ });
6734
+ const srcNm = join(pkgDir, 'node_modules');
6735
+ if (existsSync(srcNm) && !existsSync(join(copyDir, 'node_modules'))) {
6736
+ symlinkSync(srcNm, join(copyDir, 'node_modules'), 'dir');
6737
+ }
6738
+ try {
6739
+ execSync('git init -q && git add -A -f . && git -c user.email=mutation-gate@dz -c user.name=mutation-gate -c commit.gpgsign=false commit -qm scratch-baseline', { cwd: copyDir, stdio: 'pipe' });
6740
+ }
6741
+ catch { /* no git available → a suite that needs it fails the BASELINE loudly, never silently */ }
6742
+ // F-2 — rule-3 containment root: the scratch copy AS THE FILESYSTEM sees it. Every mutation
6743
+ // write below is asserted to RESOLVE inside this root before it happens.
6744
+ const realScratchRoot = realpathSync(copyDir);
6745
+ const runSuite = () => {
6746
+ try {
6747
+ const out = execSync(testCmd, { cwd: copyDir, stdio: 'pipe', encoding: 'utf-8', timeout, maxBuffer: 64 * 1024 * 1024, env: { ...process.env, FORCE_COLOR: '0' } });
6748
+ return { exitCode: 0, output: out };
6749
+ }
6750
+ catch (e) {
6751
+ const err = e;
6752
+ return {
6753
+ exitCode: typeof err.status === 'number' ? err.status : null,
6754
+ output: `${String(err.stdout ?? '')}\n${String(err.stderr ?? '')}`,
6755
+ };
6756
+ }
6757
+ };
6758
+ // Baseline BEFORE any mutation: a red copy proves nothing, and reading it as a mutation
6759
+ // result would be this gate shipping the defect class it exists to catch.
6760
+ if (!json)
6761
+ write(`mutation-gate: baseline suite in scratch copy of ${pkgDir} …`);
6762
+ const base = runSuite();
6763
+ baseline = classifyBaseline(base.exitCode);
6764
+ if (!baseline.ok) {
6765
+ if (json) {
6766
+ write(JSON.stringify({ packageDir: pkgDir, registryPath, testCommand: testCmd, baseline, results: [], exitCode: 2 }, null, 2));
6767
+ return 2;
6768
+ }
6769
+ write(renderMutationReport([], baseline, pkgDir));
6770
+ return 2;
6771
+ }
6772
+ for (const entry of entries) {
6773
+ const filePath = join(copyDir, entry.file);
6774
+ let sourceText = null;
6775
+ try {
6776
+ sourceText = readFileSync(filePath, 'utf-8');
6777
+ }
6778
+ catch { /* missing file ⇒ occurrences 0 ⇒ NOT_APPLIED */ }
6779
+ if (sourceText === null) {
6780
+ const obs = { entry, occurrences: 0, exitCode: null, failingCount: null };
6781
+ observations.push(obs);
6782
+ results.push(classifyMutationOutcome(obs));
6783
+ continue;
6784
+ }
6785
+ const applied = applyMutationToText(sourceText, entry.mutation.find, entry.mutation.replace);
6786
+ if (!applied.ok || applied.text === undefined) {
6787
+ const obs = { entry, occurrences: applied.occurrences, exitCode: null, failingCount: null };
6788
+ observations.push(obs);
6789
+ results.push(classifyMutationOutcome(obs));
6790
+ continue;
6791
+ }
6792
+ // F-2 — rule-3 containment (SPEC "Never mutate the working tree"): `join(copyDir, file)` is
6793
+ // LEXICAL; a symlink cpSync preserved inside the package (or the intentionally symlinked
6794
+ // node_modules) makes it RESOLVE outside the scratch tree, and the "scratch" write would
6795
+ // follow the link and mutate the REAL working tree for the whole suite run — restored only
6796
+ // by the finally, so a SIGKILL mid-run leaves the real tree permanently mutated (MEASURED
6797
+ // pre-fix: a registry file behind a package-local symlink; the suite-run witness read the
6798
+ // mutated text from the REAL file). Same primitive as health-advisor lock.js's
6799
+ // realCaseDir/assertLockRootIsItself: decide on realpaths, refuse an escape — exit 2, a
6800
+ // registry/setup error, never a mutation.
6801
+ let realTarget = null;
6802
+ try {
6803
+ realTarget = realpathSync(filePath);
6804
+ }
6805
+ catch { /* vanished between read and here → refuse below */ }
6806
+ if (realTarget === null || (realTarget !== realScratchRoot && !realTarget.startsWith(realScratchRoot + sep))) {
6807
+ return fail(`entry '${entry.id}': ${entry.file} resolves to ${realTarget ?? '<unresolvable>'} — OUTSIDE the scratch copy (${realScratchRoot}). A path component is a symlink escaping the scratch tree, so writing the mutation would mutate the REAL working tree (SPEC rule 3). Refused; nothing was written.`);
6808
+ }
6809
+ if (!json)
6810
+ write(`mutation-gate: ${entry.id} — mutating ${entry.file}, running suite …`);
6811
+ let run = null;
6812
+ let parseError;
6813
+ try {
6814
+ writeFileSync(filePath, applied.text);
6815
+ // Route-a guard: the mutated file must still PARSE — a load failure reddens the whole
6816
+ // suite for structural, not behavioural, reasons, and must never read as discrimination.
6817
+ const check = parseCheckMutatedFile(filePath, applied.text);
6818
+ if (check.skipped !== undefined) {
6819
+ warnings.push(`${entry.id}: parse-check SKIPPED — ${check.skipped}`);
6820
+ if (!json)
6821
+ write(`mutation-gate: WARNING ${entry.id}: parse-check skipped — ${check.skipped}`);
6822
+ }
6823
+ if (check.error !== undefined) {
6824
+ parseError = check.error; // no suite run: the verdict is MUTATION_UNPARSEABLE regardless
6825
+ }
6826
+ else {
6827
+ run = runSuite();
6828
+ }
6829
+ }
6830
+ finally {
6831
+ writeFileSync(filePath, sourceText); // restore the COPY so the next entry starts pristine
6832
+ }
6833
+ // Route-a′ guard (round-6 rework): the file-load-vs-assertion signal is derived from THE
6834
+ // SAME RUN that produced the failing count — no isolated child, no environment mismatch,
6835
+ // nothing to disagree with itself (the round-5 isolated `import()` had three measured
6836
+ // false-PASS routes, all artifacts of the isolation environment differing from the runner).
6837
+ // 'file-load' ⇒ MUTATION_LOAD_FATAL (structural); 'unrecognised' ⇒ INCONCLUSIVE (a
6838
+ // runner-coverage gap of this tool, loud, never PROVEN); 'assertions' ⇒ behavioural, the
6839
+ // count-based verdicts apply.
6840
+ let fileLoadFailure;
6841
+ let outputUnrecognised;
6842
+ if (run !== null && run.exitCode !== null && run.exitCode !== 0) {
6843
+ const cls = classifyRunFailure(run.output);
6844
+ if (cls.kind === 'file-load') {
6845
+ fileLoadFailure = cls.evidence ?? 'test file failed to load (no evidence line)';
6846
+ }
6847
+ else if (cls.kind === 'unrecognised') {
6848
+ outputUnrecognised = cls.evidence ?? `no classifier for runner '${cls.runner}'`;
6849
+ }
6850
+ }
6851
+ // Route-b guard (per-entry mode): a red mutated run is attributable only if the RESTORED
6852
+ // tree reproduces green — otherwise a flaky neighbour may be what went red. Skipped when the
6853
+ // classification already failed the entry structurally (file-load / unrecognised): those
6854
+ // verdicts outrank the rebaseline check, so the extra suite run would buy nothing.
6855
+ let rebaselineExitCode;
6856
+ if (rebaselineMode === 'per-entry' && run !== null && run.exitCode !== null && run.exitCode !== 0
6857
+ && fileLoadFailure === undefined && outputUnrecognised === undefined) {
6858
+ if (!json)
6859
+ write(`mutation-gate: ${entry.id} — re-baselining the restored tree …`);
6860
+ rebaselineExitCode = runSuite().exitCode;
6861
+ }
6862
+ const obs = {
6863
+ entry,
6864
+ occurrences: 1,
6865
+ exitCode: run === null ? null : run.exitCode,
6866
+ failingCount: run === null ? null : countFailingTests(run.output),
6867
+ ...(parseError !== undefined ? { parseError } : {}),
6868
+ ...(fileLoadFailure !== undefined ? { fileLoadFailure } : {}),
6869
+ ...(outputUnrecognised !== undefined ? { outputUnrecognised } : {}),
6870
+ ...(rebaselineExitCode !== undefined ? { rebaselineExitCode } : {}),
6871
+ };
6872
+ observations.push(obs);
6873
+ results.push(classifyMutationOutcome(obs));
6874
+ }
6875
+ // Route-b guard (final mode): one re-run after all entries. Not green ⇒ EVERY red-based
6876
+ // verdict of this run is downgraded (any of them may have been the flake, and there is no
6877
+ // per-entry evidence to say which) — re-classifying with the final exit turns them
6878
+ // INCONCLUSIVE while leaving NOT_APPLIED / UNDEFENDED / MUTATION_UNPARSEABLE /
6879
+ // MUTATION_LOAD_FATAL untouched.
6880
+ if (rebaselineMode === 'final') {
6881
+ if (!json)
6882
+ write('mutation-gate: final re-baseline of the restored tree …');
6883
+ const finalExit = runSuite().exitCode;
6884
+ if (finalExit !== 0) {
6885
+ const what = finalExit === null ? 'no exit code' : `exit ${finalExit}`;
6886
+ warnings.push(`final re-baseline NOT green (${what}) — the suite is flaky; red-based verdicts downgraded to INCONCLUSIVE`);
6887
+ if (!json)
6888
+ write(`mutation-gate: final re-baseline NOT green (${what}) — red-based verdicts downgraded to INCONCLUSIVE`);
6889
+ const reclassified = observations.map((obs) => classifyMutationOutcome({ ...obs, rebaselineExitCode: finalExit }));
6890
+ results.length = 0;
6891
+ results.push(...reclassified);
6892
+ }
6893
+ }
6894
+ }
6895
+ finally {
6896
+ if (flags.has('keep-scratch')) {
6897
+ write(`mutation-gate: scratch copy kept at ${copyDir}`);
6898
+ }
6899
+ else {
6900
+ try {
6901
+ rmSync(scratchParent, { recursive: true, force: true });
6902
+ }
6903
+ catch { /* best effort */ }
6904
+ }
6905
+ }
6906
+ const exitCode = mutationGateExitCode(results, baseline.ok);
6907
+ if (json) {
6908
+ write(JSON.stringify({ packageDir: pkgDir, registryPath, testCommand: testCmd, rebaselineMode, baseline, results, summary: summarizeMutationResults(results), warnings, exitCode }, null, 2));
6909
+ return exitCode;
6910
+ }
6911
+ write(renderMutationReport(results, baseline, pkgDir));
6912
+ return exitCode;
6913
+ }
5983
6914
  /**
5984
6915
  * `dz delivery-check` — the portable Step-10 Delivery Gate (feature portable-gates). The `manual` form that
5985
6916
  * travels to every `shell` target: the deterministic parts (artifact probes, hand-off arithmetic,
@@ -7097,6 +8028,23 @@ async function cmdBacklog(options, flags, cwd, write) {
7097
8028
  if (eff.adjusted && !json && eff.note !== undefined)
7098
8029
  write(`dz backlog: ${eff.note}`);
7099
8030
  const dryRun = flags.has('dry-run');
8031
+ // Embed-form migration (register-inflation fix): v1 vectors are FULL-TEXT embeds, v2 queries are
8032
+ // bounded excerpts — comparing across the forms is a query-vs-row space split. Re-mirror once
8033
+ // (idempotent upsert), before the dedup search. Dry-run writes nothing, so it only WARNS.
8034
+ if (dryRun) {
8035
+ if (readBacklogEmbedFormVersion(projectRoot) < DEDUP_EMBED_FORM_VERSION && readIdeas(projectRoot).length > 0 && !json) {
8036
+ write(`dz backlog: ⚠ idea vectors are in the old (full-text) embed form — dedup may be unreliable until a non-dry add or \`dz backlog harmonize\` migrates them`);
8037
+ }
8038
+ }
8039
+ else {
8040
+ const form = await ensureBacklogEmbedForm(projectRoot);
8041
+ if (form.action === 'migrated' && !json) {
8042
+ write(`dz backlog: re-embedded ${form.remirrored} idea vector(s) into the bounded dedup embed form (v${form.version})`);
8043
+ }
8044
+ else if (form.action === 'deferred' && !json) {
8045
+ write(`dz backlog: ⚠ embed-form migration deferred (${form.error ?? 'unknown error'}) — semantic dedup may compare against stale full-text vectors`);
8046
+ }
8047
+ }
7100
8048
  const verdict = await dedupIdea(projectRoot, text, cfg);
7101
8049
  // The TOP-MATCH pair (id @ cosine) is the ADR-002 calibration surface (idea ce914ac2) — observational
7102
8050
  // only: the band itself is unchanged, but a RELATED verdict now shows WHICH idea produced the cosine.
@@ -7109,19 +8057,38 @@ async function cmdBacklog(options, flags, cwd, write) {
7109
8057
  // DUPLICATE ⇒ snapshot + reinforce the existing root; NO new record (ADR-002 T-002b).
7110
8058
  const ideas = readIdeas(projectRoot);
7111
8059
  const match = ideas.find((i) => i.id === verdict.matchedId);
8060
+ let absorbErr;
7112
8061
  if (!dryRun && match !== undefined) {
7113
8062
  const snap = snapshotIdeas(projectRoot, join(projectRoot, '.dz', 'backlog', `ideas.pre-merge-${Date.now()}.jsonl`));
7114
8063
  if (snap.error !== undefined)
7115
8064
  return emitErr(snap.error);
8065
+ // The absorbed TEXT is preserved (absorbed.jsonl) — a duplicate verdict must never destroy
8066
+ // user text: two documented false absorptions (2026-08-05, 2026-08-11) were unrecoverable.
8067
+ absorbErr = recordAbsorption(projectRoot, {
8068
+ ts: new Date().toISOString(),
8069
+ matchedId: match.id,
8070
+ cosine: verdict.cosine,
8071
+ ...(verdict.containment !== undefined ? { containment: verdict.containment } : {}),
8072
+ ...(verdict.subsetMatch === true ? { subsetMatch: true } : {}),
8073
+ text,
8074
+ }).error;
7116
8075
  match.uses += 1;
7117
8076
  writeIdeas(projectRoot, ideas);
7118
8077
  }
7119
8078
  if (json)
7120
- write(JSON.stringify({ action: 'duplicate', matchedId: verdict.matchedId, cosine: verdict.cosine, ...(topMatch !== undefined ? { topMatch } : {}), ...(eff.note !== undefined ? { effortNote: eff.note } : {}), exitCode: 0 }, null, 2));
8079
+ write(JSON.stringify({ action: 'duplicate', matchedId: verdict.matchedId, cosine: verdict.cosine, ...(verdict.containment !== undefined ? { containment: verdict.containment } : {}), ...(verdict.subsetMatch === true ? { subsetMatch: true } : {}), ...(topMatch !== undefined ? { topMatch } : {}), ...(eff.note !== undefined ? { effortNote: eff.note } : {}), ...(dryRun ? {} : { absorbedLogged: absorbErr === undefined, ...(absorbErr !== undefined ? { absorbedLogError: absorbErr } : {}) }), exitCode: 0 }, null, 2));
7121
8080
  else {
7122
- write(`dz backlog: DUPLICATE of ${verdict.matchedId} (cosine ${verdict.cosine.toFixed(3)}${verdict.exactTextOnly ? ', exact-text' : ''}) — reinforced, no new record`);
8081
+ const via = verdict.subsetMatch === true
8082
+ ? `subset match: containment ${(verdict.containment ?? 0).toFixed(3)} ≥ ${cfg.dedup.subsetContainment}, cosine ${verdict.cosine.toFixed(3)}`
8083
+ : `cosine ${verdict.cosine.toFixed(3)}${verdict.exactTextOnly ? ', exact-text' : ''}`;
8084
+ write(`dz backlog: DUPLICATE of ${verdict.matchedId} (${via}) — reinforced, no new record`);
7123
8085
  if (topMatch !== undefined)
7124
8086
  write(` top match ${topMatch.id} @ cosine ${topMatch.cosine.toFixed(3)} (DUPLICATE band ≥ ${cfg.dedup.duplicateThreshold})`);
8087
+ if (!dryRun) {
8088
+ write(absorbErr === undefined
8089
+ ? ' absorbed text kept in .dz/backlog/absorbed.jsonl (re-add it from there if this verdict was wrong)'
8090
+ : ` ⚠ could NOT log the absorbed text (${absorbErr}) — if this verdict is wrong, the wording above is the only copy`);
8091
+ }
7125
8092
  }
7126
8093
  return 0;
7127
8094
  }
@@ -7143,15 +8110,22 @@ async function cmdBacklog(options, flags, cwd, write) {
7143
8110
  const proposal = options.get('proposal');
7144
8111
  if (proposal !== undefined)
7145
8112
  rec.proposal = proposal; // agent prose ONLY — the CLI never fabricates it
8113
+ // A demotion (≥-threshold cosine that failed lexical corroboration) is NEVER silent — it is the
8114
+ // register-only false-duplicate surface (the 2026-08-05 zombie x publish-gate absorption).
8115
+ const demotedLine = verdict.demoted !== undefined
8116
+ ? ` near-duplicate demoted: ${verdict.demoted.id} @ cosine ${verdict.demoted.cosine.toFixed(3)} cleared the band but shares no subject vocabulary (containment ${verdict.demoted.containment.toFixed(3)} < ${cfg.dedup.corroborationFloor}) — kept as related`
8117
+ : undefined;
7146
8118
  if (dryRun) {
7147
8119
  if (json)
7148
- write(JSON.stringify({ action: verdict.action, dryRun: true, idea: rec, ...(topMatch !== undefined ? { topMatch } : {}), ...(eff.note !== undefined ? { effortNote: eff.note } : {}), exitCode: 0 }, null, 2));
8120
+ write(JSON.stringify({ action: verdict.action, dryRun: true, idea: rec, ...(verdict.demoted !== undefined ? { demoted: verdict.demoted } : {}), ...(topMatch !== undefined ? { topMatch } : {}), ...(eff.note !== undefined ? { effortNote: eff.note } : {}), exitCode: 0 }, null, 2));
7149
8121
  else {
7150
8122
  write(`dz backlog (dry-run): ${verdict.action.toUpperCase()} — would create ${rec.id}; align ${rec.goalAlignment.toFixed(3)}${rec.goalId !== null ? ` → ${rec.goalId}` : ''}`);
7151
8123
  // The calibration surface belongs on the dry-run too (QE LOW-8) — a dry-run is exactly where a
7152
8124
  // user checks whether a near-duplicate should have crossed the band.
7153
8125
  if (topMatch !== undefined)
7154
8126
  write(` top match ${topMatch.id} @ cosine ${topMatch.cosine.toFixed(3)} (DUPLICATE band ≥ ${cfg.dedup.duplicateThreshold})`);
8127
+ if (demotedLine !== undefined)
8128
+ write(demotedLine);
7155
8129
  }
7156
8130
  return 0;
7157
8131
  }
@@ -7163,12 +8137,14 @@ async function cmdBacklog(options, flags, cwd, write) {
7163
8137
  writeIdeas(projectRoot, ideas);
7164
8138
  const mirror = await mirrorIdeaVector(projectRoot, rec); // best-effort — never blocks capture
7165
8139
  if (json)
7166
- write(JSON.stringify({ action: verdict.action, idea: rec, related: verdict.relatedIds, ...(topMatch !== undefined ? { topMatch } : {}), ...(eff.note !== undefined ? { effortNote: eff.note } : {}), gitignore: ignore, exitCode: 0 }, null, 2));
8140
+ write(JSON.stringify({ action: verdict.action, idea: rec, related: verdict.relatedIds, ...(verdict.demoted !== undefined ? { demoted: verdict.demoted } : {}), ...(topMatch !== undefined ? { topMatch } : {}), ...(eff.note !== undefined ? { effortNote: eff.note } : {}), gitignore: ignore, exitCode: 0 }, null, 2));
7167
8141
  else {
7168
8142
  write(`dz backlog: ${verdict.action.toUpperCase()} — captured ${rec.id}`);
7169
8143
  if (verdict.action === 'related' && topMatch !== undefined) {
7170
8144
  write(` top match ${topMatch.id} @ cosine ${topMatch.cosine.toFixed(3)} (DUPLICATE band ≥ ${cfg.dedup.duplicateThreshold})`);
7171
8145
  }
8146
+ if (demotedLine !== undefined)
8147
+ write(demotedLine);
7172
8148
  if (rec.goalId !== null)
7173
8149
  write(` top goal: ${rec.goalId} (alignment ${rec.goalAlignment.toFixed(3)})`);
7174
8150
  if (verdict.relatedIds.length > 0)
@@ -7372,6 +8348,43 @@ async function cmdBacklog(options, flags, cwd, write) {
7372
8348
  }
7373
8349
  return 0;
7374
8350
  }
8351
+ // ── ship | drop | reopen — the status-transition surface (the missing verb that let the roulette
8352
+ // keep re-drawing already-shipped work: without it, work finished WITHOUT `roulette --commit` —
8353
+ // the normal flow — stayed `new` forever). ALL logic lives in transitionIdeas (harness-core):
8354
+ // short-prefix resolution (unique or a loud error), the IDEA_TRANSITIONS legality table,
8355
+ // idempotent no-ops, all-or-nothing fail-closed batches, line-preserving atomic writes.
8356
+ if (sub === 'ship' || sub === 'drop' || sub === 'reopen') {
8357
+ const prefixes = [];
8358
+ for (let i = 1;; i += 1) {
8359
+ const p = options.get(`_positional_${i}`);
8360
+ if (p === undefined)
8361
+ break;
8362
+ prefixes.push(p);
8363
+ }
8364
+ if (prefixes.length === 0)
8365
+ return emitErr(`an idea id is required: dz backlog ${sub} <id> [<id>…]`);
8366
+ const report = transitionIdeas(projectRoot, sub, prefixes, {
8367
+ ...(options.get('reason') !== undefined ? { reason: options.get('reason') } : {}),
8368
+ dryRun: flags.has('dry-run'),
8369
+ });
8370
+ if (json) {
8371
+ write(JSON.stringify({ verb: sub, ...report, exitCode: report.ok ? 0 : 1 }, null, 2));
8372
+ return report.ok ? 0 : 1;
8373
+ }
8374
+ for (const e of report.errors)
8375
+ write(`dz backlog ${sub}: ${e}`);
8376
+ for (const c of report.changes) {
8377
+ if (c.action === 'noop')
8378
+ write(`dz backlog ${sub}: ${c.id} is already ${c.to} — no-op`);
8379
+ else
8380
+ write(`dz backlog ${sub}${report.dryRun ? ' (dry-run)' : ''}: ${c.id} ${c.from} → ${c.to} ${c.text}`);
8381
+ }
8382
+ if (!report.ok)
8383
+ write(` nothing was written (all-or-nothing: fix the batch and re-run)`);
8384
+ else if (report.dryRun && report.changes.some((c) => c.action === 'transitioned'))
8385
+ write(' (dry-run — nothing written; re-run without --dry-run to apply)');
8386
+ return report.ok ? 0 : 1;
8387
+ }
7375
8388
  if (sub === 'enrich') {
7376
8389
  const id = options.get('_positional_1');
7377
8390
  if (id === undefined)
@@ -7422,6 +8435,13 @@ async function cmdBacklog(options, flags, cwd, write) {
7422
8435
  if (sub === 'harmonize') {
7423
8436
  const apply = flags.has('apply');
7424
8437
  const thr = options.get('threshold');
8438
+ // Harmonize is the batch maintenance surface — migrate the mirrored vectors to the current
8439
+ // bounded embed form here too (idempotent; a deferral is warned, never fatal).
8440
+ const form = await ensureBacklogEmbedForm(projectRoot);
8441
+ if (form.action === 'migrated' && !json)
8442
+ write(`dz backlog: re-embedded ${form.remirrored} idea vector(s) into the bounded dedup embed form (v${form.version})`);
8443
+ else if (form.action === 'deferred' && !json)
8444
+ write(`dz backlog: ⚠ embed-form migration deferred (${form.error ?? 'unknown error'})`);
7425
8445
  const report = await harmonizeBacklog(projectRoot, { apply, ...(thr !== undefined ? { threshold: Number(thr) } : {}) });
7426
8446
  if (json) {
7427
8447
  write(JSON.stringify({ ...report, exitCode: 0 }, null, 2));
@@ -7445,6 +8465,9 @@ async function cmdBacklog(options, flags, cwd, write) {
7445
8465
  write(' show <id> full record');
7446
8466
  write(' goals [--validate] the compass (.dz/backlog/goals.json)');
7447
8467
  write(' roulette [--pick N][--seed n][--commit <id>] weighted draw; --commit takes the id you saw');
8468
+ write(' ship <id> [<id>…] [--reason <t>][--dry-run] mark done (new|enriched|in-progress → shipped) — run it after finishing a task, or the roulette re-draws it forever');
8469
+ write(' drop <id> [<id>…] [--reason <t>][--dry-run] retire an idea (→ dropped)');
8470
+ write(' reopen <id> [<id>…] [--reason <t>][--dry-run] back to the pool (shipped|dropped|in-progress → new)');
7448
8471
  write(' enrich <id> stage the idea2prd hand-off (agent expands)');
7449
8472
  write(` jira <id> draft a Jira issue (adapter: ${[...BACKLOG_BACKENDS].join('|')})`);
7450
8473
  write(' harmonize [--apply][--threshold 0-1] batch semantic dedup of the backlog');
@@ -7803,12 +8826,16 @@ export async function runCli(argv, io = {}) {
7803
8826
  return await cmdScout(options, flags, cwd, write);
7804
8827
  case 'workflow':
7805
8828
  return cmdWorkflow(options, flags, cwd, write);
8829
+ case 'workflow-lint':
8830
+ return cmdWorkflowLint(options, flags, cwd, write);
8831
+ case 'workflow-trace':
8832
+ return cmdWorkflowTrace(options, flags, cwd, write);
7806
8833
  case 'migrate':
7807
8834
  return cmdMigrate(options, cwd, write);
7808
8835
  case 'doctor':
7809
8836
  return await cmdDoctor(options, flags, cwd, write);
7810
8837
  case 'install':
7811
- return await cmdInstall(options, flags, cwd, write);
8838
+ return await cmdInstall(options, flags, cwd, write, io.installRunner);
7812
8839
  case 'bundle':
7813
8840
  return cmdBundle(options, flags, cwd, write);
7814
8841
  case 'teach':
@@ -7887,6 +8914,8 @@ export async function runCli(argv, io = {}) {
7887
8914
  return cmdChallenge(options, flags, cwd, write);
7888
8915
  case 'discrimination-check':
7889
8916
  return cmdDiscriminationCheck(options, flags, cwd, write);
8917
+ case 'mutation-gate':
8918
+ return cmdMutationGate(options, flags, cwd, write);
7890
8919
  case 'delivery-check':
7891
8920
  return cmdDeliveryCheck(options, flags, cwd, write);
7892
8921
  case 'skills-verify':