@dzhechkov/harness-cli 0.3.261 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.ts CHANGED
@@ -4,21 +4,23 @@
4
4
  * @packageDocumentation
5
5
  */
6
6
 
7
- 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';
8
- import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
7
+ import { appendFileSync, chmodSync, closeSync, cpSync, existsSync, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, readlinkSync, realpathSync, renameSync, rmdirSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
8
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
9
9
  import { fileURLToPath } from 'node:url';
10
10
  import { execFileSync, execSync, spawn } from 'node:child_process';
11
+ import { createHash } from 'node:crypto';
11
12
  import { homedir, tmpdir } from 'node:os';
12
13
  import { createRequire } from 'node:module';
13
14
 
14
15
  import {
15
16
  createSkill,
16
17
  getSkillInfo,
17
- getWorkflow,
18
18
  isTargetName,
19
19
  listSkills,
20
20
  runDoctor,
21
21
  runInit,
22
+ resolvePackageSkillRoots,
23
+ PACKAGE_SKILL_LAYOUTS,
22
24
  benchmarkSkill,
23
25
  benchmarkSkills,
24
26
  scanMcp,
@@ -51,7 +53,22 @@ import {
51
53
  buildParityMatrix,
52
54
  TARGET_CAPABILITIES,
53
55
  TARGET_SHORT_LABELS,
54
- WORKFLOW_NAMES,
56
+ WORKFLOW_TEMPLATES_RETIRED_MESSAGE,
57
+ parsePlan,
58
+ isParseErrors,
59
+ validatePlan,
60
+ normalizePlan,
61
+ planDigest,
62
+ toTraceProjection,
63
+ renderPlan,
64
+ mergeRender,
65
+ lint,
66
+ lintExitCode,
67
+ LOOP_BLOBS,
68
+ parseTrace,
69
+ assembleTimeline,
70
+ runInvariants,
71
+ renderTimelineHtml,
55
72
  importEcc,
56
73
  recordPattern,
57
74
  resolveLearningBackend,
@@ -128,6 +145,7 @@ import {
128
145
  guardExitCode,
129
146
  DEFAULT_RULES,
130
147
  parsePnpmLockImporters,
148
+ scannableStubPath,
131
149
  // guard-promotion (feature guard-promotion, scout idea #1)
132
150
  assembleCandidates,
133
151
  renderPromotionReport,
@@ -264,6 +282,10 @@ import {
264
282
  isSafeId,
265
283
  alignIdea,
266
284
  mirrorIdeaVector,
285
+ ensureBacklogEmbedForm,
286
+ readBacklogEmbedFormVersion,
287
+ recordAbsorption,
288
+ DEDUP_EMBED_FORM_VERSION,
267
289
  snapshotIdeas,
268
290
  spinRoulette,
269
291
  rankRoulette,
@@ -274,8 +296,18 @@ import {
274
296
  resolveJiraAdapter,
275
297
  makeBacklogIO,
276
298
  harmonizeBacklog,
299
+ transitionIdeas,
277
300
  BACKLOG_BACKENDS,
278
301
  applyDomainBoost,
302
+ DZ_OWNED_TASK_TYPES,
303
+ applyExportHoldout,
304
+ DEFAULT_HELD_OUT_DOMAINS,
305
+ canonicalDomainKey,
306
+ readAgentdbRowsByTaskType,
307
+ heldOutAfterOptIn,
308
+ renderHoldoutNote,
309
+ renderSharedStoreAdvice,
310
+ decideVectorExport,
279
311
  countDisplacedByCut,
280
312
  renderDomainBoostNote,
281
313
  renderDomainCutNote,
@@ -284,7 +316,18 @@ import {
284
316
  settleReqeDebt,
285
317
  renderReqeList,
286
318
  REQE_SCOPE,
319
+ // Mutation gate (feature ha-mutation-gate) — break each named protection, run the suite, require red.
320
+ parseMutationRegistry,
321
+ applyMutationToText,
322
+ countFailingTests,
323
+ classifyBaseline,
324
+ classifyRunFailure,
325
+ classifyMutationOutcome,
326
+ mutationGateExitCode,
327
+ summarizeMutationResults,
328
+ renderMutationReport,
287
329
  } from '@dzhechkov/harness-core';
330
+ import type { MutationEntryResult, MutationObservation, MutationRegistryEntry } from '@dzhechkov/harness-core';
288
331
  import type { ReqeDebt } from '@dzhechkov/harness-core';
289
332
  import type { IdeaRecord, IdeaStatus } from '@dzhechkov/harness-core';
290
333
  import type { Family, ModelRung, Candidate as BtoCandidate, DimScores } from '@dzhechkov/harness-core';
@@ -306,7 +349,12 @@ Usage:
306
349
  dz migrate [--project <dir>]
307
350
  dz create-skill --name <id> [--description <text>] [--skills-dir <dir>] [--tier <1-3>] [--with-references] [--no-evals] [--bto]
308
351
  dz scout [--topics <list>] [--since <date>] [--deep] [--output <file>] [--diff] [--report]
309
- dz workflow <task> [--dry-run]
352
+ dz workflow init --name <n> [--pattern pipeline|barrier|fanout|gate] [--o <plan.json>] (scaffold a loop-plan/1 plan)
353
+ dz workflow validate <plan.json> [--json] (schema + INV-1..8 checks; CI-runnable, non-zero on failure)
354
+ dz workflow render <plan.json> --o <script.js> [--check] [--force] (plan → region-delimited loop script; USER regions preserved)
355
+ dz workflow blobs [--check] (list/self-check the subsystem blob registry)
356
+ dz workflow-lint <script.js> [--plan <plan.json>] [--require-plan|--legacy] [--json] (layer-1 gate; exit 0/1/3 — inconclusive is never a pass)
357
+ dz workflow-trace <runDir|--slug <s>|--run <id>> [--invariants <plan.json>] [--html <out.html>] [--json] (timeline + SEQ invariant runner)
310
358
  dz install <npm-pkg> [--target <name>] [--project <dir>] [--force]
311
359
  dz bundle [--preset <name> | --select id,id,...] [--out <dir>] [--skills-dir <dir>] [--force] (portable self-contained skill bundles for a generic/LangGraph consumer)
312
360
  dz doctor [--project <dir>] [--pubkey <path>] [--require-signing] (health + signature check of installed packs)
@@ -325,11 +373,15 @@ Usage:
325
373
  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)
326
374
  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)
327
375
  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)
376
+ 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)
328
377
  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)
329
378
  dz backlog list [--status <s>] [--goal <id>] [--project <dir>] [--json] (list captured ideas, filterable by status/goal)
330
379
  dz backlog show <id> [--project <dir>] [--json] (full record for one idea)
331
380
  dz backlog goals [--validate] [--project <dir>] [--json] (list/validate the compass at .dz/backlog/goals.json)
332
- 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))
381
+ 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)
382
+ 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)
383
+ dz backlog drop <id> [<id>…] [--reason <t>] [--dry-run] [--project <dir>] [--json] (retire an idea: new|enriched|in-progress → dropped)
384
+ dz backlog reopen <id> [<id>…] [--reason <t>] [--dry-run] [--project <dir>] [--json] (back to the pool: shipped|dropped|in-progress → new)
333
385
  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)
334
386
  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)
335
387
  dz backlog harmonize [--apply] [--threshold <0-1>] [--project <dir>] [--json] (batch semantic dedup of the backlog ideas; --dry-run default, --apply snapshots first)
@@ -379,7 +431,7 @@ Usage:
379
431
  dz import-ecc [--local-path <dir>] [--select id,id,...] [--limit N] [--output <dir>] [--force]
380
432
  dz help
381
433
 
382
- Workflows: coverage-lift, mutation-kill, canonicalize, security-audit
434
+ 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)
383
435
 
384
436
  Targets: ${TARGET_NAMES.join(', ')}
385
437
  Presets: ${PRESET_NAMES.join(', ')}`;
@@ -401,6 +453,13 @@ export interface CliIo {
401
453
  * without spawning anything.
402
454
  */
403
455
  readonly releaseRunner?: ReleaseExecRunner;
456
+ /**
457
+ * Test seam for `dz install`: overrides the `npm install` subprocess (production leaves
458
+ * it unset → real `execSync`, stdio piped). A stub runner that pre-stages a fixture
459
+ * package under `node_modules/` makes `cmdInstall`'s layout resolution testable
460
+ * offline, hermetically — mirrors the {@link CliIo.releaseRunner} idiom.
461
+ */
462
+ readonly installRunner?: (command: string, cwd: string) => void;
404
463
  }
405
464
 
406
465
  /** Injected subprocess runner used by `dz release` (see {@link CliIo.releaseRunner}). */
@@ -840,28 +899,310 @@ async function cmdScout(options: Map<string, string>, flags: Set<string>, cwd: s
840
899
  }
841
900
  }
842
901
 
902
+ /** Scaffold plans for `dz workflow init` — one per pattern (each validates + renders lint-clean:
903
+ * the ADR-002 property "the generator cannot emit a script its own lint rejects" is enforced by
904
+ * workflow-init-lint-clean.test.ts, not convention). */
905
+ function workflowInitPlan(name: string, pattern: string): object {
906
+ const base = {
907
+ schema: 'loop-plan/1',
908
+ name,
909
+ description: `TODO: describe the ${name} loop`, // no-stubs: workflow-init scaffold sentinel the author replaces (deliberate authoring cue, not unfinished code)
910
+ whenToUse: `TODO: when to invoke ${name}`, // no-stubs: workflow-init scaffold sentinel the author replaces (deliberate authoring cue, not unfinished code)
911
+ checkpointing: { enabled: false },
912
+ subsystems: { checkpoints: false, trainingPairs: false, usageAdaptive: false, challengePanel: false, codexDispatch: false },
913
+ trace: { emit: true },
914
+ };
915
+ if (pattern === 'pipeline') {
916
+ return {
917
+ ...base,
918
+ steps: [
919
+ { stepId: 'fan', kind: 'fanout', phase: 'Work', concurrency: 'pipeline', budget: { maxAgents: 8 } },
920
+ { 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)
921
+ { 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)
922
+ { stepId: 'jn', kind: 'join', phase: 'Work', deps: ['fan'] },
923
+ ],
924
+ fanouts: [{ stage: 'fan', registry: ['item1', 'item2', 'item3'], maxFanout: 3, chain: ['a', 'b'] }],
925
+ joins: [{ stage: 'jn', forStage: 'fan', joinPolicy: 'all-activated', onInvalid: 'named-failure' }],
926
+ };
927
+ }
928
+ if (pattern === 'fanout' || pattern === 'barrier') {
929
+ return {
930
+ ...base,
931
+ steps: [
932
+ { stepId: 'fan', kind: 'fanout', phase: 'Lanes', concurrency: 'barrier', budget: { maxAgents: 6 } },
933
+ { 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)
934
+ { stepId: 'jn', kind: 'join', phase: 'Lanes', deps: ['fan'] },
935
+ // the consumer hangs off the BARRIER (jn), never the fork — barrier-postdominates teaches this
936
+ { 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)
937
+ ],
938
+ fanouts: [{ stage: 'fan', registry: ['lane1', 'lane2', 'lane3'], maxFanout: 3, chain: ['lane'] }],
939
+ joins: [{ stage: 'jn', forStage: 'fan', joinPolicy: 'all-activated', onInvalid: 'named-failure' }],
940
+ };
941
+ }
942
+ if (pattern === 'gate') {
943
+ return {
944
+ ...base,
945
+ steps: [
946
+ { 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)
947
+ { 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)
948
+ ],
949
+ gates: [{ stepId: 'gate', kind: 'parse-verdict', failRoute: 'work', maxRedos: 1 }],
950
+ };
951
+ }
952
+ // minimal default: one agent step
953
+ return {
954
+ ...base,
955
+ 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)
956
+ };
957
+ }
958
+
959
+ /**
960
+ * `dz workflow` — the loop-designer authoring verbs (ADR-002; the ADR-005 template emitter is
961
+ * RETIRED — AM-6). Subverbs: init | validate | render | blobs. Any other invocation (including
962
+ * every legacy `--task <name>` / `--name <name>` / bare positional template spelling) prints the
963
+ * pinned retirement message and exits 1 — no legacy format is silently reachable.
964
+ */
843
965
  function cmdWorkflow(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
844
- const taskName = options.get('task') ?? options.get('name') ?? '';
845
- if (taskName === '' || !WORKFLOW_NAMES.includes(taskName)) {
846
- write(`dz workflow: specify a task — one of: ${WORKFLOW_NAMES.join(', ')}`);
966
+ const sub = options.get('_positional_0') ?? '';
967
+ const legacyTask = options.get('task') ?? options.get('name');
968
+
969
+ if (sub === 'init') {
970
+ const name = options.get('name') ?? options.get('_positional_1') ?? 'my-loop';
971
+ const pattern = options.get('pattern') ?? 'pipeline';
972
+ const outPath = resolve(cwd, options.get('o') ?? options.get('out') ?? `${name}.plan.json`);
973
+ const planObj = workflowInitPlan(name, pattern);
974
+ const parsed = parsePlan(planObj);
975
+ if (isParseErrors(parsed)) {
976
+ write(`dz workflow init: internal scaffold error — ${parsed.map((e) => e.message).join('; ')}`);
977
+ return 1;
978
+ }
979
+ const diags = validatePlan(parsed);
980
+ if (diags.length > 0) {
981
+ write(`dz workflow init: internal scaffold failed validation — ${diags.map((d) => `${d.invariant} ${d.message}`).join('; ')}`);
982
+ return 1;
983
+ }
984
+ writeFileSync(outPath, JSON.stringify(normalizePlan(parsed), null, 2) + '\n');
985
+ write(`dz workflow init: wrote ${outPath} (pattern: ${pattern})`);
986
+ 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)
987
+ return 0;
988
+ }
989
+
990
+ if (sub === 'validate') {
991
+ const planPath = options.get('_positional_1') ?? options.get('plan') ?? '';
992
+ if (planPath === '') {
993
+ write('dz workflow validate: usage — dz workflow validate <plan.json> [--json]');
994
+ return 1;
995
+ }
996
+ const abs = resolve(cwd, planPath);
997
+ if (!existsSync(abs)) {
998
+ write(`dz workflow validate: no such plan file: ${abs}`);
999
+ return 1;
1000
+ }
1001
+ let raw: unknown;
1002
+ try {
1003
+ raw = JSON.parse(readFileSync(abs, 'utf8'));
1004
+ } catch (e) {
1005
+ write(`dz workflow validate: unparseable JSON — ${e instanceof Error ? e.message : String(e)}`);
1006
+ return 1;
1007
+ }
1008
+ const parsed = parsePlan(raw);
1009
+ if (isParseErrors(parsed)) {
1010
+ if (flags.has('json')) write(JSON.stringify({ ok: false, parseErrors: parsed }, null, 2));
1011
+ else for (const e of parsed) write(`PARSE ${e.path}: ${e.message}`);
1012
+ return 1;
1013
+ }
1014
+ const diags = validatePlan(parsed);
1015
+ if (flags.has('json')) {
1016
+ write(JSON.stringify({ ok: diags.length === 0, digest: planDigest(parsed), diagnostics: diags }, null, 2));
1017
+ } else {
1018
+ for (const d of diags) write(`${d.invariant} ${d.path}: ${d.message}`);
1019
+ write(diags.length === 0 ? `dz workflow validate: OK (digest sha256:${planDigest(parsed).slice(0, 16)}…)` : `dz workflow validate: ${diags.length} invariant violation(s)`);
1020
+ }
1021
+ return diags.length === 0 ? 0 : 1;
1022
+ }
1023
+
1024
+ if (sub === 'render') {
1025
+ const planPath = options.get('_positional_1') ?? options.get('plan') ?? '';
1026
+ const outPath = options.get('o') ?? options.get('out') ?? '';
1027
+ if (planPath === '' || outPath === '') {
1028
+ write('dz workflow render: usage — dz workflow render <plan.json> -o <script.js> [--check] [--force]');
1029
+ return 1;
1030
+ }
1031
+ const absPlan = resolve(cwd, planPath);
1032
+ if (!existsSync(absPlan)) {
1033
+ write(`dz workflow render: no such plan file: ${absPlan}`);
1034
+ return 1;
1035
+ }
1036
+ const parsed = parsePlan(JSON.parse(readFileSync(absPlan, 'utf8')));
1037
+ if (isParseErrors(parsed)) {
1038
+ for (const e of parsed) write(`PARSE ${e.path}: ${e.message}`);
1039
+ return 1;
1040
+ }
1041
+ const diags = validatePlan(parsed);
1042
+ if (diags.length > 0) {
1043
+ for (const d of diags) write(`${d.invariant} ${d.path}: ${d.message}`);
1044
+ write('dz workflow render: refusing to render an invalid plan');
1045
+ return 1;
1046
+ }
1047
+ const rendered = renderPlan(parsed);
1048
+ const absOut = resolve(cwd, outPath);
1049
+ const sidecar = absOut.replace(/\.js$/, '') + '.plan.json';
1050
+ const prev = existsSync(absOut) ? readFileSync(absOut, 'utf8') : '';
1051
+ const merged = prev === '' ? { text: rendered.text, conflicts: [], refused: false } : mergeRender(prev, rendered, { force: flags.has('force') });
1052
+ if (flags.has('check')) {
1053
+ const same = prev === merged.text && !merged.refused;
1054
+ write(same ? 'dz workflow render --check: up to date' : 'dz workflow render --check: DRIFT — a fresh render differs from the file on disk');
1055
+ return same ? 0 : 1;
1056
+ }
1057
+ if (merged.refused) {
1058
+ const proposed = absOut + '.proposed.js';
1059
+ writeFileSync(proposed, merged.proposedText ?? rendered.text);
1060
+ write(`dz workflow render: ${absOut} carries NO region markers (hand-written loop) — REFUSING to overwrite.`);
1061
+ write(`Proposed render written to ${proposed}; re-run with --force to replace the target.`);
1062
+ return 1;
1063
+ }
1064
+ // sidecar plan FIRST, independently of the script (FR-4.1 — the oracle diffs against it)
1065
+ writeFileSync(sidecar, rendered.planJson);
1066
+ writeFileSync(absOut, merged.text);
1067
+ for (const c of merged.conflicts) write(`CONFLICT step ${c.stepId}: ${c.reason}`);
1068
+ 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'})`);
1069
+ write('Gate it: dz workflow-lint ' + outPath + ' --plan ' + sidecar + ' --require-plan');
1070
+ return merged.conflicts.length > 0 ? 1 : 0;
1071
+ }
1072
+
1073
+ if (sub === 'blobs') {
1074
+ let bad = 0;
1075
+ for (const b of Object.values(LOOP_BLOBS)) {
1076
+ const actual = createHash('sha256').update(b.code, 'utf8').digest('hex');
1077
+ const ok = actual === b.contentHash;
1078
+ if (!ok) bad++;
1079
+ 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(', ')}])`);
1080
+ }
1081
+ if (flags.has('check')) {
1082
+ 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`);
1083
+ return bad === 0 ? 0 : 1;
1084
+ }
1085
+ return 0;
1086
+ }
1087
+
1088
+ // Everything else — the retired ADR-005 surface, BOTH spellings (bare positional task name and
1089
+ // --task/--name) — prints the pinned shim message. AM-6: never silently reachable.
1090
+ void legacyTask;
1091
+ void flags;
1092
+ write(WORKFLOW_TEMPLATES_RETIRED_MESSAGE);
1093
+ return 1;
1094
+ }
1095
+
1096
+ /** `dz workflow-lint` — layer-1 gate; exit 0/1/3 (pass/fail/inconclusive — INV-13: inconclusive is
1097
+ * never a pass; the exit convention mirrors consult-gate). */
1098
+ function cmdWorkflowLint(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
1099
+ const scriptPath = options.get('_positional_0') ?? '';
1100
+ if (scriptPath === '') {
1101
+ write('dz workflow-lint: usage — dz workflow-lint <script.js> [--plan <plan.json>] [--require-plan|--legacy] [--json]');
847
1102
  return 1;
848
1103
  }
849
- const wf = getWorkflow(taskName);
850
- if (wf === undefined) {
851
- write(`dz workflow: unknown task ${JSON.stringify(taskName)}`);
1104
+ const absScript = resolve(cwd, scriptPath);
1105
+ if (!existsSync(absScript)) {
1106
+ write(`dz workflow-lint: no such script: ${absScript}`);
852
1107
  return 1;
853
1108
  }
854
- const projectRoot = resolve(cwd, options.get('project') ?? '.');
855
- const script = wf.generate({ projectRoot, dryRun: flags.has('dry-run') });
1109
+ const scriptText = readFileSync(absScript, 'utf8');
1110
+ const planPath = options.get('plan');
1111
+ let plan = null;
1112
+ let digestValue: string | null = null;
1113
+ if (planPath !== undefined) {
1114
+ const absPlan = resolve(cwd, planPath);
1115
+ if (!existsSync(absPlan)) {
1116
+ write(`dz workflow-lint: no such plan: ${absPlan}`);
1117
+ return 1;
1118
+ }
1119
+ const parsed = parsePlan(JSON.parse(readFileSync(absPlan, 'utf8')));
1120
+ if (isParseErrors(parsed)) {
1121
+ for (const e of parsed) write(`PARSE ${e.path}: ${e.message}`);
1122
+ return 1;
1123
+ }
1124
+ plan = parsed;
1125
+ digestValue = planDigest(parsed);
1126
+ }
1127
+ const mode = flags.has('require-plan') ? 'require-plan' : flags.has('legacy') ? 'legacy' : 'default';
1128
+ const run = lint(scriptText, { plan, planDigestValue: digestValue, blobRegistry: LOOP_BLOBS, mode });
1129
+ if (flags.has('json')) {
1130
+ write(JSON.stringify(run, null, 2));
1131
+ } else {
1132
+ for (const f of run.findings) {
1133
+ write(`${f.severity.toUpperCase().padEnd(12)} ${f.rule}: ${f.message}${f.anchor ? ` [anchor: ${f.anchor}]` : ''}`);
1134
+ }
1135
+ const counts = { fail: 0, warn: 0, inconclusive: 0 };
1136
+ for (const f of run.findings) if (f.severity in counts) counts[f.severity as keyof typeof counts]++;
1137
+ 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)`);
1138
+ if (run.verdict === 'inconclusive') write('inconclusive is NOT a pass (exit 3) — bind a plan (--plan … --require-plan) or acknowledge a legacy script with --legacy');
1139
+ }
1140
+ return lintExitCode(run);
1141
+ }
856
1142
 
857
- write(`// Dynamic Workflow: ${wf.name}`);
858
- write(`// ${wf.description}`);
859
- write('');
860
- write(script);
861
- write('');
862
- write(`// To execute: paste the above into Claude Code with Opus 4.8+ and "ultracode" enabled.`);
863
- write(`// Or save to a .js file and run: claude "Execute this workflow" < workflow.js`);
864
- return 0;
1143
+ /** `dz workflow-trace` — timeline + invariant runner over a run's trace.jsonl. Scope is CAPPED
1144
+ * (AM-8): <runDir|--slug|--run>, --invariants, --html, --json. NO watch/filter/compare/search/
1145
+ * retention/access-control — adding one needs an ADR amendment (the surface test pins this). */
1146
+ function cmdWorkflowTrace(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
1147
+ const runDirArg = options.get('_positional_0');
1148
+ const slug = options.get('slug');
1149
+ const runId = options.get('run');
1150
+ let runDir: string;
1151
+ if (runDirArg !== undefined && runDirArg !== '') runDir = resolve(cwd, runDirArg);
1152
+ else if (slug !== undefined) runDir = resolve(cwd, 'features', slug);
1153
+ else if (runId !== undefined) runDir = resolve(cwd, '.dz', 'loop-trace', runId);
1154
+ else {
1155
+ write('dz workflow-trace: usage — dz workflow-trace <runDir|--slug <s>|--run <id>> [--invariants <plan.json>] [--html <out.html>] [--json]');
1156
+ return 1;
1157
+ }
1158
+ const traceFile = join(runDir, 'trace.jsonl');
1159
+ if (!existsSync(traceFile)) {
1160
+ write(`dz workflow-trace: no trace.jsonl under ${runDir} (the loop writes its own trace only when the plan sets trace.emit: true)`);
1161
+ return 1;
1162
+ }
1163
+ const traceText = readFileSync(traceFile, 'utf8');
1164
+ const ckptFile = join(runDir, '.fa-state', 'checkpoints.jsonl');
1165
+ const ledgerFile = resolve(cwd, '.dz', 'feature-adr', 'run-cost-ledger.jsonl');
1166
+ const journalFile = join(runDir, 'journal.jsonl');
1167
+ const timeline = assembleTimeline({
1168
+ trace: traceText,
1169
+ checkpoints: existsSync(ckptFile) ? readFileSync(ckptFile, 'utf8') : null,
1170
+ ledger: existsSync(ledgerFile) ? readFileSync(ledgerFile, 'utf8') : null,
1171
+ journal: existsSync(journalFile) ? readFileSync(journalFile, 'utf8') : null,
1172
+ });
1173
+ let verdicts: ReturnType<typeof runInvariants> = [];
1174
+ let projection: ReturnType<typeof toTraceProjection> | null = null;
1175
+ const invariantsPlan = options.get('invariants');
1176
+ if (invariantsPlan !== undefined) {
1177
+ const absPlan = resolve(cwd, invariantsPlan);
1178
+ if (!existsSync(absPlan)) {
1179
+ write(`dz workflow-trace: no such plan: ${absPlan}`);
1180
+ return 1;
1181
+ }
1182
+ const parsed = parsePlan(JSON.parse(readFileSync(absPlan, 'utf8')));
1183
+ if (isParseErrors(parsed)) {
1184
+ for (const e of parsed) write(`PARSE ${e.path}: ${e.message}`);
1185
+ return 1;
1186
+ }
1187
+ projection = toTraceProjection(parsed);
1188
+ verdicts = runInvariants(projection, parseTrace(traceText));
1189
+ }
1190
+ const htmlOut = options.get('html');
1191
+ if (htmlOut !== undefined) {
1192
+ const absHtml = resolve(cwd, htmlOut);
1193
+ writeFileSync(absHtml, renderTimelineHtml(timeline, projection, verdicts));
1194
+ write(`dz workflow-trace: wrote ${absHtml} (self-contained: mermaid topology + HTML waterfall)`);
1195
+ }
1196
+ if (flags.has('json')) {
1197
+ write(JSON.stringify({ timeline, verdicts }, null, 2));
1198
+ return verdicts.some((v) => v.status === 'fail') ? 1 : 0;
1199
+ }
1200
+ write(`run ${timeline.runId ?? '(unknown)'}${timeline.incomplete ? ' — INCOMPLETE (no run.closed; unflushed tail may be lost)' : ''}; sources: ${timeline.sources.join(', ')}`);
1201
+ for (const r of timeline.rows.filter((x) => x.kind === 'trace')) {
1202
+ write(` ${String(r.seq).padStart(5)} ${r.label} ${r.detail}${r.wallTime ? ` [wall ${r.wallTime} — diagnostic only]` : ''}`);
1203
+ }
1204
+ for (const v of verdicts) write(`INVARIANT ${v.status.toUpperCase().padEnd(12)} ${v.id}: ${v.message}`);
1205
+ return verdicts.some((v) => v.status === 'fail') ? 1 : 0;
865
1206
  }
866
1207
 
867
1208
  function cmdMigrate(options: Map<string, string>, cwd: string, write: Write): number {
@@ -1015,7 +1356,13 @@ function cmdBundle(options: Map<string, string>, flags: Set<string>, cwd: string
1015
1356
  return 0;
1016
1357
  }
1017
1358
 
1018
- async function cmdInstall(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): Promise<number> {
1359
+ async function cmdInstall(
1360
+ options: Map<string, string>,
1361
+ flags: Set<string>,
1362
+ cwd: string,
1363
+ write: Write,
1364
+ installRunner?: (command: string, cwd: string) => void,
1365
+ ): Promise<number> {
1019
1366
  const pkg = options.get('_positional_0');
1020
1367
  if (!pkg) {
1021
1368
  write('dz install: package name required (e.g., dz install @dzhechkov/skills-devops)');
@@ -1030,10 +1377,12 @@ async function cmdInstall(options: Map<string, string>, flags: Set<string>, cwd:
1030
1377
 
1031
1378
  const projectRoot = resolve(cwd, options.get('project') ?? '.');
1032
1379
 
1033
- // Step 1: npm install the package
1380
+ // Step 1: npm install the package (installRunner is the CliIo test seam — unset in production)
1034
1381
  write(`Installing ${pkg}...`);
1382
+ const installCmd = `npm install ${pkg} --save-dev --no-fund --no-audit`;
1035
1383
  try {
1036
- execSync(`npm install ${pkg} --save-dev --no-fund --no-audit`, { cwd: projectRoot, stdio: 'pipe', encoding: 'utf-8' });
1384
+ if (installRunner) installRunner(installCmd, projectRoot);
1385
+ else execSync(installCmd, { cwd: projectRoot, stdio: 'pipe', encoding: 'utf-8' });
1037
1386
  } catch (err) {
1038
1387
  write(`dz install: npm install failed — ${err instanceof Error ? err.message : String(err)}`);
1039
1388
  return 1;
@@ -1046,19 +1395,22 @@ async function cmdInstall(options: Map<string, string>, flags: Set<string>, cwd:
1046
1395
  return 1;
1047
1396
  }
1048
1397
 
1049
- // Discover skills look for dirs containing SKILL.md
1050
- const entries = readdirSync(pkgDir, { withFileTypes: true })
1051
- .filter((e) => e.isDirectory() && existsSync(join(pkgDir, e.name, 'SKILL.md')));
1052
-
1053
- if (entries.length === 0) {
1054
- write(`dz install: no SKILL.md files found in ${pkg}. Package may not contain agentskills.io skills.`);
1055
- return 0;
1398
+ // Resolve the package's skills root across the known layouts (flat / npx-template /
1399
+ // skills-dir) feature dz-install-npx-init. Total resolution failure is an ERROR
1400
+ // (exit 1), not the old advisory exit 0: a user asking to install skills that were
1401
+ // not installed must not see success.
1402
+ const roots = resolvePackageSkillRoots(pkgDir);
1403
+ if (roots.length === 0) {
1404
+ write(`dz install: no SKILL.md found in ${pkg}. Probed: ${PACKAGE_SKILL_LAYOUTS.map((l) => l.rel).join(', ')}.`);
1405
+ write(`If this package installs itself, try: npx -y ${pkg} init`);
1406
+ return 1;
1056
1407
  }
1408
+ const root = roots[0]!;
1057
1409
 
1058
- // Step 3: Use dz init with the package as skills source
1410
+ // Step 3: Use dz init with the resolved skills root as source
1059
1411
  const report = await runInit({
1060
1412
  target: targetOpt,
1061
- skillsDir: pkgDir,
1413
+ skillsDir: root.dir,
1062
1414
  projectRoot,
1063
1415
  force: flags.has('force'),
1064
1416
  });
@@ -1066,10 +1418,16 @@ async function cmdInstall(options: Map<string, string>, flags: Set<string>, cwd:
1066
1418
  const totalWritten = report.skills.reduce((sum, s) => sum + s.written.length, 0);
1067
1419
  const totalSkipped = report.skills.reduce((sum, s) => sum + s.skipped.length, 0);
1068
1420
 
1069
- write(`dz install ${pkg}: ${report.skills.length} skill(s), ${totalWritten} file(s) written, ${totalSkipped} skipped`);
1421
+ // Non-flat resolutions are tagged so a mis-resolution is legible in a user's paste;
1422
+ // flat output stays byte-identical to the pre-feature behavior (NFR-1 / test T5.6).
1423
+ const layoutTag = root.layout === 'flat' ? '' : ` [layout: ${root.layout}]`;
1424
+ write(`dz install ${pkg}: ${report.skills.length} skill(s), ${totalWritten} file(s) written, ${totalSkipped} skipped${layoutTag}`);
1070
1425
  for (const skill of report.skills) {
1071
1426
  write(` ${skill.id}: ${skill.written.length} written, ${skill.skipped.length} skipped`);
1072
1427
  }
1428
+ if (root.layout === 'npx-template' && root.hasCompanionAssets) {
1429
+ write(` note: ${pkg} also ships commands/hooks/agents — \`npx -y ${pkg} init\` installs the full kit.`);
1430
+ }
1073
1431
  return 0;
1074
1432
  }
1075
1433
 
@@ -1778,6 +2136,22 @@ async function cmdTeach(options: Map<string, string>, flags: Set<string>, cwd: s
1778
2136
  }
1779
2137
  write(`Imported ${imported} pattern(s) from ${fromJson}`);
1780
2138
  write(` Skipped ${skipped} (duplicates already in the store, or invalid entries)`);
2139
+ // Bulk import preserves the DOMAIN of every record, so it can put medical lessons in
2140
+ // a shared store as silently as a hand-typed teach — and it returned before the
2141
+ // advice single-teach prints. The same advice, at the same point in the flow: after
2142
+ // the write, naming the choice, blocking nothing.
2143
+ let resolvedImportRoot = projectRoot;
2144
+ try { resolvedImportRoot = realpathSync(projectRoot); } catch { /* unresolvable is not the brain */ }
2145
+ const importedMedical = importedRecs.filter(
2146
+ (r) => DEFAULT_HELD_OUT_DOMAINS.map(canonicalDomainKey).includes(canonicalDomainKey(r.domain)),
2147
+ );
2148
+ if (importedMedical.length > 0) {
2149
+ const advice = renderSharedStoreAdvice(importedMedical[0]?.domain, resolvedImportRoot);
2150
+ if (advice !== '') {
2151
+ write(` ⚠ ${importedMedical.length} of the imported lesson(s) carry a medical domain.`);
2152
+ write(advice);
2153
+ }
2154
+ }
1781
2155
  write(` Backend: memory (@dzhechkov/memory) Total now: ${loadStorePatternsSync(projectRoot).length}`);
1782
2156
  // ONE batched mirror call through the same seam as single-teach (QR-6 — no bespoke path).
1783
2157
  await emitMirror(projectRoot, importedRecs, 'dz-teach-import');
@@ -1876,6 +2250,16 @@ async function cmdTeach(options: Map<string, string>, flags: Set<string>, cwd: s
1876
2250
  write(`Learned: "${pattern.slice(0, 60)}${pattern.length > 60 ? '...' : ''}"`);
1877
2251
  write(` Domain: ${domain} Reward: ${reward} Backend: memory (@dzhechkov/memory)`);
1878
2252
  write(` Total patterns: ${count}`);
2253
+ // ADVICE, not a gate. Someone putting medical lessons in a shared store owns both
2254
+ // directories and this binary; refusing would be defending a user against themselves,
2255
+ // which this design does not attempt. Making the choice INFORMED is the part that is
2256
+ // ours to do — the write has already happened when this prints.
2257
+ // RESOLVE before deciding. A symlink named `.health-brain` pointing at a shared project
2258
+ // silenced this advice in exactly the case it exists for.
2259
+ let resolvedRoot = projectRoot;
2260
+ try { resolvedRoot = realpathSync(projectRoot); } catch { /* a path we cannot resolve is not the brain */ }
2261
+ const sharedAdvice = renderSharedStoreAdvice(domain, resolvedRoot);
2262
+ if (sharedAdvice !== '') write(sharedAdvice);
1879
2263
  if (quarantineOn) {
1880
2264
  write(' ⚠ quarantined: excluded from auto-inject, damped in recall — promote by confirming it (dz teach --reinforce "<text>") or dz recall --promote <dzId> --apply');
1881
2265
  }
@@ -2029,13 +2413,41 @@ function fmtUsageRow(row: RecallUsagePatternRow): string {
2029
2413
  }
2030
2414
 
2031
2415
  function cmdRecallUsage(options: Map<string, string>, flags: Set<string>, projectRoot: string, write: Write): number {
2032
- const report = readRecallUsageReport(projectRoot);
2416
+ const raw = readRecallUsageReport(projectRoot);
2417
+ // The hold-out applies to EVERY surface that hands out lesson TEXT, not only to the
2418
+ // one labelled "export". `--usage --json` prints whole lessons in its `top` and
2419
+ // `neverRead` lists, so it is an export by behaviour whatever it is called — a
2420
+ // self-audit before review found health-research lesson text sitting in both. A
2421
+ // guarantee that covers only the surface you were thinking about is not a guarantee.
2422
+ const heldOut = heldOutAfterOptIn(options.get('include-domain'));
2423
+ // EVERY list of records, not the two that were obvious. A first pass held out `top`
2424
+ // and `neverRead` and a health lesson still came out — in `all`, a third list further
2425
+ // down the same object. Enumerating the lists you remembered is how the surface leaks.
2426
+ const top = applyExportHoldout(raw.top, heldOut);
2427
+ const neverRead = applyExportHoldout(raw.neverRead, heldOut);
2428
+ // `unknown` is deliberately NOT held out: RecallUsageStat carries counters and
2429
+ // timestamps only — no domain, no lesson text — so there is nothing in it to withhold.
2430
+ // Filtering it would be theatre, and theatre in a privacy control is worse than a gap
2431
+ // because it looks like coverage.
2432
+ const every = applyExportHoldout(raw.all ?? [], heldOut);
2433
+ const withheld = top.withheld.length + neverRead.withheld.length + every.withheld.length;
2434
+ const report = {
2435
+ ...raw,
2436
+ top: top.exported,
2437
+ neverRead: neverRead.exported,
2438
+ ...(raw.all === undefined ? {} : { all: every.exported }),
2439
+ };
2440
+ const holdoutNote = renderHoldoutNote([top, neverRead, every]
2441
+ .reduce((a, b) => (b.withheld.length > a.withheld.length ? b : a)));
2033
2442
  if (flags.has('json')) {
2034
2443
  write(JSON.stringify({
2035
2444
  ...report,
2036
2445
  log: join(projectRoot, RECALL_USAGE_LOG_RELATIVE),
2037
2446
  retention: { maxBytes: RECALL_USAGE_LOG_MAX_BYTES },
2447
+ withheld,
2448
+ withheldDomains: [...new Set([...top.domains, ...neverRead.domains, ...every.domains])].sort(),
2038
2449
  }));
2450
+ if (withheld > 0 && holdoutNote !== '') process.stderr.write(`${holdoutNote}\n`);
2039
2451
  return 0;
2040
2452
  }
2041
2453
 
@@ -2060,6 +2472,9 @@ function cmdRecallUsage(options: Map<string, string>, flags: Set<string>, projec
2060
2472
  for (const row of report.neverRead.slice(0, displayLimit)) write(` ${row.dzId}${row.domain !== undefined ? ` (${row.domain})` : ''} ${row.pattern.slice(0, 100)}`);
2061
2473
  if (report.neverRead.length > displayLimit) write(` ... ${report.neverRead.length - displayLimit} more (use --json for the full list)`);
2062
2474
  }
2475
+ if (withheld > 0 && holdoutNote !== '') {
2476
+ write(holdoutNote);
2477
+ }
2063
2478
  return 0;
2064
2479
  }
2065
2480
 
@@ -2174,15 +2589,57 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
2174
2589
  // --all: dump the entire learned store (backend-agnostic, via loadStorePatternsSync).
2175
2590
  // With --json this is the portable export the agentdb-memory MCP bridge consumes.
2176
2591
  if (all) {
2177
- const patterns = loadStorePatternsSync(projectRoot);
2592
+ // ISOLATION (health-advisor slice H, ADR-003). This command is THE portable sharing
2593
+ // form — it is documented as such and the agentdb bridge consumes it — so it is the
2594
+ // realistic path by which a learned store leaves this machine. `health-research`
2595
+ // carries lessons drawn from one person's investigations and is held back unless the
2596
+ // caller names it. This replaced a text-inspecting privacy guard that seven rounds of
2597
+ // review could not make correct: a tag set by the writer is decidable, prose is not.
2598
+ const allPatterns = loadStorePatternsSync(projectRoot);
2599
+ const holdout = applyExportHoldout(allPatterns, heldOutAfterOptIn(options.get('include-domain')));
2600
+ const patterns = holdout.exported;
2601
+ const holdoutNote = renderHoldoutNote(holdout);
2602
+ // The opt-in is honoured without argument — and named out loud. A flag that silently
2603
+ // includes medical lessons in a portable export is a flag whose consequence the user
2604
+ // has to remember; one that says what it just handed over is a flag they can act on.
2605
+ const optedIn = options.get('include-domain');
2606
+ if (optedIn !== undefined && optedIn.trim() !== '') {
2607
+ // Count against the SPLIT list, the same way heldOutAfterOptIn parses it. Comparing
2608
+ // each record to the whole unsplit string reported `0 of 2` for a comma-separated
2609
+ // opt-in that had just exported a medical lesson — a warning that undercounts the
2610
+ // thing it warns about is worse than none.
2611
+ const optedKeys = new Set(optedIn.split(',').map((d) => canonicalDomainKey(d)).filter((d) => d !== ''));
2612
+ const medicalKeys = new Set(DEFAULT_HELD_OUT_DOMAINS.map(canonicalDomainKey));
2613
+ const releasedKeys = [...optedKeys].filter((k) => medicalKeys.has(k));
2614
+ const releasedCount = allPatterns.filter((p) => releasedKeys.includes(canonicalDomainKey(p.domain))).length;
2615
+ if (releasedKeys.length > 0) {
2616
+ process.stderr.write(
2617
+ ` ⚠ --include-domain ${releasedKeys.join(',')}: this export CONTAINS lessons from a medical `
2618
+ + `domain (${releasedCount} of ${allPatterns.length}). They travel with the file wherever it `
2619
+ + `goes. Nothing was blocked — this is your call.\n`,
2620
+ );
2621
+ }
2622
+ }
2178
2623
  if (flags.has('stats')) {
2179
- const stats = storeStats(projectRoot);
2624
+ // `stats` is computed from the UNFILTERED store, and `topUses` carries whole
2625
+ // lesson text. Filtering `patterns` two lines above and serialising this untouched
2626
+ // exported exactly what had just been withheld — found by review immediately after
2627
+ // I had written the lesson "cover every surface", in the same function. The
2628
+ // per-domain histogram goes too: it names the domain and its size.
2629
+ const rawStats = storeStats(projectRoot);
2630
+ const topHoldout = applyExportHoldout(rawStats.topUses, heldOutAfterOptIn(options.get('include-domain')));
2631
+ const perDomain = Object.fromEntries(
2632
+ Object.entries(rawStats.perDomain).filter(([d]) => !holdout.domains.includes(canonicalDomainKey(d))),
2633
+ );
2634
+ const stats = { ...rawStats, topUses: topHoldout.exported, perDomain };
2180
2635
  const backendStats = resolveLearningBackend(projectRoot).getStats();
2181
2636
  if (asJson) {
2182
- write(JSON.stringify({ patterns, stats, learning: backendStats }));
2637
+ write(JSON.stringify({ patterns, stats, learning: backendStats, withheld: holdout.withheld.length, withheldDomains: holdout.domains }));
2638
+ if (holdoutNote !== '') process.stderr.write(`${holdoutNote}\n`);
2183
2639
  return 0;
2184
2640
  }
2185
2641
  write(`dz recall --all --stats — ${patterns.length} learned pattern(s)`);
2642
+ if (holdoutNote !== '') write(holdoutNote);
2186
2643
  write(` backend: ${backendStats.backend}${backendStats.advisory !== undefined ? ` (${backendStats.advisory})` : ''}`);
2187
2644
  write(` domains: ${Object.entries(stats.perDomain).map(([k, v]) => `${k}=${v}`).join(', ') || 'none'}`);
2188
2645
  write(` exact-dup groups: ${stats.exactDupGroups}`);
@@ -2193,10 +2650,15 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
2193
2650
  return 0;
2194
2651
  }
2195
2652
  if (asJson) {
2653
+ // The note goes to STDERR so the JSON on stdout stays machine-parsable — but it is
2654
+ // still SAID. A silent hold-out would let the reader believe they exported the whole
2655
+ // store, and would make a broken hold-out look exactly like an empty domain.
2196
2656
  write(JSON.stringify(patterns));
2657
+ if (holdoutNote !== '') process.stderr.write(`${holdoutNote}\n`);
2197
2658
  } else {
2198
2659
  write(`dz recall --all — ${patterns.length} learned pattern(s)`);
2199
2660
  for (const p of patterns) write(` [${p.reward.toFixed(2)}] (${p.domain}) ${p.pattern.slice(0, 80)}`);
2661
+ if (holdoutNote !== '') write(holdoutNote);
2200
2662
  }
2201
2663
  return 0;
2202
2664
  }
@@ -2251,6 +2713,14 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
2251
2713
  // never receive its promised lift — the feature was weakest in exactly the case it
2252
2714
  // exists for (foreign-domain dilution pushing a relevant lesson just past the cut).
2253
2715
  // Fetch a bounded surplus, re-rank, then trim to the limit the caller asked for.
2716
+ // EVERY value that reaches an output line goes through this, not just stored fields.
2717
+ // Round 5: `pattern` and `domain` were sanitised but the QUERY and the requested
2718
+ // domain were not, so a caller-supplied newline could still forge a line that looks
2719
+ // like the tool's own — including the boost note a peer reads as a capability probe.
2720
+ // The rule is "one line out per line of output", and it has to hold for every field.
2721
+ const oneLine = (v: string): string => String(v).replace(/[\r\n\u2028\u2029\u0085\v\f]+/g, ' ⏎ ');
2722
+ const shownQuery = oneLine(query);
2723
+ const shownDomain = wantedDomain === undefined ? undefined : oneLine(wantedDomain);
2254
2724
  const fetchLimit = wantedDomain !== undefined ? Math.min(limit * 3, limit + 20) : limit;
2255
2725
  const result = await recallHybrid(projectRoot, query, { limit: fetchLimit, mode });
2256
2726
 
@@ -2274,22 +2744,30 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
2274
2744
  // Portable contract UNCHANGED (I-7/AC-6): a plain PatternRecord[] — round-trips through
2275
2745
  // `dz teach --from-json` regardless of which backend ranked each hit.
2276
2746
  write(JSON.stringify(hits.map((h) => h.pattern)));
2747
+ // The honesty notes go to STDERR here rather than being skipped: the JSON branch
2748
+ // used to return before them, so a scripted caller was told nothing about a boost
2749
+ // that had promoted a match and pushed a visible hit past the --limit cut.
2750
+ if (boost !== null && shownDomain !== undefined) {
2751
+ process.stderr.write(`${renderDomainBoostNote(boost, shownDomain)}\n`);
2752
+ const cutNoteJson = renderDomainCutNote(displaced, limit);
2753
+ if (cutNoteJson !== '') process.stderr.write(`${cutNoteJson}\n`);
2754
+ }
2277
2755
  return 0;
2278
2756
  }
2279
2757
 
2280
2758
  if (hits.length === 0) {
2281
- write(`dz recall "${query}"`);
2759
+ write(`dz recall "${shownQuery}"`);
2282
2760
  write(` No matching patterns (teach some with \`dz teach\`, or consolidate sessions).`);
2283
2761
  // The domain note must print here too (Codex QE #10): a --domain run with zero hits
2284
2762
  // silently said nothing about the domain, so the reader could not tell whether the
2285
2763
  // boost had been applied and found nothing, or had not run at all.
2286
- if (boost !== null && wantedDomain !== undefined) write(renderDomainBoostNote(boost, wantedDomain));
2764
+ if (boost !== null && shownDomain !== undefined) write(renderDomainBoostNote(boost, shownDomain));
2287
2765
  return 0;
2288
2766
  }
2289
2767
  const vectorOn = result.vectorEngine !== 'none' && result.vectorError === undefined && mode !== 'lexical';
2290
2768
  const lexLabel = result.lexicalBackend === 'sqlite' ? 'SQLite FTS5' : 'keyword (JSON)';
2291
2769
  const ranking = vectorOn ? `${lexLabel} + vector (${result.vectorEngine}) ranking` : `${lexLabel} ranking (lexical)`;
2292
- write(`dz recall "${query}" — ${hits.length} hit(s), ${ranking}`);
2770
+ write(`dz recall "${shownQuery}" — ${hits.length} hit(s), ${ranking}`);
2293
2771
  let sawQuarantined = false;
2294
2772
  for (const h of hits) {
2295
2773
  const backendTag = vectorOn ? ` ⟨${h.backend}⟩` : '';
@@ -2304,7 +2782,6 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
2304
2782
  // might treat as a break — not just CR/LF. Round 4 forged the domain-boost note
2305
2783
  // twice over: once through `domain`, which was rendered raw, and once through a
2306
2784
  // U+2028 that Python's splitlines() honours and this replace did not.
2307
- const oneLine = (v: string): string => v.replace(/[\r\n\u2028\u2029\u0085\v\f]+/g, ' ⏎ ');
2308
2785
  const shown = oneLine(h.pattern.pattern).slice(0, 80);
2309
2786
  write(` [${h.pattern.reward.toFixed(2)}] (${oneLine(h.pattern.domain)})${backendTag}${qTag} ${shown}`);
2310
2787
  }
@@ -2312,10 +2789,10 @@ async function cmdRecall(options: Map<string, string>, flags: Set<string>, cwd:
2312
2789
  // The loop stays VISIBLE (ADR D2): a quarantined hit is shown, marked, and explained.
2313
2790
  write(' ⚠q = quarantined (unproven hypothesis, rank damped) — confirm with dz teach --reinforce, or dz recall --promote <dzId> --apply');
2314
2791
  }
2315
- if (boost !== null && wantedDomain !== undefined) {
2792
+ if (boost !== null && shownDomain !== undefined) {
2316
2793
  // Say what the boost did — INCLUDING when it did nothing. A silent reorder would
2317
2794
  // let the reader believe the ranking was domain-aware when it had no match to work with.
2318
- write(renderDomainBoostNote(boost, wantedDomain));
2795
+ write(renderDomainBoostNote(boost, shownDomain));
2319
2796
  const cutNote = renderDomainCutNote(displaced, limit);
2320
2797
  if (cutNote !== '') write(cutNote);
2321
2798
  }
@@ -2460,6 +2937,32 @@ async function cmdVector(options: Map<string, string>, flags: Set<string>, cwd:
2460
2937
  write(' Patterns themselves stay portable via: dz recall --all --json (import with: dz teach --from-json)');
2461
2938
  return 0;
2462
2939
  }
2940
+ // The vector checkpoint carries EMBEDDINGS of the lesson text, and the RVF adapter
2941
+ // copies the whole store — there is no per-record filter to apply. So this path
2942
+ // fails CLOSED: if the store holds a held-out domain, the export is refused unless
2943
+ // the caller names that domain. An embedding is not plaintext, but ADR-003's
2944
+ // question is "does patient data leave this machine?", and a checkpoint that
2945
+ // silently carried it would answer yes while the documentation said no.
2946
+ // JUDGE THE FILE THIS COMMAND EXPORTS. The decision itself lives in harness-core as
2947
+ // a pure function WITH TESTS: the RVF engine is opt-in and absent on most machines,
2948
+ // so this branch could not be exercised in a normal checkout — reasoning about a
2949
+ // safety check I could not run is exactly what this project calls unverified.
2950
+ const optIn = options.get('include-domain');
2951
+ const vecHoldout = applyExportHoldout(
2952
+ loadStorePatternsSync(projectRoot).map((p) => ({ domain: p.domain })),
2953
+ heldOutAfterOptIn(optIn),
2954
+ );
2955
+ const decision = decideVectorExport({
2956
+ rvfExists: existsSync(join(projectRoot, '.dz', 'memory', 'patterns.rvf')),
2957
+ heldOutLexicalCount: vecHoldout.withheld.length,
2958
+ heldOutDomains: vecHoldout.domains,
2959
+ optedIn: optIn,
2960
+ });
2961
+ if (!decision.allow) {
2962
+ write(`dz vector export: REFUSED — ${decision.reason}.`);
2963
+ write(` A forgotten lesson's embedding outlives its lexical record. To export anyway, naming what travels: dz vector export ${dest} --include-domain ${decision.optInHint}`);
2964
+ return 1;
2965
+ }
2463
2966
  const r = await exporter(resolve(cwd, dest));
2464
2967
  if (r.error !== undefined) {
2465
2968
  write(`dz vector export: ${r.error}`);
@@ -3430,7 +3933,7 @@ async function cmdAutoCanonicalize(options: Map<string, string>, cwd: string, wr
3430
3933
  write(` ${skillName.padEnd(30)} ${path}`);
3431
3934
  }
3432
3935
 
3433
- write(`\nTo canonicalize, run for each skill:`);
3936
+ write(`\nTo bring each skill into the canonical tree, run:`);
3434
3937
  const packDir = resolve(cwd, pack);
3435
3938
  for (const path of skillMds) {
3436
3939
  const parts = path.split('/');
@@ -3774,6 +4277,7 @@ function cmdPublish(options: Map<string, string>, flags: Set<string>, cwd: strin
3774
4277
  if (guardResult.verdict === 'block') write(`dz publish: ⚠ guard BLOCK overridden via --no-guard: ${noGuard} (logged)`);
3775
4278
  else if (guardResult.verdict === 'warn') for (const v of guardResult.violations) write(`dz publish: ⚠ guard warn — ${v.rule}: ${v.detail}`);
3776
4279
  else write('dz publish: ✓ guard pre-flight passed');
4280
+ for (const n of guardResult.notes ?? []) write(`dz publish: ℹ guard note — ${n}`); // FN-7: on the record, never blocking
3777
4281
  }
3778
4282
 
3779
4283
  // ADR-001 (publish-provenance): decide BEFORE any work — flag validation, then a pre-flight that
@@ -4982,8 +5486,15 @@ function cmdDriftCheck(options: Map<string, string>, flags: Set<string>, cwd: st
4982
5486
 
4983
5487
  const DEFAULT_STORE_CAP = 5000;
4984
5488
 
4985
- /** Read the optional `.dz/guard.json` — `{ rules?: [...], storeCap?: number }`. Missing/broken ⇒ defaults. */
4986
- function loadGuardConfig(root: string): { rules?: unknown[]; storeCap?: number } {
5489
+ /**
5490
+ * Ceiling on how many changed files the no-stubs scan reads per evaluation. Deterministic (the file
5491
+ * list is `git status` order) and fail-open: files beyond it simply have no gathered contents, so
5492
+ * the rule reports nothing for them — a pre-flight must never become a filesystem sweep.
5493
+ */
5494
+ const MAX_STUB_SCAN_FILES = 400;
5495
+
5496
+ /** Read the optional `.dz/guard.json` — `{ rules?: [...], storeCap?: number, stubWaivers?: [...] }`. Missing/broken ⇒ defaults. */
5497
+ function loadGuardConfig(root: string): { rules?: unknown[]; storeCap?: number; stubWaivers?: unknown[] } {
4987
5498
  const p = join(root, '.dz', 'guard.json');
4988
5499
  if (!existsSync(p)) return {};
4989
5500
  try {
@@ -5037,7 +5548,7 @@ function gatherGuardFacts(op: string, root: string, text: string | undefined, st
5037
5548
  // a resolvable workspace dep becomes its real semver (safe → the rule passes); an UNRESOLVABLE one (points at
5038
5549
  // no workspace package, or not a pnpm workspace) stays `workspace:*` so the rule catches a dep that WOULD ship
5039
5550
  // raw. That is the genuinely dangerous case the rule exists for.
5040
- type Manifest = { name?: string; version?: string; private?: boolean; dependencies?: Record<string, string>; devDependencies?: Record<string, string> };
5551
+ type Manifest = { name?: string; version?: string; private?: boolean; license?: string; licenseHold?: unknown; dependencies?: Record<string, string>; devDependencies?: Record<string, string> };
5041
5552
  const manifests: Manifest[] = [];
5042
5553
  const located: { dir: string; m: Manifest }[] = [];
5043
5554
  try {
@@ -5065,6 +5576,28 @@ function gatherGuardFacts(op: string, root: string, text: string | undefined, st
5065
5576
  packages.push({ name: m.name ?? '(unnamed)', deps });
5066
5577
  }
5067
5578
  facts['packages'] = packages;
5579
+
5580
+ // licence-hold (ADR-001, hermes-claude-adaptation): for each pack DECLARING a hold via a
5581
+ // `licenseHold` field, hand the raw evidence to the pure checker. Best-effort: an unreadable
5582
+ // LICENSE reads as absent (null), which the checker treats as a violation for a publishable
5583
+ // pack — the fail direction that protects the hold, never the publish.
5584
+ try {
5585
+ const holds: { name: string; privateFlag: boolean; licenseText: string | null; noticesText: string | null; licenseField: string | null }[] = [];
5586
+ for (const { dir, m } of located) {
5587
+ if (!m || m.licenseHold === undefined || m.licenseHold === null) continue;
5588
+ const read = (rel: string): string | null => {
5589
+ try { return readFileSync(join(root, dir, rel), 'utf8'); } catch { return null; }
5590
+ };
5591
+ holds.push({
5592
+ name: m.name ?? dir,
5593
+ privateFlag: m.private === true,
5594
+ licenseText: read('LICENSE'),
5595
+ noticesText: read('THIRD_PARTY_NOTICES.md') ?? read('THIRD_PARTY_NOTICES'),
5596
+ licenseField: typeof m.license === 'string' ? m.license : null,
5597
+ });
5598
+ }
5599
+ facts['licenceHold'] = holds;
5600
+ } catch { /* unreadable tree — the rule reports nothing rather than inventing a violation */ }
5068
5601
  try { facts['drift'] = sweepSkillDrift(root, { scope: 'packages', allowlist: readDriftAllowlist(root) }).drifted.map((d) => d.name); } catch { /* skip */ }
5069
5602
  facts['counts'] = gatherReadmeCounts(root);
5070
5603
  // readme-first: from the WORKING-TREE diff (publishes happen pre-commit here), per package: does the
@@ -5120,11 +5653,26 @@ function gatherGuardFacts(op: string, root: string, text: string | undefined, st
5120
5653
  }
5121
5654
  } catch { facts['lockfile'] = { parsed: false }; /* no lockfile (not a pnpm workspace) — rule stays silent */ }
5122
5655
 
5123
- // change: the working-tree diff, for PROMOTED (template) rules. Without this fact a rule written
5124
- // by `dz guard promote --apply` would be INERT present in the config and enforcing nothing.
5125
- // Contents are read only for the globs an active `format-match` rule actually asks about.
5656
+ // change: the working-tree diff the notion of "changed" shared by PROMOTED (template) rules
5657
+ // and the no-stubs rule. (readme-first gathers its OWN pathspec-scoped porcelain call above and
5658
+ // does not read this fact — so the -uall widening below does not alter readme-first at all;
5659
+ // its collapsed-untracked-dir blind spot for a brand-new package dir is a separate, documented
5660
+ // limit of THAT gatherer.) Without this fact a rule written by
5661
+ // `dz guard promote --apply` would be INERT — present in the config and enforcing nothing.
5662
+ // Contents are read for the globs an active `format-match` rule asks about PLUS the changed
5663
+ // files the no-stubs scan reads (its explicit extension allowlist, capped: a pathological
5664
+ // change-set must not turn a pre-flight into a filesystem sweep — beyond the cap the rule
5665
+ // simply sees no contents for the excess files, the standing fail-open contract).
5126
5666
  try {
5127
- const status = execSync('git status --porcelain', { cwd: root, encoding: 'utf-8' });
5667
+ // -uall (FN-1): without it, a brand-new DIRECTORY reports as one collapsed `?? newdir/` line
5668
+ // and every file INSIDE it is invisible to the change fact — and a fresh module directory is
5669
+ // the most stub-prone artifact there is (REPRODUCED: newdir/stub.ts with a live marker ⇒
5670
+ // PASS/0 findings). -uall lists the individual files; `.gitignore` semantics are unchanged
5671
+ // (git status never lists ignored paths, -uall or not — tested live). maxBuffer is raised
5672
+ // (default 1MB) because -uall can expand a huge untracked tree into a long listing; KNOWN
5673
+ // LIMIT: a listing beyond even this bound throws, the catch below drops the whole `change`
5674
+ // fact, and no-stubs + every template rule go silently fail-open together for that run.
5675
+ const status = execSync('git status --porcelain -uall', { cwd: root, encoding: 'utf-8', maxBuffer: 32 * 1024 * 1024 });
5128
5676
  const files = status
5129
5677
  .split('\n')
5130
5678
  .map((l) => l.slice(3).trim())
@@ -5133,28 +5681,40 @@ function gatherGuardFacts(op: string, root: string, text: string | undefined, st
5133
5681
  const formatGlobs = (Array.isArray(loadGuardConfig(root).rules) ? (loadGuardConfig(root).rules as { template?: unknown; params?: { file?: unknown } }[]) : [])
5134
5682
  .filter((r) => r?.template === 'format-match' && typeof r?.params?.file === 'string')
5135
5683
  .map((r) => r.params!.file as string);
5684
+ const stubScannable = files.filter((f) => scannableStubPath(f));
5685
+ const stubWanted = new Set(stubScannable.slice(0, MAX_STUB_SCAN_FILES));
5686
+ // FN-7: fail-open must not be fail-SILENT. Count every stub-scannable changed file whose
5687
+ // contents we do NOT gather (beyond the cap here; deleted/non-regular/oversize/read-error
5688
+ // below) — the no-stubs rule surfaces the count as ONE aggregate note, never a violation.
5689
+ let stubSkipped = stubScannable.length - stubWanted.size;
5136
5690
  const contents: Record<string, string> = {};
5137
- if (formatGlobs.length > 0) {
5691
+ if (formatGlobs.length > 0 || stubWanted.size > 0) {
5138
5692
  for (const f of files) {
5139
- if (!formatGlobs.some((g) => globMatch(g, f))) continue;
5693
+ const wanted = stubWanted.has(f);
5694
+ if (!wanted && !formatGlobs.some((g) => globMatch(g, f))) continue;
5140
5695
  const abs = resolve(root, f);
5141
5696
  // Containment: a `git status` path is repo-relative, but `..` in one must never let the
5142
5697
  // LIVE reader step outside the repo the HISTORICAL reader is confined to.
5143
- if (abs !== root && !abs.startsWith(root + sep)) continue;
5698
+ if (abs !== root && !abs.startsWith(root + sep)) { if (wanted) stubSkipped++; continue; }
5144
5699
  try {
5145
5700
  // lstat, NOT stat (Codex QE MED-3). `git show <sha>:<path>` yields the SYMLINK TARGET
5146
5701
  // TEXT, never the file it points at, so a live reader that follows links answers a
5147
5702
  // different question than the replay — and `/dev/zero` behind a symlink hangs the read.
5148
5703
  // Skipping non-regular files restores replay/live equivalence and closes the DoS.
5149
5704
  const st = lstatSync(abs);
5150
- if (!st.isFile()) continue;
5151
- if (st.size > MAX_CONTENT_BYTES) continue; // too large to be a spec file — undecidable, never guessed
5705
+ if (!st.isFile()) { if (wanted) stubSkipped++; continue; }
5706
+ if (st.size > MAX_CONTENT_BYTES) { if (wanted) stubSkipped++; continue; } // too large to be a spec file — undecidable, never guessed
5152
5707
  contents[f] = readFileSync(abs, 'utf8');
5153
- } catch { /* deleted — leave it undecidable, never guess */ }
5708
+ } catch { if (wanted) stubSkipped++; /* deleted — leave it undecidable, never guess */ }
5154
5709
  }
5155
5710
  }
5156
- facts['change'] = { files, ...(Object.keys(contents).length > 0 ? { contents } : {}) };
5711
+ facts['change'] = { files, ...(Object.keys(contents).length > 0 ? { contents } : {}), ...(stubSkipped > 0 ? { stubSkipped } : {}) };
5157
5712
  } catch { /* not a git repo — every template rule stays silent (fail-open) */ }
5713
+
5714
+ // no-stubs config waivers: `.dz/guard.json` `stubWaivers: [{path, reason}]` — path-keyed, reason
5715
+ // MANDATORY (the feature-adr-setup --guards shape; the pure checker refuses a reasonless entry).
5716
+ const stubWaivers = loadGuardConfig(root).stubWaivers;
5717
+ if (Array.isArray(stubWaivers)) facts['stubWaivers'] = stubWaivers;
5158
5718
  }
5159
5719
  if (op === 'consolidate') {
5160
5720
  try { facts['drift'] = sweepSkillDrift(root, { scope: 'packages', allowlist: readDriftAllowlist(root) }).drifted.map((d) => d.name); } catch { /* skip */ }
@@ -5577,6 +6137,10 @@ function cmdGuard(options: Map<string, string>, flags: Set<string>, cwd: string,
5577
6137
  const scaffold = {
5578
6138
  storeCap: DEFAULT_STORE_CAP,
5579
6139
  rules: DEFAULT_RULES.map((r) => ({ id: r.id, severity: r.severity, enabled: true, description: r.description })),
6140
+ // The scaffold QUOTES the stub marker names in the no-stubs rule description, and this file is
6141
+ // itself a scannable changed file the moment it is written — so it carries its own reasoned
6142
+ // waiver (explicit and justified, never a silent path skip).
6143
+ stubWaivers: [{ path: '.dz/guard.json', reason: 'the guard config quotes the stub marker names in the no-stubs rule description' }],
5580
6144
  };
5581
6145
  mkdirSync(dirname(p), { recursive: true });
5582
6146
  writeFileSync(p, JSON.stringify(scaffold, null, 2) + '\n');
@@ -5624,6 +6188,7 @@ function cmdGuard(options: Map<string, string>, flags: Set<string>, cwd: string,
5624
6188
  const glyph = result.verdict === 'block' ? '✗' : result.verdict === 'warn' ? '⚠' : '✓';
5625
6189
  write(`dz guard (${op}): ${glyph} ${result.verdict.toUpperCase()} [checked: ${result.checked.join(', ') || 'no rules for this op'}]`);
5626
6190
  for (const v of result.violations) write(` [${v.severity === 'hard' ? 'BLOCK' : 'warn'}] ${v.rule}: ${v.detail}`);
6191
+ for (const n of result.notes ?? []) write(` [note] ${n}`); // information, never a verdict input (FN-7)
5627
6192
  if (result.verdict === 'block' && forced) write(` → forced through: ${force} (logged to .dz/guard-audit.jsonl)`);
5628
6193
  else if (result.verdict === 'block') write(' → blocked. Fix the HARD violation(s), or override with --force "<reason>" (logged).');
5629
6194
  return guardExitCode(result, forced);
@@ -6264,6 +6829,352 @@ function nameFor(t: { file: string; name?: string }, outcome: 'pass' | 'fail' |
6264
6829
  return t.name !== undefined ? { file: t.file, name: t.name, outcome } : { file: t.file, outcome };
6265
6830
  }
6266
6831
 
6832
+ /**
6833
+ * `dz mutation-gate` — the mutation gate (feature ha-mutation-gate, SPEC at
6834
+ * features/ha-mutation-gate/SPEC.md). A green test proves the code works; it does NOT prove the
6835
+ * test would notice the protection being DELETED. For each entry in a declarative registry this
6836
+ * gate applies the entry's exact {find, replace} mutation to a SCRATCH COPY of the package, runs
6837
+ * the package's test command there, and REQUIRES a non-zero exit (red). All logic is in
6838
+ * harness-core's pure engine (mutation-gate.ts); this is the I/O executor.
6839
+ *
6840
+ * --package <dir> the package to gate (default: cwd; must contain package.json)
6841
+ * --registry <file> registry path (default: <pkg>/test/mutation-registry.json, then
6842
+ * <pkg>/mutation-registry.json)
6843
+ * --test-cmd '<cmd>' suite command run in the scratch copy (default: registry.testCommand,
6844
+ * then `npm test`)
6845
+ * --only <id[,id]> run a subset of entries (unknown id = usage error, never a silent skip)
6846
+ * --timeout <ms> per-suite-run timeout (default 300000). A timed-out run is INCONCLUSIVE —
6847
+ * a FAILURE, never a pass.
6848
+ * --rebaseline <m> route-b guard mode: 'per-entry' (default — every red entry re-runs the
6849
+ * suite on the restored tree; not green ⇒ that entry is INCONCLUSIVE) or
6850
+ * 'final' (one re-run at the end; not green ⇒ every red-based verdict is
6851
+ * downgraded). The gate's PROVEN now means the redness was ATTRIBUTABLE.
6852
+ * --keep-scratch keep the scratch copy for inspection (default: removed in a finally)
6853
+ * --json machine contract {packageDir, registryPath, testCommand, rebaselineMode,
6854
+ * baseline, results, summary, warnings, exitCode}
6855
+ *
6856
+ * The four rules (SPEC §"Four rules") and where each is enforced:
6857
+ * 1. does-not-apply = FAILURE → core classifyMutationOutcome (occurrences !== 1 ⇒ NOT_APPLIED);
6858
+ * 2. green suite = FAILURE → core (exit 0 ⇒ UNDEFENDED, names the property);
6859
+ * 3. never mutate the working tree → HERE: every write targets the scratch copy under tmpdir();
6860
+ * the repo tree is opened read-only, and a crashed run leaves at worst a stale tmp dir;
6861
+ * 4. the gate's own discrimination proof → harness-cli/test/fixtures/mutation-gate-undefended
6862
+ * (the gate MUST fail on it; asserted by test/mutation-gate-cli.test.ts).
6863
+ *
6864
+ * Exit codes: 0 every entry PROVEN · 1 the gate ran and failed (undefended / not-applied /
6865
+ * below-min / unparseable / load-fatal / over-failing / inconclusive entry) · 2 usage or setup
6866
+ * error (missing registry, red BASELINE — a red unmutated copy proves nothing and must not be
6867
+ * read as a mutation result — or an entry whose file RESOLVES outside the scratch copy: a
6868
+ * symlink escape is refused before anything is written, SPEC rule 3).
6869
+ */
6870
+ /**
6871
+ * Route-a guard for `dz mutation-gate`: parse-check a MUTATED file as its own language BEFORE the
6872
+ * suite runs. A registry mutation must delete the protection while keeping the file loadable — a
6873
+ * file that no longer parses kills the whole suite (or its import chain), and that STRUCTURAL
6874
+ * redness says nothing about the named protection. Returns `{error}` when a parser ran and the
6875
+ * text does not parse; `{skipped}` (reported loudly, never silently) when no parser is available.
6876
+ */
6877
+ function parseCheckMutatedFile(absFile: string, text: string): { error?: string; skipped?: string } {
6878
+ interface TsLike {
6879
+ transpileModule(t: string, o: { reportDiagnostics: boolean; compilerOptions: Record<string, unknown> }): { diagnostics?: { category: number; code: number; messageText: unknown }[] };
6880
+ flattenDiagnosticMessageText(m: unknown, s: string): string;
6881
+ DiagnosticCategory: { Error: number };
6882
+ ScriptTarget: { Latest: number };
6883
+ }
6884
+ const ext = extname(absFile).toLowerCase();
6885
+ try {
6886
+ if (ext === '.ts' || ext === '.tsx' || ext === '.mts' || ext === '.cts') {
6887
+ let ts: TsLike | null = null;
6888
+ for (const from of [absFile, import.meta.url]) {
6889
+ try { ts = (createRequire(from)('typescript') as TsLike); break; } catch { /* try the next resolution root */ }
6890
+ }
6891
+ if (ts === null) return { skipped: 'no TypeScript parser resolvable (typescript installed neither near the package nor near the CLI)' };
6892
+ const out = ts.transpileModule(text, { reportDiagnostics: true, compilerOptions: { target: ts.ScriptTarget.Latest } });
6893
+ const first = (out.diagnostics ?? []).find((d) => d.category === (ts as TsLike).DiagnosticCategory.Error);
6894
+ if (first === undefined) return {};
6895
+ return { error: `TS${first.code}: ${ts.flattenDiagnosticMessageText(first.messageText, ' ')}` };
6896
+ }
6897
+ if (ext === '.json') {
6898
+ try { JSON.parse(text); return {}; } catch (e) { return { error: String((e as Error).message).slice(0, 200) }; }
6899
+ }
6900
+ if (ext === '.js' || ext === '.cjs' || ext === '.mjs' || ext === '') {
6901
+ try {
6902
+ // `node --check` on the file IN PLACE, so the nearest package.json decides the module goal.
6903
+ execFileSync(process.execPath, ['--check', absFile], { stdio: 'pipe' });
6904
+ return {};
6905
+ } catch (e) {
6906
+ const err = e as { stderr?: Buffer | string };
6907
+ const stderrLines = String(err.stderr ?? '').split('\n').map((l) => l.trim()).filter((l) => l !== '');
6908
+ // prefer the actual `SyntaxError: …` line over node's trailing version footer.
6909
+ const msg = [...stderrLines].reverse().find((l) => l.includes('Error')) ?? stderrLines.at(-1) ?? 'node --check failed';
6910
+ return { error: msg.slice(0, 200) };
6911
+ }
6912
+ }
6913
+ return { skipped: `no parser for '${ext}' files — parse-check unavailable` };
6914
+ } catch (e) {
6915
+ return { skipped: `parse-check errored: ${String((e as Error).message).slice(0, 120)}` };
6916
+ }
6917
+ }
6918
+
6919
+ function cmdMutationGate(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
6920
+ const json = flags.has('json');
6921
+ const fail = (what: string): number => {
6922
+ write(json ? JSON.stringify({ error: what, exitCode: 2 }) : `dz mutation-gate: ${what}`);
6923
+ return 2;
6924
+ };
6925
+
6926
+ const pkgDir = resolve(cwd, options.get('package') ?? '.');
6927
+ if (!existsSync(join(pkgDir, 'package.json'))) {
6928
+ return fail(`no package.json at ${pkgDir} — pass --package <dir>`);
6929
+ }
6930
+
6931
+ const registryOpt = options.get('registry');
6932
+ const registryPath = registryOpt !== undefined
6933
+ ? resolve(cwd, registryOpt)
6934
+ : [join(pkgDir, 'test', 'mutation-registry.json'), join(pkgDir, 'mutation-registry.json')].find((p) => existsSync(p));
6935
+ if (registryPath === undefined || !existsSync(registryPath)) {
6936
+ return fail(`no mutation registry found (looked for test/mutation-registry.json and mutation-registry.json under ${pkgDir}) — pass --registry <file>`);
6937
+ }
6938
+
6939
+ const parsed = parseMutationRegistry(readFileSync(registryPath, 'utf-8'));
6940
+ if (parsed.registry === null) {
6941
+ return fail(`registry ${registryPath} is invalid:\n - ${parsed.errors.join('\n - ')}`);
6942
+ }
6943
+
6944
+ let entries: readonly MutationRegistryEntry[] = parsed.registry.entries;
6945
+ const only = options.get('only');
6946
+ if (only !== undefined) {
6947
+ const ids = only.split(',').map((s) => s.trim()).filter(Boolean);
6948
+ const known = new Set(entries.map((e) => e.id));
6949
+ const unknown = ids.filter((id) => !known.has(id));
6950
+ if (unknown.length > 0) return fail(`--only names unknown entry id(s): ${unknown.join(', ')}`);
6951
+ entries = entries.filter((e) => ids.includes(e.id));
6952
+ }
6953
+
6954
+ const testCmdRaw = options.get('test-cmd') ?? parsed.registry.testCommand ?? 'npm test';
6955
+ if (/[\0\n\r]/.test(testCmdRaw)) return fail('--test-cmd may not contain NUL or newline characters');
6956
+ const testCmd = testCmdRaw;
6957
+
6958
+ const timeoutOpt = Number(options.get('timeout') ?? '300000');
6959
+ const timeout = Number.isFinite(timeoutOpt) && timeoutOpt > 0 ? timeoutOpt : 300000;
6960
+
6961
+ // Route-b guard mode: `per-entry` (default, strongest — each red entry re-baselines the restored
6962
+ // tree, so a flaky neighbour flips THAT entry to INCONCLUSIVE) or `final` (cheap — one re-run at
6963
+ // the end; if it is not green, every red-based verdict of the run is downgraded, because any of
6964
+ // them may have been the flake). MEASURED on the 18-entry health-advisor registry (~15s/suite
6965
+ // run): per-entry ≈ 37 runs, final ≈ 20 runs vs 19 pre-fix. An unknown mode is a usage error.
6966
+ const rebaselineMode = options.get('rebaseline') ?? 'per-entry';
6967
+ if (rebaselineMode !== 'per-entry' && rebaselineMode !== 'final') {
6968
+ return fail(`--rebaseline must be 'per-entry' or 'final', got '${rebaselineMode}'`);
6969
+ }
6970
+
6971
+ // Rule 3 — NEVER mutate the working tree: the package is copied into a scratch dir we own and
6972
+ // mutated THERE. The copy must actually be RUNNABLE (SPEC rule 3's note), which took three
6973
+ // measured layers on the seed package:
6974
+ // • the package's own node_modules is symlinked back (absolute), so deps + .bin resolve;
6975
+ // • the copy lives inside a SHADOW of the package's repo — every ancestor level mirrors the
6976
+ // real one with SYMLINKED siblings (root node_modules for hoisted deps, sibling packages
6977
+ // for repo-relative test paths like `../../harness-core/dist`); only the package under test
6978
+ // is a real, mutable copy (MEASURED: without this, 30 health-advisor tests failed at
6979
+ // baseline on ERR_MODULE_NOT_FOUND / a missing sibling dist);
6980
+ // • the copy is `git init`-ed and committed, because hygiene tests take `git status` before
6981
+ // and after the run — they compare before WITH after, so a pre-mutation commit keeps them
6982
+ // discriminating (MEASURED: without it, 2 tests failed at baseline on "not a git repository").
6983
+ const scratchParent = mkdtempSync(join(tmpdir(), 'dz-mutgate-'));
6984
+ let gitTop: string | null = null;
6985
+ try { gitTop = execSync('git rev-parse --show-toplevel', { cwd: pkgDir, stdio: 'pipe', encoding: 'utf-8' }).trim() || null; } catch { /* not in a git repo */ }
6986
+ let copyDir = join(scratchParent, 'pkg');
6987
+ const results: MutationEntryResult[] = [];
6988
+ const observations: MutationObservation[] = [];
6989
+ const warnings: string[] = [];
6990
+ let baseline: ReturnType<typeof classifyBaseline>;
6991
+ try {
6992
+ if (gitTop !== null && gitTop !== pkgDir && resolve(pkgDir).startsWith(resolve(gitTop) + sep)) {
6993
+ // shadow tree: mirror <gitTop>/…/<pkg> under scratch, symlinking every sibling entry.
6994
+ let realCursor = gitTop;
6995
+ let shadowCursor = join(scratchParent, 'root');
6996
+ mkdirSync(shadowCursor, { recursive: true });
6997
+ const segs = relative(gitTop, pkgDir).split(sep);
6998
+ segs.forEach((seg, i) => {
6999
+ for (const entry of readdirSync(realCursor)) {
7000
+ if (entry === seg || entry === '.git') continue;
7001
+ try { symlinkSync(join(realCursor, entry), join(shadowCursor, entry)); } catch { /* best effort */ }
7002
+ }
7003
+ realCursor = join(realCursor, seg);
7004
+ shadowCursor = join(shadowCursor, seg);
7005
+ if (i < segs.length - 1) mkdirSync(shadowCursor, { recursive: true });
7006
+ });
7007
+ copyDir = shadowCursor;
7008
+ }
7009
+ cpSync(pkgDir, copyDir, {
7010
+ recursive: true,
7011
+ filter: (src) => {
7012
+ const rel = relative(pkgDir, src);
7013
+ return rel === '' || !rel.split(sep).some((seg) => seg === 'node_modules' || seg === '.git');
7014
+ },
7015
+ });
7016
+ const srcNm = join(pkgDir, 'node_modules');
7017
+ if (existsSync(srcNm) && !existsSync(join(copyDir, 'node_modules'))) {
7018
+ symlinkSync(srcNm, join(copyDir, 'node_modules'), 'dir');
7019
+ }
7020
+ try {
7021
+ 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' });
7022
+ } catch { /* no git available → a suite that needs it fails the BASELINE loudly, never silently */ }
7023
+
7024
+ // F-2 — rule-3 containment root: the scratch copy AS THE FILESYSTEM sees it. Every mutation
7025
+ // write below is asserted to RESOLVE inside this root before it happens.
7026
+ const realScratchRoot = realpathSync(copyDir);
7027
+
7028
+ const runSuite = (): { exitCode: number | null; output: string } => {
7029
+ try {
7030
+ const out = execSync(testCmd, { cwd: copyDir, stdio: 'pipe', encoding: 'utf-8', timeout, maxBuffer: 64 * 1024 * 1024, env: { ...process.env, FORCE_COLOR: '0' } });
7031
+ return { exitCode: 0, output: out };
7032
+ } catch (e) {
7033
+ const err = e as { status?: number | null; stdout?: string; stderr?: string };
7034
+ return {
7035
+ exitCode: typeof err.status === 'number' ? err.status : null,
7036
+ output: `${String(err.stdout ?? '')}\n${String(err.stderr ?? '')}`,
7037
+ };
7038
+ }
7039
+ };
7040
+
7041
+ // Baseline BEFORE any mutation: a red copy proves nothing, and reading it as a mutation
7042
+ // result would be this gate shipping the defect class it exists to catch.
7043
+ if (!json) write(`mutation-gate: baseline suite in scratch copy of ${pkgDir} …`);
7044
+ const base = runSuite();
7045
+ baseline = classifyBaseline(base.exitCode);
7046
+ if (!baseline.ok) {
7047
+ if (json) { write(JSON.stringify({ packageDir: pkgDir, registryPath, testCommand: testCmd, baseline, results: [], exitCode: 2 }, null, 2)); return 2; }
7048
+ write(renderMutationReport([], baseline, pkgDir));
7049
+ return 2;
7050
+ }
7051
+
7052
+ for (const entry of entries) {
7053
+ const filePath = join(copyDir, entry.file);
7054
+ let sourceText: string | null = null;
7055
+ try { sourceText = readFileSync(filePath, 'utf-8'); } catch { /* missing file ⇒ occurrences 0 ⇒ NOT_APPLIED */ }
7056
+ if (sourceText === null) {
7057
+ const obs: MutationObservation = { entry, occurrences: 0, exitCode: null, failingCount: null };
7058
+ observations.push(obs);
7059
+ results.push(classifyMutationOutcome(obs));
7060
+ continue;
7061
+ }
7062
+ const applied = applyMutationToText(sourceText, entry.mutation.find, entry.mutation.replace);
7063
+ if (!applied.ok || applied.text === undefined) {
7064
+ const obs: MutationObservation = { entry, occurrences: applied.occurrences, exitCode: null, failingCount: null };
7065
+ observations.push(obs);
7066
+ results.push(classifyMutationOutcome(obs));
7067
+ continue;
7068
+ }
7069
+ // F-2 — rule-3 containment (SPEC "Never mutate the working tree"): `join(copyDir, file)` is
7070
+ // LEXICAL; a symlink cpSync preserved inside the package (or the intentionally symlinked
7071
+ // node_modules) makes it RESOLVE outside the scratch tree, and the "scratch" write would
7072
+ // follow the link and mutate the REAL working tree for the whole suite run — restored only
7073
+ // by the finally, so a SIGKILL mid-run leaves the real tree permanently mutated (MEASURED
7074
+ // pre-fix: a registry file behind a package-local symlink; the suite-run witness read the
7075
+ // mutated text from the REAL file). Same primitive as health-advisor lock.js's
7076
+ // realCaseDir/assertLockRootIsItself: decide on realpaths, refuse an escape — exit 2, a
7077
+ // registry/setup error, never a mutation.
7078
+ let realTarget: string | null = null;
7079
+ try { realTarget = realpathSync(filePath); } catch { /* vanished between read and here → refuse below */ }
7080
+ if (realTarget === null || (realTarget !== realScratchRoot && !realTarget.startsWith(realScratchRoot + sep))) {
7081
+ 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.`);
7082
+ }
7083
+ if (!json) write(`mutation-gate: ${entry.id} — mutating ${entry.file}, running suite …`);
7084
+ let run: { exitCode: number | null; output: string } | null = null;
7085
+ let parseError: string | undefined;
7086
+ try {
7087
+ writeFileSync(filePath, applied.text);
7088
+ // Route-a guard: the mutated file must still PARSE — a load failure reddens the whole
7089
+ // suite for structural, not behavioural, reasons, and must never read as discrimination.
7090
+ const check = parseCheckMutatedFile(filePath, applied.text);
7091
+ if (check.skipped !== undefined) {
7092
+ warnings.push(`${entry.id}: parse-check SKIPPED — ${check.skipped}`);
7093
+ if (!json) write(`mutation-gate: WARNING ${entry.id}: parse-check skipped — ${check.skipped}`);
7094
+ }
7095
+ if (check.error !== undefined) {
7096
+ parseError = check.error; // no suite run: the verdict is MUTATION_UNPARSEABLE regardless
7097
+ } else {
7098
+ run = runSuite();
7099
+ }
7100
+ } finally {
7101
+ writeFileSync(filePath, sourceText); // restore the COPY so the next entry starts pristine
7102
+ }
7103
+ // Route-a′ guard (round-6 rework): the file-load-vs-assertion signal is derived from THE
7104
+ // SAME RUN that produced the failing count — no isolated child, no environment mismatch,
7105
+ // nothing to disagree with itself (the round-5 isolated `import()` had three measured
7106
+ // false-PASS routes, all artifacts of the isolation environment differing from the runner).
7107
+ // 'file-load' ⇒ MUTATION_LOAD_FATAL (structural); 'unrecognised' ⇒ INCONCLUSIVE (a
7108
+ // runner-coverage gap of this tool, loud, never PROVEN); 'assertions' ⇒ behavioural, the
7109
+ // count-based verdicts apply.
7110
+ let fileLoadFailure: string | undefined;
7111
+ let outputUnrecognised: string | undefined;
7112
+ if (run !== null && run.exitCode !== null && run.exitCode !== 0) {
7113
+ const cls = classifyRunFailure(run.output);
7114
+ if (cls.kind === 'file-load') {
7115
+ fileLoadFailure = cls.evidence ?? 'test file failed to load (no evidence line)';
7116
+ } else if (cls.kind === 'unrecognised') {
7117
+ outputUnrecognised = cls.evidence ?? `no classifier for runner '${cls.runner}'`;
7118
+ }
7119
+ }
7120
+ // Route-b guard (per-entry mode): a red mutated run is attributable only if the RESTORED
7121
+ // tree reproduces green — otherwise a flaky neighbour may be what went red. Skipped when the
7122
+ // classification already failed the entry structurally (file-load / unrecognised): those
7123
+ // verdicts outrank the rebaseline check, so the extra suite run would buy nothing.
7124
+ let rebaselineExitCode: number | null | undefined;
7125
+ if (rebaselineMode === 'per-entry' && run !== null && run.exitCode !== null && run.exitCode !== 0
7126
+ && fileLoadFailure === undefined && outputUnrecognised === undefined) {
7127
+ if (!json) write(`mutation-gate: ${entry.id} — re-baselining the restored tree …`);
7128
+ rebaselineExitCode = runSuite().exitCode;
7129
+ }
7130
+ const obs: MutationObservation = {
7131
+ entry,
7132
+ occurrences: 1,
7133
+ exitCode: run === null ? null : run.exitCode,
7134
+ failingCount: run === null ? null : countFailingTests(run.output),
7135
+ ...(parseError !== undefined ? { parseError } : {}),
7136
+ ...(fileLoadFailure !== undefined ? { fileLoadFailure } : {}),
7137
+ ...(outputUnrecognised !== undefined ? { outputUnrecognised } : {}),
7138
+ ...(rebaselineExitCode !== undefined ? { rebaselineExitCode } : {}),
7139
+ };
7140
+ observations.push(obs);
7141
+ results.push(classifyMutationOutcome(obs));
7142
+ }
7143
+
7144
+ // Route-b guard (final mode): one re-run after all entries. Not green ⇒ EVERY red-based
7145
+ // verdict of this run is downgraded (any of them may have been the flake, and there is no
7146
+ // per-entry evidence to say which) — re-classifying with the final exit turns them
7147
+ // INCONCLUSIVE while leaving NOT_APPLIED / UNDEFENDED / MUTATION_UNPARSEABLE /
7148
+ // MUTATION_LOAD_FATAL untouched.
7149
+ if (rebaselineMode === 'final') {
7150
+ if (!json) write('mutation-gate: final re-baseline of the restored tree …');
7151
+ const finalExit = runSuite().exitCode;
7152
+ if (finalExit !== 0) {
7153
+ const what = finalExit === null ? 'no exit code' : `exit ${finalExit}`;
7154
+ warnings.push(`final re-baseline NOT green (${what}) — the suite is flaky; red-based verdicts downgraded to INCONCLUSIVE`);
7155
+ if (!json) write(`mutation-gate: final re-baseline NOT green (${what}) — red-based verdicts downgraded to INCONCLUSIVE`);
7156
+ const reclassified = observations.map((obs) => classifyMutationOutcome({ ...obs, rebaselineExitCode: finalExit }));
7157
+ results.length = 0;
7158
+ results.push(...reclassified);
7159
+ }
7160
+ }
7161
+ } finally {
7162
+ if (flags.has('keep-scratch')) {
7163
+ write(`mutation-gate: scratch copy kept at ${copyDir}`);
7164
+ } else {
7165
+ try { rmSync(scratchParent, { recursive: true, force: true }); } catch { /* best effort */ }
7166
+ }
7167
+ }
7168
+
7169
+ const exitCode = mutationGateExitCode(results, baseline.ok);
7170
+ if (json) {
7171
+ write(JSON.stringify({ packageDir: pkgDir, registryPath, testCommand: testCmd, rebaselineMode, baseline, results, summary: summarizeMutationResults(results), warnings, exitCode }, null, 2));
7172
+ return exitCode;
7173
+ }
7174
+ write(renderMutationReport(results, baseline, pkgDir));
7175
+ return exitCode;
7176
+ }
7177
+
6267
7178
  /**
6268
7179
  * `dz delivery-check` — the portable Step-10 Delivery Gate (feature portable-gates). The `manual` form that
6269
7180
  * travels to every `shell` target: the deterministic parts (artifact probes, hand-off arithmetic,
@@ -7343,6 +8254,21 @@ async function cmdBacklog(options: Map<string, string>, flags: Set<string>, cwd:
7343
8254
  const eff = parseEffort(options.get('effort'), cfg.roulette.defaultEffort);
7344
8255
  if (eff.adjusted && !json && eff.note !== undefined) write(`dz backlog: ${eff.note}`);
7345
8256
  const dryRun = flags.has('dry-run');
8257
+ // Embed-form migration (register-inflation fix): v1 vectors are FULL-TEXT embeds, v2 queries are
8258
+ // bounded excerpts — comparing across the forms is a query-vs-row space split. Re-mirror once
8259
+ // (idempotent upsert), before the dedup search. Dry-run writes nothing, so it only WARNS.
8260
+ if (dryRun) {
8261
+ if (readBacklogEmbedFormVersion(projectRoot) < DEDUP_EMBED_FORM_VERSION && readIdeas(projectRoot).length > 0 && !json) {
8262
+ 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`);
8263
+ }
8264
+ } else {
8265
+ const form = await ensureBacklogEmbedForm(projectRoot);
8266
+ if (form.action === 'migrated' && !json) {
8267
+ write(`dz backlog: re-embedded ${form.remirrored} idea vector(s) into the bounded dedup embed form (v${form.version})`);
8268
+ } else if (form.action === 'deferred' && !json) {
8269
+ write(`dz backlog: ⚠ embed-form migration deferred (${form.error ?? 'unknown error'}) — semantic dedup may compare against stale full-text vectors`);
8270
+ }
8271
+ }
7346
8272
  const verdict = await dedupIdea(projectRoot, text, cfg);
7347
8273
  // The TOP-MATCH pair (id @ cosine) is the ADR-002 calibration surface (idea ce914ac2) — observational
7348
8274
  // only: the band itself is unchanged, but a RELATED verdict now shows WHICH idea produced the cosine.
@@ -7355,16 +8281,35 @@ async function cmdBacklog(options: Map<string, string>, flags: Set<string>, cwd:
7355
8281
  // DUPLICATE ⇒ snapshot + reinforce the existing root; NO new record (ADR-002 T-002b).
7356
8282
  const ideas = readIdeas(projectRoot);
7357
8283
  const match = ideas.find((i) => i.id === verdict.matchedId);
8284
+ let absorbErr: string | undefined;
7358
8285
  if (!dryRun && match !== undefined) {
7359
8286
  const snap = snapshotIdeas(projectRoot, join(projectRoot, '.dz', 'backlog', `ideas.pre-merge-${Date.now()}.jsonl`));
7360
8287
  if (snap.error !== undefined) return emitErr(snap.error);
8288
+ // The absorbed TEXT is preserved (absorbed.jsonl) — a duplicate verdict must never destroy
8289
+ // user text: two documented false absorptions (2026-08-05, 2026-08-11) were unrecoverable.
8290
+ absorbErr = recordAbsorption(projectRoot, {
8291
+ ts: new Date().toISOString(),
8292
+ matchedId: match.id,
8293
+ cosine: verdict.cosine,
8294
+ ...(verdict.containment !== undefined ? { containment: verdict.containment } : {}),
8295
+ ...(verdict.subsetMatch === true ? { subsetMatch: true } : {}),
8296
+ text,
8297
+ }).error;
7361
8298
  match.uses += 1;
7362
8299
  writeIdeas(projectRoot, ideas);
7363
8300
  }
7364
- if (json) write(JSON.stringify({ action: 'duplicate', matchedId: verdict.matchedId, cosine: verdict.cosine, ...(topMatch !== undefined ? { topMatch } : {}), ...(eff.note !== undefined ? { effortNote: eff.note } : {}), exitCode: 0 }, null, 2));
8301
+ if (json) 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));
7365
8302
  else {
7366
- write(`dz backlog: DUPLICATE of ${verdict.matchedId} (cosine ${verdict.cosine.toFixed(3)}${verdict.exactTextOnly ? ', exact-text' : ''}) — reinforced, no new record`);
8303
+ const via = verdict.subsetMatch === true
8304
+ ? `subset match: containment ${(verdict.containment ?? 0).toFixed(3)} ≥ ${cfg.dedup.subsetContainment}, cosine ${verdict.cosine.toFixed(3)}`
8305
+ : `cosine ${verdict.cosine.toFixed(3)}${verdict.exactTextOnly ? ', exact-text' : ''}`;
8306
+ write(`dz backlog: DUPLICATE of ${verdict.matchedId} (${via}) — reinforced, no new record`);
7367
8307
  if (topMatch !== undefined) write(` top match ${topMatch.id} @ cosine ${topMatch.cosine.toFixed(3)} (DUPLICATE band ≥ ${cfg.dedup.duplicateThreshold})`);
8308
+ if (!dryRun) {
8309
+ write(absorbErr === undefined
8310
+ ? ' absorbed text kept in .dz/backlog/absorbed.jsonl (re-add it from there if this verdict was wrong)'
8311
+ : ` ⚠ could NOT log the absorbed text (${absorbErr}) — if this verdict is wrong, the wording above is the only copy`);
8312
+ }
7368
8313
  }
7369
8314
  return 0;
7370
8315
  }
@@ -7385,13 +8330,19 @@ async function cmdBacklog(options: Map<string, string>, flags: Set<string>, cwd:
7385
8330
  };
7386
8331
  const proposal = options.get('proposal');
7387
8332
  if (proposal !== undefined) rec.proposal = proposal; // agent prose ONLY — the CLI never fabricates it
8333
+ // A demotion (≥-threshold cosine that failed lexical corroboration) is NEVER silent — it is the
8334
+ // register-only false-duplicate surface (the 2026-08-05 zombie x publish-gate absorption).
8335
+ const demotedLine = verdict.demoted !== undefined
8336
+ ? ` 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`
8337
+ : undefined;
7388
8338
  if (dryRun) {
7389
- if (json) write(JSON.stringify({ action: verdict.action, dryRun: true, idea: rec, ...(topMatch !== undefined ? { topMatch } : {}), ...(eff.note !== undefined ? { effortNote: eff.note } : {}), exitCode: 0 }, null, 2));
8339
+ if (json) 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));
7390
8340
  else {
7391
8341
  write(`dz backlog (dry-run): ${verdict.action.toUpperCase()} — would create ${rec.id}; align ${rec.goalAlignment.toFixed(3)}${rec.goalId !== null ? ` → ${rec.goalId}` : ''}`);
7392
8342
  // The calibration surface belongs on the dry-run too (QE LOW-8) — a dry-run is exactly where a
7393
8343
  // user checks whether a near-duplicate should have crossed the band.
7394
8344
  if (topMatch !== undefined) write(` top match ${topMatch.id} @ cosine ${topMatch.cosine.toFixed(3)} (DUPLICATE band ≥ ${cfg.dedup.duplicateThreshold})`);
8345
+ if (demotedLine !== undefined) write(demotedLine);
7395
8346
  }
7396
8347
  return 0;
7397
8348
  }
@@ -7402,12 +8353,13 @@ async function cmdBacklog(options: Map<string, string>, flags: Set<string>, cwd:
7402
8353
  ideas.push(rec);
7403
8354
  writeIdeas(projectRoot, ideas);
7404
8355
  const mirror = await mirrorIdeaVector(projectRoot, rec); // best-effort — never blocks capture
7405
- if (json) 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));
8356
+ if (json) 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));
7406
8357
  else {
7407
8358
  write(`dz backlog: ${verdict.action.toUpperCase()} — captured ${rec.id}`);
7408
8359
  if (verdict.action === 'related' && topMatch !== undefined) {
7409
8360
  write(` top match ${topMatch.id} @ cosine ${topMatch.cosine.toFixed(3)} (DUPLICATE band ≥ ${cfg.dedup.duplicateThreshold})`);
7410
8361
  }
8362
+ if (demotedLine !== undefined) write(demotedLine);
7411
8363
  if (rec.goalId !== null) write(` top goal: ${rec.goalId} (alignment ${rec.goalAlignment.toFixed(3)})`);
7412
8364
  if (verdict.relatedIds.length > 0) write(` related: ${verdict.relatedIds.join(', ')}`);
7413
8365
  if (ignore.action === 'created' || ignore.action === 'appended') {
@@ -7580,6 +8532,37 @@ async function cmdBacklog(options: Map<string, string>, flags: Set<string>, cwd:
7580
8532
  return 0;
7581
8533
  }
7582
8534
 
8535
+ // ── ship | drop | reopen — the status-transition surface (the missing verb that let the roulette
8536
+ // keep re-drawing already-shipped work: without it, work finished WITHOUT `roulette --commit` —
8537
+ // the normal flow — stayed `new` forever). ALL logic lives in transitionIdeas (harness-core):
8538
+ // short-prefix resolution (unique or a loud error), the IDEA_TRANSITIONS legality table,
8539
+ // idempotent no-ops, all-or-nothing fail-closed batches, line-preserving atomic writes.
8540
+ if (sub === 'ship' || sub === 'drop' || sub === 'reopen') {
8541
+ const prefixes: string[] = [];
8542
+ for (let i = 1; ; i += 1) {
8543
+ const p = options.get(`_positional_${i}`);
8544
+ if (p === undefined) break;
8545
+ prefixes.push(p);
8546
+ }
8547
+ if (prefixes.length === 0) return emitErr(`an idea id is required: dz backlog ${sub} <id> [<id>…]`);
8548
+ const report = transitionIdeas(projectRoot, sub, prefixes, {
8549
+ ...(options.get('reason') !== undefined ? { reason: options.get('reason')! } : {}),
8550
+ dryRun: flags.has('dry-run'),
8551
+ });
8552
+ if (json) {
8553
+ write(JSON.stringify({ verb: sub, ...report, exitCode: report.ok ? 0 : 1 }, null, 2));
8554
+ return report.ok ? 0 : 1;
8555
+ }
8556
+ for (const e of report.errors) write(`dz backlog ${sub}: ${e}`);
8557
+ for (const c of report.changes) {
8558
+ if (c.action === 'noop') write(`dz backlog ${sub}: ${c.id} is already ${c.to} — no-op`);
8559
+ else write(`dz backlog ${sub}${report.dryRun ? ' (dry-run)' : ''}: ${c.id} ${c.from} → ${c.to} ${c.text}`);
8560
+ }
8561
+ if (!report.ok) write(` nothing was written (all-or-nothing: fix the batch and re-run)`);
8562
+ else if (report.dryRun && report.changes.some((c) => c.action === 'transitioned')) write(' (dry-run — nothing written; re-run without --dry-run to apply)');
8563
+ return report.ok ? 0 : 1;
8564
+ }
8565
+
7583
8566
  if (sub === 'enrich') {
7584
8567
  const id = options.get('_positional_1');
7585
8568
  if (id === undefined) return emitErr('an idea id is required: dz backlog enrich <id>');
@@ -7625,6 +8608,11 @@ async function cmdBacklog(options: Map<string, string>, flags: Set<string>, cwd:
7625
8608
  if (sub === 'harmonize') {
7626
8609
  const apply = flags.has('apply');
7627
8610
  const thr = options.get('threshold');
8611
+ // Harmonize is the batch maintenance surface — migrate the mirrored vectors to the current
8612
+ // bounded embed form here too (idempotent; a deferral is warned, never fatal).
8613
+ const form = await ensureBacklogEmbedForm(projectRoot);
8614
+ if (form.action === 'migrated' && !json) write(`dz backlog: re-embedded ${form.remirrored} idea vector(s) into the bounded dedup embed form (v${form.version})`);
8615
+ else if (form.action === 'deferred' && !json) write(`dz backlog: ⚠ embed-form migration deferred (${form.error ?? 'unknown error'})`);
7628
8616
  const report = await harmonizeBacklog(projectRoot, { apply, ...(thr !== undefined ? { threshold: Number(thr) } : {}) });
7629
8617
  if (json) {
7630
8618
  write(JSON.stringify({ ...report, exitCode: 0 }, null, 2));
@@ -7645,6 +8633,9 @@ async function cmdBacklog(options: Map<string, string>, flags: Set<string>, cwd:
7645
8633
  write(' show <id> full record');
7646
8634
  write(' goals [--validate] the compass (.dz/backlog/goals.json)');
7647
8635
  write(' roulette [--pick N][--seed n][--commit <id>] weighted draw; --commit takes the id you saw');
8636
+ 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');
8637
+ write(' drop <id> [<id>…] [--reason <t>][--dry-run] retire an idea (→ dropped)');
8638
+ write(' reopen <id> [<id>…] [--reason <t>][--dry-run] back to the pool (shipped|dropped|in-progress → new)');
7648
8639
  write(' enrich <id> stage the idea2prd hand-off (agent expands)');
7649
8640
  write(` jira <id> draft a Jira issue (adapter: ${[...BACKLOG_BACKENDS].join('|')})`);
7650
8641
  write(' harmonize [--apply][--threshold 0-1] batch semantic dedup of the backlog');
@@ -7991,12 +8982,16 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
7991
8982
  return await cmdScout(options, flags, cwd, write);
7992
8983
  case 'workflow':
7993
8984
  return cmdWorkflow(options, flags, cwd, write);
8985
+ case 'workflow-lint':
8986
+ return cmdWorkflowLint(options, flags, cwd, write);
8987
+ case 'workflow-trace':
8988
+ return cmdWorkflowTrace(options, flags, cwd, write);
7994
8989
  case 'migrate':
7995
8990
  return cmdMigrate(options, cwd, write);
7996
8991
  case 'doctor':
7997
8992
  return await cmdDoctor(options, flags, cwd, write);
7998
8993
  case 'install':
7999
- return await cmdInstall(options, flags, cwd, write);
8994
+ return await cmdInstall(options, flags, cwd, write, io.installRunner);
8000
8995
  case 'bundle':
8001
8996
  return cmdBundle(options, flags, cwd, write);
8002
8997
  case 'teach':
@@ -8075,6 +9070,8 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
8075
9070
  return cmdChallenge(options, flags, cwd, write);
8076
9071
  case 'discrimination-check':
8077
9072
  return cmdDiscriminationCheck(options, flags, cwd, write);
9073
+ case 'mutation-gate':
9074
+ return cmdMutationGate(options, flags, cwd, write);
8078
9075
  case 'delivery-check':
8079
9076
  return cmdDeliveryCheck(options, flags, cwd, write);
8080
9077
  case 'skills-verify':