@dzhechkov/harness-cli 0.4.6 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.ts CHANGED
@@ -15,8 +15,13 @@ import { createRequire } from 'node:module';
15
15
  import {
16
16
  createSkill,
17
17
  getSkillInfo,
18
- isTargetName,
19
- listSkills,
18
+ listSkillsDetailed,
19
+ formatSkillLoadFailures,
20
+ formatSkillApplyFailures,
21
+ resolveTargetName,
22
+ formatTargetProblem,
23
+ formatTargetAliasNote,
24
+ TARGET_NAMES_SORTED,
20
25
  runDoctor,
21
26
  runInit,
22
27
  resolvePackageSkillRoots,
@@ -49,6 +54,11 @@ import {
49
54
  runVerify,
50
55
  runInitAgentsMd,
51
56
  runInitGeminiMd,
57
+ runSyncAgentsPolicy,
58
+ runSyncCodexHooks,
59
+ POLICY_SOURCES,
60
+ detectPolicyDrift,
61
+ hasPolicyFence,
52
62
  TARGET_NAMES,
53
63
  buildParityMatrix,
54
64
  TARGET_CAPABILITIES,
@@ -215,6 +225,7 @@ import {
215
225
  buildChallengeBrief,
216
226
  planDiscriminationCheck,
217
227
  classifyDiscrimination,
228
+ classifyExecutionEvidence,
218
229
  pickAdversaryModel,
219
230
  CHALLENGE_QUESTIONS,
220
231
  loadOutcomes,
@@ -343,7 +354,9 @@ import {
343
354
  planImport,
344
355
  } from '@dzhechkov/harness-core';
345
356
  import type { MutationEntryResult, MutationObservation, MutationRegistryEntry } from '@dzhechkov/harness-core';
357
+ import type { SkillApplyFailure, SkillLoadFailure } from '@dzhechkov/harness-core';
346
358
  import type { ReqeDebt } from '@dzhechkov/harness-core';
359
+ import type { ClassifyResultRow, ExecutionEvidence } from '@dzhechkov/harness-core';
347
360
  import type { IdeaRecord, IdeaStatus } from '@dzhechkov/harness-core';
348
361
  import type { Family, ModelRung, Candidate as BtoCandidate, DimScores } from '@dzhechkov/harness-core';
349
362
  import type { SetupSpec } from '@dzhechkov/harness-core';
@@ -441,6 +454,8 @@ Usage:
441
454
  (build-time: reconcile project grants vs installed skills' declared capabilities; dz never enforces — the host does)
442
455
  dz sync-upstream [--package <dir>] [--list] [--all]
443
456
  dz drift-check [--json] [--project <dir>] (CI gate: exit 1 if any shared skill drifted between its monorepo copies)
457
+ dz agents-sync [--project <dir>] [--check] [--json] (sync/verify the always-on policy fence in root AGENTS.md; exit 0 synced/written, 1 drift, 3 inconclusive)
458
+ dz hooks-sync --target codex [--check] [--verify] [--remove] [--json] (install/verify the dz veto + recall hooks in $CODEX_HOME/hooks.json; exit 0 armed+trusted, 1 not armed/drift, 3 inconclusive)
444
459
  dz sync-canonical <skill> [--check] [--from <dir>] [--auto] [--project <dir>] (heal every copy from skills-meta/<skill> or --from; no canonical + --check = compare copies to each other (exit 1 on drift); no canonical + write = refuse unless --auto (LOUD, picks most-complete copy); --check writes nothing)
445
460
  dz plugin [--version <ver>]
446
461
  dz downloads
@@ -461,6 +476,18 @@ Presets: ${PRESET_NAMES.join(', ')}`;
461
476
  export interface CliIo {
462
477
  readonly cwd?: string;
463
478
  readonly write?: (line: string) => void;
479
+ /**
480
+ * Diagnostics sink — **stderr**, defaulting to `console.error`.
481
+ *
482
+ * Before feature dz-cli-defects `CliIo` had no stderr seam at all, so every
483
+ * diagnostic (including the top-level error handler) landed on stdout and
484
+ * `dz list > skills.txt` wrote the error INTO the data file. `write` stays "data
485
+ * only"; `writeErr` is "diagnosis only".
486
+ *
487
+ * There is deliberately **no** fall-back to `write`: a test that wants to assert on
488
+ * stderr must inject `writeErr`, or the assertion would be theatre.
489
+ */
490
+ readonly writeErr?: (line: string) => void;
464
491
  /**
465
492
  * Pre-read STDIN content (injectable so `dz brain ground`'s hook path is testable without
466
493
  * an actual pipe). When omitted, the CLI reads fd 0 synchronously — but only for the one
@@ -531,6 +558,8 @@ function parseArgs(argv: string[]): ParsedArgs {
531
558
  }
532
559
 
533
560
  type Write = (line: string) => void;
561
+ /** Mirrors {@link Write}, but for the stderr seam (see {@link CliIo.writeErr}). */
562
+ type WriteErr = (line: string) => void;
534
563
 
535
564
  /**
536
565
  * Discover skill source directories: explicit `--skills-dir` if given, else
@@ -560,6 +589,14 @@ interface InstallSkillsResult {
560
589
  readonly skipped: number;
561
590
  /** Selected ids found in NO searched directory (empty when no `select` was given). */
562
591
  readonly missing: string[];
592
+ /** Skill dirs that could not be LOADED, across every searched directory (D1). */
593
+ readonly failures: SkillLoadFailure[];
594
+ /**
595
+ * Skills that loaded but could not be COMPILED or WRITTEN (fix round 1, QE F4).
596
+ * A separate list because it accuses a different artifact — the target tree, not
597
+ * the source `SKILL.md`.
598
+ */
599
+ readonly applyFailures: SkillApplyFailure[];
563
600
  }
564
601
 
565
602
  /**
@@ -597,10 +634,12 @@ async function installSkills(opts: {
597
634
  let written = 0;
598
635
  let skipped = 0;
599
636
  for (const s of results) { written += s.written; skipped += s.skipped; }
600
- return { results, dirsSearched: skillsDirs.length, written, skipped, missing: [...report.missing] };
637
+ return { results, dirsSearched: skillsDirs.length, written, skipped, missing: [...report.missing], failures: [...report.failures], applyFailures: [...report.applyFailures] };
601
638
  }
602
639
 
603
640
  const results: { id: string; written: number; skipped: number }[] = [];
641
+ const failures: SkillLoadFailure[] = [];
642
+ const applyFailures: SkillApplyFailure[] = [];
604
643
  for (const skillsDir of skillsDirs) {
605
644
  const r = await runInit({
606
645
  target,
@@ -613,6 +652,8 @@ async function installSkills(opts: {
613
652
  for (const skill of r.skills) {
614
653
  results.push({ id: skill.id, written: skill.written.length, skipped: skill.skipped.length });
615
654
  }
655
+ failures.push(...r.failures);
656
+ applyFailures.push(...r.applyFailures);
616
657
  }
617
658
 
618
659
  let written = 0;
@@ -620,7 +661,7 @@ async function installSkills(opts: {
620
661
  for (const s of results) { written += s.written; skipped += s.skipped; }
621
662
  const installed = new Set(results.map((s) => s.id));
622
663
  const missing = select !== undefined ? [...select].filter((id) => !installed.has(id)) : [];
623
- return { results, dirsSearched: skillsDirs.length, written, skipped, missing };
664
+ return { results, dirsSearched: skillsDirs.length, written, skipped, missing, failures, applyFailures };
624
665
  }
625
666
 
626
667
  /** Warn about preset/select ids that weren't found in any installed pack. */
@@ -636,12 +677,22 @@ function writeMissingSkillsHint(write: Write, missing: string[], presetName: str
636
677
  }
637
678
  }
638
679
 
639
- async function cmdInit(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): Promise<number> {
640
- const target = options.get('target');
641
- if (target === undefined || !isTargetName(target)) {
642
- write(`dz init: --target must be one of: ${TARGET_NAMES.join(', ')}`);
680
+ async function cmdInit(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write, writeErr: WriteErr): Promise<number> {
681
+ const targetOpt = options.get('target');
682
+ if (targetOpt === undefined) {
683
+ // A missing `--target` is the same accusation as an unresolvable one, so it takes
684
+ // the same channel: diagnostics on stderr, stdout stays a data channel (ADR-002
685
+ // §Decision 2 / driver D6; fix round 1, QE F2).
686
+ writeErr(`dz init: --target must be one of: ${TARGET_NAMES_SORTED.join(', ')}`);
643
687
  return 1;
644
688
  }
689
+ const resolution = resolveTargetName(targetOpt);
690
+ if (resolution.kind === 'unknown') {
691
+ for (const line of formatTargetProblem('dz init', resolution)) writeErr(line);
692
+ return 1;
693
+ }
694
+ const target = resolution.target;
695
+ if (resolution.via === 'alias') writeErr(formatTargetAliasNote('dz init', targetOpt, target));
645
696
  const explicitSkillsDir = options.get('skills-dir');
646
697
  const projectRoot = resolve(cwd, options.get('project') ?? '.');
647
698
 
@@ -679,19 +730,43 @@ async function cmdInit(options: Map<string, string>, flags: Set<string>, cwd: st
679
730
  write(` (searched ${r.dirsSearched} skill directories)`);
680
731
  }
681
732
  writeMissingSkillsHint(write, r.missing, presetName);
733
+ // Skip-and-collect must not become skip-and-SILENCE: a skill that failed to load is
734
+ // named on stderr and the command exits 1 (it exited 1 before too — by throwing).
735
+ if (r.failures.length > 0 || r.applyFailures.length > 0) {
736
+ // Counts first, then the named block — the same shape `dz list` uses. The block's
737
+ // own header already says "N skipped", so this line carries what it cannot: how many
738
+ // DID install, so a reader can tell a mostly-fine install from a mostly-broken one.
739
+ //
740
+ // The two failure kinds are counted and rendered SEPARATELY (fix round 1, QE F4):
741
+ // an unwritable target directory is not a broken pack, and printing it as one names
742
+ // the wrong file.
743
+ const parts = [`${r.results.length} installed`];
744
+ if (r.failures.length > 0) parts.push(`${r.failures.length} skipped`);
745
+ if (r.applyFailures.length > 0) parts.push(`${r.applyFailures.length} failed to write`);
746
+ writeErr(`dz init: ${parts.join(', ')}`);
747
+ for (const line of formatSkillLoadFailures(r.failures)) writeErr(line);
748
+ for (const line of formatSkillApplyFailures(r.applyFailures)) writeErr(line);
749
+ return 1;
750
+ }
682
751
  return 0;
683
752
  }
684
753
 
685
- async function cmdVerify(options: Map<string, string>, cwd: string, write: Write): Promise<number> {
754
+ async function cmdVerify(options: Map<string, string>, cwd: string, write: Write, writeErr: WriteErr): Promise<number> {
686
755
  const skillsDir = resolve(cwd, options.get('skills-dir') ?? '.claude/skills');
687
756
  const targetOpt = options.get('target');
688
- if (targetOpt !== undefined && !isTargetName(targetOpt)) {
689
- write(`dz verify: --target must be one of: ${TARGET_NAMES.join(', ')}`);
690
- return 1;
757
+ let target: TargetName | undefined;
758
+ if (targetOpt !== undefined) {
759
+ const resolution = resolveTargetName(targetOpt);
760
+ if (resolution.kind === 'unknown') {
761
+ for (const line of formatTargetProblem('dz verify', resolution)) writeErr(line);
762
+ return 1;
763
+ }
764
+ target = resolution.target;
765
+ if (resolution.via === 'alias') writeErr(formatTargetAliasNote('dz verify', targetOpt, target));
691
766
  }
692
767
  const report = await runVerify({
693
768
  skillsDir,
694
- ...(targetOpt !== undefined ? { target: targetOpt } : {}),
769
+ ...(target !== undefined ? { target } : {}),
695
770
  });
696
771
  write(`dz verify (${report.target}): ${report.valid}/${report.total} skill(s) valid`);
697
772
  for (const skill of report.skills) {
@@ -700,7 +775,7 @@ async function cmdVerify(options: Map<string, string>, cwd: string, write: Write
700
775
  return report.valid === report.total ? 0 : 1;
701
776
  }
702
777
 
703
- async function cmdSync(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): Promise<number> {
778
+ async function cmdSync(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write, writeErr: WriteErr): Promise<number> {
704
779
  const projectRoot = resolve(cwd, options.get('project') ?? '.');
705
780
  const canonicalArg = options.get('canonical');
706
781
 
@@ -727,6 +802,14 @@ async function cmdSync(options: Map<string, string>, flags: Set<string>, cwd: st
727
802
  write(
728
803
  `dz sync${report.dryRun ? ' --dry-run' : ''}: ${inSync}/${total} in sync, ${missing} missing, ${drift} drift`,
729
804
  );
805
+ // Skip-and-collect (D1): the broken canonical skills are NAMED on stderr, and their
806
+ // presence keeps the exit code non-zero — a partial sync is not a clean sync.
807
+ if (report.failures.length > 0) {
808
+ // See `cmdInit` above: counts here, names in the block below.
809
+ writeErr(`dz sync: ${report.skills.length} compared, ${report.failures.length} skipped`);
810
+ for (const line of formatSkillLoadFailures(report.failures)) writeErr(line);
811
+ return 1;
812
+ }
730
813
  return missing === 0 && drift === 0 ? 0 : 1;
731
814
  }
732
815
 
@@ -773,19 +856,42 @@ function cmdCreateSkill(options: Map<string, string>, flags: Set<string>, cwd: s
773
856
  return 0;
774
857
  }
775
858
 
776
- function cmdList(options: Map<string, string>, cwd: string, write: Write): number {
859
+ /**
860
+ * `dz list` — skip-and-collect (feature dz-cli-defects, D1).
861
+ *
862
+ * One unparseable `SKILL.md` used to discard the ENTIRE listing with a message naming
863
+ * neither the file nor the count. Now the parseable skills list on stdout and the
864
+ * broken ones are named on stderr. The whole emit contract, in one place:
865
+ *
866
+ * | valid | skipped | stdout | stderr | exit |
867
+ * |-------|---------|--------|--------|------|
868
+ * | >0 | 0 | listing | *empty* | 0 |
869
+ * | >0 | >0 | listing of the valid ones | named summary | 1 |
870
+ * | 0 | >0 | *nothing* | named summary | 1 |
871
+ * | 0 | 0 | *nothing* | `no skills found in <dir>` | 1 |
872
+ *
873
+ * The last row is the ONE intentional departure from byte-identical output: that line
874
+ * used to go to stdout. Moving it keeps *stdout is data, stderr is diagnosis* whole —
875
+ * the invariant that makes `dz list > out.txt` trustworthy.
876
+ */
877
+ function cmdList(options: Map<string, string>, cwd: string, write: Write, writeErr: WriteErr): number {
777
878
  const skillsDir = resolve(cwd, options.get('skills-dir') ?? '.claude/skills');
778
- const skills = listSkills(skillsDir);
779
- if (skills.length === 0) {
780
- write(`dz list: no skills found in ${skillsDir}`);
879
+ const { skills, failures } = listSkillsDetailed(skillsDir);
880
+ if (skills.length === 0 && failures.length === 0) {
881
+ writeErr(`dz list: no skills found in ${skillsDir}`);
781
882
  return 1;
782
883
  }
783
- write(`${skills.length} skill(s) in ${skillsDir}:\n`);
784
- for (const skill of skills) {
785
- const desc = skill.description.length > 80 ? skill.description.slice(0, 77) + '...' : skill.description;
786
- write(` ${skill.id.padEnd(35)} ${desc}`);
884
+ if (skills.length > 0) {
885
+ write(`${skills.length} skill(s) in ${skillsDir}:\n`);
886
+ for (const skill of skills) {
887
+ const desc = skill.description.length > 80 ? skill.description.slice(0, 77) + '...' : skill.description;
888
+ write(` ${skill.id.padEnd(35)} ${desc}`);
889
+ }
787
890
  }
788
- return 0;
891
+ if (failures.length === 0) return 0;
892
+ writeErr(`dz list: ${skills.length} listed, ${failures.length} skipped in ${skillsDir}`);
893
+ for (const line of formatSkillLoadFailures(failures)) writeErr(line);
894
+ return 1;
789
895
  }
790
896
 
791
897
  function cmdInfo(options: Map<string, string>, args: ParsedArgs, cwd: string, write: Write): number {
@@ -1599,6 +1705,7 @@ async function cmdInstall(
1599
1705
  flags: Set<string>,
1600
1706
  cwd: string,
1601
1707
  write: Write,
1708
+ writeErr: WriteErr,
1602
1709
  installRunner?: (command: string, cwd: string) => void,
1603
1710
  ): Promise<number> {
1604
1711
  const pkg = options.get('_positional_0');
@@ -1608,10 +1715,13 @@ async function cmdInstall(
1608
1715
  }
1609
1716
 
1610
1717
  const targetOpt = options.get('target') ?? 'claude-code';
1611
- if (!isTargetName(targetOpt)) {
1612
- write(`dz install: --target must be one of: ${TARGET_NAMES.join(', ')}`);
1718
+ const targetResolution = resolveTargetName(targetOpt);
1719
+ if (targetResolution.kind === 'unknown') {
1720
+ for (const line of formatTargetProblem('dz install', targetResolution)) writeErr(line);
1613
1721
  return 1;
1614
1722
  }
1723
+ const target = targetResolution.target;
1724
+ if (targetResolution.via === 'alias') writeErr(formatTargetAliasNote('dz install', targetOpt, target));
1615
1725
 
1616
1726
  const projectRoot = resolve(cwd, options.get('project') ?? '.');
1617
1727
 
@@ -1647,7 +1757,7 @@ async function cmdInstall(
1647
1757
 
1648
1758
  // Step 3: Use dz init with the resolved skills root as source
1649
1759
  const report = await runInit({
1650
- target: targetOpt,
1760
+ target,
1651
1761
  skillsDir: root.dir,
1652
1762
  projectRoot,
1653
1763
  force: flags.has('force'),
@@ -1666,21 +1776,36 @@ async function cmdInstall(
1666
1776
  if (root.layout === 'npx-template' && root.hasCompanionAssets) {
1667
1777
  write(` note: ${pkg} also ships commands/hooks/agents — \`npx -y ${pkg} init\` installs the full kit.`);
1668
1778
  }
1779
+ // Skip-and-collect at install time (D1 / the report's D2 amendment): the offending
1780
+ // SKILL.md came out of the DOWNLOADED TARBALL, so the path is rendered relative to
1781
+ // the package root (a `node_modules/**` absolute path is not actionable) and the
1782
+ // message says whose defect it is. Exit 1 — a pack that shipped an unloadable skill
1783
+ // did not fully install.
1784
+ if (report.failures.length > 0) {
1785
+ writeErr(`dz install: ${pkg} ships ${report.failures.length} unparseable skill(s) —`);
1786
+ for (const line of formatSkillLoadFailures(report.failures, { relativeTo: pkgDir })) writeErr(line);
1787
+ writeErr('This is a defect in the package, not in your project.');
1788
+ writeErr(`Workaround: npx -y ${pkg} init`);
1789
+ return 1;
1790
+ }
1669
1791
  return 0;
1670
1792
  }
1671
1793
 
1672
- function cmdCompose(options: Map<string, string>, cwd: string, write: Write): number {
1794
+ function cmdCompose(options: Map<string, string>, cwd: string, write: Write, writeErr: WriteErr): number {
1673
1795
  const combo = options.get('_positional_0');
1674
1796
  if (!combo) {
1675
1797
  write('dz compose: preset combination required (e.g., dz compose devops+mcp+web3)');
1676
1798
  return 1;
1677
1799
  }
1678
1800
  // --target is documented; honor it in the suggested install command (was hardcoded claude-code).
1679
- const target = options.get('target') ?? 'claude-code';
1680
- if (!isTargetName(target)) {
1681
- write(`dz compose: --target must be one of: ${TARGET_NAMES.join(', ')}`);
1801
+ const targetOpt = options.get('target') ?? 'claude-code';
1802
+ const composeResolution = resolveTargetName(targetOpt);
1803
+ if (composeResolution.kind === 'unknown') {
1804
+ for (const line of formatTargetProblem('dz compose', composeResolution)) writeErr(line);
1682
1805
  return 1;
1683
1806
  }
1807
+ const target = composeResolution.target;
1808
+ if (composeResolution.via === 'alias') writeErr(formatTargetAliasNote('dz compose', targetOpt, target));
1684
1809
  const presetNames = combo.split('+').map((s) => s.trim());
1685
1810
  const allSkills = new Set<string>();
1686
1811
  const resolved: string[] = [];
@@ -3897,12 +4022,19 @@ async function cmdBrain(
3897
4022
  return sub === undefined ? 0 : 1;
3898
4023
  }
3899
4024
 
3900
- async function cmdSetup(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): Promise<number> {
3901
- const target = options.get('target');
3902
- if (!target || !isTargetName(target)) {
3903
- write(`dz setup: --target required (${TARGET_NAMES.join(', ')})`);
4025
+ async function cmdSetup(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write, writeErr: WriteErr): Promise<number> {
4026
+ const targetOpt = options.get('target');
4027
+ if (!targetOpt) {
4028
+ writeErr(`dz setup: --target required (${TARGET_NAMES_SORTED.join(', ')})`);
3904
4029
  return 1;
3905
4030
  }
4031
+ const setupResolution = resolveTargetName(targetOpt);
4032
+ if (setupResolution.kind === 'unknown') {
4033
+ for (const line of formatTargetProblem('dz setup', setupResolution)) writeErr(line);
4034
+ return 1;
4035
+ }
4036
+ const target = setupResolution.target;
4037
+ if (setupResolution.via === 'alias') writeErr(formatTargetAliasNote('dz setup', targetOpt, target));
3906
4038
 
3907
4039
  const projectRoot = resolve(cwd, options.get('project') ?? '.');
3908
4040
  const presetName = options.get('preset');
@@ -4089,12 +4221,17 @@ function cmdRecommend(options: Map<string, string>, cwd: string, write: Write):
4089
4221
  return 0;
4090
4222
  }
4091
4223
 
4092
- function cmdUpgrade(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
4224
+ function cmdUpgrade(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write, writeErr: WriteErr): number {
4093
4225
  const targetOpt = options.get('target') ?? 'claude-code';
4094
- if (!isTargetName(targetOpt)) {
4095
- write(`dz upgrade: --target must be one of: ${TARGET_NAMES.join(', ')}`);
4226
+ const upgradeResolution = resolveTargetName(targetOpt);
4227
+ if (upgradeResolution.kind === 'unknown') {
4228
+ for (const line of formatTargetProblem('dz upgrade', upgradeResolution)) writeErr(line);
4096
4229
  return 1;
4097
4230
  }
4231
+ // The dir map is keyed by the RESOLVED name — keying it by the raw `--target` would
4232
+ // let an alias validate and then miss the map.
4233
+ const upgradeTarget = upgradeResolution.target;
4234
+ if (upgradeResolution.via === 'alias') writeErr(formatTargetAliasNote('dz upgrade', targetOpt, upgradeTarget));
4098
4235
  const projectRoot = resolve(cwd, options.get('project') ?? '.');
4099
4236
  const targetDirMap: Record<string, string> = {
4100
4237
  'claude-code': '.claude/skills', codex: '.agents/skills', opencode: '.opencode/skills',
@@ -4102,9 +4239,9 @@ function cmdUpgrade(options: Map<string, string>, flags: Set<string>, cwd: strin
4102
4239
  'agents-md': 'AGENTS.md', cursor: '.cursor/rules', gemini: 'GEMINI.md',
4103
4240
  windsurf: '.windsurf/rules',
4104
4241
  };
4105
- const mappedDir = targetDirMap[targetOpt];
4242
+ const mappedDir = targetDirMap[upgradeTarget];
4106
4243
  if (mappedDir === undefined) {
4107
- write(`dz upgrade: no skills directory mapping for target ${targetOpt}`);
4244
+ write(`dz upgrade: no skills directory mapping for target ${upgradeTarget}`);
4108
4245
  return 1;
4109
4246
  }
4110
4247
  const targetDir = join(projectRoot, mappedDir);
@@ -4122,7 +4259,7 @@ function cmdUpgrade(options: Map<string, string>, flags: Set<string>, cwd: strin
4122
4259
 
4123
4260
  const report = checkUpgrades(targetDir, canonicalDirs);
4124
4261
 
4125
- write(`\ndz upgrade — ${targetOpt} (${targetDir})`);
4262
+ write(`\ndz upgrade — ${upgradeTarget} (${targetDir})`);
4126
4263
  write(` Installed: ${report.installed} Needs update: ${report.needsUpdate} Up-to-date: ${report.upToDate} Custom: ${report.notInCanonical}\n`);
4127
4264
 
4128
4265
  for (const check of report.skills) {
@@ -4131,7 +4268,7 @@ function cmdUpgrade(options: Map<string, string>, flags: Set<string>, cwd: strin
4131
4268
  }
4132
4269
 
4133
4270
  if (report.needsUpdate > 0) {
4134
- write(`\n${report.needsUpdate} skill(s) need update. Run: dz init --target ${targetOpt} --force`);
4271
+ write(`\n${report.needsUpdate} skill(s) need update. Run: dz init --target ${upgradeTarget} --force`);
4135
4272
  }
4136
4273
  // ADR-001 (verify-apply-leg): verify what we just left on disk. A TAMPERED pack aborts.
4137
4274
  const sigFatal = reportPackVerification(projectRoot, options.get('pubkey'), flags.has('require-signing'), write);
@@ -4675,7 +4812,7 @@ function cmdPublish(options: Map<string, string>, flags: Set<string>, cwd: strin
4675
4812
  /* ADR-001): computed from the declarative model, never hand-written */
4676
4813
  /* ------------------------------------------------------------------ */
4677
4814
 
4678
- function cmdParity(options: Map<string, string>, flags: Set<string>, write: Write): number {
4815
+ function cmdParity(options: Map<string, string>, flags: Set<string>, write: Write, writeErr: WriteErr): number {
4679
4816
  const json = flags.has('json');
4680
4817
  if (flags.has('help')) {
4681
4818
  write('dz parity [--target <name>] [--json] — the computed feature×target map (never hand-written)');
@@ -4704,27 +4841,44 @@ function cmdParity(options: Map<string, string>, flags: Set<string>, write: Writ
4704
4841
  }
4705
4842
 
4706
4843
  const matrix = buildParityMatrix();
4707
- const target = options.get('target');
4708
- if (target !== undefined && !TARGET_NAMES.includes(target as (typeof TARGET_NAMES)[number])) {
4709
- if (json) write(JSON.stringify({ error: `unknown target "${target}"`, targets: TARGET_NAMES, exitCode: 1 }, null, 2));
4710
- else write(`dz parity: unknown target "${target}" one of: ${TARGET_NAMES.join(', ')}`);
4711
- return 1;
4844
+ // Site 8 of the D3 rewiring, closed in fix round 1 (QE F1). It shipped spelling its
4845
+ // own bare guard `TARGET_NAMES.includes(...)` and was therefore invisible to the AM-2
4846
+ // grep-guard, which searched for the token `isTargetName(` a PRESENCE check on one
4847
+ // spelling where the property was "no call site bypasses the resolver". The guard in
4848
+ // `test/target-alias-cli.test.ts` now checks the class, and the sweep list is derived
4849
+ // from the help text so a ninth command cannot be missed the same way.
4850
+ const targetOpt = options.get('target');
4851
+ let target: TargetName | undefined;
4852
+ if (targetOpt !== undefined) {
4853
+ const parityResolution = resolveTargetName(targetOpt);
4854
+ if (parityResolution.kind === 'unknown') {
4855
+ // Both forms go to stderr: an error is not data, and `dz parity --json | jq`
4856
+ // must not be fed a diagnostic (ADR-002 §Decision 2 / driver D6).
4857
+ if (json) {
4858
+ writeErr(JSON.stringify({ error: `unknown target ${JSON.stringify(targetOpt)}`, suggestion: parityResolution.suggestion, targets: TARGET_NAMES_SORTED, exitCode: 1 }, null, 2));
4859
+ } else {
4860
+ for (const line of formatTargetProblem('dz parity', parityResolution)) writeErr(line);
4861
+ }
4862
+ return 1;
4863
+ }
4864
+ target = parityResolution.target;
4865
+ if (parityResolution.via === 'alias') writeErr(formatTargetAliasNote('dz parity', targetOpt, target));
4712
4866
  }
4713
4867
 
4714
4868
  if (json) {
4715
4869
  const rows = matrix.map((r) => ({
4716
4870
  id: r.feature.id,
4717
4871
  title: r.feature.title,
4718
- cells: target !== undefined ? { [target]: r.cells[target as (typeof TARGET_NAMES)[number]] } : r.cells,
4872
+ cells: target !== undefined ? { [target]: r.cells[target] } : r.cells,
4719
4873
  }));
4720
4874
  // A filtered response stays internally consistent: capabilities are filtered too (Codex QE gap 9).
4721
- const caps = target !== undefined ? { [target]: TARGET_CAPABILITIES[target as (typeof TARGET_NAMES)[number]] } : TARGET_CAPABILITIES;
4875
+ const caps = target !== undefined ? { [target]: TARGET_CAPABILITIES[target] } : TARGET_CAPABILITIES;
4722
4876
  write(JSON.stringify({ targets: target !== undefined ? [target] : TARGET_NAMES, capabilities: caps, features: rows }, null, 2));
4723
4877
  return 0;
4724
4878
  }
4725
4879
 
4726
4880
  if (target !== undefined) {
4727
- const t = target as (typeof TARGET_NAMES)[number];
4881
+ const t = target;
4728
4882
  write(`\ndz parity — ${t} (capabilities: ${TARGET_CAPABILITIES[t].join(', ')})\n`);
4729
4883
  for (const r of matrix) {
4730
4884
  const c = r.cells[t];
@@ -5690,6 +5844,179 @@ function readDriftAllowlist(root: string): string[] {
5690
5844
  }
5691
5845
  }
5692
5846
 
5847
+ /** Refresh or verify the root AGENTS.md bearing-policy projection. */
5848
+ /**
5849
+ * `dz hooks-sync --target codex` (`crossrt-2-codex-hooks`, AM-14).
5850
+ *
5851
+ * ONE verb in the existing target vocabulary (`parity`, `delivery-check`, `--target`), extensible to
5852
+ * a future runtime without a third surface. **No alias** — `dz codex-hooks` resolves to nothing.
5853
+ *
5854
+ * Exit map (ADR-002 §5, pinned by test):
5855
+ * 0 = `armed` AND `trust: 'trusted'` — the ONLY outcome that may print a success word (AM-17)
5856
+ * 1 = not armed, armed-but-trust-pending, drift, or a refusal
5857
+ * 3 = inconclusive (including "no codex binary on PATH")
5858
+ */
5859
+ function cmdHooksSync(
5860
+ options: Map<string, string>,
5861
+ flags: Set<string>,
5862
+ cwd: string,
5863
+ write: Write,
5864
+ writeErr: WriteErr,
5865
+ ): number {
5866
+ const json = flags.has('json');
5867
+ const usage = 'dz hooks-sync --target codex [--check] [--verify] [--remove] [--json] [--project <dir>] [--no-verify]';
5868
+ if (flags.has('help')) {
5869
+ write(`${usage} — install/verify the dz veto + recall hooks in $CODEX_HOME/hooks.json`);
5870
+ return 0;
5871
+ }
5872
+ for (const flag of flags) {
5873
+ if (!['check', 'verify', 'no-verify', 'remove', 'json', 'help'].includes(flag)) {
5874
+ const message = `dz hooks-sync: unknown option --${flag}\n${usage}`;
5875
+ (json ? write : writeErr)(json ? JSON.stringify({ error: `unknown option --${flag}`, exitCode: 1 }) : message);
5876
+ return 1;
5877
+ }
5878
+ }
5879
+ for (const key of options.keys()) {
5880
+ if (key !== 'target' && key !== 'project' && key !== 'codex-home') {
5881
+ const message = key.startsWith('_positional_') ? `unexpected argument ${JSON.stringify(options.get(key))}` : `unknown option --${key}`;
5882
+ (json ? write : writeErr)(json ? JSON.stringify({ error: message, exitCode: 1 }) : `dz hooks-sync: ${message}\n${usage}`);
5883
+ return 1;
5884
+ }
5885
+ }
5886
+ // Every `--target` read in this CLI goes through resolveTargetName (alias support + one spelling
5887
+ // of the unknown-target message), pinned by `everyTargetGuardUsesResolveTargetName`.
5888
+ const targetOpt = options.get('target');
5889
+ if (targetOpt === undefined) {
5890
+ const message = '--target is required';
5891
+ (json ? write : writeErr)(json ? JSON.stringify({ error: message, exitCode: 1 }) : `dz hooks-sync: ${message}\n${usage}`);
5892
+ return 1;
5893
+ }
5894
+ const resolution = resolveTargetName(targetOpt);
5895
+ if (resolution.kind === 'unknown') {
5896
+ if (json) {
5897
+ write(JSON.stringify({ error: `unknown target ${targetOpt}`, exitCode: 1 }));
5898
+ } else {
5899
+ for (const line of formatTargetProblem('dz hooks-sync', resolution)) writeErr(line);
5900
+ }
5901
+ return 1;
5902
+ }
5903
+ const target = resolution.target;
5904
+ if (resolution.via === 'alias') writeErr(formatTargetAliasNote('dz hooks-sync', targetOpt, target));
5905
+ if (target !== 'codex') {
5906
+ // Deliberately narrow: only Codex has a hook carrier today. Naming the reason keeps a future
5907
+ // reader from assuming the other nine are simply unimplemented here.
5908
+ const message = `unsupported --target ${target} (only "codex" has a hook carrier today)`;
5909
+ (json ? write : writeErr)(json ? JSON.stringify({ error: message, exitCode: 1 }) : `dz hooks-sync: ${message}\n${usage}`);
5910
+ return 1;
5911
+ }
5912
+
5913
+ const codexHome = options.get('codex-home');
5914
+ const report = runSyncCodexHooks({
5915
+ ...(codexHome !== undefined ? { codexHome } : {}),
5916
+ check: flags.has('check'),
5917
+ remove: flags.has('remove'),
5918
+ });
5919
+
5920
+ if (json) {
5921
+ write(JSON.stringify({ ...report, exitCode: report.exitCode }));
5922
+ return report.exitCode;
5923
+ }
5924
+
5925
+ for (const err of report.errors) writeErr(`dz hooks-sync: ${err}`);
5926
+ for (const warn of report.warnings) writeErr(`dz hooks-sync: warning: ${warn}`);
5927
+
5928
+ // SILENT in a home that never opted in — the leg-1 F12 lesson: a --check that chatters in every
5929
+ // unrelated project trains its reader to ignore it.
5930
+ if (flags.has('check') && !report.installed && report.errors.length === 0) return report.exitCode;
5931
+
5932
+ if (flags.has('remove')) {
5933
+ write(`dz hooks-sync: removed ${report.removed} managed entr(ies) from ${report.registryPath}`);
5934
+ return report.exitCode;
5935
+ }
5936
+
5937
+ // AM-17 / G-G: the success word is reachable ONLY from armed AND trusted. The trust clause is
5938
+ // asserted HERE as well as in the exit map — a read-only `--check` that could not establish trust
5939
+ // once returned exit 0 with `trust: 'unknown'`, and this line printed "ready" for it.
5940
+ if (report.exitCode === 0 && report.trust === 'trusted' && report.installed) {
5941
+ write(`dz hooks-sync: codex hooks installed and ARMED (trust: ${report.trust}) — ready`);
5942
+ } else if (report.installed) {
5943
+ writeErr(`dz hooks-sync: installed, NOT verified — ARMED = NO (trust: ${report.trust}, executable: ${report.executable})`);
5944
+ writeErr('→ open an interactive Codex session in this directory and approve the two dz hooks, then re-run with --verify');
5945
+ } else {
5946
+ writeErr('dz hooks-sync: ARMED = NO — the managed entries are not present in the registry');
5947
+ }
5948
+ return report.exitCode;
5949
+ }
5950
+
5951
+ function cmdAgentsSync(
5952
+ options: Map<string, string>,
5953
+ flags: Set<string>,
5954
+ cwd: string,
5955
+ write: Write,
5956
+ writeErr: WriteErr,
5957
+ ): number {
5958
+ const json = flags.has('json');
5959
+ const usage = 'dz agents-sync [--project <dir>] [--check] [--json]';
5960
+ if (flags.has('help')) {
5961
+ write(`${usage} — sync/verify the dz:policies fence in root AGENTS.md`);
5962
+ return 0;
5963
+ }
5964
+ for (const flag of flags) {
5965
+ if (!['check', 'json', 'help'].includes(flag)) {
5966
+ const message = `dz agents-sync: unknown option --${flag}\n${usage}`;
5967
+ (json ? write : writeErr)(json ? JSON.stringify({ error: `unknown option --${flag}`, exitCode: 1 }) : message);
5968
+ return 1;
5969
+ }
5970
+ }
5971
+ for (const key of options.keys()) {
5972
+ if (key !== 'project') {
5973
+ const message = key.startsWith('_positional_') ? `unexpected argument ${JSON.stringify(options.get(key))}` : `unknown option --${key}`;
5974
+ (json ? write : writeErr)(json ? JSON.stringify({ error: message, exitCode: 1 }) : `dz agents-sync: ${message}\n${usage}`);
5975
+ return 1;
5976
+ }
5977
+ }
5978
+
5979
+ const root = resolve(cwd, options.get('project') ?? '.');
5980
+ try {
5981
+ const report = runSyncAgentsPolicy({ projectRoot: root, check: flags.has('check') });
5982
+ const drifted = report.drift.filter((finding) => finding.status !== 'ok');
5983
+ const inconclusive = report.missing.length > 0;
5984
+ const failed = flags.has('check')
5985
+ ? report.changed || report.budget.overflow || drifted.length > 0
5986
+ : !report.inSync;
5987
+ const exitCode = inconclusive ? 3 : failed ? 1 : 0;
5988
+ if (json) {
5989
+ write(JSON.stringify({ ...report, sections: report.blocks, exitCode }));
5990
+ return exitCode;
5991
+ }
5992
+ if (inconclusive) {
5993
+ writeErr(`dz agents-sync: INCONCLUSIVE — unreadable or unanchored policy source(s): ${report.missing.join(', ')}`);
5994
+ writeErr('→ heal with: restore the named source anchors, then run dz agents-sync');
5995
+ return 3;
5996
+ }
5997
+ if (failed) {
5998
+ const effect = flags.has('check') ? 'AGENTS.md would change' : 'AGENTS.md was not rewritten';
5999
+ writeErr(`dz agents-sync: DRIFT — ${drifted.length} stale/missing section(s); ${effect}`);
6000
+ for (const finding of drifted) writeErr(` ${finding.id}: ${finding.file} (${finding.status})`);
6001
+ if (drifted.some((finding) => finding.id === 'dz:policies')) {
6002
+ writeErr('→ heal with: repair duplicate/unmatched dz:policies markers, then run dz agents-sync');
6003
+ } else {
6004
+ writeErr('→ heal with: dz agents-sync');
6005
+ }
6006
+ return 1;
6007
+ }
6008
+ const verb = report.written ? 'wrote' : 'in sync';
6009
+ write(`dz agents-sync: ${verb} — ${report.blocks.length} policy section(s), ${report.budget.bytes} bytes (${report.budget.pct}% of ${report.budget.cap})`);
6010
+ for (const warning of report.warnings) writeErr(`dz agents-sync: warning: ${warning}`);
6011
+ return 0;
6012
+ } catch (error) {
6013
+ const message = error instanceof Error ? error.message : String(error);
6014
+ if (json) write(JSON.stringify({ error: message, exitCode: 1 }));
6015
+ else writeErr(`dz agents-sync: ${message}`);
6016
+ return 1;
6017
+ }
6018
+ }
6019
+
5693
6020
  function cmdDriftCheck(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
5694
6021
  const root = resolve(cwd, options.get('project') ?? '.');
5695
6022
  // Default scope = PUBLISHED packages only: the `.claude/skills` dogfood copies legitimately lag the
@@ -5803,6 +6130,31 @@ function gatherReadmeCounts(root: string): { label: string; a: number; b: number
5803
6130
  function gatherGuardFacts(op: string, root: string, text: string | undefined, storeCap: number): Record<string, unknown> {
5804
6131
  const facts: Record<string, unknown> = { op };
5805
6132
  if (op === 'publish') {
6133
+ // agents-md-policy-sync: fixed registry, no tree walk. The pure detector
6134
+ // recomputes every expected hash from current source text; this gatherer
6135
+ // only supplies bytes. Any unexpected gather failure omits the fact, and
6136
+ // evaluateGuard records that advisory coverage gap in `notes`.
6137
+ try {
6138
+ const policyFiles = new Map<string, string | null>();
6139
+ for (const file of new Set(POLICY_SOURCES.map((source) => source.file))) {
6140
+ try { policyFiles.set(file, readFileSync(join(root, file), 'utf8')); }
6141
+ catch { policyFiles.set(file, null); }
6142
+ }
6143
+ let agentsMd: string | null = null;
6144
+ try { agentsMd = readFileSync(join(root, 'AGENTS.md'), 'utf8'); } catch { /* missing stamp evidence */ }
6145
+ const policyDrift = detectPolicyDrift(policyFiles, agentsMd, POLICY_SOURCES);
6146
+ facts['policyDrift'] = {
6147
+ applicable: policyDrift.applicable,
6148
+ // Did this repo OPT IN? A `dz:policies` fence in AGENTS.md is the only durable signal that
6149
+ // someone ran `dz agents-sync` here. Without it the advisory rule is out of scope and stays
6150
+ // silent; with it, unreadable sources become a loud note instead of a silent skip.
6151
+ fenced: hasPolicyFence(agentsMd),
6152
+ drifted: policyDrift.findings
6153
+ .filter((finding) => finding.status !== 'ok')
6154
+ .map((finding) => `${finding.id}:${finding.file}:${finding.status}`),
6155
+ };
6156
+ } catch { /* unexpected gather failure — omission becomes a visible guard note */ }
6157
+
5806
6158
  // Read every workspace manifest ONCE: build a name→version map, then resolve each `workspace:*` dep to the
5807
6159
  // version pnpm WOULD publish it as. In a pnpm workspace (pnpm-workspace.yaml present) `workspace:*` in source
5808
6160
  // is correct and gets rewritten at publish — so reporting it raw would be a FALSE gate. We mirror the rewrite:
@@ -6837,7 +7189,7 @@ async function cmdRetro(options: Map<string, string>, flags: Set<string>, cwd: s
6837
7189
  * --from-spec <spec.json> preview the scaffold (create / augment per file); the SKILL fills the spec
6838
7190
  * --apply with --from-spec: WRITE (create missing, AUGMENT existing — never clobber)
6839
7191
  */
6840
- function cmdFeatureAdrSetup(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write): number {
7192
+ function cmdFeatureAdrSetup(options: Map<string, string>, flags: Set<string>, cwd: string, write: Write, writeErr: WriteErr): number {
6841
7193
  let repoRoot = cwd;
6842
7194
  try { repoRoot = execSync('git rev-parse --show-toplevel', { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || cwd; } catch { /* not git */ }
6843
7195
 
@@ -6856,11 +7208,24 @@ function cmdFeatureAdrSetup(options: Map<string, string>, flags: Set<string>, cw
6856
7208
  // Its "runnable here" list is computed for --target (default agents-md, the AGENTS.md-class target class).
6857
7209
  const wantGates = flags.has('gates');
6858
7210
  const targetOpt = options.get('target');
6859
- if (targetOpt !== undefined && !isTargetName(targetOpt)) {
6860
- write(`dz feature-adr-setup: --target must be one of: ${TARGET_NAMES.join(', ')}`);
6861
- return 1;
7211
+ // Sites 7 AND 8 of the D3 rewiring. Site 8 (the coercion below) is NOT a guard — it
7212
+ // is a silent fallback, and a mechanical "replace isTargetName with resolveTargetName"
7213
+ // pass would miss it. With aliasing in place and the coercion left alone,
7214
+ // `dz feature-adr-setup --gates --target claude` would pass validation and then emit
7215
+ // for **agents-md**. So the coercion CONSUMES the resolution computed once, above;
7216
+ // `agents-md` is the default only when `--target` is ABSENT.
7217
+ let gatesTarget: TargetName = 'agents-md';
7218
+ if (targetOpt !== undefined) {
7219
+ const gatesResolution = resolveTargetName(targetOpt);
7220
+ if (gatesResolution.kind === 'unknown') {
7221
+ for (const line of formatTargetProblem('dz feature-adr-setup', gatesResolution)) writeErr(line);
7222
+ return 1;
7223
+ }
7224
+ gatesTarget = gatesResolution.target;
7225
+ if (gatesResolution.via === 'alias') {
7226
+ writeErr(formatTargetAliasNote('dz feature-adr-setup', targetOpt, gatesTarget));
7227
+ }
6862
7228
  }
6863
- const gatesTarget: TargetName = isTargetName(targetOpt ?? '') ? (targetOpt as TargetName) : 'agents-md';
6864
7229
 
6865
7230
  const specPath = options.get('from-spec');
6866
7231
  if (specPath === undefined && !wantGuards && !wantGates) {
@@ -6976,7 +7341,15 @@ function cmdChallenge(options: Map<string, string>, flags: Set<string>, cwd: str
6976
7341
  * --base <ref> the base ref to fail against (default HEAD)
6977
7342
  * --name '<filter>' optional -t test-name filter applied to every target
6978
7343
  * --runner '<cmd>' test runner (default `npx vitest run`)
6979
- * --json machine-readable {plan, results, verdict, finding}
7344
+ * --timeout <ms> per-run timeout (default 300000; a timed-out run is CANNOT_ISOLATE)
7345
+ * --json machine-readable {plan, results, tipTree, perTest, aggregate,
7346
+ * findings, measurementValid, primaryAction}
7347
+ *
7348
+ * This executor is THIN by design (house style: pure classifier + thin executor). It performs exactly the
7349
+ * I/O the pure gate cannot — stat, worktree, run, capture — and hands OBSERVATIONS back. It no longer
7350
+ * interprets anything: the pre-epoch load-error regex that lived here (`/cannot find module|failed to
7351
+ * load|.../i`) is DELETED, because a regex over a runner's stderr, written in the executor, is exactly the
7352
+ * probabilistic channel that minted `DISCRIMINATES` for `--runner false`.
6980
7353
  *
6981
7354
  * NEVER auto-aborts: a non-discriminating (false-green) test is reported as a HIGH finding for the owner to
6982
7355
  * decide (dz's rule — a false gate kills trust). Exit code is 0 on a clean run regardless of verdict; 2 only on
@@ -6996,6 +7369,9 @@ function cmdDiscriminationCheck(options: Map<string, string>, flags: Set<string>
6996
7369
  nameFilter !== undefined && nameFilter.trim() !== '' ? { file, name: nameFilter.trim() } : { file });
6997
7370
  const baseRef = options.get('base') ?? 'HEAD';
6998
7371
  const runnerOpt = options.get('runner');
7372
+ // R11: a hung runner is a loud non-answer, never a pass. Same default + parse shape as mutation-gate.
7373
+ const timeoutOpt = Number(options.get('timeout') ?? '300000');
7374
+ const timeoutMs = Number.isFinite(timeoutOpt) && timeoutOpt > 0 ? timeoutOpt : 300000;
6999
7375
 
7000
7376
  const plan = planDiscriminationCheck(runnerOpt !== undefined ? { baseRef, propertyTests, runner: runnerOpt } : { baseRef, propertyTests });
7001
7377
 
@@ -7009,82 +7385,186 @@ function cmdDiscriminationCheck(options: Map<string, string>, flags: Set<string>
7009
7385
  return 0;
7010
7386
  }
7011
7387
 
7012
- // Execute the plan in a temp worktree WE own; substitute {{WORKTREE}} and always clean up.
7013
- // `git worktree add` must CREATE the path, so compute a fresh non-existent one (do NOT mkdtemp it).
7014
- const worktree = join(mkdtempSync(join(tmpdir(), 'dz-disc-')), 'wt');
7015
- const results: { file: string; name?: string; outcome: 'pass' | 'fail' | 'error' }[] = [];
7016
- try {
7017
- // 1) add the detached worktree at base (git creates `worktree`; its parent already exists).
7018
- const addCmd = plan.commands[0]!.replace(/\{\{WORKTREE\}\}/g, worktree);
7019
- execSync(addCmd, { cwd: repoRoot, stdio: 'pipe', encoding: 'utf-8' });
7020
-
7021
- // 1b) a fresh worktree has NO node_modules — without this, every test fails to load (runner + deps
7022
- // unresolvable) and the gate collapses to always-VIA_ERROR, blind to false greens. Absolute-path
7023
- // symlinks point back at the main checkout's already-installed trees, robust across pnpm's layout.
7024
- const linkNodeModules = (relDir: string): void => {
7025
- const srcNm = join(repoRoot, relDir, 'node_modules');
7026
- if (!existsSync(srcNm)) return;
7027
- const dstNm = join(worktree, relDir, 'node_modules');
7028
- if (existsSync(dstNm)) return;
7029
- try { mkdirSync(dirname(dstNm), { recursive: true }); symlinkSync(srcNm, dstNm, 'dir'); } catch { /* best effort */ }
7030
- };
7031
- linkNodeModules('.'); // root (hoisted deps + .bin)
7032
- const pkgDirs = new Set<string>();
7033
- for (const t of plan.targets) {
7034
- let d = dirname(t.file);
7035
- while (d && d !== '.' && d !== sep) {
7036
- if (existsSync(join(repoRoot, d, 'package.json'))) { pkgDirs.add(d); break; }
7037
- d = dirname(d);
7388
+ // ── (1) stat + isFile, BEFORE the worktree (AM-6 / FR-A1) ──────────────────────────────────
7389
+ // R12: `stat` FOLLOWS symlinks on purpose. A dangling symlink lstat-exists but has no readable
7390
+ // content that IS absence of the named check. A directory stat-exists but is not a regular
7391
+ // file. Pre-epoch both reached the copy step, threw, and were caught into `outcome:'error'`,
7392
+ // which minted the near-pass DISCRIMINATES_VIA_ERROR (MEASURED — acid A1 / the dangling-symlink
7393
+ // and directory rows of features/wave1-instrument-repair/07_code_changes/acid-red-runs.md).
7394
+ const absent: { file: string; name?: string; outcome: 'absent'; evidence?: ExecutionEvidence }[] = [];
7395
+ const present: { file: string; name?: string }[] = [];
7396
+ for (const t of plan.targets) {
7397
+ let isRegular = false;
7398
+ let isDirectory = false;
7399
+ try {
7400
+ const st = statSync(resolve(repoRoot, t.file));
7401
+ isRegular = st.isFile();
7402
+ isDirectory = st.isDirectory();
7403
+ } catch { /* ENOENT / dangling symlink / permission — absence, either way */ }
7404
+ if (isRegular) { present.push(t.name !== undefined ? { file: t.file, name: t.name } : { file: t.file }); continue; }
7405
+ const row = t.name !== undefined ? { file: t.file, name: t.name, outcome: 'absent' as const } : { file: t.file, outcome: 'absent' as const };
7406
+ // Out-of-band detail channel: TEST_FILE_ABSENT is evidence-EXEMPT (its evidence is the stat
7407
+ // itself), and absent rows never consult the evidence gate — so this object cannot degrade the
7408
+ // row. It exists only so the finding can tell the operator WHY the path is not a test file.
7409
+ absent.push(isDirectory
7410
+ ? { ...row, evidence: { exitCode: null, runner: 'unrecognised', failureKind: 'unrecognised', testsExecuted: null, targetSeen: false, evidenceLine: 'not-a-regular-file' } }
7411
+ : row);
7412
+ }
7413
+
7414
+ const results: ClassifyResultRow[] = [...absent];
7415
+ let tipTree: { headSha: string; dirtyFiles: number } | null = null;
7416
+
7417
+ // Confirmation 12: with nothing present there is nothing to run — no worktree is built at all.
7418
+ if (present.length > 0) {
7419
+ const runner = runnerOpt !== undefined && plan.commands.some((c) => c.includes(runnerOpt)) ? runnerOpt : 'npx vitest run';
7420
+ // Execute the plan in a temp worktree WE own; substitute {{WORKTREE}} and always clean up.
7421
+ // `git worktree add` must CREATE the path, so compute a fresh non-existent one (do NOT mkdtemp it).
7422
+ const worktree = join(mkdtempSync(join(tmpdir(), 'dz-disc-')), 'wt');
7423
+ try {
7424
+ // 1) add the detached worktree at base (git creates `worktree`; its parent already exists).
7425
+ const addCmd = plan.commands[0]!.replace(/\{\{WORKTREE\}\}/g, worktree);
7426
+ execSync(addCmd, { cwd: repoRoot, stdio: 'pipe', encoding: 'utf-8' });
7427
+
7428
+ // 1b) a fresh worktree has NO node_modules — without this, every test fails to load (runner + deps
7429
+ // unresolvable) and the gate collapses to always-VIA_ERROR, blind to false greens. Absolute-path
7430
+ // symlinks point back at the main checkout's already-installed trees, robust across pnpm's layout.
7431
+ const linkNodeModules = (relDir: string): void => {
7432
+ const srcNm = join(repoRoot, relDir, 'node_modules');
7433
+ if (!existsSync(srcNm)) return;
7434
+ const dstNm = join(worktree, relDir, 'node_modules');
7435
+ if (existsSync(dstNm)) return;
7436
+ try { mkdirSync(dirname(dstNm), { recursive: true }); symlinkSync(srcNm, dstNm, 'dir'); } catch { /* best effort */ }
7437
+ };
7438
+ linkNodeModules('.'); // root (hoisted deps + .bin)
7439
+ const pkgDirs = new Set<string>();
7440
+ for (const t of present) {
7441
+ let d = dirname(t.file);
7442
+ while (d && d !== '.' && d !== sep) {
7443
+ if (existsSync(join(repoRoot, d, 'package.json'))) { pkgDirs.add(d); break; }
7444
+ d = dirname(d);
7445
+ }
7038
7446
  }
7039
- }
7040
- for (const d of pkgDirs) linkNodeModules(d);
7447
+ for (const d of pkgDirs) linkNodeModules(d);
7041
7448
 
7042
- // 2) copy each property test into the base worktree, then 3) run it and record pass/fail/error per target.
7043
- for (const t of plan.targets) {
7044
- try {
7045
- const src = resolve(repoRoot, t.file);
7046
- // containment guard (defense in depth beyond planDiscriminationCheck's path sanitation).
7047
- if (!resolve(src).startsWith(resolve(repoRoot) + sep)) { results.push(nameFor(t, 'error')); continue; }
7048
- const dst = join(worktree, t.file);
7049
- mkdirSync(dirname(dst), { recursive: true });
7050
- writeFileSync(dst, readFileSync(src));
7051
- } catch { results.push(nameFor(t, 'error')); continue; }
7052
-
7053
- const runner = (runnerOpt !== undefined && plan.commands.some((c) => c.includes(runnerOpt))) ? runnerOpt : 'npx vitest run';
7054
- // t.file + t.name already passed the engine's strict sanitation (no quotes/metacharacters/leading-dash);
7055
- // still quote + `--` so a path can never be read as a runner option or split a word.
7056
- const nameArg = t.name ? ` -t '${t.name}'` : '';
7057
- try {
7058
- execSync(`${runner}${nameArg} -- '${t.file}'`, { cwd: worktree, stdio: 'pipe', encoding: 'utf-8' });
7059
- results.push(nameFor(t, 'pass')); // exit 0 test PASSED at base false green
7060
- } catch (e) {
7061
- // vitest exits non-zero on failure AND on load/compile error. Distinguish: a load error usually names
7062
- // "Cannot find module"/"Failed to load"/"No test files"; otherwise treat as an assertion failure (red).
7063
- const out = String((e as { stdout?: string; stderr?: string }).stdout ?? '') + String((e as { stderr?: string }).stderr ?? '');
7064
- const isLoadError = /cannot find module|failed to load|no test (files )?found|error: cannot|transform failed|esbuild/i.test(out);
7065
- results.push(nameFor(t, isLoadError ? 'error' : 'fail'));
7449
+ // 2) copy each property test into the base worktree, then 3) run it and record the OBSERVATION.
7450
+ for (const t of present) {
7451
+ try {
7452
+ const src = resolve(repoRoot, t.file);
7453
+ // containment guard (defense in depth beyond planDiscriminationCheck's path sanitation).
7454
+ if (!resolve(src).startsWith(resolve(repoRoot) + sep)) { results.push(nameFor(t, 'error')); continue; }
7455
+ const dst = join(worktree, t.file);
7456
+ mkdirSync(dirname(dst), { recursive: true });
7457
+ writeFileSync(dst, readFileSync(src));
7458
+ } catch {
7459
+ // the file STAT-PASSED and the copy still failed: degrade LOUDLY as an error with NO
7460
+ // evidence (the gate reads it as CANNOT_ISOLATE), never as absence and never as a pass.
7461
+ results.push(nameFor(t, 'error'));
7462
+ continue;
7463
+ }
7464
+
7465
+ // t.file + t.name already passed the engine's strict sanitation (no quotes/metacharacters/leading-dash);
7466
+ // still quote + `--` so a path can never be read as a runner option or split a word.
7467
+ const nameArg = t.name ? ` -t '${t.name}'` : '';
7468
+ const cmd = `${runner}${nameArg} -- '${t.file}'`;
7469
+ const base = runCapturedTest(cmd, worktree, timeoutMs);
7470
+ const evidence = classifyExecutionEvidence(base.output, base.exitCode, t.file);
7471
+ const outcome = discriminationOutcomeOf(base.exitCode, evidence);
7472
+ const row: Record<string, unknown> = t.name !== undefined
7473
+ ? { file: t.file, name: t.name, outcome, evidence }
7474
+ : { file: t.file, outcome, evidence };
7475
+
7476
+ // 4) TIP CONTROL (FR-A2 + Confirmation 17). Run it for ALL non-assertion redness — file-load
7477
+ // redness (the matrix's EVIDENCED-error rows) AND unrecognised redness (so the invocation
7478
+ // ledger can prove the tip was REACHED). The CLASSIFIER still ignores the tip for unevidenced
7479
+ // base rows per the matrix; running it is cheap and only ever on an already-broken path.
7480
+ // Do NOT "simplify" this to evidenced-error-only — that silently breaks Confirmation 17.
7481
+ if (base.exitCode !== null && base.exitCode !== 0 && evidence.failureKind !== 'assertions') {
7482
+ const tip = runCapturedTest(cmd, repoRoot, timeoutMs);
7483
+ const tipEvidence = classifyExecutionEvidence(tip.output, tip.exitCode, t.file);
7484
+ row['tipOutcome'] = discriminationOutcomeOf(tip.exitCode, tipEvidence);
7485
+ row['tipEvidence'] = tipEvidence;
7486
+ // R15, named honestly: the base run is isolated in a worktree, but the tip runs in the LIVE
7487
+ // tree, where a concurrent writer can flip the observation mid-gate. No lock is taken
7488
+ // (deferred to backlog 9520e506); instead every tip-derived reading carries the tree
7489
+ // CONDITIONS it was taken under, so a surprising verdict can be re-read against them.
7490
+ if (tipTree === null) tipTree = readTipTreeConditions(repoRoot);
7491
+ }
7492
+ results.push(row as unknown as ClassifyResultRow);
7066
7493
  }
7494
+ } catch (e) {
7495
+ if (flags.has('json')) { write(JSON.stringify({ plan, error: 'worktree-setup-failed', detail: String((e as Error).message).slice(0, 300) }, null, 2)); }
7496
+ else write(`discrimination-check: could not create worktree at ${baseRef}: ${String((e as Error).message).slice(0, 200)}`);
7497
+ return 2;
7498
+ } finally {
7499
+ try { execSync(`git worktree remove --force ${worktree}`, { cwd: repoRoot, stdio: 'pipe' }); } catch { /* fall through to rm */ }
7500
+ // remove the whole mkdtemp parent (worktree is `<mkdtemp>/wt`), so nothing leaks under tmp even on error.
7501
+ try { rmSync(dirname(worktree), { recursive: true, force: true }); } catch { /* best effort */ }
7502
+ try { execSync('git worktree prune', { cwd: repoRoot, stdio: 'pipe' }); } catch { /* best effort */ }
7067
7503
  }
7068
- } catch (e) {
7069
- if (flags.has('json')) { write(JSON.stringify({ plan, error: 'worktree-setup-failed', detail: String((e as Error).message).slice(0, 300) }, null, 2)); }
7070
- else write(`discrimination-check: could not create worktree at ${baseRef}: ${String((e as Error).message).slice(0, 200)}`);
7071
- return 2;
7072
- } finally {
7073
- try { execSync(`git worktree remove --force ${worktree}`, { cwd: repoRoot, stdio: 'pipe' }); } catch { /* fall through to rm */ }
7074
- // remove the whole mkdtemp parent (worktree is `<mkdtemp>/wt`), so nothing leaks under tmp even on error.
7075
- try { rmSync(dirname(worktree), { recursive: true, force: true }); } catch { /* best effort */ }
7076
- try { execSync('git worktree prune', { cwd: repoRoot, stdio: 'pipe' }); } catch { /* best effort */ }
7077
7504
  }
7078
7505
 
7079
7506
  const result = classifyDiscrimination({ propertyTests, results });
7080
- if (flags.has('json')) { write(JSON.stringify({ plan, results, ...result }, null, 2)); return 0; }
7507
+ if (flags.has('json')) { write(JSON.stringify({ plan, results, tipTree, ...result }, null, 2)); return 0; }
7081
7508
 
7082
7509
  write(`discrimination-check @ ${baseRef} — verdict: ${result.aggregate}`);
7083
- for (const p of result.perTest) write(` ${p.verdict === 'NON_DISCRIMINATING' ? '✗' : '✓'} ${p.file}${p.name ? ` (${p.name})` : ''}: ${p.verdict}`);
7084
- if (result.finding) write(`\n [${result.finding.severity}] ${result.finding.title}\n ${result.finding.detail}`);
7510
+ for (const p of result.perTest) {
7511
+ // FR-A6: ✓ is reserved for the two ESTABLISHED trust verdicts. Every other value — including
7512
+ // every degraded reading — renders ✗, because a ✗ the operator investigates beats a ✓ that
7513
+ // silently meant "we could not tell".
7514
+ const mark = p.verdict === 'DISCRIMINATES' || p.verdict === 'DISCRIMINATES_VIA_ERROR' ? '✓' : '✗';
7515
+ write(` ${mark} ${p.file}${p.name ? ` (${p.name})` : ''}: ${p.verdict}${p.reason ? ` (reason: ${p.reason})` : ''}`);
7516
+ }
7517
+ write(` measurementValid: ${String(result.measurementValid)} · primaryAction: ${result.primaryAction}`);
7518
+ // ALL findings print, not just the worst: the scalar aggregate names one state, and a corpus with
7519
+ // a false green AND an absent file has two problems, each with its own operator action.
7520
+ for (const f of result.findings) write(`\n [${f.severity}] ${f.title}\n ${f.detail}`);
7085
7521
  return 0;
7086
7522
  }
7087
7523
 
7524
+ /**
7525
+ * Run one test command and CAPTURE the observation — output plus the exit code, including the
7526
+ * "no exit code at all" case. `execSync`'s timeout kills the child via signal and leaves
7527
+ * `status` null; a spawn failure does the same. That null is not an error to swallow, it is the
7528
+ * evidence (`CANNOT_ISOLATE` reason `'timeout'`), so it is returned as data.
7529
+ */
7530
+ function runCapturedTest(cmd: string, cwd: string, timeoutMs: number): { output: string; exitCode: number | null } {
7531
+ try {
7532
+ const stdout = execSync(cmd, { cwd, stdio: 'pipe', encoding: 'utf-8', timeout: timeoutMs });
7533
+ return { output: String(stdout ?? ''), exitCode: 0 };
7534
+ } catch (e) {
7535
+ const err = e as { stdout?: string; stderr?: string; status?: unknown };
7536
+ return {
7537
+ output: String(err.stdout ?? '') + String(err.stderr ?? ''),
7538
+ exitCode: typeof err.status === 'number' ? err.status : null,
7539
+ };
7540
+ }
7541
+ }
7542
+
7543
+ /**
7544
+ * The outcome VALUE for one captured run. The executor's whole remaining judgment, and it is
7545
+ * mechanical: exit 0 is a pass, no exit code is an error, and a non-zero exit is an error only when
7546
+ * the classifier RECOGNISED a file-load failure. An unrecognised red is deliberately recorded as a
7547
+ * `fail` VALUE whose evidence then degrades it — exactly acid A6's pinned shape, and the reason the
7548
+ * executor no longer owns a regex.
7549
+ */
7550
+ function discriminationOutcomeOf(exitCode: number | null, evidence: ExecutionEvidence): 'pass' | 'fail' | 'error' {
7551
+ if (exitCode === null) return 'error';
7552
+ if (exitCode === 0) return 'pass';
7553
+ return evidence.failureKind === 'file-load' ? 'error' : 'fail';
7554
+ }
7555
+
7556
+ /** The live tree's identity at tip-run time (R15). Best-effort: unknown conditions read as such. */
7557
+ function readTipTreeConditions(repoRoot: string): { headSha: string; dirtyFiles: number } {
7558
+ let headSha = 'unknown';
7559
+ let dirtyFiles = -1;
7560
+ try { headSha = execSync('git rev-parse HEAD', { cwd: repoRoot, stdio: 'pipe', encoding: 'utf-8' }).trim(); } catch { /* best effort */ }
7561
+ try {
7562
+ const porcelain = execSync('git status --porcelain', { cwd: repoRoot, stdio: 'pipe', encoding: 'utf-8' });
7563
+ dirtyFiles = String(porcelain).split('\n').filter((l) => l.trim() !== '').length;
7564
+ } catch { /* best effort */ }
7565
+ return { headSha, dirtyFiles };
7566
+ }
7567
+
7088
7568
  /** small helper: build a result row, omitting `name` when absent (exactOptionalPropertyTypes). */
7089
7569
  function nameFor(t: { file: string; name?: string }, outcome: 'pass' | 'fail' | 'error'): { file: string; name?: string; outcome: 'pass' | 'fail' | 'error' } {
7090
7570
  return t.name !== undefined ? { file: t.file, name: t.name, outcome } : { file: t.file, outcome };
@@ -9334,6 +9814,8 @@ async function cmdImportEcc(options: Map<string, string>, flags: Set<string>, cw
9334
9814
  export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
9335
9815
  const cwd = io.cwd ?? process.cwd();
9336
9816
  const write: Write = io.write ?? ((line) => { console.log(line); });
9817
+ // Diagnostics go to stderr so `dz <cmd> > out.txt` yields clean data (feature dz-cli-defects).
9818
+ const writeErr: WriteErr = io.writeErr ?? ((line) => { console.error(line); });
9337
9819
  // Lazy STDIN reader — only `dz brain ground` reads it, and only when no positional prompt is
9338
9820
  // given. Never blocks: injected `io.stdin` wins; else read fd 0 synchronously, but bail to '' on
9339
9821
  // a TTY (nothing piped) or any read error. Grounding must never hang waiting on an empty pipe.
@@ -9376,14 +9858,14 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
9376
9858
  try {
9377
9859
  switch (command) {
9378
9860
  case 'init':
9379
- return await cmdInit(options, flags, cwd, write);
9861
+ return await cmdInit(options, flags, cwd, write, writeErr);
9380
9862
  case 'verify':
9381
- return await cmdVerify(options, cwd, write);
9863
+ return await cmdVerify(options, cwd, write, writeErr);
9382
9864
  case 'sync':
9383
9865
  case 'update':
9384
- return await cmdSync(options, flags, cwd, write);
9866
+ return await cmdSync(options, flags, cwd, write, writeErr);
9385
9867
  case 'list':
9386
- return cmdList(options, cwd, write);
9868
+ return cmdList(options, cwd, write, writeErr);
9387
9869
  case 'create-skill':
9388
9870
  return cmdCreateSkill(options, flags, cwd, write);
9389
9871
  case 'info':
@@ -9401,7 +9883,7 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
9401
9883
  case 'doctor':
9402
9884
  return await cmdDoctor(options, flags, cwd, write);
9403
9885
  case 'install':
9404
- return await cmdInstall(options, flags, cwd, write, io.installRunner);
9886
+ return await cmdInstall(options, flags, cwd, write, writeErr, io.installRunner);
9405
9887
  case 'bundle':
9406
9888
  return cmdBundle(options, flags, cwd, write);
9407
9889
  case 'teach':
@@ -9429,17 +9911,17 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
9429
9911
  case 'verify-pack':
9430
9912
  return cmdVerifyPack(options, flags, cwd, write);
9431
9913
  case 'setup':
9432
- return await cmdSetup(options, flags, cwd, write);
9914
+ return await cmdSetup(options, flags, cwd, write, writeErr);
9433
9915
  case 'pretrain':
9434
9916
  return cmdPretrain(options, cwd, write);
9435
9917
  case 'compose':
9436
- return cmdCompose(options, cwd, write);
9918
+ return cmdCompose(options, cwd, write, writeErr);
9437
9919
  case 'diff':
9438
9920
  return cmdDiff(options, cwd, write);
9439
9921
  case 'recommend':
9440
9922
  return cmdRecommend(options, cwd, write);
9441
9923
  case 'upgrade':
9442
- return cmdUpgrade(options, flags, cwd, write);
9924
+ return cmdUpgrade(options, flags, cwd, write, writeErr);
9443
9925
  case 'auto-canonicalize':
9444
9926
  return await cmdAutoCanonicalize(options, cwd, write);
9445
9927
  case 'publish':
@@ -9447,7 +9929,7 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
9447
9929
  case 'release':
9448
9930
  return cmdRelease(options, flags, cwd, write, io.releaseRunner);
9449
9931
  case 'parity':
9450
- return cmdParity(options, flags, write);
9932
+ return cmdParity(options, flags, write, writeErr);
9451
9933
  case 'registry':
9452
9934
  return cmdRegistry(options, cwd, write);
9453
9935
  case 'benchmark':
@@ -9458,6 +9940,10 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
9458
9940
  return await cmdSyncUpstream(options, flags, cwd, write);
9459
9941
  case 'drift-check':
9460
9942
  return cmdDriftCheck(options, flags, cwd, write);
9943
+ case 'hooks-sync':
9944
+ return cmdHooksSync(options, flags, cwd, write, writeErr);
9945
+ case 'agents-sync':
9946
+ return cmdAgentsSync(options, flags, cwd, write, writeErr);
9461
9947
  case 'sync-canonical':
9462
9948
  return cmdSyncCanonical(options, flags, cwd, write);
9463
9949
  case 'plugin':
@@ -9475,7 +9961,7 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
9475
9961
  case 'retro':
9476
9962
  return await cmdRetro(options, flags, cwd, write);
9477
9963
  case 'feature-adr-setup':
9478
- return cmdFeatureAdrSetup(options, flags, cwd, write);
9964
+ return cmdFeatureAdrSetup(options, flags, cwd, write, writeErr);
9479
9965
  case 'challenge':
9480
9966
  return cmdChallenge(options, flags, cwd, write);
9481
9967
  case 'discrimination-check':
@@ -9512,7 +9998,9 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
9512
9998
  return 1;
9513
9999
  }
9514
10000
  } catch (error) {
9515
- write(`dz: ${error instanceof Error ? error.message : String(error)}`);
10001
+ // stderr, not stdout: an uncaught failure is a diagnostic, and routing it through
10002
+ // `write` is what made `dz list > skills.txt` write the error into the data file.
10003
+ writeErr(`dz: ${error instanceof Error ? error.message : String(error)}`);
9516
10004
  return 1;
9517
10005
  }
9518
10006
  }