@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/dist/cli.js CHANGED
@@ -10,9 +10,9 @@ import { execFileSync, execSync, spawn } from 'node:child_process';
10
10
  import { createHash } from 'node:crypto';
11
11
  import { homedir, tmpdir } from 'node:os';
12
12
  import { createRequire } from 'node:module';
13
- import { createSkill, getSkillInfo, isTargetName, listSkills, runDoctor, runInit, resolvePackageSkillRoots, PACKAGE_SKILL_LAYOUTS, benchmarkSkill, benchmarkSkills, scanMcp, reconcileCapabilities, RECONCILE_BANNER, buildRegistry, discoverSkillPackDirs, checkUpstream, compareSkills, checkAllUpstream, sweepSkillDrift, syncCanonicalSkill, checkUpgrades, discoverPackages, discoverSourcePackages, fetchAllDownloads, filterByCategory, pretrain, recommend, generatePlugin, publishPackages, runSetup, runMigrate, searchRegistry, runSync, runVerify, runInitAgentsMd, runInitGeminiMd, TARGET_NAMES, buildParityMatrix, TARGET_CAPABILITIES, TARGET_SHORT_LABELS, WORKFLOW_TEMPLATES_RETIRED_MESSAGE, parsePlan, isParseErrors, validatePlan, normalizePlan, planDigest, toTraceProjection, renderPlan, mergeRender, lint, lintExitCode, LOOP_BLOBS, parseTrace, assembleTimeline, runInvariants, renderTimelineHtml, importEcc, recordPattern, resolveLearningBackend, storeStats, consolidateSessions, pruneNoisePatterns, lessonDeltaReport, removePatternsByIds, snapshotStore, recallHybrid, teachGuard, mirrorPatternsToVector, mirrorEntriesToVector, patternVectorEntry, readMemoryLearningConfig, promotePatterns, quarantineExpiryCandidates, pruneQuarantinePatterns, clearAgentdbQuarantine, vectorMirrorEnabled, vectorTierStatus, resolveVectorEngine, reindexVectorStore, harmonizeVectorStore, importRvfCheckpoint, statuslineData, writeFeatureAdrState, computeUsage, deriveCostLedger, renderCostLedger, verifyCostLedgerReport, writeCostLedgerJsonl, COST_LEDGER_SCOPE, deriveUsageCalibration, normalizeClaudeUsageModelKey, readUsageLimits, parseWeeklyResetAnchor, claimCheck, summarize, queryBookKnowledge, loadStorePatternsSync, patternRecordId, loadStoreRecords, recordToPattern, bundleSkills, brainHome, listBrain, promoteProjectToBrain, updateBrainSource, queryBrain, groundPrompt, expandKu, reindexBrainVectors, buildPrimer, exportBrainSlice, importBrainSlice, registerKusToBrain, RECALL_USAGE_LOG_RELATIVE, RECALL_USAGE_LOG_MAX_BYTES, parseRecallUsageLog, buildRecallUsageReport, EVENT_CHAIN_TAIL_BYTES, EMPTY_LOG_TAIL, readTailInfo, appendChainedLines, verifyEventChainText, buildManifest, buildSbom, resolveTrustRoot, decideVerifyPolicy, generateSigningKeypair, evaluateGuard, resolveRules, auditRecord, guardExitCode, DEFAULT_RULES, parsePnpmLockImporters, scannableStubPath,
13
+ import { createSkill, getSkillInfo, listSkillsDetailed, formatSkillLoadFailures, formatSkillApplyFailures, resolveTargetName, formatTargetProblem, formatTargetAliasNote, TARGET_NAMES_SORTED, runDoctor, runInit, resolvePackageSkillRoots, PACKAGE_SKILL_LAYOUTS, benchmarkSkill, benchmarkSkills, scanMcp, reconcileCapabilities, RECONCILE_BANNER, buildRegistry, discoverSkillPackDirs, checkUpstream, compareSkills, checkAllUpstream, sweepSkillDrift, syncCanonicalSkill, checkUpgrades, discoverPackages, discoverSourcePackages, fetchAllDownloads, filterByCategory, pretrain, recommend, generatePlugin, publishPackages, runSetup, runMigrate, searchRegistry, runSync, runVerify, runInitAgentsMd, runInitGeminiMd, runSyncAgentsPolicy, runSyncCodexHooks, POLICY_SOURCES, detectPolicyDrift, hasPolicyFence, TARGET_NAMES, buildParityMatrix, TARGET_CAPABILITIES, TARGET_SHORT_LABELS, WORKFLOW_TEMPLATES_RETIRED_MESSAGE, parsePlan, isParseErrors, validatePlan, normalizePlan, planDigest, toTraceProjection, renderPlan, mergeRender, lint, lintExitCode, LOOP_BLOBS, parseTrace, assembleTimeline, runInvariants, renderTimelineHtml, importEcc, recordPattern, resolveLearningBackend, storeStats, consolidateSessions, pruneNoisePatterns, lessonDeltaReport, removePatternsByIds, snapshotStore, recallHybrid, teachGuard, mirrorPatternsToVector, mirrorEntriesToVector, patternVectorEntry, readMemoryLearningConfig, promotePatterns, quarantineExpiryCandidates, pruneQuarantinePatterns, clearAgentdbQuarantine, vectorMirrorEnabled, vectorTierStatus, resolveVectorEngine, reindexVectorStore, harmonizeVectorStore, importRvfCheckpoint, statuslineData, writeFeatureAdrState, computeUsage, deriveCostLedger, renderCostLedger, verifyCostLedgerReport, writeCostLedgerJsonl, COST_LEDGER_SCOPE, deriveUsageCalibration, normalizeClaudeUsageModelKey, readUsageLimits, parseWeeklyResetAnchor, claimCheck, summarize, queryBookKnowledge, loadStorePatternsSync, patternRecordId, loadStoreRecords, recordToPattern, bundleSkills, brainHome, listBrain, promoteProjectToBrain, updateBrainSource, queryBrain, groundPrompt, expandKu, reindexBrainVectors, buildPrimer, exportBrainSlice, importBrainSlice, registerKusToBrain, RECALL_USAGE_LOG_RELATIVE, RECALL_USAGE_LOG_MAX_BYTES, parseRecallUsageLog, buildRecallUsageReport, EVENT_CHAIN_TAIL_BYTES, EMPTY_LOG_TAIL, readTailInfo, appendChainedLines, verifyEventChainText, buildManifest, buildSbom, resolveTrustRoot, decideVerifyPolicy, generateSigningKeypair, evaluateGuard, resolveRules, auditRecord, guardExitCode, DEFAULT_RULES, parsePnpmLockImporters, scannableStubPath,
14
14
  // guard-promotion (feature guard-promotion, scout idea #1)
15
- assembleCandidates, renderPromotionReport, renderPromotionAdr, normalizePromotionState, nextPromotionState, globMatch, promotionAdrRelPath, DEFAULT_WINDOW_DAYS, DEFAULT_PERIODS, MAX_CONTENT_FETCHES, BUILTIN_COVERAGE, decideProvenance, isInsideTree, signManifest, verifyManifest, listSignablePackFiles, assertKeyOutsideTree, decidePublishGate, collectPackageFacts, planReleaseGates, selectAffectedPackages, classifyGateExecutions, buildFailureIssue, buildReleaseNotes, releaseTagName, firstOutputLine, formatPublishError, MANIFEST_NAME, SBOM_NAME, buildArchitectureMap, renderMapHuman, findArchitectureDrift, renderDriftReport, scanWorkspacePackages, loadSubsystemManifest, loadProductVision, checkFeatureAgainstArchitecture, renderArchCheck, planProjectSkills, guidanceForStage, renderInjectionReport, analyzeCorpus, renderRakeReport, renderCriticSection, rakeAsLesson, rakeReward, DEFAULT_RAKE_THRESHOLDS, streamSessionEvents, findLatestTranscript, detectProcessRakes, buildRetro, renderRetro, retroLessonText, PROCESS_SIGNATURES, RETRO_DOMAIN, scanForSetup, buildSetupPlan, scaffoldFromSpec, renderScaffoldPreview, readExistingForScaffold, assembleChallengeContext, buildChallengeBrief, planDiscriminationCheck, classifyDiscrimination, pickAdversaryModel, CHALLENGE_QUESTIONS, loadOutcomes, renderOutcomes, statsForKey, selectAutoCost, recordProvisional, finalizeOutcome, harvestStageOutcomes, recommendModels, planFeed, GRADE_SUCCESS_FLOOR, COST_LADDER, splitScenarios, budgetPlan, selectWinner, proseScopeOk, renderProseDiff, readScenarioIds, DEFAULT_MAX_JUDGE_RUNS, collectDeliveryFacts, planDeliveryCheck, renderDeliveryBrief, classifyDelivery, isUsablePlaneResult, renderDeliveryReview, scanSkillsLayout, declaredPluginSurface, parseInitFacts, verifyRegistration, buildContentProbePrompt, classifyContentProbe, renderContentProbe, findNonRegistrableSkillDirs, assembleCompoundingReport,
15
+ assembleCandidates, renderPromotionReport, renderPromotionAdr, normalizePromotionState, nextPromotionState, globMatch, promotionAdrRelPath, DEFAULT_WINDOW_DAYS, DEFAULT_PERIODS, MAX_CONTENT_FETCHES, BUILTIN_COVERAGE, decideProvenance, isInsideTree, signManifest, verifyManifest, listSignablePackFiles, assertKeyOutsideTree, decidePublishGate, collectPackageFacts, planReleaseGates, selectAffectedPackages, classifyGateExecutions, buildFailureIssue, buildReleaseNotes, releaseTagName, firstOutputLine, formatPublishError, MANIFEST_NAME, SBOM_NAME, buildArchitectureMap, renderMapHuman, findArchitectureDrift, renderDriftReport, scanWorkspacePackages, loadSubsystemManifest, loadProductVision, checkFeatureAgainstArchitecture, renderArchCheck, planProjectSkills, guidanceForStage, renderInjectionReport, analyzeCorpus, renderRakeReport, renderCriticSection, rakeAsLesson, rakeReward, DEFAULT_RAKE_THRESHOLDS, streamSessionEvents, findLatestTranscript, detectProcessRakes, buildRetro, renderRetro, retroLessonText, PROCESS_SIGNATURES, RETRO_DOMAIN, scanForSetup, buildSetupPlan, scaffoldFromSpec, renderScaffoldPreview, readExistingForScaffold, assembleChallengeContext, buildChallengeBrief, planDiscriminationCheck, classifyDiscrimination, classifyExecutionEvidence, pickAdversaryModel, CHALLENGE_QUESTIONS, loadOutcomes, renderOutcomes, statsForKey, selectAutoCost, recordProvisional, finalizeOutcome, harvestStageOutcomes, recommendModels, planFeed, GRADE_SUCCESS_FLOOR, COST_LADDER, splitScenarios, budgetPlan, selectWinner, proseScopeOk, renderProseDiff, readScenarioIds, DEFAULT_MAX_JUDGE_RUNS, collectDeliveryFacts, planDeliveryCheck, renderDeliveryBrief, classifyDelivery, isUsablePlaneResult, renderDeliveryReview, scanSkillsLayout, declaredPluginSurface, parseInitFacts, verifyRegistration, buildContentProbePrompt, classifyContentProbe, renderContentProbe, findNonRegistrableSkillDirs, assembleCompoundingReport,
16
16
  // Cold-vs-warm EPOCH RUNNER (feature epoch-replay) — orchestrates + scores, never calls a model.
17
17
  replayableInstances, buildWorkOrder, buildJudgePrompts, unblindJudgments, verifyWorkOrder, isValidMargin, DIGEST_HONEST_SCOPE, scoreEpochReplay, generateMockOutcomes, renderEpochReplayResult, renderWorkOrderSummary, renderJudgePromptsSummary, WORK_ORDER_KIND, DEFAULT_MOCK_N, DEFAULT_MOCK_SEED, scoreRun, renderScorecard, renderCompoundingReport, readReinforcementState, readQuarantineState, registrationExitCode, renderRegistrationReport,
18
18
  // Smart Backlog (feature smart-backlog) — goal-directed idea pipeline over the Brain vector engine.
@@ -110,6 +110,8 @@ Usage:
110
110
  (build-time: reconcile project grants vs installed skills' declared capabilities; dz never enforces — the host does)
111
111
  dz sync-upstream [--package <dir>] [--list] [--all]
112
112
  dz drift-check [--json] [--project <dir>] (CI gate: exit 1 if any shared skill drifted between its monorepo copies)
113
+ 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)
114
+ 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)
113
115
  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)
114
116
  dz plugin [--version <ver>]
115
117
  dz downloads
@@ -208,9 +210,11 @@ async function installSkills(opts) {
208
210
  written += s.written;
209
211
  skipped += s.skipped;
210
212
  }
211
- return { results, dirsSearched: skillsDirs.length, written, skipped, missing: [...report.missing] };
213
+ return { results, dirsSearched: skillsDirs.length, written, skipped, missing: [...report.missing], failures: [...report.failures], applyFailures: [...report.applyFailures] };
212
214
  }
213
215
  const results = [];
216
+ const failures = [];
217
+ const applyFailures = [];
214
218
  for (const skillsDir of skillsDirs) {
215
219
  const r = await runInit({
216
220
  target,
@@ -223,6 +227,8 @@ async function installSkills(opts) {
223
227
  for (const skill of r.skills) {
224
228
  results.push({ id: skill.id, written: skill.written.length, skipped: skill.skipped.length });
225
229
  }
230
+ failures.push(...r.failures);
231
+ applyFailures.push(...r.applyFailures);
226
232
  }
227
233
  let written = 0;
228
234
  let skipped = 0;
@@ -232,7 +238,7 @@ async function installSkills(opts) {
232
238
  }
233
239
  const installed = new Set(results.map((s) => s.id));
234
240
  const missing = select !== undefined ? [...select].filter((id) => !installed.has(id)) : [];
235
- return { results, dirsSearched: skillsDirs.length, written, skipped, missing };
241
+ return { results, dirsSearched: skillsDirs.length, written, skipped, missing, failures, applyFailures };
236
242
  }
237
243
  /** Warn about preset/select ids that weren't found in any installed pack. */
238
244
  function writeMissingSkillsHint(write, missing, presetName) {
@@ -248,12 +254,24 @@ function writeMissingSkillsHint(write, missing, presetName) {
248
254
  write(` Install their packs first (e.g. dz install @dzhechkov/skills-<pack>) or check the ids.`);
249
255
  }
250
256
  }
251
- async function cmdInit(options, flags, cwd, write) {
252
- const target = options.get('target');
253
- if (target === undefined || !isTargetName(target)) {
254
- write(`dz init: --target must be one of: ${TARGET_NAMES.join(', ')}`);
257
+ async function cmdInit(options, flags, cwd, write, writeErr) {
258
+ const targetOpt = options.get('target');
259
+ if (targetOpt === undefined) {
260
+ // A missing `--target` is the same accusation as an unresolvable one, so it takes
261
+ // the same channel: diagnostics on stderr, stdout stays a data channel (ADR-002
262
+ // §Decision 2 / driver D6; fix round 1, QE F2).
263
+ writeErr(`dz init: --target must be one of: ${TARGET_NAMES_SORTED.join(', ')}`);
264
+ return 1;
265
+ }
266
+ const resolution = resolveTargetName(targetOpt);
267
+ if (resolution.kind === 'unknown') {
268
+ for (const line of formatTargetProblem('dz init', resolution))
269
+ writeErr(line);
255
270
  return 1;
256
271
  }
272
+ const target = resolution.target;
273
+ if (resolution.via === 'alias')
274
+ writeErr(formatTargetAliasNote('dz init', targetOpt, target));
257
275
  const explicitSkillsDir = options.get('skills-dir');
258
276
  const projectRoot = resolve(cwd, options.get('project') ?? '.');
259
277
  const presetName = options.get('preset');
@@ -289,18 +307,48 @@ async function cmdInit(options, flags, cwd, write) {
289
307
  write(` (searched ${r.dirsSearched} skill directories)`);
290
308
  }
291
309
  writeMissingSkillsHint(write, r.missing, presetName);
310
+ // Skip-and-collect must not become skip-and-SILENCE: a skill that failed to load is
311
+ // named on stderr and the command exits 1 (it exited 1 before too — by throwing).
312
+ if (r.failures.length > 0 || r.applyFailures.length > 0) {
313
+ // Counts first, then the named block — the same shape `dz list` uses. The block's
314
+ // own header already says "N skipped", so this line carries what it cannot: how many
315
+ // DID install, so a reader can tell a mostly-fine install from a mostly-broken one.
316
+ //
317
+ // The two failure kinds are counted and rendered SEPARATELY (fix round 1, QE F4):
318
+ // an unwritable target directory is not a broken pack, and printing it as one names
319
+ // the wrong file.
320
+ const parts = [`${r.results.length} installed`];
321
+ if (r.failures.length > 0)
322
+ parts.push(`${r.failures.length} skipped`);
323
+ if (r.applyFailures.length > 0)
324
+ parts.push(`${r.applyFailures.length} failed to write`);
325
+ writeErr(`dz init: ${parts.join(', ')}`);
326
+ for (const line of formatSkillLoadFailures(r.failures))
327
+ writeErr(line);
328
+ for (const line of formatSkillApplyFailures(r.applyFailures))
329
+ writeErr(line);
330
+ return 1;
331
+ }
292
332
  return 0;
293
333
  }
294
- async function cmdVerify(options, cwd, write) {
334
+ async function cmdVerify(options, cwd, write, writeErr) {
295
335
  const skillsDir = resolve(cwd, options.get('skills-dir') ?? '.claude/skills');
296
336
  const targetOpt = options.get('target');
297
- if (targetOpt !== undefined && !isTargetName(targetOpt)) {
298
- write(`dz verify: --target must be one of: ${TARGET_NAMES.join(', ')}`);
299
- return 1;
337
+ let target;
338
+ if (targetOpt !== undefined) {
339
+ const resolution = resolveTargetName(targetOpt);
340
+ if (resolution.kind === 'unknown') {
341
+ for (const line of formatTargetProblem('dz verify', resolution))
342
+ writeErr(line);
343
+ return 1;
344
+ }
345
+ target = resolution.target;
346
+ if (resolution.via === 'alias')
347
+ writeErr(formatTargetAliasNote('dz verify', targetOpt, target));
300
348
  }
301
349
  const report = await runVerify({
302
350
  skillsDir,
303
- ...(targetOpt !== undefined ? { target: targetOpt } : {}),
351
+ ...(target !== undefined ? { target } : {}),
304
352
  });
305
353
  write(`dz verify (${report.target}): ${report.valid}/${report.total} skill(s) valid`);
306
354
  for (const skill of report.skills) {
@@ -309,7 +357,7 @@ async function cmdVerify(options, cwd, write) {
309
357
  }
310
358
  return report.valid === report.total ? 0 : 1;
311
359
  }
312
- async function cmdSync(options, flags, cwd, write) {
360
+ async function cmdSync(options, flags, cwd, write, writeErr) {
313
361
  const projectRoot = resolve(cwd, options.get('project') ?? '.');
314
362
  const canonicalArg = options.get('canonical');
315
363
  // Auto-discover all skills-* packs, or use explicit --canonical
@@ -333,6 +381,15 @@ async function cmdSync(options, flags, cwd, write) {
333
381
  });
334
382
  const { total, inSync, missing, drift } = report.summary;
335
383
  write(`dz sync${report.dryRun ? ' --dry-run' : ''}: ${inSync}/${total} in sync, ${missing} missing, ${drift} drift`);
384
+ // Skip-and-collect (D1): the broken canonical skills are NAMED on stderr, and their
385
+ // presence keeps the exit code non-zero — a partial sync is not a clean sync.
386
+ if (report.failures.length > 0) {
387
+ // See `cmdInit` above: counts here, names in the block below.
388
+ writeErr(`dz sync: ${report.skills.length} compared, ${report.failures.length} skipped`);
389
+ for (const line of formatSkillLoadFailures(report.failures))
390
+ writeErr(line);
391
+ return 1;
392
+ }
336
393
  return missing === 0 && drift === 0 ? 0 : 1;
337
394
  }
338
395
  function cmdCreateSkill(options, flags, cwd, write) {
@@ -374,19 +431,44 @@ function cmdCreateSkill(options, flags, cwd, write) {
374
431
  }
375
432
  return 0;
376
433
  }
377
- function cmdList(options, cwd, write) {
434
+ /**
435
+ * `dz list` — skip-and-collect (feature dz-cli-defects, D1).
436
+ *
437
+ * One unparseable `SKILL.md` used to discard the ENTIRE listing with a message naming
438
+ * neither the file nor the count. Now the parseable skills list on stdout and the
439
+ * broken ones are named on stderr. The whole emit contract, in one place:
440
+ *
441
+ * | valid | skipped | stdout | stderr | exit |
442
+ * |-------|---------|--------|--------|------|
443
+ * | >0 | 0 | listing | *empty* | 0 |
444
+ * | >0 | >0 | listing of the valid ones | named summary | 1 |
445
+ * | 0 | >0 | *nothing* | named summary | 1 |
446
+ * | 0 | 0 | *nothing* | `no skills found in <dir>` | 1 |
447
+ *
448
+ * The last row is the ONE intentional departure from byte-identical output: that line
449
+ * used to go to stdout. Moving it keeps *stdout is data, stderr is diagnosis* whole —
450
+ * the invariant that makes `dz list > out.txt` trustworthy.
451
+ */
452
+ function cmdList(options, cwd, write, writeErr) {
378
453
  const skillsDir = resolve(cwd, options.get('skills-dir') ?? '.claude/skills');
379
- const skills = listSkills(skillsDir);
380
- if (skills.length === 0) {
381
- write(`dz list: no skills found in ${skillsDir}`);
454
+ const { skills, failures } = listSkillsDetailed(skillsDir);
455
+ if (skills.length === 0 && failures.length === 0) {
456
+ writeErr(`dz list: no skills found in ${skillsDir}`);
382
457
  return 1;
383
458
  }
384
- write(`${skills.length} skill(s) in ${skillsDir}:\n`);
385
- for (const skill of skills) {
386
- const desc = skill.description.length > 80 ? skill.description.slice(0, 77) + '...' : skill.description;
387
- write(` ${skill.id.padEnd(35)} ${desc}`);
459
+ if (skills.length > 0) {
460
+ write(`${skills.length} skill(s) in ${skillsDir}:\n`);
461
+ for (const skill of skills) {
462
+ const desc = skill.description.length > 80 ? skill.description.slice(0, 77) + '...' : skill.description;
463
+ write(` ${skill.id.padEnd(35)} ${desc}`);
464
+ }
388
465
  }
389
- return 0;
466
+ if (failures.length === 0)
467
+ return 0;
468
+ writeErr(`dz list: ${skills.length} listed, ${failures.length} skipped in ${skillsDir}`);
469
+ for (const line of formatSkillLoadFailures(failures))
470
+ writeErr(line);
471
+ return 1;
390
472
  }
391
473
  function cmdInfo(options, args, cwd, write) {
392
474
  const skillsDir = resolve(cwd, options.get('skills-dir') ?? '.claude/skills');
@@ -1217,17 +1299,22 @@ function cmdBundle(options, flags, cwd, write) {
1217
1299
  }
1218
1300
  return 0;
1219
1301
  }
1220
- async function cmdInstall(options, flags, cwd, write, installRunner) {
1302
+ async function cmdInstall(options, flags, cwd, write, writeErr, installRunner) {
1221
1303
  const pkg = options.get('_positional_0');
1222
1304
  if (!pkg) {
1223
1305
  write('dz install: package name required (e.g., dz install @dzhechkov/skills-devops)');
1224
1306
  return 1;
1225
1307
  }
1226
1308
  const targetOpt = options.get('target') ?? 'claude-code';
1227
- if (!isTargetName(targetOpt)) {
1228
- write(`dz install: --target must be one of: ${TARGET_NAMES.join(', ')}`);
1309
+ const targetResolution = resolveTargetName(targetOpt);
1310
+ if (targetResolution.kind === 'unknown') {
1311
+ for (const line of formatTargetProblem('dz install', targetResolution))
1312
+ writeErr(line);
1229
1313
  return 1;
1230
1314
  }
1315
+ const target = targetResolution.target;
1316
+ if (targetResolution.via === 'alias')
1317
+ writeErr(formatTargetAliasNote('dz install', targetOpt, target));
1231
1318
  const projectRoot = resolve(cwd, options.get('project') ?? '.');
1232
1319
  // Step 1: npm install the package (installRunner is the CliIo test seam — unset in production)
1233
1320
  write(`Installing ${pkg}...`);
@@ -1261,7 +1348,7 @@ async function cmdInstall(options, flags, cwd, write, installRunner) {
1261
1348
  const root = roots[0];
1262
1349
  // Step 3: Use dz init with the resolved skills root as source
1263
1350
  const report = await runInit({
1264
- target: targetOpt,
1351
+ target,
1265
1352
  skillsDir: root.dir,
1266
1353
  projectRoot,
1267
1354
  force: flags.has('force'),
@@ -1278,20 +1365,38 @@ async function cmdInstall(options, flags, cwd, write, installRunner) {
1278
1365
  if (root.layout === 'npx-template' && root.hasCompanionAssets) {
1279
1366
  write(` note: ${pkg} also ships commands/hooks/agents — \`npx -y ${pkg} init\` installs the full kit.`);
1280
1367
  }
1368
+ // Skip-and-collect at install time (D1 / the report's D2 amendment): the offending
1369
+ // SKILL.md came out of the DOWNLOADED TARBALL, so the path is rendered relative to
1370
+ // the package root (a `node_modules/**` absolute path is not actionable) and the
1371
+ // message says whose defect it is. Exit 1 — a pack that shipped an unloadable skill
1372
+ // did not fully install.
1373
+ if (report.failures.length > 0) {
1374
+ writeErr(`dz install: ${pkg} ships ${report.failures.length} unparseable skill(s) —`);
1375
+ for (const line of formatSkillLoadFailures(report.failures, { relativeTo: pkgDir }))
1376
+ writeErr(line);
1377
+ writeErr('This is a defect in the package, not in your project.');
1378
+ writeErr(`Workaround: npx -y ${pkg} init`);
1379
+ return 1;
1380
+ }
1281
1381
  return 0;
1282
1382
  }
1283
- function cmdCompose(options, cwd, write) {
1383
+ function cmdCompose(options, cwd, write, writeErr) {
1284
1384
  const combo = options.get('_positional_0');
1285
1385
  if (!combo) {
1286
1386
  write('dz compose: preset combination required (e.g., dz compose devops+mcp+web3)');
1287
1387
  return 1;
1288
1388
  }
1289
1389
  // --target is documented; honor it in the suggested install command (was hardcoded claude-code).
1290
- const target = options.get('target') ?? 'claude-code';
1291
- if (!isTargetName(target)) {
1292
- write(`dz compose: --target must be one of: ${TARGET_NAMES.join(', ')}`);
1390
+ const targetOpt = options.get('target') ?? 'claude-code';
1391
+ const composeResolution = resolveTargetName(targetOpt);
1392
+ if (composeResolution.kind === 'unknown') {
1393
+ for (const line of formatTargetProblem('dz compose', composeResolution))
1394
+ writeErr(line);
1293
1395
  return 1;
1294
1396
  }
1397
+ const target = composeResolution.target;
1398
+ if (composeResolution.via === 'alias')
1399
+ writeErr(formatTargetAliasNote('dz compose', targetOpt, target));
1295
1400
  const presetNames = combo.split('+').map((s) => s.trim());
1296
1401
  const allSkills = new Set();
1297
1402
  const resolved = [];
@@ -3489,12 +3594,21 @@ async function cmdBrain(options, flags, cwd, write, readStdin) {
3489
3594
  write(BRAIN_USAGE);
3490
3595
  return sub === undefined ? 0 : 1;
3491
3596
  }
3492
- async function cmdSetup(options, flags, cwd, write) {
3493
- const target = options.get('target');
3494
- if (!target || !isTargetName(target)) {
3495
- write(`dz setup: --target required (${TARGET_NAMES.join(', ')})`);
3597
+ async function cmdSetup(options, flags, cwd, write, writeErr) {
3598
+ const targetOpt = options.get('target');
3599
+ if (!targetOpt) {
3600
+ writeErr(`dz setup: --target required (${TARGET_NAMES_SORTED.join(', ')})`);
3601
+ return 1;
3602
+ }
3603
+ const setupResolution = resolveTargetName(targetOpt);
3604
+ if (setupResolution.kind === 'unknown') {
3605
+ for (const line of formatTargetProblem('dz setup', setupResolution))
3606
+ writeErr(line);
3496
3607
  return 1;
3497
3608
  }
3609
+ const target = setupResolution.target;
3610
+ if (setupResolution.via === 'alias')
3611
+ writeErr(formatTargetAliasNote('dz setup', targetOpt, target));
3498
3612
  const projectRoot = resolve(cwd, options.get('project') ?? '.');
3499
3613
  const presetName = options.get('preset');
3500
3614
  write(`\n╔══════════════════════════════════════════════════════╗`);
@@ -3659,12 +3773,19 @@ function cmdRecommend(options, cwd, write) {
3659
3773
  write(` Get it: dz init --target claude-code --select skill-advisor (or --preset meta)`);
3660
3774
  return 0;
3661
3775
  }
3662
- function cmdUpgrade(options, flags, cwd, write) {
3776
+ function cmdUpgrade(options, flags, cwd, write, writeErr) {
3663
3777
  const targetOpt = options.get('target') ?? 'claude-code';
3664
- if (!isTargetName(targetOpt)) {
3665
- write(`dz upgrade: --target must be one of: ${TARGET_NAMES.join(', ')}`);
3778
+ const upgradeResolution = resolveTargetName(targetOpt);
3779
+ if (upgradeResolution.kind === 'unknown') {
3780
+ for (const line of formatTargetProblem('dz upgrade', upgradeResolution))
3781
+ writeErr(line);
3666
3782
  return 1;
3667
3783
  }
3784
+ // The dir map is keyed by the RESOLVED name — keying it by the raw `--target` would
3785
+ // let an alias validate and then miss the map.
3786
+ const upgradeTarget = upgradeResolution.target;
3787
+ if (upgradeResolution.via === 'alias')
3788
+ writeErr(formatTargetAliasNote('dz upgrade', targetOpt, upgradeTarget));
3668
3789
  const projectRoot = resolve(cwd, options.get('project') ?? '.');
3669
3790
  const targetDirMap = {
3670
3791
  'claude-code': '.claude/skills', codex: '.agents/skills', opencode: '.opencode/skills',
@@ -3672,9 +3793,9 @@ function cmdUpgrade(options, flags, cwd, write) {
3672
3793
  'agents-md': 'AGENTS.md', cursor: '.cursor/rules', gemini: 'GEMINI.md',
3673
3794
  windsurf: '.windsurf/rules',
3674
3795
  };
3675
- const mappedDir = targetDirMap[targetOpt];
3796
+ const mappedDir = targetDirMap[upgradeTarget];
3676
3797
  if (mappedDir === undefined) {
3677
- write(`dz upgrade: no skills directory mapping for target ${targetOpt}`);
3798
+ write(`dz upgrade: no skills directory mapping for target ${upgradeTarget}`);
3678
3799
  return 1;
3679
3800
  }
3680
3801
  const targetDir = join(projectRoot, mappedDir);
@@ -3689,14 +3810,14 @@ function cmdUpgrade(options, flags, cwd, write) {
3689
3810
  }
3690
3811
  }
3691
3812
  const report = checkUpgrades(targetDir, canonicalDirs);
3692
- write(`\ndz upgrade — ${targetOpt} (${targetDir})`);
3813
+ write(`\ndz upgrade — ${upgradeTarget} (${targetDir})`);
3693
3814
  write(` Installed: ${report.installed} Needs update: ${report.needsUpdate} Up-to-date: ${report.upToDate} Custom: ${report.notInCanonical}\n`);
3694
3815
  for (const check of report.skills) {
3695
3816
  const icon = check.needsUpdate ? '△' : check.canonicalSize === undefined ? '?' : '✓';
3696
3817
  write(` ${icon} ${check.id.padEnd(25)} ${check.reason}`);
3697
3818
  }
3698
3819
  if (report.needsUpdate > 0) {
3699
- write(`\n${report.needsUpdate} skill(s) need update. Run: dz init --target ${targetOpt} --force`);
3820
+ write(`\n${report.needsUpdate} skill(s) need update. Run: dz init --target ${upgradeTarget} --force`);
3700
3821
  }
3701
3822
  // ADR-001 (verify-apply-leg): verify what we just left on disk. A TAMPERED pack aborts.
3702
3823
  const sigFatal = reportPackVerification(projectRoot, options.get('pubkey'), flags.has('require-signing'), write);
@@ -4249,7 +4370,7 @@ function cmdPublish(options, flags, cwd, write) {
4249
4370
  /* dz parity — the honest feature×target map (target-parity-matrix, */
4250
4371
  /* ADR-001): computed from the declarative model, never hand-written */
4251
4372
  /* ------------------------------------------------------------------ */
4252
- function cmdParity(options, flags, write) {
4373
+ function cmdParity(options, flags, write, writeErr) {
4253
4374
  const json = flags.has('json');
4254
4375
  if (flags.has('help')) {
4255
4376
  write('dz parity [--target <name>] [--json] — the computed feature×target map (never hand-written)');
@@ -4278,13 +4399,31 @@ function cmdParity(options, flags, write) {
4278
4399
  }
4279
4400
  }
4280
4401
  const matrix = buildParityMatrix();
4281
- const target = options.get('target');
4282
- if (target !== undefined && !TARGET_NAMES.includes(target)) {
4283
- if (json)
4284
- write(JSON.stringify({ error: `unknown target "${target}"`, targets: TARGET_NAMES, exitCode: 1 }, null, 2));
4285
- else
4286
- write(`dz parity: unknown target "${target}" one of: ${TARGET_NAMES.join(', ')}`);
4287
- return 1;
4402
+ // Site 8 of the D3 rewiring, closed in fix round 1 (QE F1). It shipped spelling its
4403
+ // own bare guard `TARGET_NAMES.includes(...)` and was therefore invisible to the AM-2
4404
+ // grep-guard, which searched for the token `isTargetName(` — a PRESENCE check on one
4405
+ // spelling where the property was "no call site bypasses the resolver". The guard in
4406
+ // `test/target-alias-cli.test.ts` now checks the class, and the sweep list is derived
4407
+ // from the help text so a ninth command cannot be missed the same way.
4408
+ const targetOpt = options.get('target');
4409
+ let target;
4410
+ if (targetOpt !== undefined) {
4411
+ const parityResolution = resolveTargetName(targetOpt);
4412
+ if (parityResolution.kind === 'unknown') {
4413
+ // Both forms go to stderr: an error is not data, and `dz parity --json | jq`
4414
+ // must not be fed a diagnostic (ADR-002 §Decision 2 / driver D6).
4415
+ if (json) {
4416
+ writeErr(JSON.stringify({ error: `unknown target ${JSON.stringify(targetOpt)}`, suggestion: parityResolution.suggestion, targets: TARGET_NAMES_SORTED, exitCode: 1 }, null, 2));
4417
+ }
4418
+ else {
4419
+ for (const line of formatTargetProblem('dz parity', parityResolution))
4420
+ writeErr(line);
4421
+ }
4422
+ return 1;
4423
+ }
4424
+ target = parityResolution.target;
4425
+ if (parityResolution.via === 'alias')
4426
+ writeErr(formatTargetAliasNote('dz parity', targetOpt, target));
4288
4427
  }
4289
4428
  if (json) {
4290
4429
  const rows = matrix.map((r) => ({
@@ -5273,6 +5412,172 @@ function readDriftAllowlist(root) {
5273
5412
  return [];
5274
5413
  }
5275
5414
  }
5415
+ /** Refresh or verify the root AGENTS.md bearing-policy projection. */
5416
+ /**
5417
+ * `dz hooks-sync --target codex` (`crossrt-2-codex-hooks`, AM-14).
5418
+ *
5419
+ * ONE verb in the existing target vocabulary (`parity`, `delivery-check`, `--target`), extensible to
5420
+ * a future runtime without a third surface. **No alias** — `dz codex-hooks` resolves to nothing.
5421
+ *
5422
+ * Exit map (ADR-002 §5, pinned by test):
5423
+ * 0 = `armed` AND `trust: 'trusted'` — the ONLY outcome that may print a success word (AM-17)
5424
+ * 1 = not armed, armed-but-trust-pending, drift, or a refusal
5425
+ * 3 = inconclusive (including "no codex binary on PATH")
5426
+ */
5427
+ function cmdHooksSync(options, flags, cwd, write, writeErr) {
5428
+ const json = flags.has('json');
5429
+ const usage = 'dz hooks-sync --target codex [--check] [--verify] [--remove] [--json] [--project <dir>] [--no-verify]';
5430
+ if (flags.has('help')) {
5431
+ write(`${usage} — install/verify the dz veto + recall hooks in $CODEX_HOME/hooks.json`);
5432
+ return 0;
5433
+ }
5434
+ for (const flag of flags) {
5435
+ if (!['check', 'verify', 'no-verify', 'remove', 'json', 'help'].includes(flag)) {
5436
+ const message = `dz hooks-sync: unknown option --${flag}\n${usage}`;
5437
+ (json ? write : writeErr)(json ? JSON.stringify({ error: `unknown option --${flag}`, exitCode: 1 }) : message);
5438
+ return 1;
5439
+ }
5440
+ }
5441
+ for (const key of options.keys()) {
5442
+ if (key !== 'target' && key !== 'project' && key !== 'codex-home') {
5443
+ const message = key.startsWith('_positional_') ? `unexpected argument ${JSON.stringify(options.get(key))}` : `unknown option --${key}`;
5444
+ (json ? write : writeErr)(json ? JSON.stringify({ error: message, exitCode: 1 }) : `dz hooks-sync: ${message}\n${usage}`);
5445
+ return 1;
5446
+ }
5447
+ }
5448
+ // Every `--target` read in this CLI goes through resolveTargetName (alias support + one spelling
5449
+ // of the unknown-target message), pinned by `everyTargetGuardUsesResolveTargetName`.
5450
+ const targetOpt = options.get('target');
5451
+ if (targetOpt === undefined) {
5452
+ const message = '--target is required';
5453
+ (json ? write : writeErr)(json ? JSON.stringify({ error: message, exitCode: 1 }) : `dz hooks-sync: ${message}\n${usage}`);
5454
+ return 1;
5455
+ }
5456
+ const resolution = resolveTargetName(targetOpt);
5457
+ if (resolution.kind === 'unknown') {
5458
+ if (json) {
5459
+ write(JSON.stringify({ error: `unknown target ${targetOpt}`, exitCode: 1 }));
5460
+ }
5461
+ else {
5462
+ for (const line of formatTargetProblem('dz hooks-sync', resolution))
5463
+ writeErr(line);
5464
+ }
5465
+ return 1;
5466
+ }
5467
+ const target = resolution.target;
5468
+ if (resolution.via === 'alias')
5469
+ writeErr(formatTargetAliasNote('dz hooks-sync', targetOpt, target));
5470
+ if (target !== 'codex') {
5471
+ // Deliberately narrow: only Codex has a hook carrier today. Naming the reason keeps a future
5472
+ // reader from assuming the other nine are simply unimplemented here.
5473
+ const message = `unsupported --target ${target} (only "codex" has a hook carrier today)`;
5474
+ (json ? write : writeErr)(json ? JSON.stringify({ error: message, exitCode: 1 }) : `dz hooks-sync: ${message}\n${usage}`);
5475
+ return 1;
5476
+ }
5477
+ const codexHome = options.get('codex-home');
5478
+ const report = runSyncCodexHooks({
5479
+ ...(codexHome !== undefined ? { codexHome } : {}),
5480
+ check: flags.has('check'),
5481
+ remove: flags.has('remove'),
5482
+ });
5483
+ if (json) {
5484
+ write(JSON.stringify({ ...report, exitCode: report.exitCode }));
5485
+ return report.exitCode;
5486
+ }
5487
+ for (const err of report.errors)
5488
+ writeErr(`dz hooks-sync: ${err}`);
5489
+ for (const warn of report.warnings)
5490
+ writeErr(`dz hooks-sync: warning: ${warn}`);
5491
+ // SILENT in a home that never opted in — the leg-1 F12 lesson: a --check that chatters in every
5492
+ // unrelated project trains its reader to ignore it.
5493
+ if (flags.has('check') && !report.installed && report.errors.length === 0)
5494
+ return report.exitCode;
5495
+ if (flags.has('remove')) {
5496
+ write(`dz hooks-sync: removed ${report.removed} managed entr(ies) from ${report.registryPath}`);
5497
+ return report.exitCode;
5498
+ }
5499
+ // AM-17 / G-G: the success word is reachable ONLY from armed AND trusted. The trust clause is
5500
+ // asserted HERE as well as in the exit map — a read-only `--check` that could not establish trust
5501
+ // once returned exit 0 with `trust: 'unknown'`, and this line printed "ready" for it.
5502
+ if (report.exitCode === 0 && report.trust === 'trusted' && report.installed) {
5503
+ write(`dz hooks-sync: codex hooks installed and ARMED (trust: ${report.trust}) — ready`);
5504
+ }
5505
+ else if (report.installed) {
5506
+ writeErr(`dz hooks-sync: installed, NOT verified — ARMED = NO (trust: ${report.trust}, executable: ${report.executable})`);
5507
+ writeErr('→ open an interactive Codex session in this directory and approve the two dz hooks, then re-run with --verify');
5508
+ }
5509
+ else {
5510
+ writeErr('dz hooks-sync: ARMED = NO — the managed entries are not present in the registry');
5511
+ }
5512
+ return report.exitCode;
5513
+ }
5514
+ function cmdAgentsSync(options, flags, cwd, write, writeErr) {
5515
+ const json = flags.has('json');
5516
+ const usage = 'dz agents-sync [--project <dir>] [--check] [--json]';
5517
+ if (flags.has('help')) {
5518
+ write(`${usage} — sync/verify the dz:policies fence in root AGENTS.md`);
5519
+ return 0;
5520
+ }
5521
+ for (const flag of flags) {
5522
+ if (!['check', 'json', 'help'].includes(flag)) {
5523
+ const message = `dz agents-sync: unknown option --${flag}\n${usage}`;
5524
+ (json ? write : writeErr)(json ? JSON.stringify({ error: `unknown option --${flag}`, exitCode: 1 }) : message);
5525
+ return 1;
5526
+ }
5527
+ }
5528
+ for (const key of options.keys()) {
5529
+ if (key !== 'project') {
5530
+ const message = key.startsWith('_positional_') ? `unexpected argument ${JSON.stringify(options.get(key))}` : `unknown option --${key}`;
5531
+ (json ? write : writeErr)(json ? JSON.stringify({ error: message, exitCode: 1 }) : `dz agents-sync: ${message}\n${usage}`);
5532
+ return 1;
5533
+ }
5534
+ }
5535
+ const root = resolve(cwd, options.get('project') ?? '.');
5536
+ try {
5537
+ const report = runSyncAgentsPolicy({ projectRoot: root, check: flags.has('check') });
5538
+ const drifted = report.drift.filter((finding) => finding.status !== 'ok');
5539
+ const inconclusive = report.missing.length > 0;
5540
+ const failed = flags.has('check')
5541
+ ? report.changed || report.budget.overflow || drifted.length > 0
5542
+ : !report.inSync;
5543
+ const exitCode = inconclusive ? 3 : failed ? 1 : 0;
5544
+ if (json) {
5545
+ write(JSON.stringify({ ...report, sections: report.blocks, exitCode }));
5546
+ return exitCode;
5547
+ }
5548
+ if (inconclusive) {
5549
+ writeErr(`dz agents-sync: INCONCLUSIVE — unreadable or unanchored policy source(s): ${report.missing.join(', ')}`);
5550
+ writeErr('→ heal with: restore the named source anchors, then run dz agents-sync');
5551
+ return 3;
5552
+ }
5553
+ if (failed) {
5554
+ const effect = flags.has('check') ? 'AGENTS.md would change' : 'AGENTS.md was not rewritten';
5555
+ writeErr(`dz agents-sync: DRIFT — ${drifted.length} stale/missing section(s); ${effect}`);
5556
+ for (const finding of drifted)
5557
+ writeErr(` ${finding.id}: ${finding.file} (${finding.status})`);
5558
+ if (drifted.some((finding) => finding.id === 'dz:policies')) {
5559
+ writeErr('→ heal with: repair duplicate/unmatched dz:policies markers, then run dz agents-sync');
5560
+ }
5561
+ else {
5562
+ writeErr('→ heal with: dz agents-sync');
5563
+ }
5564
+ return 1;
5565
+ }
5566
+ const verb = report.written ? 'wrote' : 'in sync';
5567
+ write(`dz agents-sync: ${verb} — ${report.blocks.length} policy section(s), ${report.budget.bytes} bytes (${report.budget.pct}% of ${report.budget.cap})`);
5568
+ for (const warning of report.warnings)
5569
+ writeErr(`dz agents-sync: warning: ${warning}`);
5570
+ return 0;
5571
+ }
5572
+ catch (error) {
5573
+ const message = error instanceof Error ? error.message : String(error);
5574
+ if (json)
5575
+ write(JSON.stringify({ error: message, exitCode: 1 }));
5576
+ else
5577
+ writeErr(`dz agents-sync: ${message}`);
5578
+ return 1;
5579
+ }
5580
+ }
5276
5581
  function cmdDriftCheck(options, flags, cwd, write) {
5277
5582
  const root = resolve(cwd, options.get('project') ?? '.');
5278
5583
  // Default scope = PUBLISHED packages only: the `.claude/skills` dogfood copies legitimately lag the
@@ -5380,6 +5685,38 @@ function gatherReadmeCounts(root) {
5380
5685
  function gatherGuardFacts(op, root, text, storeCap) {
5381
5686
  const facts = { op };
5382
5687
  if (op === 'publish') {
5688
+ // agents-md-policy-sync: fixed registry, no tree walk. The pure detector
5689
+ // recomputes every expected hash from current source text; this gatherer
5690
+ // only supplies bytes. Any unexpected gather failure omits the fact, and
5691
+ // evaluateGuard records that advisory coverage gap in `notes`.
5692
+ try {
5693
+ const policyFiles = new Map();
5694
+ for (const file of new Set(POLICY_SOURCES.map((source) => source.file))) {
5695
+ try {
5696
+ policyFiles.set(file, readFileSync(join(root, file), 'utf8'));
5697
+ }
5698
+ catch {
5699
+ policyFiles.set(file, null);
5700
+ }
5701
+ }
5702
+ let agentsMd = null;
5703
+ try {
5704
+ agentsMd = readFileSync(join(root, 'AGENTS.md'), 'utf8');
5705
+ }
5706
+ catch { /* missing stamp evidence */ }
5707
+ const policyDrift = detectPolicyDrift(policyFiles, agentsMd, POLICY_SOURCES);
5708
+ facts['policyDrift'] = {
5709
+ applicable: policyDrift.applicable,
5710
+ // Did this repo OPT IN? A `dz:policies` fence in AGENTS.md is the only durable signal that
5711
+ // someone ran `dz agents-sync` here. Without it the advisory rule is out of scope and stays
5712
+ // silent; with it, unreadable sources become a loud note instead of a silent skip.
5713
+ fenced: hasPolicyFence(agentsMd),
5714
+ drifted: policyDrift.findings
5715
+ .filter((finding) => finding.status !== 'ok')
5716
+ .map((finding) => `${finding.id}:${finding.file}:${finding.status}`),
5717
+ };
5718
+ }
5719
+ catch { /* unexpected gather failure — omission becomes a visible guard note */ }
5383
5720
  const manifests = [];
5384
5721
  const located = [];
5385
5722
  try {
@@ -6494,7 +6831,7 @@ async function cmdRetro(options, flags, cwd, write) {
6494
6831
  * --from-spec <spec.json> preview the scaffold (create / augment per file); the SKILL fills the spec
6495
6832
  * --apply with --from-spec: WRITE (create missing, AUGMENT existing — never clobber)
6496
6833
  */
6497
- function cmdFeatureAdrSetup(options, flags, cwd, write) {
6834
+ function cmdFeatureAdrSetup(options, flags, cwd, write, writeErr) {
6498
6835
  let repoRoot = cwd;
6499
6836
  try {
6500
6837
  repoRoot = execSync('git rev-parse --show-toplevel', { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || cwd;
@@ -6514,11 +6851,25 @@ function cmdFeatureAdrSetup(options, flags, cwd, write) {
6514
6851
  // Its "runnable here" list is computed for --target (default agents-md, the AGENTS.md-class target class).
6515
6852
  const wantGates = flags.has('gates');
6516
6853
  const targetOpt = options.get('target');
6517
- if (targetOpt !== undefined && !isTargetName(targetOpt)) {
6518
- write(`dz feature-adr-setup: --target must be one of: ${TARGET_NAMES.join(', ')}`);
6519
- return 1;
6854
+ // Sites 7 AND 8 of the D3 rewiring. Site 8 (the coercion below) is NOT a guard — it
6855
+ // is a silent fallback, and a mechanical "replace isTargetName with resolveTargetName"
6856
+ // pass would miss it. With aliasing in place and the coercion left alone,
6857
+ // `dz feature-adr-setup --gates --target claude` would pass validation and then emit
6858
+ // for **agents-md**. So the coercion CONSUMES the resolution computed once, above;
6859
+ // `agents-md` is the default only when `--target` is ABSENT.
6860
+ let gatesTarget = 'agents-md';
6861
+ if (targetOpt !== undefined) {
6862
+ const gatesResolution = resolveTargetName(targetOpt);
6863
+ if (gatesResolution.kind === 'unknown') {
6864
+ for (const line of formatTargetProblem('dz feature-adr-setup', gatesResolution))
6865
+ writeErr(line);
6866
+ return 1;
6867
+ }
6868
+ gatesTarget = gatesResolution.target;
6869
+ if (gatesResolution.via === 'alias') {
6870
+ writeErr(formatTargetAliasNote('dz feature-adr-setup', targetOpt, gatesTarget));
6871
+ }
6520
6872
  }
6521
- const gatesTarget = isTargetName(targetOpt ?? '') ? targetOpt : 'agents-md';
6522
6873
  const specPath = options.get('from-spec');
6523
6874
  if (specPath === undefined && !wantGuards && !wantGates) {
6524
6875
  // default + --plan: the read-only "which documents, and where?" answer.
@@ -6652,7 +7003,15 @@ function cmdChallenge(options, flags, cwd, write) {
6652
7003
  * --base <ref> the base ref to fail against (default HEAD)
6653
7004
  * --name '<filter>' optional -t test-name filter applied to every target
6654
7005
  * --runner '<cmd>' test runner (default `npx vitest run`)
6655
- * --json machine-readable {plan, results, verdict, finding}
7006
+ * --timeout <ms> per-run timeout (default 300000; a timed-out run is CANNOT_ISOLATE)
7007
+ * --json machine-readable {plan, results, tipTree, perTest, aggregate,
7008
+ * findings, measurementValid, primaryAction}
7009
+ *
7010
+ * This executor is THIN by design (house style: pure classifier + thin executor). It performs exactly the
7011
+ * I/O the pure gate cannot — stat, worktree, run, capture — and hands OBSERVATIONS back. It no longer
7012
+ * interprets anything: the pre-epoch load-error regex that lived here (`/cannot find module|failed to
7013
+ * load|.../i`) is DELETED, because a regex over a runner's stderr, written in the executor, is exactly the
7014
+ * probabilistic channel that minted `DISCRIMINATES` for `--runner false`.
6656
7015
  *
6657
7016
  * NEVER auto-aborts: a non-discriminating (false-green) test is reported as a HIGH finding for the owner to
6658
7017
  * decide (dz's rule — a false gate kills trust). Exit code is 0 on a clean run regardless of verdict; 2 only on
@@ -6673,6 +7032,9 @@ function cmdDiscriminationCheck(options, flags, cwd, write) {
6673
7032
  const propertyTests = testArg.split(',').map((s) => s.trim()).filter(Boolean).map((file) => nameFilter !== undefined && nameFilter.trim() !== '' ? { file, name: nameFilter.trim() } : { file });
6674
7033
  const baseRef = options.get('base') ?? 'HEAD';
6675
7034
  const runnerOpt = options.get('runner');
7035
+ // R11: a hung runner is a loud non-answer, never a pass. Same default + parse shape as mutation-gate.
7036
+ const timeoutOpt = Number(options.get('timeout') ?? '300000');
7037
+ const timeoutMs = Number.isFinite(timeoutOpt) && timeoutOpt > 0 ? timeoutOpt : 300000;
6676
7038
  const plan = planDiscriminationCheck(runnerOpt !== undefined ? { baseRef, propertyTests, runner: runnerOpt } : { baseRef, propertyTests });
6677
7039
  if (!plan.runnable) {
6678
7040
  // No safe target to run → this is the existing "property untested" finding (empty propertyTests classify).
@@ -6687,113 +7049,218 @@ function cmdDiscriminationCheck(options, flags, cwd, write) {
6687
7049
  write(` → ${result.finding?.detail ?? 'no property test to check'}`);
6688
7050
  return 0;
6689
7051
  }
6690
- // Execute the plan in a temp worktree WE own; substitute {{WORKTREE}} and always clean up.
6691
- // `git worktree add` must CREATE the path, so compute a fresh non-existent one (do NOT mkdtemp it).
6692
- const worktree = join(mkdtempSync(join(tmpdir(), 'dz-disc-')), 'wt');
6693
- const results = [];
6694
- try {
6695
- // 1) add the detached worktree at base (git creates `worktree`; its parent already exists).
6696
- const addCmd = plan.commands[0].replace(/\{\{WORKTREE\}\}/g, worktree);
6697
- execSync(addCmd, { cwd: repoRoot, stdio: 'pipe', encoding: 'utf-8' });
6698
- // 1b) a fresh worktree has NO node_modules — without this, every test fails to load (runner + deps
6699
- // unresolvable) and the gate collapses to always-VIA_ERROR, blind to false greens. Absolute-path
6700
- // symlinks point back at the main checkout's already-installed trees, robust across pnpm's layout.
6701
- const linkNodeModules = (relDir) => {
6702
- const srcNm = join(repoRoot, relDir, 'node_modules');
6703
- if (!existsSync(srcNm))
6704
- return;
6705
- const dstNm = join(worktree, relDir, 'node_modules');
6706
- if (existsSync(dstNm))
6707
- return;
6708
- try {
6709
- mkdirSync(dirname(dstNm), { recursive: true });
6710
- symlinkSync(srcNm, dstNm, 'dir');
6711
- }
6712
- catch { /* best effort */ }
6713
- };
6714
- linkNodeModules('.'); // root (hoisted deps + .bin)
6715
- const pkgDirs = new Set();
6716
- for (const t of plan.targets) {
6717
- let d = dirname(t.file);
6718
- while (d && d !== '.' && d !== sep) {
6719
- if (existsSync(join(repoRoot, d, 'package.json'))) {
6720
- pkgDirs.add(d);
6721
- break;
7052
+ // ── (1) stat + isFile, BEFORE the worktree (AM-6 / FR-A1) ──────────────────────────────────
7053
+ // R12: `stat` FOLLOWS symlinks on purpose. A dangling symlink lstat-exists but has no readable
7054
+ // content that IS absence of the named check. A directory stat-exists but is not a regular
7055
+ // file. Pre-epoch both reached the copy step, threw, and were caught into `outcome:'error'`,
7056
+ // which minted the near-pass DISCRIMINATES_VIA_ERROR (MEASURED — acid A1 / the dangling-symlink
7057
+ // and directory rows of features/wave1-instrument-repair/07_code_changes/acid-red-runs.md).
7058
+ const absent = [];
7059
+ const present = [];
7060
+ for (const t of plan.targets) {
7061
+ let isRegular = false;
7062
+ let isDirectory = false;
7063
+ try {
7064
+ const st = statSync(resolve(repoRoot, t.file));
7065
+ isRegular = st.isFile();
7066
+ isDirectory = st.isDirectory();
7067
+ }
7068
+ catch { /* ENOENT / dangling symlink / permission — absence, either way */ }
7069
+ if (isRegular) {
7070
+ present.push(t.name !== undefined ? { file: t.file, name: t.name } : { file: t.file });
7071
+ continue;
7072
+ }
7073
+ const row = t.name !== undefined ? { file: t.file, name: t.name, outcome: 'absent' } : { file: t.file, outcome: 'absent' };
7074
+ // Out-of-band detail channel: TEST_FILE_ABSENT is evidence-EXEMPT (its evidence is the stat
7075
+ // itself), and absent rows never consult the evidence gate — so this object cannot degrade the
7076
+ // row. It exists only so the finding can tell the operator WHY the path is not a test file.
7077
+ absent.push(isDirectory
7078
+ ? { ...row, evidence: { exitCode: null, runner: 'unrecognised', failureKind: 'unrecognised', testsExecuted: null, targetSeen: false, evidenceLine: 'not-a-regular-file' } }
7079
+ : row);
7080
+ }
7081
+ const results = [...absent];
7082
+ let tipTree = null;
7083
+ // Confirmation 12: with nothing present there is nothing to run — no worktree is built at all.
7084
+ if (present.length > 0) {
7085
+ const runner = runnerOpt !== undefined && plan.commands.some((c) => c.includes(runnerOpt)) ? runnerOpt : 'npx vitest run';
7086
+ // Execute the plan in a temp worktree WE own; substitute {{WORKTREE}} and always clean up.
7087
+ // `git worktree add` must CREATE the path, so compute a fresh non-existent one (do NOT mkdtemp it).
7088
+ const worktree = join(mkdtempSync(join(tmpdir(), 'dz-disc-')), 'wt');
7089
+ try {
7090
+ // 1) add the detached worktree at base (git creates `worktree`; its parent already exists).
7091
+ const addCmd = plan.commands[0].replace(/\{\{WORKTREE\}\}/g, worktree);
7092
+ execSync(addCmd, { cwd: repoRoot, stdio: 'pipe', encoding: 'utf-8' });
7093
+ // 1b) a fresh worktree has NO node_modules — without this, every test fails to load (runner + deps
7094
+ // unresolvable) and the gate collapses to always-VIA_ERROR, blind to false greens. Absolute-path
7095
+ // symlinks point back at the main checkout's already-installed trees, robust across pnpm's layout.
7096
+ const linkNodeModules = (relDir) => {
7097
+ const srcNm = join(repoRoot, relDir, 'node_modules');
7098
+ if (!existsSync(srcNm))
7099
+ return;
7100
+ const dstNm = join(worktree, relDir, 'node_modules');
7101
+ if (existsSync(dstNm))
7102
+ return;
7103
+ try {
7104
+ mkdirSync(dirname(dstNm), { recursive: true });
7105
+ symlinkSync(srcNm, dstNm, 'dir');
7106
+ }
7107
+ catch { /* best effort */ }
7108
+ };
7109
+ linkNodeModules('.'); // root (hoisted deps + .bin)
7110
+ const pkgDirs = new Set();
7111
+ for (const t of present) {
7112
+ let d = dirname(t.file);
7113
+ while (d && d !== '.' && d !== sep) {
7114
+ if (existsSync(join(repoRoot, d, 'package.json'))) {
7115
+ pkgDirs.add(d);
7116
+ break;
7117
+ }
7118
+ d = dirname(d);
6722
7119
  }
6723
- d = dirname(d);
6724
7120
  }
6725
- }
6726
- for (const d of pkgDirs)
6727
- linkNodeModules(d);
6728
- // 2) copy each property test into the base worktree, then 3) run it and record pass/fail/error per target.
6729
- for (const t of plan.targets) {
6730
- try {
6731
- const src = resolve(repoRoot, t.file);
6732
- // containment guard (defense in depth beyond planDiscriminationCheck's path sanitation).
6733
- if (!resolve(src).startsWith(resolve(repoRoot) + sep)) {
7121
+ for (const d of pkgDirs)
7122
+ linkNodeModules(d);
7123
+ // 2) copy each property test into the base worktree, then 3) run it and record the OBSERVATION.
7124
+ for (const t of present) {
7125
+ try {
7126
+ const src = resolve(repoRoot, t.file);
7127
+ // containment guard (defense in depth beyond planDiscriminationCheck's path sanitation).
7128
+ if (!resolve(src).startsWith(resolve(repoRoot) + sep)) {
7129
+ results.push(nameFor(t, 'error'));
7130
+ continue;
7131
+ }
7132
+ const dst = join(worktree, t.file);
7133
+ mkdirSync(dirname(dst), { recursive: true });
7134
+ writeFileSync(dst, readFileSync(src));
7135
+ }
7136
+ catch {
7137
+ // the file STAT-PASSED and the copy still failed: degrade LOUDLY as an error with NO
7138
+ // evidence (the gate reads it as CANNOT_ISOLATE), never as absence and never as a pass.
6734
7139
  results.push(nameFor(t, 'error'));
6735
7140
  continue;
6736
7141
  }
6737
- const dst = join(worktree, t.file);
6738
- mkdirSync(dirname(dst), { recursive: true });
6739
- writeFileSync(dst, readFileSync(src));
7142
+ // t.file + t.name already passed the engine's strict sanitation (no quotes/metacharacters/leading-dash);
7143
+ // still quote + `--` so a path can never be read as a runner option or split a word.
7144
+ const nameArg = t.name ? ` -t '${t.name}'` : '';
7145
+ const cmd = `${runner}${nameArg} -- '${t.file}'`;
7146
+ const base = runCapturedTest(cmd, worktree, timeoutMs);
7147
+ const evidence = classifyExecutionEvidence(base.output, base.exitCode, t.file);
7148
+ const outcome = discriminationOutcomeOf(base.exitCode, evidence);
7149
+ const row = t.name !== undefined
7150
+ ? { file: t.file, name: t.name, outcome, evidence }
7151
+ : { file: t.file, outcome, evidence };
7152
+ // 4) TIP CONTROL (FR-A2 + Confirmation 17). Run it for ALL non-assertion redness — file-load
7153
+ // redness (the matrix's EVIDENCED-error rows) AND unrecognised redness (so the invocation
7154
+ // ledger can prove the tip was REACHED). The CLASSIFIER still ignores the tip for unevidenced
7155
+ // base rows per the matrix; running it is cheap and only ever on an already-broken path.
7156
+ // Do NOT "simplify" this to evidenced-error-only — that silently breaks Confirmation 17.
7157
+ if (base.exitCode !== null && base.exitCode !== 0 && evidence.failureKind !== 'assertions') {
7158
+ const tip = runCapturedTest(cmd, repoRoot, timeoutMs);
7159
+ const tipEvidence = classifyExecutionEvidence(tip.output, tip.exitCode, t.file);
7160
+ row['tipOutcome'] = discriminationOutcomeOf(tip.exitCode, tipEvidence);
7161
+ row['tipEvidence'] = tipEvidence;
7162
+ // R15, named honestly: the base run is isolated in a worktree, but the tip runs in the LIVE
7163
+ // tree, where a concurrent writer can flip the observation mid-gate. No lock is taken
7164
+ // (deferred to backlog 9520e506); instead every tip-derived reading carries the tree
7165
+ // CONDITIONS it was taken under, so a surprising verdict can be re-read against them.
7166
+ if (tipTree === null)
7167
+ tipTree = readTipTreeConditions(repoRoot);
7168
+ }
7169
+ results.push(row);
6740
7170
  }
6741
- catch {
6742
- results.push(nameFor(t, 'error'));
6743
- continue;
7171
+ }
7172
+ catch (e) {
7173
+ if (flags.has('json')) {
7174
+ write(JSON.stringify({ plan, error: 'worktree-setup-failed', detail: String(e.message).slice(0, 300) }, null, 2));
6744
7175
  }
6745
- const runner = (runnerOpt !== undefined && plan.commands.some((c) => c.includes(runnerOpt))) ? runnerOpt : 'npx vitest run';
6746
- // t.file + t.name already passed the engine's strict sanitation (no quotes/metacharacters/leading-dash);
6747
- // still quote + `--` so a path can never be read as a runner option or split a word.
6748
- const nameArg = t.name ? ` -t '${t.name}'` : '';
7176
+ else
7177
+ write(`discrimination-check: could not create worktree at ${baseRef}: ${String(e.message).slice(0, 200)}`);
7178
+ return 2;
7179
+ }
7180
+ finally {
6749
7181
  try {
6750
- execSync(`${runner}${nameArg} -- '${t.file}'`, { cwd: worktree, stdio: 'pipe', encoding: 'utf-8' });
6751
- results.push(nameFor(t, 'pass')); // exit 0 → test PASSED at base → false green
7182
+ execSync(`git worktree remove --force ${worktree}`, { cwd: repoRoot, stdio: 'pipe' });
6752
7183
  }
6753
- catch (e) {
6754
- // vitest exits non-zero on failure AND on load/compile error. Distinguish: a load error usually names
6755
- // "Cannot find module"/"Failed to load"/"No test files"; otherwise treat as an assertion failure (red).
6756
- const out = String(e.stdout ?? '') + String(e.stderr ?? '');
6757
- const isLoadError = /cannot find module|failed to load|no test (files )?found|error: cannot|transform failed|esbuild/i.test(out);
6758
- results.push(nameFor(t, isLoadError ? 'error' : 'fail'));
7184
+ catch { /* fall through to rm */ }
7185
+ // remove the whole mkdtemp parent (worktree is `<mkdtemp>/wt`), so nothing leaks under tmp even on error.
7186
+ try {
7187
+ rmSync(dirname(worktree), { recursive: true, force: true });
6759
7188
  }
7189
+ catch { /* best effort */ }
7190
+ try {
7191
+ execSync('git worktree prune', { cwd: repoRoot, stdio: 'pipe' });
7192
+ }
7193
+ catch { /* best effort */ }
6760
7194
  }
6761
7195
  }
6762
- catch (e) {
6763
- if (flags.has('json')) {
6764
- write(JSON.stringify({ plan, error: 'worktree-setup-failed', detail: String(e.message).slice(0, 300) }, null, 2));
6765
- }
6766
- else
6767
- write(`discrimination-check: could not create worktree at ${baseRef}: ${String(e.message).slice(0, 200)}`);
6768
- return 2;
6769
- }
6770
- finally {
6771
- try {
6772
- execSync(`git worktree remove --force ${worktree}`, { cwd: repoRoot, stdio: 'pipe' });
6773
- }
6774
- catch { /* fall through to rm */ }
6775
- // remove the whole mkdtemp parent (worktree is `<mkdtemp>/wt`), so nothing leaks under tmp even on error.
6776
- try {
6777
- rmSync(dirname(worktree), { recursive: true, force: true });
6778
- }
6779
- catch { /* best effort */ }
6780
- try {
6781
- execSync('git worktree prune', { cwd: repoRoot, stdio: 'pipe' });
6782
- }
6783
- catch { /* best effort */ }
6784
- }
6785
7196
  const result = classifyDiscrimination({ propertyTests, results });
6786
7197
  if (flags.has('json')) {
6787
- write(JSON.stringify({ plan, results, ...result }, null, 2));
7198
+ write(JSON.stringify({ plan, results, tipTree, ...result }, null, 2));
6788
7199
  return 0;
6789
7200
  }
6790
7201
  write(`discrimination-check @ ${baseRef} — verdict: ${result.aggregate}`);
6791
- for (const p of result.perTest)
6792
- write(` ${p.verdict === 'NON_DISCRIMINATING' ? '✗' : '✓'} ${p.file}${p.name ? ` (${p.name})` : ''}: ${p.verdict}`);
6793
- if (result.finding)
6794
- write(`\n [${result.finding.severity}] ${result.finding.title}\n ${result.finding.detail}`);
7202
+ for (const p of result.perTest) {
7203
+ // FR-A6: is reserved for the two ESTABLISHED trust verdicts. Every other value — including
7204
+ // every degraded reading — renders ✗, because a ✗ the operator investigates beats a ✓ that
7205
+ // silently meant "we could not tell".
7206
+ const mark = p.verdict === 'DISCRIMINATES' || p.verdict === 'DISCRIMINATES_VIA_ERROR' ? '✓' : '✗';
7207
+ write(` ${mark} ${p.file}${p.name ? ` (${p.name})` : ''}: ${p.verdict}${p.reason ? ` (reason: ${p.reason})` : ''}`);
7208
+ }
7209
+ write(` measurementValid: ${String(result.measurementValid)} · primaryAction: ${result.primaryAction}`);
7210
+ // ALL findings print, not just the worst: the scalar aggregate names one state, and a corpus with
7211
+ // a false green AND an absent file has two problems, each with its own operator action.
7212
+ for (const f of result.findings)
7213
+ write(`\n [${f.severity}] ${f.title}\n ${f.detail}`);
6795
7214
  return 0;
6796
7215
  }
7216
+ /**
7217
+ * Run one test command and CAPTURE the observation — output plus the exit code, including the
7218
+ * "no exit code at all" case. `execSync`'s timeout kills the child via signal and leaves
7219
+ * `status` null; a spawn failure does the same. That null is not an error to swallow, it is the
7220
+ * evidence (`CANNOT_ISOLATE` reason `'timeout'`), so it is returned as data.
7221
+ */
7222
+ function runCapturedTest(cmd, cwd, timeoutMs) {
7223
+ try {
7224
+ const stdout = execSync(cmd, { cwd, stdio: 'pipe', encoding: 'utf-8', timeout: timeoutMs });
7225
+ return { output: String(stdout ?? ''), exitCode: 0 };
7226
+ }
7227
+ catch (e) {
7228
+ const err = e;
7229
+ return {
7230
+ output: String(err.stdout ?? '') + String(err.stderr ?? ''),
7231
+ exitCode: typeof err.status === 'number' ? err.status : null,
7232
+ };
7233
+ }
7234
+ }
7235
+ /**
7236
+ * The outcome VALUE for one captured run. The executor's whole remaining judgment, and it is
7237
+ * mechanical: exit 0 is a pass, no exit code is an error, and a non-zero exit is an error only when
7238
+ * the classifier RECOGNISED a file-load failure. An unrecognised red is deliberately recorded as a
7239
+ * `fail` VALUE whose evidence then degrades it — exactly acid A6's pinned shape, and the reason the
7240
+ * executor no longer owns a regex.
7241
+ */
7242
+ function discriminationOutcomeOf(exitCode, evidence) {
7243
+ if (exitCode === null)
7244
+ return 'error';
7245
+ if (exitCode === 0)
7246
+ return 'pass';
7247
+ return evidence.failureKind === 'file-load' ? 'error' : 'fail';
7248
+ }
7249
+ /** The live tree's identity at tip-run time (R15). Best-effort: unknown conditions read as such. */
7250
+ function readTipTreeConditions(repoRoot) {
7251
+ let headSha = 'unknown';
7252
+ let dirtyFiles = -1;
7253
+ try {
7254
+ headSha = execSync('git rev-parse HEAD', { cwd: repoRoot, stdio: 'pipe', encoding: 'utf-8' }).trim();
7255
+ }
7256
+ catch { /* best effort */ }
7257
+ try {
7258
+ const porcelain = execSync('git status --porcelain', { cwd: repoRoot, stdio: 'pipe', encoding: 'utf-8' });
7259
+ dirtyFiles = String(porcelain).split('\n').filter((l) => l.trim() !== '').length;
7260
+ }
7261
+ catch { /* best effort */ }
7262
+ return { headSha, dirtyFiles };
7263
+ }
6797
7264
  /** small helper: build a result row, omitting `name` when absent (exactOptionalPropertyTypes). */
6798
7265
  function nameFor(t, outcome) {
6799
7266
  return t.name !== undefined ? { file: t.file, name: t.name, outcome } : { file: t.file, outcome };
@@ -9195,6 +9662,8 @@ async function cmdImportEcc(options, flags, cwd, write) {
9195
9662
  export async function runCli(argv, io = {}) {
9196
9663
  const cwd = io.cwd ?? process.cwd();
9197
9664
  const write = io.write ?? ((line) => { console.log(line); });
9665
+ // Diagnostics go to stderr so `dz <cmd> > out.txt` yields clean data (feature dz-cli-defects).
9666
+ const writeErr = io.writeErr ?? ((line) => { console.error(line); });
9198
9667
  // Lazy STDIN reader — only `dz brain ground` reads it, and only when no positional prompt is
9199
9668
  // given. Never blocks: injected `io.stdin` wins; else read fd 0 synchronously, but bail to '' on
9200
9669
  // a TTY (nothing piped) or any read error. Grounding must never hang waiting on an empty pipe.
@@ -9238,14 +9707,14 @@ export async function runCli(argv, io = {}) {
9238
9707
  try {
9239
9708
  switch (command) {
9240
9709
  case 'init':
9241
- return await cmdInit(options, flags, cwd, write);
9710
+ return await cmdInit(options, flags, cwd, write, writeErr);
9242
9711
  case 'verify':
9243
- return await cmdVerify(options, cwd, write);
9712
+ return await cmdVerify(options, cwd, write, writeErr);
9244
9713
  case 'sync':
9245
9714
  case 'update':
9246
- return await cmdSync(options, flags, cwd, write);
9715
+ return await cmdSync(options, flags, cwd, write, writeErr);
9247
9716
  case 'list':
9248
- return cmdList(options, cwd, write);
9717
+ return cmdList(options, cwd, write, writeErr);
9249
9718
  case 'create-skill':
9250
9719
  return cmdCreateSkill(options, flags, cwd, write);
9251
9720
  case 'info':
@@ -9263,7 +9732,7 @@ export async function runCli(argv, io = {}) {
9263
9732
  case 'doctor':
9264
9733
  return await cmdDoctor(options, flags, cwd, write);
9265
9734
  case 'install':
9266
- return await cmdInstall(options, flags, cwd, write, io.installRunner);
9735
+ return await cmdInstall(options, flags, cwd, write, writeErr, io.installRunner);
9267
9736
  case 'bundle':
9268
9737
  return cmdBundle(options, flags, cwd, write);
9269
9738
  case 'teach':
@@ -9291,17 +9760,17 @@ export async function runCli(argv, io = {}) {
9291
9760
  case 'verify-pack':
9292
9761
  return cmdVerifyPack(options, flags, cwd, write);
9293
9762
  case 'setup':
9294
- return await cmdSetup(options, flags, cwd, write);
9763
+ return await cmdSetup(options, flags, cwd, write, writeErr);
9295
9764
  case 'pretrain':
9296
9765
  return cmdPretrain(options, cwd, write);
9297
9766
  case 'compose':
9298
- return cmdCompose(options, cwd, write);
9767
+ return cmdCompose(options, cwd, write, writeErr);
9299
9768
  case 'diff':
9300
9769
  return cmdDiff(options, cwd, write);
9301
9770
  case 'recommend':
9302
9771
  return cmdRecommend(options, cwd, write);
9303
9772
  case 'upgrade':
9304
- return cmdUpgrade(options, flags, cwd, write);
9773
+ return cmdUpgrade(options, flags, cwd, write, writeErr);
9305
9774
  case 'auto-canonicalize':
9306
9775
  return await cmdAutoCanonicalize(options, cwd, write);
9307
9776
  case 'publish':
@@ -9309,7 +9778,7 @@ export async function runCli(argv, io = {}) {
9309
9778
  case 'release':
9310
9779
  return cmdRelease(options, flags, cwd, write, io.releaseRunner);
9311
9780
  case 'parity':
9312
- return cmdParity(options, flags, write);
9781
+ return cmdParity(options, flags, write, writeErr);
9313
9782
  case 'registry':
9314
9783
  return cmdRegistry(options, cwd, write);
9315
9784
  case 'benchmark':
@@ -9320,6 +9789,10 @@ export async function runCli(argv, io = {}) {
9320
9789
  return await cmdSyncUpstream(options, flags, cwd, write);
9321
9790
  case 'drift-check':
9322
9791
  return cmdDriftCheck(options, flags, cwd, write);
9792
+ case 'hooks-sync':
9793
+ return cmdHooksSync(options, flags, cwd, write, writeErr);
9794
+ case 'agents-sync':
9795
+ return cmdAgentsSync(options, flags, cwd, write, writeErr);
9323
9796
  case 'sync-canonical':
9324
9797
  return cmdSyncCanonical(options, flags, cwd, write);
9325
9798
  case 'plugin':
@@ -9337,7 +9810,7 @@ export async function runCli(argv, io = {}) {
9337
9810
  case 'retro':
9338
9811
  return await cmdRetro(options, flags, cwd, write);
9339
9812
  case 'feature-adr-setup':
9340
- return cmdFeatureAdrSetup(options, flags, cwd, write);
9813
+ return cmdFeatureAdrSetup(options, flags, cwd, write, writeErr);
9341
9814
  case 'challenge':
9342
9815
  return cmdChallenge(options, flags, cwd, write);
9343
9816
  case 'discrimination-check':
@@ -9375,7 +9848,9 @@ export async function runCli(argv, io = {}) {
9375
9848
  }
9376
9849
  }
9377
9850
  catch (error) {
9378
- write(`dz: ${error instanceof Error ? error.message : String(error)}`);
9851
+ // stderr, not stdout: an uncaught failure is a diagnostic, and routing it through
9852
+ // `write` is what made `dz list > skills.txt` write the error into the data file.
9853
+ writeErr(`dz: ${error instanceof Error ? error.message : String(error)}`);
9379
9854
  return 1;
9380
9855
  }
9381
9856
  }