@dzhechkov/harness-cli 0.4.5 → 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, 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
@@ -119,6 +121,8 @@ Usage:
119
121
  dz import-ecc [--local-path <dir>] [--select id,id,...] [--limit N] [--output <dir>] [--force]
120
122
  dz help
121
123
 
124
+ Global: --version | -v [--json] (prints this CLI's own semver on one line, exit 0; "unknown" + exit 1 when unresolvable)
125
+
122
126
  Workflows: author loop-plan/1 plans with dz workflow init/validate/render; gate them with dz workflow-lint; read runs with dz workflow-trace (the ADR-005 templates are retired)
123
127
 
124
128
  Targets: ${TARGET_NAMES.join(', ')}
@@ -206,9 +210,11 @@ async function installSkills(opts) {
206
210
  written += s.written;
207
211
  skipped += s.skipped;
208
212
  }
209
- 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] };
210
214
  }
211
215
  const results = [];
216
+ const failures = [];
217
+ const applyFailures = [];
212
218
  for (const skillsDir of skillsDirs) {
213
219
  const r = await runInit({
214
220
  target,
@@ -221,6 +227,8 @@ async function installSkills(opts) {
221
227
  for (const skill of r.skills) {
222
228
  results.push({ id: skill.id, written: skill.written.length, skipped: skill.skipped.length });
223
229
  }
230
+ failures.push(...r.failures);
231
+ applyFailures.push(...r.applyFailures);
224
232
  }
225
233
  let written = 0;
226
234
  let skipped = 0;
@@ -230,7 +238,7 @@ async function installSkills(opts) {
230
238
  }
231
239
  const installed = new Set(results.map((s) => s.id));
232
240
  const missing = select !== undefined ? [...select].filter((id) => !installed.has(id)) : [];
233
- return { results, dirsSearched: skillsDirs.length, written, skipped, missing };
241
+ return { results, dirsSearched: skillsDirs.length, written, skipped, missing, failures, applyFailures };
234
242
  }
235
243
  /** Warn about preset/select ids that weren't found in any installed pack. */
236
244
  function writeMissingSkillsHint(write, missing, presetName) {
@@ -246,12 +254,24 @@ function writeMissingSkillsHint(write, missing, presetName) {
246
254
  write(` Install their packs first (e.g. dz install @dzhechkov/skills-<pack>) or check the ids.`);
247
255
  }
248
256
  }
249
- async function cmdInit(options, flags, cwd, write) {
250
- const target = options.get('target');
251
- if (target === undefined || !isTargetName(target)) {
252
- 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(', ')}`);
253
264
  return 1;
254
265
  }
266
+ const resolution = resolveTargetName(targetOpt);
267
+ if (resolution.kind === 'unknown') {
268
+ for (const line of formatTargetProblem('dz init', resolution))
269
+ writeErr(line);
270
+ return 1;
271
+ }
272
+ const target = resolution.target;
273
+ if (resolution.via === 'alias')
274
+ writeErr(formatTargetAliasNote('dz init', targetOpt, target));
255
275
  const explicitSkillsDir = options.get('skills-dir');
256
276
  const projectRoot = resolve(cwd, options.get('project') ?? '.');
257
277
  const presetName = options.get('preset');
@@ -287,18 +307,48 @@ async function cmdInit(options, flags, cwd, write) {
287
307
  write(` (searched ${r.dirsSearched} skill directories)`);
288
308
  }
289
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
+ }
290
332
  return 0;
291
333
  }
292
- async function cmdVerify(options, cwd, write) {
334
+ async function cmdVerify(options, cwd, write, writeErr) {
293
335
  const skillsDir = resolve(cwd, options.get('skills-dir') ?? '.claude/skills');
294
336
  const targetOpt = options.get('target');
295
- if (targetOpt !== undefined && !isTargetName(targetOpt)) {
296
- write(`dz verify: --target must be one of: ${TARGET_NAMES.join(', ')}`);
297
- 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));
298
348
  }
299
349
  const report = await runVerify({
300
350
  skillsDir,
301
- ...(targetOpt !== undefined ? { target: targetOpt } : {}),
351
+ ...(target !== undefined ? { target } : {}),
302
352
  });
303
353
  write(`dz verify (${report.target}): ${report.valid}/${report.total} skill(s) valid`);
304
354
  for (const skill of report.skills) {
@@ -307,7 +357,7 @@ async function cmdVerify(options, cwd, write) {
307
357
  }
308
358
  return report.valid === report.total ? 0 : 1;
309
359
  }
310
- async function cmdSync(options, flags, cwd, write) {
360
+ async function cmdSync(options, flags, cwd, write, writeErr) {
311
361
  const projectRoot = resolve(cwd, options.get('project') ?? '.');
312
362
  const canonicalArg = options.get('canonical');
313
363
  // Auto-discover all skills-* packs, or use explicit --canonical
@@ -331,6 +381,15 @@ async function cmdSync(options, flags, cwd, write) {
331
381
  });
332
382
  const { total, inSync, missing, drift } = report.summary;
333
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
+ }
334
393
  return missing === 0 && drift === 0 ? 0 : 1;
335
394
  }
336
395
  function cmdCreateSkill(options, flags, cwd, write) {
@@ -372,19 +431,44 @@ function cmdCreateSkill(options, flags, cwd, write) {
372
431
  }
373
432
  return 0;
374
433
  }
375
- 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) {
376
453
  const skillsDir = resolve(cwd, options.get('skills-dir') ?? '.claude/skills');
377
- const skills = listSkills(skillsDir);
378
- if (skills.length === 0) {
379
- 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}`);
380
457
  return 1;
381
458
  }
382
- write(`${skills.length} skill(s) in ${skillsDir}:\n`);
383
- for (const skill of skills) {
384
- const desc = skill.description.length > 80 ? skill.description.slice(0, 77) + '...' : skill.description;
385
- 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
+ }
386
465
  }
387
- 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;
388
472
  }
389
473
  function cmdInfo(options, args, cwd, write) {
390
474
  const skillsDir = resolve(cwd, options.get('skills-dir') ?? '.claude/skills');
@@ -529,8 +613,8 @@ function workflowInitPlan(name, pattern) {
529
613
  ...base,
530
614
  steps: [
531
615
  { stepId: 'fan', kind: 'fanout', phase: 'Work', concurrency: 'pipeline', budget: { maxAgents: 8 } },
532
- { stepId: 'a', kind: 'agent', phase: 'Work', prompt: 'TODO: stage A per item', budget: { maxAgents: 4 } }, // no-stubs: workflow-init scaffold sentinel the author replaces (deliberate authoring cue, not unfinished code)
533
- { stepId: 'b', kind: 'agent', phase: 'Work', prompt: 'TODO: stage B per item', budget: { maxAgents: 4 } }, // no-stubs: workflow-init scaffold sentinel the author replaces (deliberate authoring cue, not unfinished code)
616
+ { stepId: 'a', kind: 'agent', phase: 'Work', prompt: 'TODO: stage A per item', budget: { maxAgents: 4 }, tools: [] }, // no-stubs: workflow-init scaffold sentinel the author replaces (deliberate authoring cue, not unfinished code)
617
+ { stepId: 'b', kind: 'agent', phase: 'Work', prompt: 'TODO: stage B per item', budget: { maxAgents: 4 }, tools: [] }, // no-stubs: workflow-init scaffold sentinel the author replaces (deliberate authoring cue, not unfinished code)
534
618
  { stepId: 'jn', kind: 'join', phase: 'Work', deps: ['fan'] },
535
619
  ],
536
620
  fanouts: [{ stage: 'fan', registry: ['item1', 'item2', 'item3'], maxFanout: 3, chain: ['a', 'b'] }],
@@ -542,10 +626,10 @@ function workflowInitPlan(name, pattern) {
542
626
  ...base,
543
627
  steps: [
544
628
  { stepId: 'fan', kind: 'fanout', phase: 'Lanes', concurrency: 'barrier', budget: { maxAgents: 6 } },
545
- { stepId: 'lane', kind: 'agent', phase: 'Lanes', prompt: 'TODO: one lane', budget: { maxAgents: 6 } }, // no-stubs: workflow-init scaffold sentinel the author replaces (deliberate authoring cue, not unfinished code)
629
+ { stepId: 'lane', kind: 'agent', phase: 'Lanes', prompt: 'TODO: one lane', budget: { maxAgents: 6 }, tools: [] }, // no-stubs: workflow-init scaffold sentinel the author replaces (deliberate authoring cue, not unfinished code)
546
630
  { stepId: 'jn', kind: 'join', phase: 'Lanes', deps: ['fan'] },
547
631
  // the consumer hangs off the BARRIER (jn), never the fork — barrier-postdominates teaches this
548
- { stepId: 'synthesize', kind: 'agent', phase: 'Synthesize', deps: ['jn'], prompt: 'TODO: synthesize across lanes', budget: { maxAgents: 1 } }, // no-stubs: workflow-init scaffold sentinel the author replaces (deliberate authoring cue, not unfinished code)
632
+ { stepId: 'synthesize', kind: 'agent', phase: 'Synthesize', deps: ['jn'], prompt: 'TODO: synthesize across lanes', budget: { maxAgents: 1 }, tools: [] }, // no-stubs: workflow-init scaffold sentinel the author replaces (deliberate authoring cue, not unfinished code)
549
633
  ],
550
634
  fanouts: [{ stage: 'fan', registry: ['lane1', 'lane2', 'lane3'], maxFanout: 3, chain: ['lane'] }],
551
635
  joins: [{ stage: 'jn', forStage: 'fan', joinPolicy: 'all-activated', onInvalid: 'named-failure' }],
@@ -555,8 +639,8 @@ function workflowInitPlan(name, pattern) {
555
639
  return {
556
640
  ...base,
557
641
  steps: [
558
- { stepId: 'work', kind: 'agent', phase: 'Work', prompt: 'TODO: produce the artifact', budget: { maxAgents: 2 } }, // no-stubs: workflow-init scaffold sentinel the author replaces (deliberate authoring cue, not unfinished code)
559
- { stepId: 'gate', kind: 'gate', phase: 'Gate', deps: ['work'], prompt: 'TODO: gate check (parse the verdict, never synthesize one)', budget: { maxAgents: 1 } }, // no-stubs: workflow-init scaffold sentinel the author replaces (deliberate authoring cue, not unfinished code)
642
+ { stepId: 'work', kind: 'agent', phase: 'Work', prompt: 'TODO: produce the artifact', budget: { maxAgents: 2 }, tools: [] }, // no-stubs: workflow-init scaffold sentinel the author replaces (deliberate authoring cue, not unfinished code)
643
+ { stepId: 'gate', kind: 'gate', phase: 'Gate', deps: ['work'], prompt: 'TODO: gate check (parse the verdict, never synthesize one)', budget: { maxAgents: 1 }, tools: [] }, // no-stubs: workflow-init scaffold sentinel the author replaces (deliberate authoring cue, not unfinished code)
560
644
  ],
561
645
  gates: [{ stepId: 'gate', kind: 'parse-verdict', failRoute: 'work', maxRedos: 1 }],
562
646
  };
@@ -564,7 +648,7 @@ function workflowInitPlan(name, pattern) {
564
648
  // minimal default: one agent step
565
649
  return {
566
650
  ...base,
567
- steps: [{ stepId: 'main', kind: 'agent', phase: 'Work', prompt: 'TODO: the one step', budget: { maxAgents: 1 } }], // no-stubs: workflow-init scaffold sentinel the author replaces (deliberate authoring cue, not unfinished code)
651
+ steps: [{ stepId: 'main', kind: 'agent', phase: 'Work', prompt: 'TODO: the one step', budget: { maxAgents: 1 }, tools: [] }], // no-stubs: workflow-init scaffold sentinel the author replaces (deliberate authoring cue, not unfinished code)
568
652
  };
569
653
  }
570
654
  /**
@@ -762,8 +846,12 @@ function cmdWorkflowLint(options, flags, cwd, write) {
762
846
  /** `dz workflow-trace` — timeline + invariant runner over a run's trace.jsonl. Scope is CAPPED
763
847
  * (AM-8): <runDir|--slug|--run>, --invariants, --html, --json. NO watch/filter/compare/search/
764
848
  * retention/access-control — adding one needs an ADR amendment (the surface test pins this). */
765
- /** The tool version stamped into a bundle's provenance. Unknown is honest; a throw is not. */
766
- function traceBundleToolVersion() {
849
+ /**
850
+ * This CLI's OWN semver, read from its package.json. `unknown` is honest; a throw is not, and an
851
+ * invented number is worse than either — a downstream version guard that is handed a fabricated
852
+ * version happily calls a binary it should have refused.
853
+ */
854
+ function dzOwnVersion() {
767
855
  try {
768
856
  const req = createRequire(import.meta.url);
769
857
  const pkg = req('../package.json');
@@ -773,6 +861,10 @@ function traceBundleToolVersion() {
773
861
  return 'unknown';
774
862
  }
775
863
  }
864
+ /** The tool version stamped into a bundle's provenance. Unknown is honest; a throw is not. */
865
+ function traceBundleToolVersion() {
866
+ return dzOwnVersion();
867
+ }
776
868
  /** Resolve a run the SAME three ways the timeline reader does — positional, --slug, --run — so a
777
869
  * bundle can never address a run by a scheme the rest of the command does not understand. */
778
870
  function resolveTraceRun(options, cwd, positional) {
@@ -1207,17 +1299,22 @@ function cmdBundle(options, flags, cwd, write) {
1207
1299
  }
1208
1300
  return 0;
1209
1301
  }
1210
- async function cmdInstall(options, flags, cwd, write, installRunner) {
1302
+ async function cmdInstall(options, flags, cwd, write, writeErr, installRunner) {
1211
1303
  const pkg = options.get('_positional_0');
1212
1304
  if (!pkg) {
1213
1305
  write('dz install: package name required (e.g., dz install @dzhechkov/skills-devops)');
1214
1306
  return 1;
1215
1307
  }
1216
1308
  const targetOpt = options.get('target') ?? 'claude-code';
1217
- if (!isTargetName(targetOpt)) {
1218
- 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);
1219
1313
  return 1;
1220
1314
  }
1315
+ const target = targetResolution.target;
1316
+ if (targetResolution.via === 'alias')
1317
+ writeErr(formatTargetAliasNote('dz install', targetOpt, target));
1221
1318
  const projectRoot = resolve(cwd, options.get('project') ?? '.');
1222
1319
  // Step 1: npm install the package (installRunner is the CliIo test seam — unset in production)
1223
1320
  write(`Installing ${pkg}...`);
@@ -1251,7 +1348,7 @@ async function cmdInstall(options, flags, cwd, write, installRunner) {
1251
1348
  const root = roots[0];
1252
1349
  // Step 3: Use dz init with the resolved skills root as source
1253
1350
  const report = await runInit({
1254
- target: targetOpt,
1351
+ target,
1255
1352
  skillsDir: root.dir,
1256
1353
  projectRoot,
1257
1354
  force: flags.has('force'),
@@ -1268,20 +1365,38 @@ async function cmdInstall(options, flags, cwd, write, installRunner) {
1268
1365
  if (root.layout === 'npx-template' && root.hasCompanionAssets) {
1269
1366
  write(` note: ${pkg} also ships commands/hooks/agents — \`npx -y ${pkg} init\` installs the full kit.`);
1270
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
+ }
1271
1381
  return 0;
1272
1382
  }
1273
- function cmdCompose(options, cwd, write) {
1383
+ function cmdCompose(options, cwd, write, writeErr) {
1274
1384
  const combo = options.get('_positional_0');
1275
1385
  if (!combo) {
1276
1386
  write('dz compose: preset combination required (e.g., dz compose devops+mcp+web3)');
1277
1387
  return 1;
1278
1388
  }
1279
1389
  // --target is documented; honor it in the suggested install command (was hardcoded claude-code).
1280
- const target = options.get('target') ?? 'claude-code';
1281
- if (!isTargetName(target)) {
1282
- 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);
1283
1395
  return 1;
1284
1396
  }
1397
+ const target = composeResolution.target;
1398
+ if (composeResolution.via === 'alias')
1399
+ writeErr(formatTargetAliasNote('dz compose', targetOpt, target));
1285
1400
  const presetNames = combo.split('+').map((s) => s.trim());
1286
1401
  const allSkills = new Set();
1287
1402
  const resolved = [];
@@ -3479,12 +3594,21 @@ async function cmdBrain(options, flags, cwd, write, readStdin) {
3479
3594
  write(BRAIN_USAGE);
3480
3595
  return sub === undefined ? 0 : 1;
3481
3596
  }
3482
- async function cmdSetup(options, flags, cwd, write) {
3483
- const target = options.get('target');
3484
- if (!target || !isTargetName(target)) {
3485
- 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(', ')})`);
3486
3601
  return 1;
3487
3602
  }
3603
+ const setupResolution = resolveTargetName(targetOpt);
3604
+ if (setupResolution.kind === 'unknown') {
3605
+ for (const line of formatTargetProblem('dz setup', setupResolution))
3606
+ writeErr(line);
3607
+ return 1;
3608
+ }
3609
+ const target = setupResolution.target;
3610
+ if (setupResolution.via === 'alias')
3611
+ writeErr(formatTargetAliasNote('dz setup', targetOpt, target));
3488
3612
  const projectRoot = resolve(cwd, options.get('project') ?? '.');
3489
3613
  const presetName = options.get('preset');
3490
3614
  write(`\n╔══════════════════════════════════════════════════════╗`);
@@ -3649,12 +3773,19 @@ function cmdRecommend(options, cwd, write) {
3649
3773
  write(` Get it: dz init --target claude-code --select skill-advisor (or --preset meta)`);
3650
3774
  return 0;
3651
3775
  }
3652
- function cmdUpgrade(options, flags, cwd, write) {
3776
+ function cmdUpgrade(options, flags, cwd, write, writeErr) {
3653
3777
  const targetOpt = options.get('target') ?? 'claude-code';
3654
- if (!isTargetName(targetOpt)) {
3655
- 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);
3656
3782
  return 1;
3657
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));
3658
3789
  const projectRoot = resolve(cwd, options.get('project') ?? '.');
3659
3790
  const targetDirMap = {
3660
3791
  'claude-code': '.claude/skills', codex: '.agents/skills', opencode: '.opencode/skills',
@@ -3662,9 +3793,9 @@ function cmdUpgrade(options, flags, cwd, write) {
3662
3793
  'agents-md': 'AGENTS.md', cursor: '.cursor/rules', gemini: 'GEMINI.md',
3663
3794
  windsurf: '.windsurf/rules',
3664
3795
  };
3665
- const mappedDir = targetDirMap[targetOpt];
3796
+ const mappedDir = targetDirMap[upgradeTarget];
3666
3797
  if (mappedDir === undefined) {
3667
- write(`dz upgrade: no skills directory mapping for target ${targetOpt}`);
3798
+ write(`dz upgrade: no skills directory mapping for target ${upgradeTarget}`);
3668
3799
  return 1;
3669
3800
  }
3670
3801
  const targetDir = join(projectRoot, mappedDir);
@@ -3679,14 +3810,14 @@ function cmdUpgrade(options, flags, cwd, write) {
3679
3810
  }
3680
3811
  }
3681
3812
  const report = checkUpgrades(targetDir, canonicalDirs);
3682
- write(`\ndz upgrade — ${targetOpt} (${targetDir})`);
3813
+ write(`\ndz upgrade — ${upgradeTarget} (${targetDir})`);
3683
3814
  write(` Installed: ${report.installed} Needs update: ${report.needsUpdate} Up-to-date: ${report.upToDate} Custom: ${report.notInCanonical}\n`);
3684
3815
  for (const check of report.skills) {
3685
3816
  const icon = check.needsUpdate ? '△' : check.canonicalSize === undefined ? '?' : '✓';
3686
3817
  write(` ${icon} ${check.id.padEnd(25)} ${check.reason}`);
3687
3818
  }
3688
3819
  if (report.needsUpdate > 0) {
3689
- 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`);
3690
3821
  }
3691
3822
  // ADR-001 (verify-apply-leg): verify what we just left on disk. A TAMPERED pack aborts.
3692
3823
  const sigFatal = reportPackVerification(projectRoot, options.get('pubkey'), flags.has('require-signing'), write);
@@ -4239,7 +4370,7 @@ function cmdPublish(options, flags, cwd, write) {
4239
4370
  /* dz parity — the honest feature×target map (target-parity-matrix, */
4240
4371
  /* ADR-001): computed from the declarative model, never hand-written */
4241
4372
  /* ------------------------------------------------------------------ */
4242
- function cmdParity(options, flags, write) {
4373
+ function cmdParity(options, flags, write, writeErr) {
4243
4374
  const json = flags.has('json');
4244
4375
  if (flags.has('help')) {
4245
4376
  write('dz parity [--target <name>] [--json] — the computed feature×target map (never hand-written)');
@@ -4268,13 +4399,31 @@ function cmdParity(options, flags, write) {
4268
4399
  }
4269
4400
  }
4270
4401
  const matrix = buildParityMatrix();
4271
- const target = options.get('target');
4272
- if (target !== undefined && !TARGET_NAMES.includes(target)) {
4273
- if (json)
4274
- write(JSON.stringify({ error: `unknown target "${target}"`, targets: TARGET_NAMES, exitCode: 1 }, null, 2));
4275
- else
4276
- write(`dz parity: unknown target "${target}" one of: ${TARGET_NAMES.join(', ')}`);
4277
- 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));
4278
4427
  }
4279
4428
  if (json) {
4280
4429
  const rows = matrix.map((r) => ({
@@ -5263,6 +5412,172 @@ function readDriftAllowlist(root) {
5263
5412
  return [];
5264
5413
  }
5265
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
+ }
5266
5581
  function cmdDriftCheck(options, flags, cwd, write) {
5267
5582
  const root = resolve(cwd, options.get('project') ?? '.');
5268
5583
  // Default scope = PUBLISHED packages only: the `.claude/skills` dogfood copies legitimately lag the
@@ -5370,6 +5685,38 @@ function gatherReadmeCounts(root) {
5370
5685
  function gatherGuardFacts(op, root, text, storeCap) {
5371
5686
  const facts = { op };
5372
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 */ }
5373
5720
  const manifests = [];
5374
5721
  const located = [];
5375
5722
  try {
@@ -6484,7 +6831,7 @@ async function cmdRetro(options, flags, cwd, write) {
6484
6831
  * --from-spec <spec.json> preview the scaffold (create / augment per file); the SKILL fills the spec
6485
6832
  * --apply with --from-spec: WRITE (create missing, AUGMENT existing — never clobber)
6486
6833
  */
6487
- function cmdFeatureAdrSetup(options, flags, cwd, write) {
6834
+ function cmdFeatureAdrSetup(options, flags, cwd, write, writeErr) {
6488
6835
  let repoRoot = cwd;
6489
6836
  try {
6490
6837
  repoRoot = execSync('git rev-parse --show-toplevel', { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || cwd;
@@ -6504,11 +6851,25 @@ function cmdFeatureAdrSetup(options, flags, cwd, write) {
6504
6851
  // Its "runnable here" list is computed for --target (default agents-md, the AGENTS.md-class target class).
6505
6852
  const wantGates = flags.has('gates');
6506
6853
  const targetOpt = options.get('target');
6507
- if (targetOpt !== undefined && !isTargetName(targetOpt)) {
6508
- write(`dz feature-adr-setup: --target must be one of: ${TARGET_NAMES.join(', ')}`);
6509
- 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
+ }
6510
6872
  }
6511
- const gatesTarget = isTargetName(targetOpt ?? '') ? targetOpt : 'agents-md';
6512
6873
  const specPath = options.get('from-spec');
6513
6874
  if (specPath === undefined && !wantGuards && !wantGates) {
6514
6875
  // default + --plan: the read-only "which documents, and where?" answer.
@@ -6642,7 +7003,15 @@ function cmdChallenge(options, flags, cwd, write) {
6642
7003
  * --base <ref> the base ref to fail against (default HEAD)
6643
7004
  * --name '<filter>' optional -t test-name filter applied to every target
6644
7005
  * --runner '<cmd>' test runner (default `npx vitest run`)
6645
- * --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`.
6646
7015
  *
6647
7016
  * NEVER auto-aborts: a non-discriminating (false-green) test is reported as a HIGH finding for the owner to
6648
7017
  * decide (dz's rule — a false gate kills trust). Exit code is 0 on a clean run regardless of verdict; 2 only on
@@ -6663,6 +7032,9 @@ function cmdDiscriminationCheck(options, flags, cwd, write) {
6663
7032
  const propertyTests = testArg.split(',').map((s) => s.trim()).filter(Boolean).map((file) => nameFilter !== undefined && nameFilter.trim() !== '' ? { file, name: nameFilter.trim() } : { file });
6664
7033
  const baseRef = options.get('base') ?? 'HEAD';
6665
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;
6666
7038
  const plan = planDiscriminationCheck(runnerOpt !== undefined ? { baseRef, propertyTests, runner: runnerOpt } : { baseRef, propertyTests });
6667
7039
  if (!plan.runnable) {
6668
7040
  // No safe target to run → this is the existing "property untested" finding (empty propertyTests classify).
@@ -6677,113 +7049,218 @@ function cmdDiscriminationCheck(options, flags, cwd, write) {
6677
7049
  write(` → ${result.finding?.detail ?? 'no property test to check'}`);
6678
7050
  return 0;
6679
7051
  }
6680
- // Execute the plan in a temp worktree WE own; substitute {{WORKTREE}} and always clean up.
6681
- // `git worktree add` must CREATE the path, so compute a fresh non-existent one (do NOT mkdtemp it).
6682
- const worktree = join(mkdtempSync(join(tmpdir(), 'dz-disc-')), 'wt');
6683
- const results = [];
6684
- try {
6685
- // 1) add the detached worktree at base (git creates `worktree`; its parent already exists).
6686
- const addCmd = plan.commands[0].replace(/\{\{WORKTREE\}\}/g, worktree);
6687
- execSync(addCmd, { cwd: repoRoot, stdio: 'pipe', encoding: 'utf-8' });
6688
- // 1b) a fresh worktree has NO node_modules — without this, every test fails to load (runner + deps
6689
- // unresolvable) and the gate collapses to always-VIA_ERROR, blind to false greens. Absolute-path
6690
- // symlinks point back at the main checkout's already-installed trees, robust across pnpm's layout.
6691
- const linkNodeModules = (relDir) => {
6692
- const srcNm = join(repoRoot, relDir, 'node_modules');
6693
- if (!existsSync(srcNm))
6694
- return;
6695
- const dstNm = join(worktree, relDir, 'node_modules');
6696
- if (existsSync(dstNm))
6697
- return;
6698
- try {
6699
- mkdirSync(dirname(dstNm), { recursive: true });
6700
- symlinkSync(srcNm, dstNm, 'dir');
6701
- }
6702
- catch { /* best effort */ }
6703
- };
6704
- linkNodeModules('.'); // root (hoisted deps + .bin)
6705
- const pkgDirs = new Set();
6706
- for (const t of plan.targets) {
6707
- let d = dirname(t.file);
6708
- while (d && d !== '.' && d !== sep) {
6709
- if (existsSync(join(repoRoot, d, 'package.json'))) {
6710
- pkgDirs.add(d);
6711
- 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);
6712
7119
  }
6713
- d = dirname(d);
6714
7120
  }
6715
- }
6716
- for (const d of pkgDirs)
6717
- linkNodeModules(d);
6718
- // 2) copy each property test into the base worktree, then 3) run it and record pass/fail/error per target.
6719
- for (const t of plan.targets) {
6720
- try {
6721
- const src = resolve(repoRoot, t.file);
6722
- // containment guard (defense in depth beyond planDiscriminationCheck's path sanitation).
6723
- 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.
6724
7139
  results.push(nameFor(t, 'error'));
6725
7140
  continue;
6726
7141
  }
6727
- const dst = join(worktree, t.file);
6728
- mkdirSync(dirname(dst), { recursive: true });
6729
- 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);
6730
7170
  }
6731
- catch {
6732
- results.push(nameFor(t, 'error'));
6733
- 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));
6734
7175
  }
6735
- const runner = (runnerOpt !== undefined && plan.commands.some((c) => c.includes(runnerOpt))) ? runnerOpt : 'npx vitest run';
6736
- // t.file + t.name already passed the engine's strict sanitation (no quotes/metacharacters/leading-dash);
6737
- // still quote + `--` so a path can never be read as a runner option or split a word.
6738
- 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 {
6739
7181
  try {
6740
- execSync(`${runner}${nameArg} -- '${t.file}'`, { cwd: worktree, stdio: 'pipe', encoding: 'utf-8' });
6741
- results.push(nameFor(t, 'pass')); // exit 0 → test PASSED at base → false green
7182
+ execSync(`git worktree remove --force ${worktree}`, { cwd: repoRoot, stdio: 'pipe' });
6742
7183
  }
6743
- catch (e) {
6744
- // vitest exits non-zero on failure AND on load/compile error. Distinguish: a load error usually names
6745
- // "Cannot find module"/"Failed to load"/"No test files"; otherwise treat as an assertion failure (red).
6746
- const out = String(e.stdout ?? '') + String(e.stderr ?? '');
6747
- const isLoadError = /cannot find module|failed to load|no test (files )?found|error: cannot|transform failed|esbuild/i.test(out);
6748
- 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 });
6749
7188
  }
7189
+ catch { /* best effort */ }
7190
+ try {
7191
+ execSync('git worktree prune', { cwd: repoRoot, stdio: 'pipe' });
7192
+ }
7193
+ catch { /* best effort */ }
6750
7194
  }
6751
7195
  }
6752
- catch (e) {
6753
- if (flags.has('json')) {
6754
- write(JSON.stringify({ plan, error: 'worktree-setup-failed', detail: String(e.message).slice(0, 300) }, null, 2));
6755
- }
6756
- else
6757
- write(`discrimination-check: could not create worktree at ${baseRef}: ${String(e.message).slice(0, 200)}`);
6758
- return 2;
6759
- }
6760
- finally {
6761
- try {
6762
- execSync(`git worktree remove --force ${worktree}`, { cwd: repoRoot, stdio: 'pipe' });
6763
- }
6764
- catch { /* fall through to rm */ }
6765
- // remove the whole mkdtemp parent (worktree is `<mkdtemp>/wt`), so nothing leaks under tmp even on error.
6766
- try {
6767
- rmSync(dirname(worktree), { recursive: true, force: true });
6768
- }
6769
- catch { /* best effort */ }
6770
- try {
6771
- execSync('git worktree prune', { cwd: repoRoot, stdio: 'pipe' });
6772
- }
6773
- catch { /* best effort */ }
6774
- }
6775
7196
  const result = classifyDiscrimination({ propertyTests, results });
6776
7197
  if (flags.has('json')) {
6777
- write(JSON.stringify({ plan, results, ...result }, null, 2));
7198
+ write(JSON.stringify({ plan, results, tipTree, ...result }, null, 2));
6778
7199
  return 0;
6779
7200
  }
6780
7201
  write(`discrimination-check @ ${baseRef} — verdict: ${result.aggregate}`);
6781
- for (const p of result.perTest)
6782
- write(` ${p.verdict === 'NON_DISCRIMINATING' ? '✗' : '✓'} ${p.file}${p.name ? ` (${p.name})` : ''}: ${p.verdict}`);
6783
- if (result.finding)
6784
- 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}`);
6785
7214
  return 0;
6786
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
+ }
6787
7264
  /** small helper: build a result row, omitting `name` when absent (exactOptionalPropertyTypes). */
6788
7265
  function nameFor(t, outcome) {
6789
7266
  return t.name !== undefined ? { file: t.file, name: t.name, outcome } : { file: t.file, outcome };
@@ -7926,7 +8403,7 @@ const PROBE_SCRUB_ENV = [
7926
8403
  * lands, then kill it — the model never answers, so the probe costs ~no tokens. Never throws:
7927
8404
  * every failure becomes an `error` string, which the pure classifier turns into `inconclusive`.
7928
8405
  */
7929
- function probeInitStream(projectDir, timeoutMs) {
8406
+ function probeInitStream(projectDir, timeoutMs, pluginDir = null) {
7930
8407
  return new Promise((resolveProbe) => {
7931
8408
  const env = { ...process.env };
7932
8409
  for (const key of PROBE_SCRUB_ENV)
@@ -7952,7 +8429,13 @@ function probeInitStream(projectDir, timeoutMs) {
7952
8429
  const timer = setTimeout(() => finish(`no init event within ${Math.round(timeoutMs / 1000)}s (is \`claude\` logged in?)`), timeoutMs);
7953
8430
  try {
7954
8431
  // NOT `--bare`: that mode skips plugin credentials and fails with "Not logged in".
7955
- child = spawn('claude', ['-p', 'ok', '--output-format', 'stream-json', '--verbose'], {
8432
+ const args = ['-p', 'ok', '--output-format', 'stream-json', '--verbose'];
8433
+ // Session-scoped plugin load — the marketplace-free vehicle (ADR-003 D-3). Without this the
8434
+ // probe reads a session in which the plugin was never loaded, and reports its commands
8435
+ // missing for a reason that has nothing to do with the package under test.
8436
+ if (pluginDir !== null)
8437
+ args.push('--plugin-dir', pluginDir);
8438
+ child = spawn('claude', args, {
7956
8439
  cwd: projectDir,
7957
8440
  env,
7958
8441
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -8026,8 +8509,11 @@ function probeContent(projectDir, prompt, timeoutMs) {
8026
8509
  async function cmdSkillsVerify(options, flags, cwd, write) {
8027
8510
  const json = flags.has('json');
8028
8511
  if (flags.has('help')) {
8029
- write('dz skills-verify [--dir <project>] [--expect a,b] [--static] [--strict] [--timeout <s>] [--json]');
8512
+ write('dz skills-verify [--dir <project>] [--expect a,b] [--expect-commands a,b] [--plugin-dir <dir>] [--static] [--strict] [--timeout <s>] [--json]');
8030
8513
  write(' Verifies that a project\'s .claude/skills/ actually register in Claude Code.');
8514
+ write(' --plugin-dir <dir> load a plugin into the probe session (session-scoped, no marketplace);');
8515
+ write(' with no --expect-commands, the expectation defaults to the manifest\'s own commands[]');
8516
+ write(' --expect-commands slash commands that MUST appear in the session listing, e.g. loop-designer:init');
8031
8517
  write(' --static layout scan only (no Claude session, CI-safe): flags dirs that can never register');
8032
8518
  write(' --live-content ADVISORY extra turn: ask a live model to name the skills and quote one, proving');
8033
8519
  write(' the CONTENT is usable — registration is not usability. Never changes the exit code.');
@@ -8036,8 +8522,8 @@ async function cmdSkillsVerify(options, flags, cwd, write) {
8036
8522
  return 0;
8037
8523
  }
8038
8524
  const allowedFlags = new Set(['json', 'help', 'static', 'strict', 'live-content']);
8039
- const allowedOptions = new Set(['dir', 'expect', 'timeout']);
8040
- const usage = ' allowed: --dir <project>, --expect a,b, --timeout <s>, --static, --strict, --live-content, --json';
8525
+ const allowedOptions = new Set(['dir', 'expect', 'expect-commands', 'plugin-dir', 'timeout']);
8526
+ const usage = ' allowed: --dir <project>, --expect a,b, --expect-commands a,b, --plugin-dir <dir>, --timeout <s>, --static, --strict, --live-content, --json';
8041
8527
  if (options.has('_positional_0')) {
8042
8528
  const message = `unexpected argument "${options.get('_positional_0')}"`;
8043
8529
  write(json ? JSON.stringify({ error: message, exitCode: 1 }) : `dz skills-verify: ${message}\n${usage}`);
@@ -8065,6 +8551,27 @@ async function cmdSkillsVerify(options, flags, cwd, write) {
8065
8551
  .map((s) => s.trim())
8066
8552
  .filter(Boolean)
8067
8553
  : scan.registrable;
8554
+ // A plugin loaded with `--plugin-dir` is session-scoped, so its surface is NOT on the project's
8555
+ // disk and `scan.registrable` cannot describe it. The manifest can — and defaulting to the
8556
+ // manifest's own `commands[]` keeps the gate honest without a hand-typed list that silently
8557
+ // drifts from the manifest it is supposed to be checking. An UNREADABLE manifest is refused
8558
+ // rather than defaulted to an empty expectation: an empty expectation passes without checking.
8559
+ const pluginDir = options.has('plugin-dir') ? resolve(cwd, options.get('plugin-dir') ?? '') : null;
8560
+ let expectedCommands = options.has('expect-commands')
8561
+ ? (options.get('expect-commands') ?? '')
8562
+ .split(',')
8563
+ .map((s) => s.trim())
8564
+ .filter(Boolean)
8565
+ : [];
8566
+ if (pluginDir !== null && !options.has('expect-commands')) {
8567
+ const surface = declaredPluginSurface(pluginDir);
8568
+ if (surface === null) {
8569
+ const message = `cannot read ${join(pluginDir, '.claude-plugin', 'plugin.json')} (or it declares no name) — pass --expect-commands explicitly`;
8570
+ write(json ? JSON.stringify({ error: message, exitCode: 1 }) : `dz skills-verify: ${message}`);
8571
+ return 1;
8572
+ }
8573
+ expectedCommands = surface.commands;
8574
+ }
8068
8575
  // ── L1 only: deterministic, no session, safe for CI ──
8069
8576
  if (flags.has('static')) {
8070
8577
  const exitCode = scan.findings.length > 0 ? 1 : 0;
@@ -8078,6 +8585,12 @@ async function cmdSkillsVerify(options, flags, cwd, write) {
8078
8585
  for (const f of scan.findings)
8079
8586
  write(` [${f.kind}] ${f.detail}`);
8080
8587
  write(scan.findings.length ? ` ${scan.findings.length} layout problem(s) — these can never register` : ' no layout problems found');
8588
+ // Advisories were collected but never PRINTED in static mode: a `.claude-plugin/plugin.json`
8589
+ // under `.claude/skills` produced "no layout problems found" and nothing else, so the one
8590
+ // shape most likely to be a silent non-registration was invisible in exactly the mode CI and
8591
+ // humans run most. Reported, still never fatal (that distinction is the whole point).
8592
+ for (const a of scan.advisories)
8593
+ write(` [${a.kind}] ADVISORY: ${a.detail}`);
8081
8594
  write(' (static is a PROXY — run without --static to read the real registration listing)');
8082
8595
  }
8083
8596
  return exitCode;
@@ -8087,7 +8600,7 @@ async function cmdSkillsVerify(options, flags, cwd, write) {
8087
8600
  const timeoutMs = Number.isFinite(timeoutSec) && timeoutSec > 0 ? timeoutSec * 1000 : 180_000;
8088
8601
  if (!json)
8089
8602
  write(`dz skills-verify: starting a session in ${projectDir} to read the real registration listing…`);
8090
- const { stream, error } = await probeInitStream(projectDir, timeoutMs);
8603
+ const { stream, error } = await probeInitStream(projectDir, timeoutMs, pluginDir);
8091
8604
  // `init.skills` carries names, not provenance: a USER-level skill of the same name would satisfy
8092
8605
  // the expectation while the project's own copy stays broken. Collect the collisions so the
8093
8606
  // classifier can refuse to attribute registration to this project (Codex QE #2).
@@ -8109,6 +8622,7 @@ async function cmdSkillsVerify(options, flags, cwd, write) {
8109
8622
  // The provenance check RAN (that is what `checked: true` asserts) — see `ambiguous` above.
8110
8623
  provenance: { checked: true, ambiguous },
8111
8624
  ...(options.has('expect') ? { expected } : {}),
8625
+ ...(expectedCommands.length > 0 ? { expectedCommands } : {}),
8112
8626
  }, { resolvePath: canonical });
8113
8627
  const exitCode = registrationExitCode(result.verdict, flags.has('strict'));
8114
8628
  // ADVISORY layer: registration is not usability. Costs a real model turn, so it is opt-in, and it
@@ -9148,6 +9662,8 @@ async function cmdImportEcc(options, flags, cwd, write) {
9148
9662
  export async function runCli(argv, io = {}) {
9149
9663
  const cwd = io.cwd ?? process.cwd();
9150
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); });
9151
9667
  // Lazy STDIN reader — only `dz brain ground` reads it, and only when no positional prompt is
9152
9668
  // given. Never blocks: injected `io.stdin` wins; else read fd 0 synchronously, but bail to '' on
9153
9669
  // a TTY (nothing piped) or any read error. Grounding must never hang waiting on an empty pipe.
@@ -9164,6 +9680,26 @@ export async function runCli(argv, io = {}) {
9164
9680
  }
9165
9681
  };
9166
9682
  const { command, options, optionLists, flags } = parseArgs(argv);
9683
+ // ── `dz --version` / `dz -v` / `dz version` — PRE-DISPATCH, before the help branch ──
9684
+ //
9685
+ // Until now `dz --version` printed the whole USAGE manual and exited 0 (MEASURED 2026-08-17,
9686
+ // reproducer `node dist/bin.js --version`). Exit 0 plus prose is the worst possible answer for a
9687
+ // caller that must decide whether a `dz` it found on PATH is safe to invoke: the status code says
9688
+ // "fine" and there is no number to parse. Any wrapper guarding a version range needs exactly one
9689
+ // parseable line. Recognised only as the FIRST token (or the `version` subcommand) so that a
9690
+ // later positional `-v` belonging to a subcommand keeps its own meaning.
9691
+ if (argv[0] === '--version' || argv[0] === '-v' || command === 'version') {
9692
+ const version = dzOwnVersion();
9693
+ if (flags.has('json')) {
9694
+ write(JSON.stringify({ name: 'dz', version, node: process.version, schemas: { loopPlan: 'loop-plan/1' } }));
9695
+ }
9696
+ else {
9697
+ write(version);
9698
+ }
9699
+ // An unresolvable version is a FAILURE, not a value: exiting 0 with the literal `unknown` would
9700
+ // let a guard treat "I could not tell you" as "I answered you".
9701
+ return version === 'unknown' ? 1 : 0;
9702
+ }
9167
9703
  if (command === '' || command === 'help' || flags.has('help')) {
9168
9704
  write(USAGE);
9169
9705
  return 0;
@@ -9171,14 +9707,14 @@ export async function runCli(argv, io = {}) {
9171
9707
  try {
9172
9708
  switch (command) {
9173
9709
  case 'init':
9174
- return await cmdInit(options, flags, cwd, write);
9710
+ return await cmdInit(options, flags, cwd, write, writeErr);
9175
9711
  case 'verify':
9176
- return await cmdVerify(options, cwd, write);
9712
+ return await cmdVerify(options, cwd, write, writeErr);
9177
9713
  case 'sync':
9178
9714
  case 'update':
9179
- return await cmdSync(options, flags, cwd, write);
9715
+ return await cmdSync(options, flags, cwd, write, writeErr);
9180
9716
  case 'list':
9181
- return cmdList(options, cwd, write);
9717
+ return cmdList(options, cwd, write, writeErr);
9182
9718
  case 'create-skill':
9183
9719
  return cmdCreateSkill(options, flags, cwd, write);
9184
9720
  case 'info':
@@ -9196,7 +9732,7 @@ export async function runCli(argv, io = {}) {
9196
9732
  case 'doctor':
9197
9733
  return await cmdDoctor(options, flags, cwd, write);
9198
9734
  case 'install':
9199
- return await cmdInstall(options, flags, cwd, write, io.installRunner);
9735
+ return await cmdInstall(options, flags, cwd, write, writeErr, io.installRunner);
9200
9736
  case 'bundle':
9201
9737
  return cmdBundle(options, flags, cwd, write);
9202
9738
  case 'teach':
@@ -9224,17 +9760,17 @@ export async function runCli(argv, io = {}) {
9224
9760
  case 'verify-pack':
9225
9761
  return cmdVerifyPack(options, flags, cwd, write);
9226
9762
  case 'setup':
9227
- return await cmdSetup(options, flags, cwd, write);
9763
+ return await cmdSetup(options, flags, cwd, write, writeErr);
9228
9764
  case 'pretrain':
9229
9765
  return cmdPretrain(options, cwd, write);
9230
9766
  case 'compose':
9231
- return cmdCompose(options, cwd, write);
9767
+ return cmdCompose(options, cwd, write, writeErr);
9232
9768
  case 'diff':
9233
9769
  return cmdDiff(options, cwd, write);
9234
9770
  case 'recommend':
9235
9771
  return cmdRecommend(options, cwd, write);
9236
9772
  case 'upgrade':
9237
- return cmdUpgrade(options, flags, cwd, write);
9773
+ return cmdUpgrade(options, flags, cwd, write, writeErr);
9238
9774
  case 'auto-canonicalize':
9239
9775
  return await cmdAutoCanonicalize(options, cwd, write);
9240
9776
  case 'publish':
@@ -9242,7 +9778,7 @@ export async function runCli(argv, io = {}) {
9242
9778
  case 'release':
9243
9779
  return cmdRelease(options, flags, cwd, write, io.releaseRunner);
9244
9780
  case 'parity':
9245
- return cmdParity(options, flags, write);
9781
+ return cmdParity(options, flags, write, writeErr);
9246
9782
  case 'registry':
9247
9783
  return cmdRegistry(options, cwd, write);
9248
9784
  case 'benchmark':
@@ -9253,6 +9789,10 @@ export async function runCli(argv, io = {}) {
9253
9789
  return await cmdSyncUpstream(options, flags, cwd, write);
9254
9790
  case 'drift-check':
9255
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);
9256
9796
  case 'sync-canonical':
9257
9797
  return cmdSyncCanonical(options, flags, cwd, write);
9258
9798
  case 'plugin':
@@ -9270,7 +9810,7 @@ export async function runCli(argv, io = {}) {
9270
9810
  case 'retro':
9271
9811
  return await cmdRetro(options, flags, cwd, write);
9272
9812
  case 'feature-adr-setup':
9273
- return cmdFeatureAdrSetup(options, flags, cwd, write);
9813
+ return cmdFeatureAdrSetup(options, flags, cwd, write, writeErr);
9274
9814
  case 'challenge':
9275
9815
  return cmdChallenge(options, flags, cwd, write);
9276
9816
  case 'discrimination-check':
@@ -9308,7 +9848,9 @@ export async function runCli(argv, io = {}) {
9308
9848
  }
9309
9849
  }
9310
9850
  catch (error) {
9311
- 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)}`);
9312
9854
  return 1;
9313
9855
  }
9314
9856
  }