@dzhechkov/harness-cli 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -7,16 +7,20 @@ import { appendFileSync, chmodSync, closeSync, cpSync, existsSync, fstatSync, ls
7
7
  import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import { execFileSync, execSync, spawn } from 'node:child_process';
10
- import { createHash } from 'node:crypto';
10
+ import { createHash, randomBytes } from 'node:crypto';
11
11
  import { homedir, tmpdir } from 'node:os';
12
12
  import { createRequire } from 'node:module';
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,
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, resolveCodexHome, withNamedLockSync,
14
+ // dz workflow run (feature dz-workflow-run): the pure scheduler + the dispatch adapters.
15
+ TRACE_RUNID_RE, WF_RUN_OWNER_HOST, preflight, runWorkflow, makeClaudePDispatcher, makeCodexExecDispatcher, NamedLockTimeoutError, NamedLockCompromisedError, POLICY_SOURCES, detectPolicyDrift, hasPolicyFence, TARGET_NAMES, buildParityMatrix, downgradeForStaleEvidence, findStaleTranscriptEvidence, TARGET_CAPABILITIES, TARGET_SHORT_LABELS, WORKFLOW_TEMPLATES_RETIRED_MESSAGE, parsePlan, isParseErrors, validatePlan, normalizePlan, planDigest, toTraceProjection, renderPlan, mergeRender, lint, lintExitCode, LOOP_BLOBS, parseTrace, assembleTimeline, runInvariants, deriveAttestation, stampAttestation, corroborate, NOT_WITNESSED, 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
16
  // guard-promotion (feature guard-promotion, scout idea #1)
15
17
  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
18
  // Cold-vs-warm EPOCH RUNNER (feature epoch-replay) — orchestrates + scores, never calls a model.
17
19
  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
20
  // Smart Backlog (feature smart-backlog) — goal-directed idea pipeline over the Brain vector engine.
19
- readBacklogConfig, readIdeas, writeIdeas, ideaId, dedupIdea, readGoalMap, readGoalMapDetailed, parseEffort, ensureBacklogGitignored, isSafeId, alignIdea, mirrorIdeaVector, ensureBacklogEmbedForm, readBacklogEmbedFormVersion, recordAbsorption, DEDUP_EMBED_FORM_VERSION, snapshotIdeas, spinRoulette, rankRoulette, seededRng, eligibleIdeas, stageEnrichment, buildJiraDraft, resolveJiraAdapter, makeBacklogIO, harmonizeBacklog, transitionIdeas, editIdea, clearEmbedStale, BACKLOG_BACKENDS, applyDomainBoost, DZ_OWNED_TASK_TYPES, applyExportHoldout, DEFAULT_HELD_OUT_DOMAINS, canonicalDomainKey, readAgentdbRowsByTaskType, heldOutAfterOptIn, renderHoldoutNote, renderSharedStoreAdvice, decideVectorExport, countDisplacedByCut, renderDomainBoostNote, renderDomainCutNote, parseReqeDebt, buildReqeBrief, settleReqeDebt, renderReqeList, REQE_SCOPE,
21
+ readBacklogConfig, readIdeas, writeIdeas, ideaId, dedupIdea, readGoalMap, readGoalMapDetailed, parseEffort, ensureBacklogGitignored, isSafeId, alignIdea, mirrorIdeaVector, ensureBacklogEmbedForm, readBacklogEmbedFormVersion, recordAbsorption, DEDUP_EMBED_FORM_VERSION, snapshotIdeas, spinRoulette, rankRoulette, seededRng, eligibleIdeas, stageEnrichment, buildJiraDraft, resolveJiraAdapter, makeBacklogIO, harmonizeBacklog, transitionIdeas, editIdea, clearEmbedStale, BACKLOG_BACKENDS, applyDomainBoost, DZ_OWNED_TASK_TYPES, applyExportHoldout, DEFAULT_HELD_OUT_DOMAINS, canonicalDomainKey, readAgentdbRowsByTaskType, heldOutAfterOptIn, renderHoldoutNote, renderSharedStoreAdvice, decideVectorExport, countDisplacedByCut, renderDomainBoostNote, renderDomainCutNote, parseReqeDebt,
22
+ // qe-bridge (feature qe-bridge-claude, ADR-001): the pure half of the reverse QE bridge.
23
+ KNOWN_CLAUDE, isSafeClaudeId, claudeProbeArgs, claudeReviewArgs, interpretClaudeProbe, modelFamily, buildBridgePrompt, parseBridgeOutput, buildBridgeFailureRecord, buildBridgeSignoffRecord, renderBridgeReport, isSafeSlug, hasUnsafePathChars, hasDotDotSegment, buildReqeBrief, settleReqeDebt, renderReqeList, REQE_SCOPE,
20
24
  // Mutation gate (feature ha-mutation-gate) — break each named protection, run the suite, require red.
21
25
  parseMutationRegistry, applyMutationToText, countFailingTests, classifyBaseline, classifyRunFailure, classifyMutationOutcome, mutationGateExitCode, summarizeMutationResults, renderMutationReport, TRACE_BUNDLE_LEDGER_PATH, TRACE_BUNDLE_SCHEMA, TRACE_BUNDLE_RUN_META_FILE, buildBundle, serializeBundle, parseBundle, planImport, } from '@dzhechkov/harness-core';
22
26
  import { getPreset, PRESET_NAMES } from '@dzhechkov/harness-presets';
@@ -24,7 +28,7 @@ import { scanGitHub, analyzeRepo, generateReport, deepAnalyze, scanAllSources, S
24
28
  const USAGE = `dz - DZ cross-platform harness CLI
25
29
 
26
30
  Usage:
27
- dz init --target <name> [--skills-dir <dir>] [--project <dir>] [--preset <name>] [--select id,id,...] [--force] [--enrich]
31
+ dz init --target <name> [--skills-dir <dir>] [--project <dir>] [--preset <name>] [--select id,id,...] [--force] [--enrich] [--no-hooks] [--no-verify] (--target codex ALSO installs the user-global dz veto+recall hooks and LIVE-verifies them (ADR-001 §8); --no-hooks = skills only; --no-verify skips the live probe and can never report ready)
28
32
  dz verify [--skills-dir <dir>] [--target <name>]
29
33
  dz sync [--canonical <dir>] [--project <dir>] [--dry-run] [--force]
30
34
  dz update (alias of sync)
@@ -38,7 +42,7 @@ Usage:
38
42
  dz workflow render <plan.json> --o <script.js> [--check] [--force] (plan → region-delimited loop script; USER regions preserved)
39
43
  dz workflow blobs [--check] (list/self-check the subsystem blob registry)
40
44
  dz workflow-lint <script.js> [--plan <plan.json>] [--require-plan|--legacy] [--json] (layer-1 gate; exit 0/1/3 — inconclusive is never a pass)
41
- dz workflow-trace <runDir|--slug <s>|--run <id>> [--invariants <plan.json>] [--html <out.html>] [--json] (timeline + SEQ invariant runner)
45
+ dz workflow-trace <runDir|--slug <s>|--run <id>> [--invariants <plan.json>] [--corroborate <hostRunDir>] [--html <out.html>] [--json] (timeline + SEQ invariant runner; ALWAYS reports who attested the trace — instrument|agent|unknown — and --corroborate checks the Claude host's own records for the half they can witness)
42
46
  dz workflow-trace export <run> --o <file> [--include-pairs --yes] [--strict] (one run's telemetry as ONE movable file: events, not aggregates; degradation is typed and LOUD, --strict fails closed)
43
47
  dz workflow-trace import <bundle> --into <root> [--force] [--with-pairs] (reconstruct that run under an explicit root; FAIL-CLOSED — never writes over a run that already has content)
44
48
  dz install <npm-pkg> [--target <name>] [--project <dir>] [--force]
@@ -59,6 +63,7 @@ Usage:
59
63
  dz epoch-replay --score <judgments.json> --work-order <file> [--slice <name>] [--json] (un-blind against the pre-registered assignment → SUPPORTED only when the two 95% Wilson CIs are DISJOINT, else FALSIFIED / INCONCLUSIVE)
60
64
  dz score --slug <feature> [--project <dir>] [--json] (process scorecard for ONE feature-adr run, from its artifacts: ADR confirmation, discrimination, cross-model QE grade, live verification, README-first, learning loop, amendments — descriptive-only, a low score exits 0)
61
65
  dz reqe [--slug <feature> [--done --report <f>]] [--json] (the re-QE debt ledger: a usage-switched run whose Step-8 QE ran on the coder's OWN family records a debt; list debts, print the cross-family review brief, settle FAIL-CLOSED against a graded report — the settlement lands in 08_qe_report.md)
66
+ dz qe-bridge --family claude --slug <feature> [--coder-family codex|claude] [--model <id>] [--files a,b] [--out <f>] [--timeout <s>] [--allow-same-family] [--json] (the REVERSE QE bridge: run an INDEPENDENT Claude reviewer over a feature's Step-8 artifacts from ANY host — a Codex session included, plain shell, no Claude agent plane needed — and land a PARSED signoff. The reviewer runs ISOLATED: an EMPTY temp cwd plus --safe-mode --strict-mcp-config --tools '' --no-session-persistence, so no CLAUDE.md/skills/plugins/hooks/MCP load, and the verdict is read from the --output-format json RESULT ENVELOPE — text a session customization printed onto the same stdout can never become a signoff. Probes the model before trusting it; sends SCOPED extracts with a loud 200k-char ceiling (never silent truncation); the grade must AGREE across three LAST-anchored channels (terminal marker line, fenced qe-bridge-signoff JSON, the report's own GRADE line) AND the marker must be the FINAL content — empty, gradeless, self-contradicting or miscounted output is one of 18 NAMED failures with an audit record under features/<slug>/.fa-state/qe-bridge/ (runId, resolved executable + binOverride, prompt sha256, channel offsets, requestedOut, reportWritten, retained raw stdout; 0600 files in a 0700 dir), never a clean review. A --coder-family that contradicts the recorded reqe debt is refused. Writes features/<slug>/08b_reqe_report.md, which dz reqe --done settles unchanged. DISCLOSURE: the extracts you scope are sent to the Claude runtime; the bridge cannot classify secrets. DZ_QE_BRIDGE_CLAUDE_BIN is a TEST SEAM, not a flag. exit 0 signoff parsed (ANY grade — it reports, it does not gate) / 1 named failure / 2 usage)
62
67
  dz mutation-gate [--package <dir>] [--registry <file>] [--test-cmd "<cmd>"] [--only <id[,id]>] [--timeout <ms>] [--rebaseline per-entry|final] [--keep-scratch] [--json] (prove each NAMED protection has a test that DISCRIMINATES: copy the package to a scratch dir, verify the baseline suite is green, apply each registry mutation, run the suite, REQUIRE red, restore. The red must be BEHAVIOURAL: a mutation that no longer parses is MUTATION_UNPARSEABLE; a red run whose OWN output reports a test FILE failing to load (node --test file-level not-ok with exitCode, vitest Failed Suites) is MUTATION_LOAD_FATAL — the signal comes from the same run as the failing count, never from a separate isolated import; red output whose shape matches no known runner is INCONCLUSIVE (a runner-coverage gap, loud, never PROVEN); a count far above the entry's bound is OVER_FAILING; a restored tree that does not reproduce green makes the entry INCONCLUSIVE (flaky). Mutation writes are realpath-contained to the scratch copy: a symlink escape or a node_modules/ target is refused (exit 2), the real tree is never written. A mutation that does not apply, a green suite, or an inconclusive run is a FAILURE — never a skip. exit 0 all proven / 1 gate failed / 2 setup error)
63
68
  dz backlog add "<idea>" [--effort 1-5] [--proposal <text>] [--dry-run] [--project <dir>] [--json] (capture an idea: semantic dedup against existing ideas via the Brain vector engine (DUPLICATE>=0.92 merges, RELATED links, NEW creates) + GoalMap alignment; --dry-run classifies without writing)
64
69
  dz backlog list [--status <s>] [--goal <id>] [--project <dir>] [--json] (list captured ideas, filterable by status/goal)
@@ -73,7 +78,7 @@ Usage:
73
78
  dz backlog enrich <id> [--project <dir>] [--json] (stage the idea2prd input scaffold in features/<slug>/ and hand off to the idea2prd-manual skill — the CLI never fabricates a PRD)
74
79
  dz backlog jira <id> [--project <dir>] [--json] (draft a Jira issue via the configurable adapter (backlog.jira.adapter: jira-mcp|copilot-mcp|none); none writes an auditable jira-outbox/<id>.json stub)
75
80
  dz backlog harmonize [--apply] [--threshold <0-1>] [--project <dir>] [--json] (batch semantic dedup of the backlog ideas; --dry-run default, --apply snapshots first)
76
- dz setup --target <name> [--preset <name>] [--select id,id,...] [--skills-dir <dir>] [--project <dir>] [--memory agentdb] [--no-memory] [--no-hooks] [--install-driver] [--force] [--enrich]
81
+ dz setup --target <name> [--preset <name>] [--select id,id,...] [--skills-dir <dir>] [--project <dir>] [--memory agentdb] [--no-memory] [--no-hooks] [--no-verify] [--install-driver] [--force] [--enrich] (--target codex ALSO installs + LIVE-verifies the codex hooks; an unverified hook exits non-zero WITHOUT aborting the rest of setup)
77
82
  dz teach "<pattern>" [--reward <0-1>] [--domain <name>] [--type rule|success-pattern|lesson-learned] [--project <dir>] [--no-mirror] (--project pins the learned store to <dir>/.dz, not the cwd — pin to a canonical brain)
78
83
  dz teach --from-json <file> [--project <dir>] [--no-mirror] (bulk-import a 'dz recall --all --json' export — share a learned store across machines)
79
84
  dz consolidate [--sessions-dir <dir>] [--project <dir>] [--no-mirror] [--prune-noise [--apply]] [--prune-quarantine [--apply]] (both prunes: DRY-RUN by default; --apply snapshots then deletes; prune-quarantine = expired unproven lessons ONLY, never coupled to noise)
@@ -123,6 +128,18 @@ Usage:
123
128
 
124
129
  Global: --version | -v [--json] (prints this CLI's own semver on one line, exit 0; "unknown" + exit 1 when unresolvable)
125
130
 
131
+ dz workflow run <plan.json> [--run-id <id>] [--resume <runId>] [--arg k=v]... [--coder-family codex|claude]
132
+ [--default-family codex|claude] [--budget <n>] [--max-wall-clock <s>] [--stage-timeout <s>]
133
+ [--budget-extra <n>] [--wall-clock-extra <s>] [--run-dir <dir>] [--allow-same-family-qe] [--json]
134
+ (INTERPRET a loop-plan/1 plan host-independently; writes trace/budget/checkpoints under .dz/loop-trace/<runId>/)
135
+
136
+ EXIT CODES - "workflow run" and "workflow-lint" have DIFFERENT tables, side by side:
137
+ run 0 completed | 1 failed (named reason) | 2 usage/invalid plan | 75 typed pause (sysexits EX_TEMPFAIL)
138
+ lint 0 clean | 1 findings | 3 inconclusive
139
+ 75 is NOT 3: 3 reads ignorable and collides with lint's inconclusive, while a pause strands resumable work.
140
+ On a pause the LAST stdout line is a "wf-pause-envelope/1" JSON object; a FAILURE emits none, so a wrapper
141
+ tells the two apart from stdout + exit code alone, without parsing prose.
142
+
126
143
  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)
127
144
 
128
145
  Targets: ${TARGET_NAMES.join(', ')}
@@ -307,6 +324,20 @@ async function cmdInit(options, flags, cwd, write, writeErr) {
307
324
  write(` (searched ${r.dirsSearched} skill directories)`);
308
325
  }
309
326
  writeMissingSkillsHint(write, r.missing, presetName);
327
+ // Codex-targeted init DELIVERS the hooks and verifies them (ADR-001 §8). Skills alone are not the
328
+ // target's harness: the veto + recall legs are what `--target codex` promises.
329
+ // `--no-hooks` is the documented escape for "skills only" (the same flag `dz setup` already
330
+ // carries): hook delivery writes USER-GLOBAL config, so a command that only wants skills compiled
331
+ // must be able to say so — and every test that is about skills says it.
332
+ let codexHooksOk = true;
333
+ if (target === 'codex' && !flags.has('no-hooks')) {
334
+ const delivery = deliverCodexHooks({ project: projectRoot, verify: !flags.has('no-verify') }, undefined, 'dz init');
335
+ codexHooksOk = delivery.ok;
336
+ for (const line of delivery.stdout)
337
+ write(line);
338
+ for (const line of delivery.stderr)
339
+ writeErr(line);
340
+ }
310
341
  // Skip-and-collect must not become skip-and-SILENCE: a skill that failed to load is
311
342
  // named on stderr and the command exits 1 (it exited 1 before too — by throwing).
312
343
  if (r.failures.length > 0 || r.applyFailures.length > 0) {
@@ -329,7 +360,7 @@ async function cmdInit(options, flags, cwd, write, writeErr) {
329
360
  writeErr(line);
330
361
  return 1;
331
362
  }
332
- return 0;
363
+ return codexHooksOk ? 0 : 1;
333
364
  }
334
365
  async function cmdVerify(options, cwd, write, writeErr) {
335
366
  const skillsDir = resolve(cwd, options.get('skills-dir') ?? '.claude/skills');
@@ -1115,6 +1146,58 @@ function cmdWorkflowTrace(options, flags, cwd, write) {
1115
1146
  const ckptFile = join(runDir, '.fa-state', 'checkpoints.jsonl');
1116
1147
  const ledgerFile = resolve(cwd, '.dz', 'feature-adr', 'run-cost-ledger.jsonl');
1117
1148
  const journalFile = join(runDir, 'journal.jsonl');
1149
+ // ── provenance (feature honest-trace-provenance) ──────────────────────────────────────────────
1150
+ // The attestation is DERIVED here, from run-state.json — an artifact the sandboxed rendered
1151
+ // script cannot write. It is never read out of the trace itself (ADR-001).
1152
+ const parsedRun = parseTrace(traceText);
1153
+ const runStateFile = join(runDir, 'run-state.json');
1154
+ const runStateForAttestation = existsSync(runStateFile)
1155
+ ? (() => {
1156
+ try {
1157
+ return JSON.parse(readFileSync(runStateFile, 'utf8'));
1158
+ }
1159
+ catch {
1160
+ return null; // an unreadable state binds nothing — the reader will say `unknown`
1161
+ }
1162
+ })()
1163
+ : null;
1164
+ const attestation = deriveAttestation(parsedRun, runStateForAttestation, {
1165
+ sha256: createHash('sha256').update(traceText, 'utf8').digest('hex'),
1166
+ lines: traceText.split('\n').filter((l) => l.trim() !== '').length,
1167
+ });
1168
+ const ATTESTATION_SENTENCE = {
1169
+ instrument: 'ATTESTATION instrument — the bytes read match the identifiers, hash and line count asserted by the co-located run-state.json. That is NOT proof that dz historically wrote them, nor that this is the directory it wrote them in.',
1170
+ agent: 'ATTESTATION agent — this trace was appended by an AGENT asked to run the flush command, not by an instrument. Treat every verdict below as testimony.',
1171
+ unknown: 'ATTESTATION unknown — no binding run-state.json and no emitter declaration. Not instrument; treat as testimony.',
1172
+ };
1173
+ // ── corroboration (ADR-002, opt-in) ───────────────────────────────────────────────────────────
1174
+ // The host's OWN records, for the half they can witness. Directory containment is the ONLY
1175
+ // binding available — trace and journal.jsonl share no identifier — and every result says so.
1176
+ const corroborateDir = options.get('corroborate');
1177
+ let corroboration = null;
1178
+ if (corroborateDir !== undefined) {
1179
+ const hostDir = resolve(cwd, corroborateDir);
1180
+ const jf = join(hostDir, 'journal.jsonl');
1181
+ const transcripts = {};
1182
+ try {
1183
+ for (const f of readdirSync(hostDir)) {
1184
+ const m = /^agent-(.+)\.jsonl$/.exec(f);
1185
+ if (m)
1186
+ transcripts[m[1]] = readFileSync(join(hostDir, f), 'utf8');
1187
+ }
1188
+ }
1189
+ catch {
1190
+ /* an unreadable host dir is INCONCLUSIVE, decided by corroborate() below — never a throw */
1191
+ }
1192
+ // The trace side, projected: the agent ids the trace claims took part, in seq order. The trace
1193
+ // has no agentId of its own, so the invocationId is what we can offer — and that is exactly why
1194
+ // the binding is by-directory and the verdict is scoped.
1195
+ const claimed = [];
1196
+ for (const e of parsedRun.events)
1197
+ if (e.event === 'dispatched')
1198
+ claimed.push(e.invocationId);
1199
+ corroboration = corroborate({ agentIds: claimed }, { journal: existsSync(jf) ? readFileSync(jf, 'utf8') : null, agentTranscripts: transcripts }, hostDir);
1200
+ }
1118
1201
  const timeline = assembleTimeline({
1119
1202
  trace: traceText,
1120
1203
  checkpoints: existsSync(ckptFile) ? readFileSync(ckptFile, 'utf8') : null,
@@ -1137,7 +1220,7 @@ function cmdWorkflowTrace(options, flags, cwd, write) {
1137
1220
  return 1;
1138
1221
  }
1139
1222
  projection = toTraceProjection(parsed);
1140
- verdicts = runInvariants(projection, parseTrace(traceText));
1223
+ verdicts = stampAttestation(runInvariants(projection, parsedRun), attestation);
1141
1224
  }
1142
1225
  const htmlOut = options.get('html');
1143
1226
  if (htmlOut !== undefined) {
@@ -1146,9 +1229,14 @@ function cmdWorkflowTrace(options, flags, cwd, write) {
1146
1229
  write(`dz workflow-trace: wrote ${absHtml} (self-contained: mermaid topology + HTML waterfall)`);
1147
1230
  }
1148
1231
  if (flags.has('json')) {
1149
- write(JSON.stringify({ timeline, verdicts }, null, 2));
1232
+ write(JSON.stringify({ timeline, attestation, corroboration, verdicts }, null, 2));
1150
1233
  return verdicts.some((v) => v.status === 'fail') ? 1 : 0;
1151
1234
  }
1235
+ write(ATTESTATION_SENTENCE[attestation]);
1236
+ if (corroboration !== null) {
1237
+ write(`CORROBORATION ${corroboration.verdict} (binding: ${corroboration.binding}, ${corroboration.hostDir}) — ${corroboration.detail}`);
1238
+ write(` witnessed: ${corroboration.witnessed.join(', ')} NOT witnessed: ${NOT_WITNESSED.join(', ')}`);
1239
+ }
1152
1240
  write(`run ${timeline.runId ?? '(unknown)'}${timeline.incomplete ? ' — INCOMPLETE (no run.closed; unflushed tail may be lost)' : ''}; sources: ${timeline.sources.join(', ')}`);
1153
1241
  for (const r of timeline.rows.filter((x) => x.kind === 'trace')) {
1154
1242
  write(` ${String(r.seq).padStart(5)} ${r.label} ${r.detail}${r.wallTime ? ` [wall ${r.wallTime} — diagnostic only]` : ''}`);
@@ -1197,6 +1285,16 @@ function cmdRoam(options, flags, cwd, write) {
1197
1285
  write('dz roam: not a git repository');
1198
1286
  return 1;
1199
1287
  }
1288
+ // The roam archive is its OWN git repository (owner decision 2026-08-20), so `git rev-parse` run
1289
+ // from inside it resolves to the archive, not the project. Left unguarded that computes
1290
+ // `<repo>/roam/roam/claude-state` and, with --apply, creates a symlink under a fabricated slug.
1291
+ // Detect the nested archive by its own shape rather than by its name alone: a directory called
1292
+ // `roam` that CONTAINS `claude-state` is the archive, wherever it lives.
1293
+ if (basename(repoRoot) === 'roam' && existsSync(join(repoRoot, 'claude-state'))) {
1294
+ write('dz roam: this is the roam ARCHIVE, not the project — its git root is its own.');
1295
+ write(` Run from the project root instead: cd ${dirname(repoRoot)} && dz roam`);
1296
+ return 1;
1297
+ }
1200
1298
  const roamDir = join(repoRoot, 'roam', 'claude-state');
1201
1299
  const claudeProjectsDir = process.env['CLAUDE_PROJECTS_DIR'] ?? join(homedir(), '.claude', 'projects');
1202
1300
  // Slug: replace every : \ / with -
@@ -3654,6 +3752,18 @@ async function cmdSetup(options, flags, cwd, write, writeErr) {
3654
3752
  else {
3655
3753
  write(`║ (no skills resolved) ║`);
3656
3754
  }
3755
+ // Step 5 (ADR-001 §8): DELIVER the codex hooks and verify them live. Non-aborting — the rest of
3756
+ // setup has already run and the summary still prints; only the exit code carries the failure.
3757
+ let codexHooksOk = true;
3758
+ if (target === 'codex' && !flags.has('no-hooks')) {
3759
+ write(`║ 5. Delivering codex hooks (live verify)... ║`);
3760
+ const delivery = deliverCodexHooks({ project: projectRoot, verify: !flags.has('no-verify') }, undefined, 'dz setup');
3761
+ codexHooksOk = delivery.ok;
3762
+ for (const line of delivery.stdout)
3763
+ write(line);
3764
+ for (const line of delivery.stderr)
3765
+ writeErr(line);
3766
+ }
3657
3767
  write(`╠══════════════════════════════════════════════════════╣`);
3658
3768
  write(`║ Setup: ${String(setupResult.completed).padStart(2)} done, ${String(setupResult.skipped).padStart(2)} skipped ║`);
3659
3769
  // Honest label derived from the ACTUAL wiring (runSetup's 'agentdb wiring' invariant check),
@@ -3672,7 +3782,9 @@ async function cmdSetup(options, flags, cwd, write, writeErr) {
3672
3782
  + (install.dirsSearched > 1 ? ` (searched ${install.dirsSearched} skill dirs)` : ''));
3673
3783
  writeMissingSkillsHint(write, install.missing, selectArg !== undefined ? undefined : preset);
3674
3784
  }
3675
- return 0;
3785
+ // A hook that was written but never witnessed firing is NOT a completed setup (ADR-002 §5): the
3786
+ // step is reported failed, the process was not aborted.
3787
+ return codexHooksOk ? 0 : 1;
3676
3788
  }
3677
3789
  function cmdPretrain(options, cwd, write) {
3678
3790
  const projectRoot = resolve(cwd, options.get('project') ?? '.');
@@ -4399,6 +4511,19 @@ function cmdParity(options, flags, write, writeErr) {
4399
4511
  }
4400
4512
  }
4401
4513
  const matrix = buildParityMatrix();
4514
+ // EVIDENCE staleness, folded into the report (fix round 2, R2-3). Derived from the records
4515
+ // themselves — no `codex --version`, no subprocess, so `dz parity` stays a deterministic function
4516
+ // of the model. A cell whose deciding form rests on a transcript that is older than the newest
4517
+ // recording for the SAME target is reported `inconclusive`, never `full`: the round-1 gate could
4518
+ // already tell, and nothing a user runs was asking it.
4519
+ const staleEvidence = findStaleTranscriptEvidence();
4520
+ const staleByTarget = new Map();
4521
+ for (const s of staleEvidence)
4522
+ staleByTarget.set(s.target, [...(staleByTarget.get(s.target) ?? []), s.capability]);
4523
+ const reportCell = (feature, t, cell) => downgradeForStaleEvidence(feature, cell, staleByTarget.get(t) ?? []);
4524
+ const staleNote = (t) => staleEvidence
4525
+ .filter((s) => s.target === t)
4526
+ .map((s) => ` ⚠ ${s.capability}: evidence recorded on ${s.recordedVersion ?? '(no runtime version recorded)'}, newest recording for this target is ${s.probedVersion ?? '(none)'} — INCONCLUSIVE until re-probed (${s.evidence ?? ''})`);
4402
4527
  // Site 8 of the D3 rewiring, closed in fix round 1 (QE F1). It shipped spelling its
4403
4528
  // own bare guard `TARGET_NAMES.includes(...)` and was therefore invisible to the AM-2
4404
4529
  // grep-guard, which searched for the token `isTargetName(` — a PRESENCE check on one
@@ -4426,25 +4551,41 @@ function cmdParity(options, flags, write, writeErr) {
4426
4551
  writeErr(formatTargetAliasNote('dz parity', targetOpt, target));
4427
4552
  }
4428
4553
  if (json) {
4429
- const rows = matrix.map((r) => ({
4430
- id: r.feature.id,
4431
- title: r.feature.title,
4432
- cells: target !== undefined ? { [target]: r.cells[target] } : r.cells,
4433
- }));
4554
+ const shown = target !== undefined ? [target] : TARGET_NAMES;
4555
+ const rows = matrix.map((r) => {
4556
+ const cells = {};
4557
+ for (const t of shown)
4558
+ cells[t] = reportCell(r.feature, t, r.cells[t]);
4559
+ return { id: r.feature.id, title: r.feature.title, cells };
4560
+ });
4434
4561
  // A filtered response stays internally consistent: capabilities are filtered too (Codex QE gap 9).
4435
4562
  const caps = target !== undefined ? { [target]: TARGET_CAPABILITIES[target] } : TARGET_CAPABILITIES;
4436
- write(JSON.stringify({ targets: target !== undefined ? [target] : TARGET_NAMES, capabilities: caps, features: rows }, null, 2));
4563
+ write(JSON.stringify({
4564
+ targets: shown,
4565
+ capabilities: caps,
4566
+ // The evidence axis travels WITH the matrix: a consumer that reads `level` must be able to
4567
+ // read why a cell is inconclusive without a second command.
4568
+ staleEvidence: staleEvidence.filter((sv) => shown.includes(sv.target)),
4569
+ features: rows,
4570
+ }, null, 2));
4437
4571
  return 0;
4438
4572
  }
4439
4573
  if (target !== undefined) {
4440
4574
  const t = target;
4441
4575
  write(`\ndz parity — ${t} (capabilities: ${TARGET_CAPABILITIES[t].join(', ')})\n`);
4442
4576
  for (const r of matrix) {
4443
- const c = r.cells[t];
4444
- const icon = c.level === 'full' ? '✓' : c.level === 'manual' ? '◐' : '—';
4445
- write(` ${icon} ${r.feature.title.padEnd(58)} ${c.level === 'none' ? 'not available on this target' : `via ${c.via ?? ''}`}`);
4446
- }
4447
- write('\n ✓ full (the complete experience) ◐ manual (works, you drive it by hand) — not available');
4577
+ const c = reportCell(r.feature, t, r.cells[t]);
4578
+ const icon = c.level === 'full' ? '✓' : c.level === 'manual' ? '◐' : c.level === 'inconclusive' ? '?' : '—';
4579
+ const detail = c.level === 'none'
4580
+ ? 'not available on this target'
4581
+ : c.level === 'inconclusive'
4582
+ ? `via ${c.via ?? ''} — INCONCLUSIVE: stale evidence for ${(c.staleEvidence ?? []).join(', ')}`
4583
+ : `via ${c.via ?? ''}`;
4584
+ write(` ${icon} ${r.feature.title.padEnd(58)} ${detail}`);
4585
+ }
4586
+ write('\n ✓ full (the complete experience) ◐ manual (works, you drive it by hand) ? evidence stale (re-probe) — not available');
4587
+ for (const line of staleNote(t))
4588
+ write(line);
4448
4589
  return 0;
4449
4590
  }
4450
4591
  // Grid: one column per target, short header, one row per feature. Computed, never hand-written;
@@ -4454,8 +4595,8 @@ function cmdParity(options, flags, write, writeErr) {
4454
4595
  write(` ${'feature'.padEnd(52)} ${TARGET_NAMES.map((t) => (short[t] ?? t).padStart(4)).join('')}`);
4455
4596
  for (const r of matrix) {
4456
4597
  const cells = TARGET_NAMES.map((t) => {
4457
- const c = r.cells[t];
4458
- return (c.level === 'full' ? '✓' : c.level === 'manual' ? '◐' : '—').padStart(4);
4598
+ const c = reportCell(r.feature, t, r.cells[t]);
4599
+ return (c.level === 'full' ? '✓' : c.level === 'manual' ? '◐' : c.level === 'inconclusive' ? '?' : '—').padStart(4);
4459
4600
  }).join('');
4460
4601
  write(` ${r.feature.title.slice(0, 52).padEnd(52)} ${cells}`);
4461
4602
  }
@@ -5475,11 +5616,16 @@ function cmdHooksSync(options, flags, cwd, write, writeErr) {
5475
5616
  return 1;
5476
5617
  }
5477
5618
  const codexHome = options.get('codex-home');
5478
- const report = runSyncCodexHooks({
5619
+ const projectOpt = options.get('project');
5620
+ // `--no-verify` wins over `--verify`: an explicit refusal to measure is never overridden by the
5621
+ // flag that asks for a measurement.
5622
+ const report = runSyncCodexHooksGuarded(codexHooksSyncOptions({
5479
5623
  ...(codexHome !== undefined ? { codexHome } : {}),
5624
+ ...(projectOpt !== undefined ? { project: resolve(cwd, projectOpt) } : {}),
5480
5625
  check: flags.has('check'),
5481
5626
  remove: flags.has('remove'),
5482
- });
5627
+ verify: !flags.has('no-verify'),
5628
+ }));
5483
5629
  if (json) {
5484
5630
  write(JSON.stringify({ ...report, exitCode: report.exitCode }));
5485
5631
  return report.exitCode;
@@ -5496,20 +5642,174 @@ function cmdHooksSync(options, flags, cwd, write, writeErr) {
5496
5642
  write(`dz hooks-sync: removed ${report.removed} managed entr(ies) from ${report.registryPath}`);
5497
5643
  return report.exitCode;
5498
5644
  }
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');
5645
+ // AM-17 / G-G: the success word is reachable ONLY from `report.ready` — armed AND trusted AND
5646
+ // WITNESSED blocking by a live, non-bypassed probe. The pre-fix version printed it off
5647
+ // `exitCode === 0 && trust && installed`, none of which is evidence that the guard fires.
5648
+ const summary = codexHooksSummary(report);
5649
+ for (const line of summary.stdout)
5650
+ write(line);
5651
+ for (const line of summary.stderr)
5652
+ writeErr(line);
5653
+ return report.exitCode;
5654
+ }
5655
+ /**
5656
+ * The argv → operation mapping, extracted so it can be PINNED.
5657
+ *
5658
+ * It is the mapping that was broken: `--verify`, `--no-verify` and `--project` were parsed,
5659
+ * validated, listed in the usage line — and then never reached `runSyncCodexHooks`, so the CRITICAL
5660
+ * finding (a `ready` with no live proof behind it) lived entirely in three missing object keys.
5661
+ * A function that returns the options object is testable without a codex binary; an inline literal
5662
+ * is not.
5663
+ */
5664
+ export function codexHooksSyncOptions(input) {
5665
+ return {
5666
+ ...(input.codexHome !== undefined ? { codexHome: input.codexHome } : {}),
5667
+ ...(input.project !== undefined ? { project: input.project } : {}),
5668
+ check: input.check === true,
5669
+ remove: input.remove === true,
5670
+ verify: input.verify !== false,
5671
+ };
5672
+ }
5673
+ /**
5674
+ * What the user is told about a sync report — the ONE place the success word can be printed.
5675
+ *
5676
+ * `report.ready` is the whole gate: installed ∧ executable ∧ trusted ∧ a live, non-bypassed probe
5677
+ * that WITNESSED our block. Nothing else may print "ready" (AM-17 / G-G), and `--no-verify` never
5678
+ * can, because it never measured.
5679
+ */
5680
+ export function codexHooksSummary(report, label = 'dz hooks-sync') {
5681
+ const stdout = [];
5682
+ const stderr = [];
5683
+ if (report.ready) {
5684
+ stdout.push(`${label}: codex hooks installed and ARMED (trust: ${report.trust}) — VERIFIED by a live veto probe — ready`);
5685
+ return { ok: true, stdout, stderr };
5686
+ }
5687
+ if (report.installed) {
5688
+ const verdict = report.verify === null ? 'not verified (no live probe ran)' : `${report.verify.verdict} — ${report.verify.reason}`;
5689
+ // Say what the report ESTABLISHED, not a hopeful summary of it: `installed+trusted` used to
5690
+ // print verbatim even when the same line went on to report `trust: unknown` (the re-QE's
5691
+ // non-closure note). A message that argues with its own parenthesis teaches the reader to skip
5692
+ // the parenthesis.
5693
+ const established = report.trust === 'trusted' ? 'installed+trusted' : `installed, trust ${report.trust}`;
5694
+ stderr.push(`${label}: ${established}, NOT verified — ARMED = NO (trust: ${report.trust}, executable: ${report.executable}, verify: ${verdict})`);
5695
+ stderr.push('→ open an interactive Codex session in this directory, approve the two dz hooks, then re-run `dz hooks-sync --target codex --verify`');
5508
5696
  }
5509
5697
  else {
5510
- writeErr('dz hooks-sync: ARMED = NO — the managed entries are not present in the registry');
5698
+ stderr.push(`${label}: ARMED = NO — the managed entries are not present in the registry`);
5511
5699
  }
5512
- return report.exitCode;
5700
+ return { ok: false, stdout, stderr };
5701
+ }
5702
+ /**
5703
+ * ADR-001 §8: `dz setup` and `dz init --target codex` DELIVER the hooks and verify them.
5704
+ *
5705
+ * Non-aborting by contract (ADR-002 D6): the caller keeps going and folds `ok` into its own exit
5706
+ * code. Before this round no production path called `runSyncCodexHooks` at all — the operation, its
5707
+ * classifier and its exit map existed and were reachable only from the dedicated command
5708
+ * (independent review, finding 2).
5709
+ */
5710
+ /**
5711
+ * Serialize dz's own `hooks.json` read-merge-write behind the `codex-hooks` named lock
5712
+ * (feature qe-bridge-claude, ADR-001 D4-A — the exit condition of the accepted degradation in
5713
+ * `architecture/degradations.md`).
5714
+ *
5715
+ * The lock lives BESIDE the registry it guards (`$CODEX_HOME/.dz/locks/codex-hooks.lock`), not in
5716
+ * this repo: two dz processes running from two different worktrees share a `CODEX_HOME`, not a
5717
+ * project root, so a lock under the project would serialize nothing.
5718
+ *
5719
+ * READ-ONLY runs (`--check`) are NOT locked: they write nothing, and a check that can be blocked by
5720
+ * a writer would be a new failure mode in exchange for no guarantee.
5721
+ *
5722
+ * HONEST LIMIT: this is an ADVISORY lock. It serializes dz-side writers only; a foreign installer
5723
+ * (ruvnet-brain ships its own Codex hooks bundle) never takes it. For that case the pre-existing
5724
+ * mitigations remain the backstop — foreign entries preserved byte-for-byte, a timestamped backup
5725
+ * before every modifying write, and atomic temp+rename so no reader sees a partial file.
5726
+ */
5727
+ function runSyncCodexHooksLocked(options = {}) {
5728
+ if (options?.check === true)
5729
+ return runSyncCodexHooks(options);
5730
+ const codexHome = resolveCodexHome(options?.codexHome);
5731
+ // AM-35a is a SHIPPED property with a test: a run that refuses (no `codex` on PATH) must leave
5732
+ // CODEX_HOME untouched — dz does not create user-global state for a runtime that is not there.
5733
+ // Taking the lock creates `<codexHome>/.dz/locks/`, so when the guarded operation turned out to
5734
+ // write nothing, the lock scaffolding is removed again (empty-dir removals only: a directory that
5735
+ // still holds another process's live lock simply refuses to go).
5736
+ const preexisting = existsSync(join(codexHome, '.dz'));
5737
+ const tidyLockScaffold = (report) => {
5738
+ if (preexisting || report.written || report.writes.length > 0)
5739
+ return;
5740
+ for (const dir of [join(codexHome, '.dz', 'locks'), join(codexHome, '.dz')]) {
5741
+ try {
5742
+ rmdirSync(dir);
5743
+ }
5744
+ catch { /* non-empty (someone else's lock) or gone — leave it */ }
5745
+ }
5746
+ };
5747
+ // ROUND-2 C2: the lock now wraps ONLY the registry read-plan-write transaction, passed in as the
5748
+ // operation's `criticalSection` seam. It used to wrap the WHOLE operation, including a live veto
5749
+ // probe that can block for ~300s — ten times the 30s stale threshold, after which a waiter is
5750
+ // entitled to break the lock and the holder is no longer excluding anyone. The probes mutate
5751
+ // nothing shared, so they run unlocked by design.
5752
+ let lockError = null;
5753
+ const report = runSyncCodexHooks({
5754
+ ...options,
5755
+ criticalSection: (fn) => {
5756
+ try {
5757
+ return withNamedLockSync(codexHome, 'codex-hooks', fn);
5758
+ }
5759
+ catch (error) {
5760
+ if (error instanceof NamedLockTimeoutError || error instanceof NamedLockCompromisedError) {
5761
+ lockError = error;
5762
+ throw error;
5763
+ }
5764
+ throw error;
5765
+ }
5766
+ },
5767
+ });
5768
+ tidyLockScaffold(report);
5769
+ if (lockError !== null)
5770
+ throw lockError;
5771
+ return report;
5772
+ }
5773
+ /** Wrap the delivery so a lock refusal becomes a REPORT (loud, nothing written), not a stack trace. */
5774
+ function runSyncCodexHooksGuarded(options = {}) {
5775
+ const codexHome = resolveCodexHome(options?.codexHome);
5776
+ try {
5777
+ return runSyncCodexHooksLocked(options);
5778
+ }
5779
+ catch (error) {
5780
+ if (error instanceof NamedLockTimeoutError || error instanceof NamedLockCompromisedError) {
5781
+ const why = error instanceof NamedLockTimeoutError
5782
+ ? `another dz process is writing ${join(codexHome, 'hooks.json')} (${error.message}) — NOTHING was written; retry once it finishes`
5783
+ : `the codex-hooks lock was broken while this run held it (${error.message}) — the registry write may have raced; re-run and re-verify`;
5784
+ return {
5785
+ codexHome,
5786
+ registryPath: join(codexHome, 'hooks.json'),
5787
+ installed: false,
5788
+ executable: false,
5789
+ written: false,
5790
+ removed: 0,
5791
+ foreignPreserved: 0,
5792
+ unattributable: 0,
5793
+ drift: [],
5794
+ trust: 'unknown',
5795
+ codexVersion: null,
5796
+ writes: [],
5797
+ verify: null,
5798
+ verified: false,
5799
+ ready: false,
5800
+ exitCode: 1,
5801
+ warnings: [],
5802
+ errors: [why],
5803
+ };
5804
+ }
5805
+ throw error;
5806
+ }
5807
+ }
5808
+ export function deliverCodexHooks(input, sync = runSyncCodexHooksGuarded, label = 'dz setup') {
5809
+ const report = sync(codexHooksSyncOptions(input));
5810
+ const summary = codexHooksSummary(report, label);
5811
+ const stderr = [...report.errors.map((e) => `${label}: ${e}`), ...report.warnings.map((w) => `${label}: warning: ${w}`), ...summary.stderr];
5812
+ return { ok: summary.ok, stdout: summary.stdout, stderr, report };
5513
5813
  }
5514
5814
  function cmdAgentsSync(options, flags, cwd, write, writeErr) {
5515
5815
  const json = flags.has('json');
@@ -7661,18 +7961,619 @@ function cmdMutationGate(options, flags, cwd, write) {
7661
7961
  * debt); an oversized or non-regular due-file counts as MALFORMED, never a silent skip (QE #8);
7662
7962
  * a debt whose embedded slug differs from its directory is MALFORMED — identity is the directory,
7663
7963
  * the JSON only confirms it (QE #4: an embedded foreign slug must not redirect settlement). */
7964
+ /* ── `dz workflow run` — the impure half of the loop-plan executor (feature dz-workflow-run) ──────
7965
+ *
7966
+ * Everything DECIDABLE lives in harness-core's pure scheduler. This half owns exactly four things
7967
+ * core refuses to touch: the filesystem, the lock, the child processes, and the exit code. Keeping
7968
+ * that line sharp is what lets the whole feature be tested without a child process — so anything
7969
+ * added here that could have been a decision belongs upstream instead.
7970
+ */
7971
+ /** The env TEST SEAM (the `DZ_QE_BRIDGE_CLAUDE_BIN` precedent: an env var, never a flag — a flag
7972
+ * invites production use). Recorded LOUDLY in run-state as `dispatcherOverride: true`, because a
7973
+ * test seam that leaves no trace in the artifact is indistinguishable from a real run. */
7974
+ const WF_RUN_DISPATCH_SCRIPT_ENV = 'DZ_WF_RUN_DISPATCH_SCRIPT';
7975
+ const WF_RUN_OWNER_FILE = 'run-owner.json';
7976
+ const WF_RUN_STATE_FILE = 'run-state.json';
7977
+ /**
7978
+ * THE ONE PLACE anything in the loop runner is signalled (Step-8 re-QE NEW-C4).
7979
+ *
7980
+ * Round 1 guarded `process.kill(-pid, …)` — the process-GROUP path — and left `child.kill(sig)`
7981
+ * unguarded beside it. A fake child reporting `pid: 0` therefore still received the signal through
7982
+ * the object method, which is the same defect wearing a different call shape. `process.kill(-0, …)`
7983
+ * signals the CALLER'S OWN process group; round 0 of this exact class took down the vitest worker
7984
+ * pool. Two call shapes meant two chances to forget, so now there is one.
7985
+ *
7986
+ * The guard is on the PID, not on the shape: an unsignalable pid (not an integer, or <= 1) reaches
7987
+ * NOTHING — neither `process.kill` nor `child.kill`. Returns whether a signal was actually sent, so
7988
+ * a caller can never mistake "refused" for "delivered".
7989
+ */
7990
+ function signalChildSafely(child, signal, detached) {
7991
+ const raw = child?.pid;
7992
+ if (typeof raw !== 'number' || !Number.isInteger(raw) || raw <= 1)
7993
+ return false;
7994
+ try {
7995
+ if (detached)
7996
+ process.kill(-raw, signal);
7997
+ else
7998
+ child?.kill?.(signal);
7999
+ return true;
8000
+ }
8001
+ catch {
8002
+ return false; // already gone, or not ours — both mean nothing more to do
8003
+ }
8004
+ }
8005
+ /** Test seam for the chokepoint: NEW-C4's proof needs to call it with a hostile pid. */
8006
+ export function __wfSignalChildTestSeam(child, signal, detached) {
8007
+ return signalChildSafely(child, signal, detached);
8008
+ }
8009
+ /** Live children, keyed by pid, for the kill-group handlers (AM-10). */
8010
+ const wfLiveChildren = new Map();
8011
+ let wfHandlersInstalled = false;
8012
+ /** Kill the process GROUP of every live child. A detached child leads its own group, so killing the
8013
+ * leader alone would leave whatever it spawned running — that is the orphan class AM-10 closes.
8014
+ * NAMED RESIDUE: a SIGKILL of the runner itself runs no handler, so that case still orphans. */
8015
+ function wfKillLiveChildren(signal = 'SIGTERM') {
8016
+ const killed = [];
8017
+ for (const [pid, child] of [...wfLiveChildren.entries()]) {
8018
+ // ONE chokepoint: group first (a detached child leads its own), then the leader. Neither shape
8019
+ // is reachable without the pid guard.
8020
+ if (signalChildSafely(child, signal, true) || signalChildSafely(child, signal, false))
8021
+ killed.push(pid);
8022
+ wfLiveChildren.delete(pid);
8023
+ }
8024
+ return killed;
8025
+ }
8026
+ function wfInstallKillHandlers() {
8027
+ if (wfHandlersInstalled)
8028
+ return;
8029
+ wfHandlersInstalled = true;
8030
+ process.on('exit', () => { wfKillLiveChildren(); });
8031
+ process.on('SIGTERM', () => { wfKillLiveChildren(); process.exit(143); });
8032
+ process.on('SIGINT', () => { wfKillLiveChildren(); process.exit(130); });
8033
+ }
8034
+ /** Exposed for the unit test: the kill set must NAME every live child's pid. */
8035
+ export function __wfKillGroupTestSeam() {
8036
+ return {
8037
+ register: (pid, child) => { wfLiveChildren.set(pid, child); },
8038
+ killAll: () => wfKillLiveChildren(),
8039
+ size: () => wfLiveChildren.size,
8040
+ };
8041
+ }
8042
+ /** A `ChildRunner` over the generalized wrapper, registering every live child for the kill set. */
8043
+ /** A `ChildRunner` bound to ONE family, so its child can only ever receive that family's
8044
+ * credentials (re-QE H9). Two runners, two credential sets, one wrapper. */
8045
+ const wfChildRunnerFor = (family) => async (bin, argv, opts) => runChildBridge(bin, argv, {
8046
+ ...opts,
8047
+ // a dispatched model gets a NAMED minimal environment, never the runner's whole one (HIGH-9)
8048
+ envMode: 'allowlist',
8049
+ envExtra: CHILD_ENV_BY_FAMILY[family],
8050
+ onSpawn: (child) => {
8051
+ if (typeof child.pid !== 'number')
8052
+ return;
8053
+ const pid = child.pid;
8054
+ wfLiveChildren.set(pid, child);
8055
+ child.on('close', () => { wfLiveChildren.delete(pid); });
8056
+ },
8057
+ });
8058
+ /** Build the SCRIPTED dispatcher from the env seam's JSON file (test-only). */
8059
+ function wfScriptedDispatcher(scriptPath, family) {
8060
+ const raw = JSON.parse(readFileSync(scriptPath, 'utf8'));
8061
+ const script = (raw['steps'] ?? raw);
8062
+ const probeId = typeof raw['probeId'] === 'string' ? raw['probeId'] : `${family}-scripted`;
8063
+ const consumed = new Map();
8064
+ return {
8065
+ probe: async (candidates) => ({ id: raw['probeFails'] === true ? null : probeId, wallMs: 1, detail: `scripted probe (${candidates.join(',') || 'defaults'})` }),
8066
+ dispatch: async (req) => {
8067
+ const key = req.itemKey === null ? req.stepId : `${req.stepId}:${req.itemKey}`;
8068
+ const entry = script[key] ?? script[req.stepId];
8069
+ const n = consumed.get(key) ?? 0;
8070
+ consumed.set(key, n + 1);
8071
+ const picked = Array.isArray(entry) ? entry[Math.min(n, entry.length - 1)] : entry;
8072
+ const base = {
8073
+ outcome: 'ok',
8074
+ text: `scripted:${key}`,
8075
+ family,
8076
+ modelUsed: req.resolvedModelId,
8077
+ wallMs: 1,
8078
+ tokensIn: null,
8079
+ tokensOut: null,
8080
+ tokensSource: null,
8081
+ };
8082
+ if (picked === undefined || picked === null)
8083
+ return base;
8084
+ // a scripted step may ask the runner to CREATE its declared writes (the landed-barrier leg)
8085
+ if (picked['writes'] === true) {
8086
+ for (const rel of req.expectedWrites) {
8087
+ // the seam re-checks containment too — a test double that could write outside the root
8088
+ // would be a hole in exactly the guard the real path is being tested for (CRITICAL-4)
8089
+ const abs = wfContainedPath(req.cwd, rel);
8090
+ if (abs === null)
8091
+ continue;
8092
+ mkdirSync(dirname(abs), { recursive: true });
8093
+ writeFileSync(abs, `scripted write for ${req.stepId} attempt ${req.attempt}\n`);
8094
+ }
8095
+ }
8096
+ return { ...base, ...picked };
8097
+ },
8098
+ };
8099
+ }
8100
+ /** Read a small JSON file, or null. */
8101
+ function wfReadJson(path) {
8102
+ try {
8103
+ return JSON.parse(readFileSync(path, 'utf8'));
8104
+ }
8105
+ catch {
8106
+ return null;
8107
+ }
8108
+ }
8109
+ /** Is a recorded pid still alive? `kill(pid, 0)` is the portable liveness probe. */
8110
+ function wfPidAlive(pid) {
8111
+ if (!Number.isInteger(pid) || pid <= 0)
8112
+ return false;
8113
+ try {
8114
+ process.kill(pid, 0);
8115
+ return true;
8116
+ }
8117
+ catch (e) {
8118
+ return e.code === 'EPERM'; // alive, just not ours
8119
+ }
8120
+ }
8121
+ /** The fs `RunStore`. Every write that must be atomic is temp+rename; every file that must be NEW
8122
+ * is `wx`. Nothing here decides anything. */
8123
+ /**
8124
+ * Containment, re-checked at the LAST possible moment (Step-8 CRITICAL-4: "repeat containment
8125
+ * immediately before filesystem access").
8126
+ *
8127
+ * Preflight validated these paths minutes earlier, against a filesystem that has since been written
8128
+ * to — by the very models this run dispatched. A symlink planted between preflight and the probe is
8129
+ * not a hypothetical here; creating files is what the file-deliverable steps DO. Returns the
8130
+ * absolute path, or null when the path no longer resolves inside the root.
8131
+ */
8132
+ function wfContainedPath(targetCwd, rel) {
8133
+ const check = containedUnderRoot(targetCwd, rel);
8134
+ return check.ok ? check.path : null;
8135
+ }
8136
+ function wfMakeStore(runDir, repoRoot, targetCwd) {
8137
+ const statePath = join(runDir, WF_RUN_STATE_FILE);
8138
+ const tracePath = join(runDir, 'trace.jsonl');
8139
+ const stateDir = join(runDir, '.fa-state');
8140
+ const ckptPath = join(stateDir, 'checkpoints.jsonl');
8141
+ const budgetPath = join(runDir, 'budget.jsonl');
8142
+ const duePath = join(stateDir, 'reqe-due.json');
8143
+ const ledgerPath = join(repoRoot, '.dz', 'feature-adr', 'run-cost-ledger.jsonl');
8144
+ const hashOf = (abs) => {
8145
+ try {
8146
+ return createHash('sha256').update(readFileSync(abs)).digest('hex');
8147
+ }
8148
+ catch {
8149
+ return null;
8150
+ }
8151
+ };
8152
+ return {
8153
+ runDirExists: () => existsSync(runDir),
8154
+ hasTrace: () => existsSync(tracePath),
8155
+ readTraceText: () => (existsSync(tracePath) ? readFileSync(tracePath, 'utf8') : null),
8156
+ // The CONTENT half of the attestation binding. Hashes the file AS IT STANDS, so it must be
8157
+ // called after the final flush — the scheduler's stampTraceBinding is the single caller.
8158
+ measureTrace: () => {
8159
+ if (!existsSync(tracePath))
8160
+ return null;
8161
+ const text = readFileSync(tracePath, 'utf8');
8162
+ return { sha256: createHash('sha256').update(text, 'utf8').digest('hex'), lines: text.split('\n').filter((l) => l.trim() !== '').length };
8163
+ },
8164
+ readRunState: () => wfReadJson(statePath),
8165
+ writeRunState: (s) => {
8166
+ mkdirSync(runDir, { recursive: true });
8167
+ const tmp = statePath + '.tmp';
8168
+ writeFileSync(tmp, JSON.stringify(s, null, 2) + '\n');
8169
+ renameSync(tmp, statePath); // atomic: a half-written state is a foreign run forever
8170
+ },
8171
+ appendTraceLines: (lines) => {
8172
+ if (lines.length === 0)
8173
+ return;
8174
+ mkdirSync(runDir, { recursive: true });
8175
+ appendFileSync(tracePath, lines.join('\n') + '\n');
8176
+ },
8177
+ readCheckpointsText: () => (existsSync(ckptPath) ? readFileSync(ckptPath, 'utf8') : null),
8178
+ appendCheckpointLine: (line) => {
8179
+ mkdirSync(stateDir, { recursive: true });
8180
+ appendFileSync(ckptPath, line + '\n');
8181
+ },
8182
+ appendBudgetRow: (row) => {
8183
+ mkdirSync(runDir, { recursive: true });
8184
+ appendFileSync(budgetPath, JSON.stringify(row) + '\n');
8185
+ },
8186
+ appendLedgerLine: (line) => {
8187
+ try {
8188
+ mkdirSync(dirname(ledgerPath), { recursive: true });
8189
+ appendFileSync(ledgerPath, line + '\n');
8190
+ }
8191
+ catch {
8192
+ /* telemetry is SECONDARY: a ledger failure never fails a run */
8193
+ }
8194
+ },
8195
+ probeArtifact: (rel) => {
8196
+ const abs = wfContainedPath(targetCwd, rel);
8197
+ // a path that no longer resolves inside the root is NOT LANDED, whatever is at the other end
8198
+ return abs !== null && existsSync(abs);
8199
+ },
8200
+ // re-QE R3-A: the same realpath + symlinked-ancestor discipline, for reads AND writes, at the
8201
+ // moment before the dispatch grants filesystem access
8202
+ pathContainmentOk: (rel) => wfContainedPath(targetCwd, rel) !== null,
8203
+ snapshotWrites: (rels) => {
8204
+ const out = {};
8205
+ for (const rel of rels) {
8206
+ const abs = wfContainedPath(targetCwd, rel);
8207
+ out[rel] = abs === null ? null : hashOf(abs);
8208
+ }
8209
+ return out;
8210
+ },
8211
+ writeReqeDebt: (record) => {
8212
+ mkdirSync(stateDir, { recursive: true });
8213
+ writeFileSync(duePath, JSON.stringify(record, null, 2) + '\n');
8214
+ },
8215
+ };
8216
+ }
8217
+ /**
8218
+ * `dz workflow run <plan.json>` — INTERPRET the plan (ADR-001). Registered inside the existing
8219
+ * `case 'workflow':` branch when `_positional_0 === 'run'`, BEFORE the sync `cmdWorkflow`.
8220
+ *
8221
+ * Exit codes (AM-11): `0` completed · `1` failed (named) · `2` usage / invalid plan ·
8222
+ * `75` typed pause (sysexits EX_TEMPFAIL). NOT `3`: that collides with workflow-lint's
8223
+ * inconclusive and reads ignorable, while a pause strands resumable progress.
8224
+ */
8225
+ async function cmdWorkflowRun(options, optionLists, flags, cwd, write) {
8226
+ const json = flags.has('json');
8227
+ const usage = 'dz workflow run <plan.json> [--run-id <id>] [--resume <runId>] [--arg k=v]… '
8228
+ + '[--coder-family codex|claude] [--default-family codex|claude] [--budget <n>] [--max-wall-clock <s>] '
8229
+ + '[--stage-timeout <s>] [--budget-extra <n>] [--wall-clock-extra <s>] [--run-dir <dir>] '
8230
+ + '[--allow-same-family-qe] [--json]';
8231
+ const usageError = (message) => {
8232
+ write(json ? JSON.stringify({ ok: false, reason: 'plan-invalid', error: message, exitCode: 2 }) : `dz workflow run: ${message}\n${usage}`);
8233
+ return 2;
8234
+ };
8235
+ if (flags.has('help')) {
8236
+ write(usage);
8237
+ write('');
8238
+ write('EXIT CODES — `dz workflow run` and `dz workflow-lint` have DIFFERENT tables (AM-11):');
8239
+ write(' run 0 completed · 1 failed (named reason) · 2 usage/invalid plan · 75 typed pause (EX_TEMPFAIL)');
8240
+ write(' lint 0 clean · 1 findings · 3 inconclusive');
8241
+ write(' 75 is NOT 3: 3 reads ignorable and collides with lint, while a pause strands resumable work.');
8242
+ write('On a pause the LAST stdout line is a `wf-pause-envelope/1` JSON object; a FAILURE emits none,');
8243
+ write('so a wrapper can tell the two apart from stdout + exit code alone, without parsing prose.');
8244
+ return 0;
8245
+ }
8246
+ // ── closed allowlists (the cmdQeBridge discipline: an unknown flag is a usage error, never a
8247
+ // silently-ignored intention) ──
8248
+ const OPTS = new Set(['run-id', 'resume', 'arg', 'coder-family', 'default-family', 'budget', 'max-wall-clock', 'stage-timeout', 'budget-extra', 'wall-clock-extra', 'run-dir', 'project', '_positional_0', '_positional_1']);
8249
+ const FLAGS = new Set(['allow-same-family-qe', 'json', 'help']);
8250
+ for (const k of options.keys())
8251
+ if (!OPTS.has(k))
8252
+ return usageError(`unknown option --${k}`);
8253
+ for (const f of flags)
8254
+ if (!FLAGS.has(f))
8255
+ return usageError(`unknown flag --${f}`);
8256
+ // ── SINGLETONS (Step-8 HIGH-8 — the recurring class) ──
8257
+ //
8258
+ // `parseArgs` keeps every occurrence in `optionLists` but the main map is LAST-WINS, so
8259
+ // `--coder-family codex --coder-family claude` was accepted and only `claude` reached preflight.
8260
+ // For a SAFETY option that is a bypass: the same-family guard compares the coder family against a
8261
+ // qe step's family, and whoever supplies the last occurrence chooses the answer. Every option here
8262
+ // is a singleton BY MEANING — a run has one coder family, one budget, one run directory — so a
8263
+ // second occurrence is not a preference, it is an ambiguity, and the only honest answer is to
8264
+ // refuse. `--arg` is deliberately absent: it is the one genuinely repeatable option.
8265
+ const SINGLETON_OPTS = ['run-id', 'resume', 'coder-family', 'default-family', 'budget', 'max-wall-clock', 'stage-timeout', 'budget-extra', 'wall-clock-extra', 'run-dir', 'project'];
8266
+ for (const key of SINGLETON_OPTS) {
8267
+ const occurrences = optionLists.get(key) ?? [];
8268
+ if (occurrences.length > 1) {
8269
+ return usageError(`--${key} was given ${occurrences.length} times (${occurrences.map((v) => JSON.stringify(v)).join(', ')}) — it is a singleton, and a second occurrence is an ambiguity, not a preference. `
8270
+ + 'Stacking a safety option would let the LAST value decide what the first one refused.');
8271
+ }
8272
+ }
8273
+ const planPath = options.get('_positional_1') ?? '';
8274
+ if (planPath === '')
8275
+ return usageError('a plan.json positional is required');
8276
+ const root = resolve(cwd, options.get('project') ?? '.');
8277
+ const absPlan = resolve(cwd, planPath);
8278
+ if (!existsSync(absPlan))
8279
+ return usageError(`no such plan file: ${absPlan}`);
8280
+ let rawPlan;
8281
+ try {
8282
+ rawPlan = JSON.parse(readFileSync(absPlan, 'utf8'));
8283
+ }
8284
+ catch (e) {
8285
+ return usageError(`unparseable plan JSON — ${e instanceof Error ? e.message : String(e)}`);
8286
+ }
8287
+ const parsed = parsePlan(rawPlan);
8288
+ if (isParseErrors(parsed)) {
8289
+ if (json)
8290
+ write(JSON.stringify({ ok: false, reason: 'plan-invalid', parseErrors: parsed, exitCode: 2 }));
8291
+ else
8292
+ for (const e of parsed)
8293
+ write(`PARSE ${e.path}: ${e.message}`);
8294
+ return 2;
8295
+ }
8296
+ const diags = validatePlan(parsed);
8297
+ if (diags.length > 0) {
8298
+ if (json)
8299
+ write(JSON.stringify({ ok: false, reason: 'plan-invalid', diagnostics: diags, exitCode: 2 }));
8300
+ else
8301
+ for (const d of diags)
8302
+ write(`${d.invariant} ${d.path}: ${d.message}`);
8303
+ return 2;
8304
+ }
8305
+ // ── numeric options ride the Number.isFinite clamp (every numeric config clamp needs it) ──
8306
+ const num = (key, scale = 1) => {
8307
+ const raw = options.get(key);
8308
+ if (raw === undefined)
8309
+ return { ok: true, value: null };
8310
+ const n = Number(raw);
8311
+ if (!Number.isFinite(n) || n < 0)
8312
+ return { ok: false, why: `--${key} must be a finite non-negative number (got ${JSON.stringify(raw)})` };
8313
+ return { ok: true, value: Math.floor(n * scale) };
8314
+ };
8315
+ const nums = {};
8316
+ for (const [key, scale] of [['budget', 1], ['max-wall-clock', 1000], ['stage-timeout', 1000], ['budget-extra', 1], ['wall-clock-extra', 1000]]) {
8317
+ const r = num(key, scale);
8318
+ if (!r.ok)
8319
+ return usageError(r.why);
8320
+ nums[key] = r.value;
8321
+ }
8322
+ const familyOpt = (key) => {
8323
+ const raw = options.get(key);
8324
+ if (raw === undefined)
8325
+ return { ok: true, value: null };
8326
+ if (raw !== 'codex' && raw !== 'openai' && raw !== 'claude')
8327
+ return { ok: false, why: `--${key} must be codex or claude (got ${JSON.stringify(raw)})` };
8328
+ return { ok: true, value: modelFamily(raw) };
8329
+ };
8330
+ const coder = familyOpt('coder-family');
8331
+ if (!coder.ok)
8332
+ return usageError(coder.why);
8333
+ const dflt = familyOpt('default-family');
8334
+ if (!dflt.ok)
8335
+ return usageError(dflt.why);
8336
+ const resumeArgs = {};
8337
+ for (const kv of optionLists.get('arg') ?? []) {
8338
+ const eq = kv.indexOf('=');
8339
+ if (eq <= 0)
8340
+ return usageError(`--arg must be k=v (got ${JSON.stringify(kv)})`);
8341
+ resumeArgs[kv.slice(0, eq)] = kv.slice(eq + 1);
8342
+ }
8343
+ const resumeId = options.get('resume') ?? null;
8344
+ const runId = options.get('run-id') ?? resumeId ?? `${parsed.name}-${randomBytes(2).toString('hex')}`;
8345
+ if (!TRACE_RUNID_RE.test(runId))
8346
+ return usageError(`runId ${JSON.stringify(runId)} fails ${String(TRACE_RUNID_RE)}`);
8347
+ if (resumeId !== null && options.get('run-id') !== undefined && options.get('run-id') !== resumeId) {
8348
+ return usageError('--run-id and --resume name different runs — a resume continues the run it names');
8349
+ }
8350
+ const runDirOpt = options.get('run-dir');
8351
+ let runDir;
8352
+ if (runDirOpt !== undefined) {
8353
+ const contained = containedUnderRoot(root, runDirOpt);
8354
+ if (!contained.ok)
8355
+ return usageError(`--run-dir ${contained.why}`);
8356
+ runDir = contained.path;
8357
+ }
8358
+ else {
8359
+ runDir = join(root, '.dz', 'loop-trace', runId); // the addressing `dz workflow-trace --run <id>` already uses
8360
+ }
8361
+ // The scripted-seam marker is established BEFORE the first refusal can emit (re-QE MINOR): an
8362
+ // EARLY refusal is still a run that would have dispatched to no real model, and a reader of that
8363
+ // envelope has the same right to know as a reader of a completed one.
8364
+ const seamScriptPath = process.env[WF_RUN_DISPATCH_SCRIPT_ENV];
8365
+ const dispatcherOverride = typeof seamScriptPath === 'string' && seamScriptPath !== '';
8366
+ const emit = (payload) => {
8367
+ write(JSON.stringify(dispatcherOverride ? { ...payload, dispatcherOverride: true } : payload));
8368
+ };
8369
+ // ── AM-7 ownership, ATOMICALLY (Step-8 HIGH-6) ──
8370
+ //
8371
+ // The previous shape read the owner marker, decided, and wrote it later — a window in which two
8372
+ // processes both saw "no live owner" and both proceeded. And the write itself was wrapped in a
8373
+ // swallowing try, so a run could execute while its durable claim silently did not exist.
8374
+ //
8375
+ // Now: check and claim happen INSIDE the named lock, the claim is `wx` (create-or-fail, so the
8376
+ // filesystem itself arbitrates), a STALE marker is only replaced under that same lock, and a
8377
+ // failure to claim FAILS THE RUN. One writer per run is not a convention here; it is an atomic
8378
+ // filesystem operation.
8379
+ const ownerPath = join(runDir, WF_RUN_OWNER_FILE);
8380
+ const startedMarker = new Date().toISOString();
8381
+ const RUN_ARTIFACTS = [WF_RUN_STATE_FILE, 'trace.jsonl', 'budget.jsonl', join('.fa-state', 'checkpoints.jsonl')];
8382
+ const claim = withNamedLockSync(root, `wf-run-${runId}`, () => {
8383
+ mkdirSync(runDir, { recursive: true });
8384
+ // ORDER MATTERS. A LIVE owner is `run-locked` whichever kind of invocation this is — that is
8385
+ // the precise fact, and it outranks "the directory has files in it". Only then does a FRESH run
8386
+ // refuse a directory that already holds a run's artifacts.
8387
+ const existingOwner = wfReadJson(ownerPath);
8388
+ if (existingOwner !== null && typeof existingOwner.pid === 'number' && wfPidAlive(existingOwner.pid)) {
8389
+ return { ok: false, reason: 'run-locked', detail: `another dz workflow run (pid ${existingOwner.pid}, started ${existingOwner.startedMarker ?? 'unknown'}) owns ${runDir} — ONE writer per run, always` };
8390
+ }
8391
+ if (resumeId === null) {
8392
+ // a FRESH run may not write into a directory that already holds ANY artifact of a run — not
8393
+ // merely one with a readable state file (a HALF-written run is exactly the dangerous case).
8394
+ // The owner marker is excluded: a dead one is stale residue, handled by the claim below.
8395
+ const found = RUN_ARTIFACTS.filter((rel) => existsSync(join(runDir, rel)));
8396
+ if (found.length > 0) {
8397
+ return {
8398
+ ok: false,
8399
+ reason: 'run-exists',
8400
+ detail: `run directory ${runDir} already holds ${found.join(', ')} — pass --resume ${runId} to continue it, or choose another --run-id. A fresh run never writes into an existing run's artifacts`,
8401
+ };
8402
+ }
8403
+ }
8404
+ for (let attempt = 0; attempt < 2; attempt++) {
8405
+ try {
8406
+ writeFileSync(ownerPath, JSON.stringify({ host: WF_RUN_OWNER_HOST, pid: process.pid, runnerVersion: dzOwnVersion(), startedMarker }, null, 2) + '\n', { flag: 'wx' });
8407
+ return { ok: true };
8408
+ }
8409
+ catch (e) {
8410
+ if (e.code !== 'EEXIST') {
8411
+ return { ok: false, reason: 'run-locked', detail: `cannot claim ${ownerPath}: ${e instanceof Error ? e.message : String(e)} — refusing to run without a durable owner record` };
8412
+ }
8413
+ const held = wfReadJson(ownerPath);
8414
+ if (held !== null && typeof held.pid === 'number' && wfPidAlive(held.pid)) {
8415
+ return { ok: false, reason: 'run-locked', detail: `another dz workflow run (pid ${held.pid}, started ${held.startedMarker ?? 'unknown'}) owns ${runDir} — ONE writer per run, always` };
8416
+ }
8417
+ // STALE: the recorded owner is gone. Replacing it is safe HERE and only here, because this
8418
+ // whole block holds the named lock, so no concurrent claimant can be mid-decision.
8419
+ try {
8420
+ unlinkSync(ownerPath);
8421
+ }
8422
+ catch {
8423
+ /* someone else just cleared it; the retry's `wx` decides */
8424
+ }
8425
+ }
8426
+ }
8427
+ return { ok: false, reason: 'run-locked', detail: `could not claim ${ownerPath} after replacing a stale marker — another writer is racing for this run` };
8428
+ });
8429
+ if (!claim.ok) {
8430
+ if (json)
8431
+ emit({ schema: 'wf-run-result/1', runId, status: 'failed', reason: claim.reason, exitCode: 1 });
8432
+ else
8433
+ write(`dz workflow run: ${claim.reason} — ${claim.detail}`);
8434
+ return 1;
8435
+ }
8436
+ // ── NEW-H (re-QE): from HERE to the end, every exit path — return, throw, or completion — runs
8437
+ // the cleanup. Round 1 opened the try only around `runWorkflow`, so a `usageError` return or a
8438
+ // throw while CONSTRUCTING the dispatchers (a malformed seam file is enough) left `run-owner.json`
8439
+ // behind: a durable claim held by a process that had already exited.
8440
+ try {
8441
+ const targetCwd = root;
8442
+ const inputs = {
8443
+ plan: parsed,
8444
+ runId,
8445
+ coderFamily: coder.value ?? 'claude',
8446
+ allowSameFamilyQe: flags.has('allow-same-family-qe'),
8447
+ defaultFamily: dflt.value,
8448
+ budgetOverride: nums['budget'] ?? null,
8449
+ maxWallClockMsOverride: nums['max-wall-clock'] ?? null,
8450
+ stageTimeoutMsOverride: nums['stage-timeout'] ?? null,
8451
+ resume: resumeId,
8452
+ resumeArgs,
8453
+ budgetExtra: nums['budget-extra'] ?? null,
8454
+ wallClockExtraMs: nums['wall-clock-extra'] ?? null,
8455
+ runnerVersion: dzOwnVersion(),
8456
+ cwdRoot: targetCwd,
8457
+ };
8458
+ const pre = preflight(inputs, {
8459
+ realpath: (p) => { try {
8460
+ return realpathSync(p);
8461
+ }
8462
+ catch {
8463
+ return null;
8464
+ } },
8465
+ exists: (p) => existsSync(p),
8466
+ });
8467
+ if (!pre.ok) {
8468
+ if (json)
8469
+ emit({ schema: 'wf-run-result/1', runId, status: 'failed', reason: pre.reason, exitCode: 1 });
8470
+ else
8471
+ write(`dz workflow run: ${pre.reason} — ${pre.detail}`);
8472
+ return 1;
8473
+ }
8474
+ // ── dispatchers: the real adapters, or the scripted env TEST SEAM ──
8475
+ const scriptPath = seamScriptPath;
8476
+ let dispatchers;
8477
+ if (dispatcherOverride) {
8478
+ if (!existsSync(scriptPath))
8479
+ return usageError(`${WF_RUN_DISPATCH_SCRIPT_ENV}=${String(scriptPath)} does not exist`);
8480
+ dispatchers = { claude: wfScriptedDispatcher(scriptPath, 'claude'), openai: wfScriptedDispatcher(scriptPath, 'openai') };
8481
+ }
8482
+ else {
8483
+ wfInstallKillHandlers();
8484
+ const isolated = mkdtempSync(join(tmpdir(), 'dz-wf-run-'));
8485
+ const monotonicMs = () => Number(process.hrtime.bigint() / 1000000n);
8486
+ dispatchers = {
8487
+ claude: makeClaudePDispatcher(wfChildRunnerFor('claude'), { isolatedCwd: () => isolated, monotonicMs }),
8488
+ openai: makeCodexExecDispatcher(wfChildRunnerFor('openai'), { isolatedCwd: () => isolated, monotonicMs }),
8489
+ };
8490
+ }
8491
+ const store = wfMakeStore(runDir, root, targetCwd);
8492
+ const deps = {
8493
+ store,
8494
+ dispatchers,
8495
+ lock: (fn) => withNamedLockSync(root, `wf-run-${runId}`, fn),
8496
+ now: () => new Date().toISOString(),
8497
+ monotonicMs: () => Number(process.hrtime.bigint() / 1000000n),
8498
+ dispatcherOverride,
8499
+ planPath: relative(root, absPlan) || planPath,
8500
+ slug: parsed.name,
8501
+ // the envelope must point at THIS run's state file and reproduce THIS run's flags (HIGH-7)
8502
+ runStatePath: relative(root, join(runDir, WF_RUN_STATE_FILE)) || join(runDir, WF_RUN_STATE_FILE),
8503
+ runDirArg: runDirOpt ?? null,
8504
+ // the run-state owner records the process that actually holds the claim (HIGH-6) — `pid: 0`
8505
+ // was a durable record of a process that never existed
8506
+ ownerPid: process.pid,
8507
+ ownerStartedMarker: startedMarker,
8508
+ };
8509
+ const outcome = await runWorkflow(inputs, pre, deps);
8510
+ // A run that dispatched to NO REAL MODEL says so on every channel (Step-8 MEDIUM-11): the
8511
+ // result/pause envelope carries `dispatcherOverride`, and the human line says it in words. A seam
8512
+ // visible only inside a state file is a seam a reader of the output cannot know about.
8513
+ const seamNote = dispatcherOverride ? ' [SCRIPTED DISPATCHER — no real model ran]' : '';
8514
+ if (outcome.kind === 'paused') {
8515
+ if (!json)
8516
+ write(`dz workflow run: PAUSED (${outcome.envelope.pauseState})${seamNote} — resume with: ${outcome.envelope.resumeCmd}`);
8517
+ // the envelope is the LAST stdout line, ALWAYS (AM-16)
8518
+ emit({ ...outcome.envelope, ...(dispatcherOverride ? { dispatcherOverride: true } : {}) });
8519
+ return 75;
8520
+ }
8521
+ if (outcome.kind === 'failed') {
8522
+ if (!json)
8523
+ write(`dz workflow run: ${outcome.reason}${seamNote} — ${outcome.detail}`);
8524
+ emit(outcome.result); // a wf-run-result/1 line — and NEVER a pause envelope
8525
+ return 1;
8526
+ }
8527
+ if (!json) {
8528
+ const terminal = outcome.result.terminalRoute === undefined ? '' : ` via the plan's terminal route ${outcome.result.terminalRoute}`;
8529
+ write(`dz workflow run: completed (${runId})${terminal}${seamNote} — trace at ${join(relative(root, runDir) || '.', 'trace.jsonl')}`);
8530
+ }
8531
+ emit(outcome.result);
8532
+ return 0;
8533
+ }
8534
+ finally {
8535
+ // the claim is released exactly once, on EVERY path out of the claimed region
8536
+ try {
8537
+ unlinkSync(ownerPath);
8538
+ }
8539
+ catch { /* already gone */ }
8540
+ wfKillLiveChildren();
8541
+ }
8542
+ }
7664
8543
  function scanReqeDebts(root) {
7665
8544
  const out = [];
7666
8545
  let malformed = 0;
7667
- const featuresDir = join(root, 'features');
8546
+ /**
8547
+ * TWO scan roots (K1 — feature dz-workflow-run):
8548
+ * `features/<slug>/.fa-state/` — the feature-adr home, the original root;
8549
+ * `.dz/loop-trace/<runId>/.fa-state/` — the DEFAULT home of a `dz workflow run` (ADR-003).
8550
+ * Without the second root the ADR-002 waiver promise ("a waived run's debt is surfaced by
8551
+ * `dz reqe`") is FALSE for every default-homed loop run — the record would be written to a
8552
+ * directory nothing ever reads. The scan's own rules are unchanged: symlinked containers are
8553
+ * skipped, an oversize or non-plain file is NAMED as malformed rather than silently dropped, and
8554
+ * a debt whose `slug` disagrees with its directory is malformed too.
8555
+ */
8556
+ const scanRoots = [
8557
+ { base: join(root, 'features'), keyMatchesDir: true },
8558
+ // a loop run's directory is its runId; the debt's `slug` is the PLAN's name, so the two need
8559
+ // not agree — the identity check that applies under features/ does not apply here
8560
+ { base: join(root, '.dz', 'loop-trace'), keyMatchesDir: false },
8561
+ ];
8562
+ for (const scanRoot of scanRoots)
8563
+ scanOneReqeRoot(scanRoot.base, scanRoot.keyMatchesDir, out, (n) => { malformed += n; });
8564
+ return { debts: out, malformed };
8565
+ }
8566
+ /** One scan root's walk — extracted verbatim from the original single-root body (K1). */
8567
+ function scanOneReqeRoot(featuresDir, keyMatchesDir, out, addMalformed) {
8568
+ let malformed = 0;
7668
8569
  let slugs = [];
7669
8570
  try {
7670
8571
  if (lstatSync(featuresDir).isSymbolicLink())
7671
- return { debts: [], malformed: 0 }; // r2 #4: features/ itself
8572
+ return; // r2 #4: the container itself
7672
8573
  slugs = readdirSync(featuresDir);
7673
8574
  }
7674
8575
  catch {
7675
- return { debts: [], malformed: 0 };
8576
+ return;
7676
8577
  }
7677
8578
  for (const slug of slugs.sort()) {
7678
8579
  const dir = join(featuresDir, slug);
@@ -7698,7 +8599,7 @@ function scanReqeDebts(root) {
7698
8599
  }
7699
8600
  try {
7700
8601
  const debt = parseReqeDebt(readFileSync(duePath, 'utf-8'));
7701
- if (debt && debt.slug === slug)
8602
+ if (debt && (!keyMatchesDir || debt.slug === slug))
7702
8603
  out.push({ debt, duePath, dir });
7703
8604
  else
7704
8605
  malformed++;
@@ -7707,7 +8608,7 @@ function scanReqeDebts(root) {
7707
8608
  malformed++;
7708
8609
  }
7709
8610
  }
7710
- return { debts: out, malformed };
8611
+ addMalformed(malformed);
7711
8612
  }
7712
8613
  /**
7713
8614
  * `dz reqe` — the re-QE debt ledger (backlog 6b40e667): list usage-switched same-family QE debts,
@@ -7863,6 +8764,711 @@ function cmdReqe(options, flags, cwd, write) {
7863
8764
  write(json ? JSON.stringify({ settled: true, grade: settlement.grade, exitCode: 0 }) : `dz reqe: ${okMsg}`);
7864
8765
  return 0;
7865
8766
  }
8767
+ /* -------------------------------------------------------------------------- */
8768
+ /* `dz qe-bridge` — the reverse QE bridge (feature qe-bridge-claude, ADR-001) */
8769
+ /* -------------------------------------------------------------------------- */
8770
+ /** Review timeout default: an adversarial QE pass legitimately takes minutes (NFR-3). */
8771
+ const QE_BRIDGE_DEFAULT_TIMEOUT_S = 600;
8772
+ const QE_BRIDGE_MIN_TIMEOUT_S = 30;
8773
+ const QE_BRIDGE_MAX_TIMEOUT_S = 3600;
8774
+ /** Probe timeout — the mirror of `codexProbeCommand`'s `timeout 60`. */
8775
+ const QE_BRIDGE_PROBE_TIMEOUT_MS = 60_000;
8776
+ /**
8777
+ * Run one `claude` call with the prompt on STDIN. Spawn-injectable, and NEVER throws: a missing
8778
+ * binary, a crash and a hang all come back as DATA, because the taxonomy above them can only name
8779
+ * a failure it is handed. (The first draft of this function let the ENOENT escape as an uncaught
8780
+ * exception and the command never settled — the acid A1 red, quoted in red-green.md.)
8781
+ *
8782
+ * Mirrors `probeContent`'s settled-flag + SIGTERM deadline shape (`cli.ts` probes) and scrubs
8783
+ * `PROBE_SCRUB_ENV`, so a bridge launched from inside a nested Claude session cannot inherit the
8784
+ * parent's session identity (SEC-4).
8785
+ */
8786
+ export async function runClaudeBridge(bin, argv, promptStdin, timeoutMs, cwd = process.cwd(), spawnImpl = spawn) {
8787
+ // A THIN WRAPPER over runChildBridge since the loop runner needed the same machinery with two
8788
+ // extra knobs. This signature is consumed by the qe-bridge suites and MUST NOT change.
8789
+ return runChildBridge(bin, argv, { stdinText: promptStdin, timeoutMs, cwd, detached: false, spawnImpl });
8790
+ }
8791
+ /** How long a child's process group gets to honour SIGTERM before SIGKILL (Step-8 MEDIUM-14). */
8792
+ const CHILD_SIGKILL_GRACE_MS = 2000;
8793
+ /**
8794
+ * The MINIMAL environment a dispatched child gets (Step-8 HIGH-9).
8795
+ *
8796
+ * A deny-list removes what somebody remembered; an allow-list carries what the child needs and
8797
+ * nothing else. The named set is deliberately boring — enough for a binary to find itself, resolve
8798
+ * a home directory, write a temp file and talk to a proxy — plus each runtime's own credential
8799
+ * variables, which are listed because they are REQUIRED, not because they happened to be present.
8800
+ * Anything a future adapter needs is added HERE, visibly, with a reason.
8801
+ */
8802
+ const CHILD_ENV_BASE = [
8803
+ 'PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL', 'LANG', 'LC_ALL', 'TZ',
8804
+ 'TMPDIR', 'TEMP', 'TMP',
8805
+ 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy',
8806
+ // node itself, for a child that IS node
8807
+ 'NODE_EXTRA_CA_CERTS',
8808
+ ];
8809
+ /**
8810
+ * CREDENTIALS ARE PER FAMILY (Step-8 re-QE H9 — the round-1 allowlist shipped BOTH sets to BOTH
8811
+ * runtimes, which is a shorter list of the same mistake).
8812
+ *
8813
+ * A codex dispatch has no business holding an Anthropic key, and vice versa. The two runtimes are
8814
+ * separate blast radii precisely because the cross-model rule makes them review each other: if one
8815
+ * is compromised or simply misbehaves, it must not be carrying the other's credentials. The base
8816
+ * above is boring on purpose — enough to find a binary, a home directory and a proxy — and nothing
8817
+ * in it authenticates anything.
8818
+ */
8819
+ const CHILD_ENV_BY_FAMILY = {
8820
+ claude: ['ANTHROPIC_API_KEY', 'CLAUDE_CONFIG_DIR'],
8821
+ openai: ['OPENAI_API_KEY', 'CODEX_HOME'],
8822
+ };
8823
+ function buildAllowlistEnv(extra, parent = process.env) {
8824
+ const out = {};
8825
+ for (const key of [...CHILD_ENV_BASE, ...extra]) {
8826
+ const v = parent[key];
8827
+ if (typeof v === 'string')
8828
+ out[key] = v;
8829
+ }
8830
+ return out;
8831
+ }
8832
+ /** Test seam for H9: the exact environment ONE family's child would receive. */
8833
+ export function __wfChildEnvTestSeam(family, parent) {
8834
+ return buildAllowlistEnv(CHILD_ENV_BY_FAMILY[family], parent);
8835
+ }
8836
+ /**
8837
+ * THE child-process wrapper both the qe-bridge and the loop runner ride (ADR-002 O1: ONE impure
8838
+ * wrapper, not two). Generalized from `runClaudeBridge` with the same guarantees — a settled flag so
8839
+ * no path resolves twice, a deadline timer that SIGTERMs, the `PROBE_SCRUB_ENV` scrub so a bridge
8840
+ * launched from inside a nested Claude session cannot inherit it, and an injectable `spawnImpl` —
8841
+ * plus the two knobs the generalization adds:
8842
+ *
8843
+ * • `stdinText: null` ⇒ `stdio[0] = 'ignore'`. MEASURED: codex-cli 0.148.0 prints
8844
+ * `Reading additional input from stdin...` and WAITS when stdin is left open. Passing an empty
8845
+ * string is not the same thing as closing it.
8846
+ * • `detached: true` ⇒ the child leads its OWN process group, so the runner can kill the whole
8847
+ * group (AM-10). `onSpawn` hands the live child to the caller's registry at the only moment the
8848
+ * pid is knowable.
8849
+ *
8850
+ * Never throws: a spawn failure resolves with `spawnError` set, exactly like the original.
8851
+ */
8852
+ export async function runChildBridge(bin, argv, opts) {
8853
+ const spawnImpl = opts.spawnImpl ?? spawn;
8854
+ return new Promise((resolveRun) => {
8855
+ const env = opts.envMode === 'allowlist'
8856
+ ? buildAllowlistEnv(opts.envExtra ?? [])
8857
+ : { ...process.env };
8858
+ if (opts.envMode !== 'allowlist')
8859
+ for (const key of PROBE_SCRUB_ENV)
8860
+ delete env[key];
8861
+ let out = '';
8862
+ let err = '';
8863
+ let settled = false;
8864
+ let timedOut = false;
8865
+ let child;
8866
+ const finish = (spawnError, exitCode) => {
8867
+ if (settled)
8868
+ return;
8869
+ settled = true;
8870
+ clearTimeout(timer);
8871
+ // A DETACHED child leads its own group, so kill the GROUP — killing the leader alone leaves
8872
+ // whatever it spawned running (the orphan class AM-10 exists to close). SIGTERM is a REQUEST;
8873
+ // a group that ignores it would outlive the runner, so a bounded grace period later the same
8874
+ // group gets SIGKILL, which is not a request (Step-8 MEDIUM-14).
8875
+ // EVERY termination goes through the one guarded chokepoint (re-QE NEW-C4) — there is no
8876
+ // second call shape here to forget to guard.
8877
+ const sent = signalChildSafely(child, 'SIGTERM', opts.detached);
8878
+ if (sent) {
8879
+ const escalation = setTimeout(() => {
8880
+ signalChildSafely(child, 'SIGKILL', opts.detached);
8881
+ }, CHILD_SIGKILL_GRACE_MS);
8882
+ escalation.unref?.(); // the grace timer must never hold the runner's event loop open
8883
+ }
8884
+ resolveRun({ stdout: out, stderr: err, exitCode, timedOut, spawnError });
8885
+ };
8886
+ const timer = setTimeout(() => {
8887
+ timedOut = true;
8888
+ finish(null, null);
8889
+ }, opts.timeoutMs);
8890
+ try {
8891
+ child = spawnImpl(bin, argv, {
8892
+ cwd: opts.cwd,
8893
+ env,
8894
+ detached: opts.detached,
8895
+ stdio: [opts.stdinText === null ? 'ignore' : 'pipe', 'pipe', 'pipe'],
8896
+ });
8897
+ }
8898
+ catch (error) {
8899
+ finish(`cannot run \`${bin}\`: ${error instanceof Error ? error.message : String(error)}`, null);
8900
+ return;
8901
+ }
8902
+ opts.onSpawn?.(child);
8903
+ child.on('error', (error) => finish(`cannot run \`${bin}\`: ${error.message}`, null));
8904
+ child.stdout?.on('data', (c) => { out += c.toString(); });
8905
+ child.stderr?.on('data', (c) => { err += c.toString(); });
8906
+ child.on('close', (code) => finish(null, code));
8907
+ // EPIPE when the child died before reading: already reported through 'error'/'close'.
8908
+ child.stdin?.on('error', () => { });
8909
+ if (opts.stdinText !== null) {
8910
+ try {
8911
+ child.stdin?.write(opts.stdinText);
8912
+ child.stdin?.end();
8913
+ }
8914
+ catch {
8915
+ /* the close/error handlers decide the outcome */
8916
+ }
8917
+ }
8918
+ });
8919
+ }
8920
+ /** stderr wording that PROVES a login problem. Anything else stays unclassified: a guessed reason
8921
+ * is a small lie, and the failure record carries the raw evidence instead (ADR-001 D3-A). */
8922
+ function classifyClaudeStderr(stderr) {
8923
+ return /invalid api key|not logged in|please run \/login|unauthorized|authentication (failed|error)|oauth token (has )?expired/i.test(stderr)
8924
+ ? 'claude-not-logged-in'
8925
+ : 'exit-nonzero';
8926
+ }
8927
+ /** Test seam AND escape hatch for a non-standard install: the executable the bridge spawns.
8928
+ * Deliberately an ENV VAR and not a flag — a reviewer's identity should not be something a caller
8929
+ * can redirect with a casual command-line switch, and every record says loudly when it was used
8930
+ * (round-2 M3). */
8931
+ const QE_BRIDGE_CLAUDE_BIN_ENV = 'DZ_QE_BRIDGE_CLAUDE_BIN';
8932
+ /**
8933
+ * CRASH FAILPOINT (round-4 R4-1) — test-only, and the ONLY thing it can do is stop. Set
8934
+ * `DZ_QE_BRIDGE_FAILPOINT=hang-before-rename` and the process blocks after the temp record is
8935
+ * written and before the rename, so a test can SIGKILL it exactly inside the window the atomic
8936
+ * update exists to close. Unset (the normal case) it is one string comparison and no behaviour.
8937
+ * A crash-window property that no test can enter is a claim, not a guarantee.
8938
+ */
8939
+ const QE_BRIDGE_FAILPOINT_ENV = 'DZ_QE_BRIDGE_FAILPOINT';
8940
+ /** Files under `.fa-state/qe-bridge/` may quote reviewed source and reviewer prose: owner-only. */
8941
+ const RECORD_FILE_MODE = 0o600;
8942
+ const RECORD_DIR_MODE = 0o700;
8943
+ /**
8944
+ * Write a NEW file, never through a symlink, never over an existing one, and never world-readable.
8945
+ * `wx` gives O_EXCL (no overwrite, no symlink follow at the final component); the explicit chmods
8946
+ * defeat the process umask, which `mode:` alone does not (MEASURED: under umask 022 the round-1
8947
+ * writes landed 0644/0755 — round-2 MAJOR M6).
8948
+ */
8949
+ function writeNewFileOrThrow(path, content, mode = RECORD_FILE_MODE) {
8950
+ const dir = dirname(path);
8951
+ mkdirSync(dir, { recursive: true, mode: RECORD_DIR_MODE });
8952
+ try {
8953
+ chmodSync(dir, RECORD_DIR_MODE);
8954
+ }
8955
+ catch { /* not ours to tighten (a pre-existing shared dir) — the file mode below still applies */ }
8956
+ writeFileSync(path, content, { flag: 'wx', mode });
8957
+ chmodSync(path, mode);
8958
+ }
8959
+ /**
8960
+ * Path containment that survives a symlinked PARENT (round-2 M6). Lexical `startsWith` is not
8961
+ * containment: `features/x/` can be a symlink to `/etc`, and `wx` only refuses a symlink at the
8962
+ * FINAL component. So: walk from the deepest EXISTING ancestor, realpath it, and require the result
8963
+ * to stay under the realpath of the root — and refuse outright if any existing component on the way
8964
+ * is a symlink.
8965
+ */
8966
+ function containedUnderRoot(root, target) {
8967
+ let realRoot;
8968
+ try {
8969
+ realRoot = realpathSync(root);
8970
+ }
8971
+ catch {
8972
+ return { ok: false, why: `the project root ${root} does not resolve` };
8973
+ }
8974
+ const abs = resolve(root, target);
8975
+ const rel = relative(realRoot, abs);
8976
+ if (rel === '' || rel.startsWith('..') || isAbsolute(rel))
8977
+ return { ok: false, why: `${target} resolves outside the project root` };
8978
+ // deepest existing ancestor, with every existing component checked for a symlink
8979
+ const parts = rel.split(sep).filter((p) => p !== '');
8980
+ let walked = realRoot;
8981
+ for (const part of parts) {
8982
+ const next = join(walked, part);
8983
+ let st;
8984
+ try {
8985
+ st = lstatSync(next);
8986
+ }
8987
+ catch {
8988
+ break; // this component does not exist yet: nothing below it can be a symlink either
8989
+ }
8990
+ if (st.isSymbolicLink())
8991
+ return { ok: false, why: `${target} passes through the symlink ${relative(realRoot, next)} — refusing (a symlinked parent can redirect a new file out of the repository)` };
8992
+ walked = next;
8993
+ }
8994
+ let realWalked;
8995
+ try {
8996
+ realWalked = realpathSync(walked);
8997
+ }
8998
+ catch {
8999
+ return { ok: false, why: `${target} has an unresolvable parent` };
9000
+ }
9001
+ const realRel = relative(realRoot, realWalked);
9002
+ if (realRel.startsWith('..') || isAbsolute(realRel))
9003
+ return { ok: false, why: `${target} escapes the project root through its parent directories` };
9004
+ return { ok: true, path: abs };
9005
+ }
9006
+ /**
9007
+ * `dz qe-bridge --family claude` — run a Claude reviewer over Step-8-scoped inputs and land a
9008
+ * PARSED signoff (feature qe-bridge-claude, ADR-001).
9009
+ *
9010
+ * The point of the command: when CODEX hosts the run there is no Claude agent plane to dispatch
9011
+ * from, so the cross-family QE rule has no vehicle — `buildReqeBrief` hands the human a `null`
9012
+ * command template for exactly this case. This is that vehicle, callable from a plain shell.
9013
+ *
9014
+ * ISOLATION (round-2 CRITICAL C1): both calls run from an EMPTY temporary directory with the
9015
+ * runtime's own `--safe-mode --strict-mcp-config --tools '' --no-session-persistence`, and the
9016
+ * verdict is read out of the `--output-format json` result envelope. Without that, the reviewer is a
9017
+ * fully customized session running INSIDE the repository under review, and hooks/plugins can print a
9018
+ * complete signoff onto the same stdout the parser reads (MEASURED — see red-green.md).
9019
+ *
9020
+ * Exit codes: 0 = a signoff was PARSED (any grade — the bridge reports, `dz reqe` gates),
9021
+ * 1 = a NAMED failure (record in `.fa-state/qe-bridge/failed-*.json`, raw output beside it,
9022
+ * never at `--out`), 2 = a usage error (nothing spawned, nothing written).
9023
+ */
9024
+ async function cmdQeBridge(options, flags, cwd, write) {
9025
+ const json = flags.has('json');
9026
+ const usage = 'dz qe-bridge --family claude --slug <feature> [--coder-family codex|claude] [--model <id>] [--files a,b] [--out <file>] [--timeout <s>] [--allow-same-family] [--project <dir>] [--json]';
9027
+ const usageError = (message) => {
9028
+ write(json ? JSON.stringify({ ok: false, error: message, exitCode: 2 }) : `dz qe-bridge: ${message}\n${usage}`);
9029
+ return 2;
9030
+ };
9031
+ if (flags.has('help')) {
9032
+ if (json) {
9033
+ write(JSON.stringify({ help: usage, exitCode: 0 }));
9034
+ return 0;
9035
+ }
9036
+ write(usage);
9037
+ write(' Runs a CLAUDE reviewer over a feature’s Step-8 artifacts from ANY host (a Codex session included)');
9038
+ write(' and writes a parsed SIGNOFF. Exit 0 = a signoff was parsed (ANY grade — the bridge reports, it does');
9039
+ write(' not gate); 1 = a named failure (see features/<slug>/.fa-state/qe-bridge/failed-*.json); 2 = usage.');
9040
+ write(' --family codex is reserved: the forward bridge is `codex exec` (see .claude/rules/feature-adr-conventions.md).');
9041
+ write(' Default report: features/<slug>/08b_reqe_report.md — settle it with');
9042
+ write(' dz reqe --slug <feature> --done --report features/<feature>/08b_reqe_report.md');
9043
+ write(' DISCLOSURE: the bridge sends the extracts you scope (--files, plus the feature’s manifest/ADR/QE report)');
9044
+ write(' to the Claude runtime. It cannot classify secrets — scoping the content you scope is YOUR decision (SEC-5).');
9045
+ return 0;
9046
+ }
9047
+ const ALLOWED_FLAGS = new Set(['json', 'help', 'allow-same-family']);
9048
+ for (const flag of flags) {
9049
+ if (!ALLOWED_FLAGS.has(flag)) {
9050
+ return usageError(`unknown option --${flag}` + (['model', 'slug', 'family', 'out', 'files', 'timeout', 'coder-family', 'project'].includes(flag) ? ` (it takes a value: --${flag} <value>)` : ''));
9051
+ }
9052
+ }
9053
+ const ALLOWED_OPTIONS = new Set(['family', 'slug', 'coder-family', 'model', 'files', 'out', 'timeout', 'project']);
9054
+ for (const key of options.keys()) {
9055
+ if (key.startsWith('_positional_'))
9056
+ return usageError(`unexpected argument "${options.get(key)}"`);
9057
+ if (key === 'claude-bin')
9058
+ return usageError(`--claude-bin was removed in favour of the ${QE_BRIDGE_CLAUDE_BIN_ENV} environment variable — a TEST SEAM, recorded loudly in every signoff (binOverride:true). Who reviews is not a casual command-line switch.`);
9059
+ if (!ALLOWED_OPTIONS.has(key))
9060
+ return usageError(`unknown option --${key}`);
9061
+ }
9062
+ // ── family (the reserved codex direction errors with a pointer, never a silent alias) ──
9063
+ const family = options.get('family');
9064
+ if (family === undefined)
9065
+ return usageError('--family claude is required');
9066
+ if (family === 'codex' || family === 'openai') {
9067
+ return usageError('--family codex is reserved — the FORWARD bridge already exists: dispatch `codex exec -m <probed-id> --sandbox read-only "<brief>" < /dev/null` (one bridge per direction; see .claude/rules/feature-adr-conventions.md)');
9068
+ }
9069
+ if (family !== 'claude')
9070
+ return usageError(`unsupported --family ${family} (this leg ships "claude" only)`);
9071
+ const slug = options.get('slug') ?? '';
9072
+ if (!isSafeSlug(slug))
9073
+ return usageError('a kebab-case --slug <feature> is required (no path separators, max 40 chars)');
9074
+ const root = resolve(cwd, options.get('project') ?? '.');
9075
+ const featureDir = join(root, 'features', slug);
9076
+ if (!existsSync(featureDir))
9077
+ return usageError(`no feature directory at features/${slug} — the bridge reviews an existing feature’s artifacts`);
9078
+ // ── coder family: the RECORDED DEBT is the authority; the flag may only fill a gap ──
9079
+ //
9080
+ // Round-2 MAJOR M3: round 1 let `--coder-family codex` override a debt that said `claude`, which
9081
+ // turns the loud `--allow-same-family` escape into an optional formality — a Claude-coded feature
9082
+ // could be Claude-reviewed by mis-declaring one flag. The debt is written by the pipeline; the
9083
+ // flag is written by whoever is running the command.
9084
+ let coderFamily = null;
9085
+ let coderFamilySource = 'flag';
9086
+ let recordedDebtFamily = null;
9087
+ const duePath = join(featureDir, '.fa-state', 'reqe-due.json');
9088
+ if (existsSync(duePath)) {
9089
+ try {
9090
+ const debt = parseReqeDebt(readFileSync(duePath, 'utf-8'));
9091
+ if (debt)
9092
+ recordedDebtFamily = debt.coderFamily;
9093
+ }
9094
+ catch { /* unreadable debt: treated as absent, and the flag must then be given */ }
9095
+ }
9096
+ const coderOpt = options.get('coder-family');
9097
+ if (coderOpt !== undefined) {
9098
+ // The FLAG surface stays a closed allowlist (the cmdQeBridge discipline — a flag is not a
9099
+ // place to accept whatever parses); the FAMILY behind it comes from the ONE mapper the loop
9100
+ // runner also uses for its same-family comparison (ADR-002 W20/AM-17). A second normalization
9101
+ // here is how a codex-coded run comes to be reviewed by codex under a claude label — the
9102
+ // agreement between the two call sites is pinned by a test, not by care.
9103
+ const asked = coderOpt === 'codex' || coderOpt === 'openai' || coderOpt === 'claude' ? modelFamily(coderOpt) : null;
9104
+ if (asked === null)
9105
+ return usageError(`--coder-family must be codex or claude (got "${coderOpt}")`);
9106
+ if (recordedDebtFamily !== null && recordedDebtFamily !== asked) {
9107
+ return usageError(`--coder-family ${coderOpt} contradicts the recorded debt at features/${slug}/.fa-state/reqe-due.json, which says the coder family was ${recordedDebtFamily}. ` +
9108
+ 'The debt is the authority: it was written by the run being reviewed, the flag by whoever is invoking this command. ' +
9109
+ 'Refusing rather than letting a flag re-label who wrote the code — that label is what decides whether this review is cross-family. ' +
9110
+ 'Fix the flag, or correct the debt file if IT is wrong.');
9111
+ }
9112
+ coderFamily = asked;
9113
+ }
9114
+ else if (recordedDebtFamily !== null) {
9115
+ coderFamily = recordedDebtFamily;
9116
+ coderFamilySource = 'reqe-due.json';
9117
+ }
9118
+ if (coderFamily === null) {
9119
+ return usageError(`--coder-family codex|claude is required (no readable re-QE debt at features/${slug}/.fa-state/reqe-due.json to read it from) — who WROTE the code decides whether this review is cross-family`);
9120
+ }
9121
+ // ── --out: under the repo, no traversal, no control characters, no symlinked parents ──
9122
+ const outOpt = options.get('out') ?? join('features', slug, '08b_reqe_report.md');
9123
+ if (hasUnsafePathChars(outOpt) || hasDotDotSegment(outOpt))
9124
+ return usageError('--out must not contain control characters or ".." segments');
9125
+ const outCheck = containedUnderRoot(root, outOpt);
9126
+ if (!outCheck.ok)
9127
+ return usageError(`--out ${outCheck.why}`);
9128
+ const outPath = outCheck.path;
9129
+ // ── timeouts: Number.isFinite-safe clamp (the numeric-clamp lesson) ──
9130
+ let timeoutS = QE_BRIDGE_DEFAULT_TIMEOUT_S;
9131
+ const timeoutRaw = options.get('timeout');
9132
+ if (timeoutRaw !== undefined) {
9133
+ const n = Number(timeoutRaw);
9134
+ if (!Number.isFinite(n))
9135
+ return usageError(`--timeout must be a number of seconds (got "${timeoutRaw}")`);
9136
+ timeoutS = Math.min(QE_BRIDGE_MAX_TIMEOUT_S, Math.max(QE_BRIDGE_MIN_TIMEOUT_S, Math.floor(n)));
9137
+ }
9138
+ // ── model candidates: an allowlist says a name is spellable, only the probe says it answers ──
9139
+ const modelOpt = options.get('model');
9140
+ if (modelOpt !== undefined && !isSafeClaudeId(modelOpt)) {
9141
+ return usageError(`unsafe --model id "${modelOpt}" — ids must match /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/ (a leading "-" would become an option)`);
9142
+ }
9143
+ const candidates = modelOpt !== undefined ? [modelOpt] : Object.keys(KNOWN_CLAUDE);
9144
+ const binRaw = process.env[QE_BRIDGE_CLAUDE_BIN_ENV];
9145
+ const binOverride = typeof binRaw === 'string' && binRaw.trim() !== '';
9146
+ const binOpt = binOverride ? binRaw.trim() : 'claude';
9147
+ if (hasUnsafePathChars(binOpt))
9148
+ return usageError(`${QE_BRIDGE_CLAUDE_BIN_ENV} must not contain control characters`);
9149
+ let resolvedBin = binOpt;
9150
+ if (binOverride) {
9151
+ try {
9152
+ resolvedBin = realpathSync(binOpt);
9153
+ }
9154
+ catch {
9155
+ resolvedBin = binOpt; // unresolvable: recorded as given, and the spawn will name the failure
9156
+ }
9157
+ }
9158
+ // ── extracts: SCOPED, never a repo dump ──
9159
+ const extracts = [];
9160
+ const pushIfPresent = (rel, label) => {
9161
+ const p = join(root, rel);
9162
+ try {
9163
+ if (!lstatSync(p).isFile())
9164
+ return;
9165
+ extracts.push({ label, text: readFileSync(p, 'utf-8') });
9166
+ }
9167
+ catch { /* absent: the brief says so by omission */ }
9168
+ };
9169
+ pushIfPresent(join('features', slug, '07_code_changes', 'change_manifest.md'), `features/${slug}/07_code_changes/change_manifest.md`);
9170
+ const adrDir = join(featureDir, '03_adr');
9171
+ if (existsSync(adrDir)) {
9172
+ for (const f of readdirSync(adrDir).filter((n) => n.endsWith('.md')).sort()) {
9173
+ pushIfPresent(join('features', slug, '03_adr', f), `features/${slug}/03_adr/${f}`);
9174
+ }
9175
+ }
9176
+ pushIfPresent(join('features', slug, '08_qe_report.md'), `features/${slug}/08_qe_report.md (the review ON RECORD — judge it, do not inherit it)`);
9177
+ const filesOpt = options.get('files');
9178
+ for (const rel of (filesOpt ?? '').split(',').map((s) => s.trim()).filter((s) => s !== '')) {
9179
+ if (hasUnsafePathChars(rel) || hasDotDotSegment(rel) || isAbsolute(rel)) {
9180
+ return usageError(`--files entry "${rel}" must be a repo-relative path with no ".." segments and no control characters`);
9181
+ }
9182
+ const check = containedUnderRoot(root, rel);
9183
+ if (!check.ok)
9184
+ return usageError(`--files entry "${check.why}"`);
9185
+ let st;
9186
+ try {
9187
+ st = lstatSync(check.path);
9188
+ }
9189
+ catch {
9190
+ return usageError(`--files entry "${rel}" does not exist`);
9191
+ }
9192
+ if (!st.isFile())
9193
+ return usageError(`--files entry "${rel}" is not a regular file (symlinks are refused)`);
9194
+ extracts.push({ label: rel, text: readFileSync(check.path, 'utf-8') });
9195
+ }
9196
+ if (extracts.length === 0) {
9197
+ return usageError(`nothing to review: features/${slug} has no change manifest, ADR or QE report, and no --files were given`);
9198
+ }
9199
+ // ── run identity + the audit bundle (round-2 M7) ──
9200
+ //
9201
+ // R3-2: the state directory is checked with the SAME containment walk as `--out`, BEFORE anything
9202
+ // is created in it. Round 2 contained the record PATHS but not the directory they live in, so a
9203
+ // symlinked `.fa-state/qe-bridge` (or `.fa-state`) silently redirected every write — and every
9204
+ // chmod — outside the repository. A guard that covers the leaves but not the branch is not a guard.
9205
+ const stateDirRel = join('features', slug, '.fa-state', 'qe-bridge');
9206
+ const stateCheck = containedUnderRoot(root, stateDirRel);
9207
+ if (!stateCheck.ok)
9208
+ return usageError(`the audit state directory ${stateCheck.why}`);
9209
+ const stateDir = stateCheck.path;
9210
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
9211
+ const runId = `${stamp}-${randomBytes(4).toString('hex')}`;
9212
+ const requestedOut = relative(root, outPath);
9213
+ const uniquePath = (base, ext) => {
9214
+ let candidate = `${base}${ext}`;
9215
+ for (let n = 2; existsSync(candidate); n += 1)
9216
+ candidate = `${base}-${n}${ext}`;
9217
+ return candidate;
9218
+ };
9219
+ /** Retain the raw reviewer stdout. THROWS on failure (R3-3): round 2 swallowed the error and
9220
+ * recorded `rawStdoutFile: null`, which turns "we could not keep the evidence" into a field nobody
9221
+ * reads. On the success path an unretainable stdout fails the run; on a failure path the forensics
9222
+ * are best-effort, because the run is already failing for a named reason. */
9223
+ const retainRaw = (stdout, kind) => {
9224
+ if (stdout === '')
9225
+ return null;
9226
+ const p = uniquePath(join(stateDir, `${kind}-${runId}`), '.stdout.txt');
9227
+ writeNewFileOrThrow(p, stdout);
9228
+ return relative(root, p);
9229
+ };
9230
+ const retainRawBestEffort = (stdout, kind) => {
9231
+ try {
9232
+ return retainRaw(stdout, kind);
9233
+ }
9234
+ catch {
9235
+ return null;
9236
+ }
9237
+ };
9238
+ let promptSha256 = null;
9239
+ const failRun = (reason, detail, model, forensics) => {
9240
+ const emittedAt = new Date().toISOString();
9241
+ const rawStdoutFile = forensics === undefined ? null : retainRawBestEffort(forensics.stdout, 'failed');
9242
+ const record = buildBridgeFailureRecord(reason, detail, {
9243
+ slug,
9244
+ model,
9245
+ emittedAt,
9246
+ runId,
9247
+ claudeBin: resolvedBin,
9248
+ binOverride,
9249
+ requestedOut,
9250
+ reportWritten: false,
9251
+ rawStdoutFile,
9252
+ promptSha256,
9253
+ });
9254
+ let recordPath = '';
9255
+ try {
9256
+ recordPath = uniquePath(join(stateDir, `failed-${runId}`), '.json');
9257
+ writeNewFileOrThrow(recordPath, `${JSON.stringify(record, null, 2)}\n`);
9258
+ if (forensics && forensics.stderr.trim() !== '')
9259
+ writeNewFileOrThrow(uniquePath(join(stateDir, `failed-${runId}`), '.stderr.txt'), forensics.stderr);
9260
+ }
9261
+ catch (primaryError) {
9262
+ // R3-1: the one condition that breaks the record medium ITSELF (`audit-write-failed`) must
9263
+ // still leave a named record behind, or the closed taxonomy has a member nothing can evidence.
9264
+ // Fall back ONE level up, inside the same per-feature state plane — not to an invented path.
9265
+ try {
9266
+ recordPath = uniquePath(join(featureDir, '.fa-state', `qe-bridge-fallback-${runId}`), '.json');
9267
+ writeNewFileOrThrow(recordPath, `${JSON.stringify({ ...record, fallbackFrom: relative(root, stateDir), fallbackReason: String(primaryError) }, null, 2)}\n`);
9268
+ }
9269
+ catch (fallbackError) {
9270
+ write(json ? JSON.stringify({ ok: false, reason, detail, recordError: String(fallbackError), exitCode: 1 }) : `dz qe-bridge: ${reason} — ${detail}\n (the failure record could NOT be written: ${String(fallbackError)})`);
9271
+ return 1;
9272
+ }
9273
+ }
9274
+ if (json)
9275
+ write(JSON.stringify({ ok: false, reason, detail, record: relative(root, recordPath), runId, reportWritten: false, requestedOut, exitCode: 1 }));
9276
+ else {
9277
+ write(`dz qe-bridge: FAILED — ${reason}`);
9278
+ write(` ${detail}`);
9279
+ write(` record: ${relative(root, recordPath)}`);
9280
+ write(` no report was written at ${requestedOut} — an unparseable or absent review is never a passing one.`);
9281
+ }
9282
+ return 1;
9283
+ };
9284
+ // ── the prompt (built BEFORE any model call: a same-family refusal must cost nothing) ──
9285
+ const built = buildBridgePrompt({ slug, coderFamily, allowSameFamily: flags.has('allow-same-family'), extracts });
9286
+ if (!built.ok)
9287
+ return failRun(built.reason, built.detail, null);
9288
+ const prompt = built.prompt;
9289
+ promptSha256 = createHash('sha256').update(prompt).digest('hex');
9290
+ // ── the isolated working directory: an EMPTY dir, so project-scoped discovery finds nothing ──
9291
+ let isolatedCwd;
9292
+ try {
9293
+ isolatedCwd = mkdtempSync(join(tmpdir(), 'dz-qe-bridge-iso-'));
9294
+ }
9295
+ catch (error) {
9296
+ return failRun('probe-failed', `could not create an isolated working directory for the reviewer: ${String(error)}`, null);
9297
+ }
9298
+ const cleanupIsolated = () => {
9299
+ try {
9300
+ rmSync(isolatedCwd, { recursive: true, force: true });
9301
+ }
9302
+ catch { /* a leftover empty temp dir is not worth failing a review over */ }
9303
+ };
9304
+ try {
9305
+ // ── probe: the allowlist is a search order, the probe is the answer ──
9306
+ let probed = null;
9307
+ const probeNotes = [];
9308
+ for (const id of candidates) {
9309
+ const probeArgs = claudeProbeArgs(id);
9310
+ if (probeArgs === null) {
9311
+ probeNotes.push(`${id}: unsafe id, never spawned`);
9312
+ continue;
9313
+ }
9314
+ const run = await runClaudeBridge(binOpt, probeArgs, '', QE_BRIDGE_PROBE_TIMEOUT_MS, isolatedCwd);
9315
+ if (run.spawnError !== null) {
9316
+ const enoent = /ENOENT|not found|no such file/i.test(run.spawnError);
9317
+ return failRun(enoent ? 'claude-not-found' : 'probe-failed', enoent
9318
+ ? `\`${binOpt}\` is not runnable (${run.spawnError}) — install/authenticate the Claude CLI, or point ${QE_BRIDGE_CLAUDE_BIN_ENV} at it`
9319
+ : run.spawnError, null);
9320
+ }
9321
+ if (run.timedOut) {
9322
+ probeNotes.push(`${id}: probe timed out after ${QE_BRIDGE_PROBE_TIMEOUT_MS / 1000}s`);
9323
+ continue;
9324
+ }
9325
+ if (interpretClaudeProbe({ stdout: run.stdout, exitCode: run.exitCode ?? 1 })) {
9326
+ probed = id;
9327
+ break;
9328
+ }
9329
+ const loginish = classifyClaudeStderr(run.stderr) === 'claude-not-logged-in';
9330
+ if (loginish) {
9331
+ return failRun('claude-not-logged-in', `the liveness probe for ${id} failed with a login error: ${run.stderr.trim().split('\n')[0]}`, null, { stdout: run.stdout, stderr: run.stderr });
9332
+ }
9333
+ probeNotes.push(`${id}: exit ${String(run.exitCode)}, no model-authored \`OK\` in the result envelope (${run.stdout.length} chars of stdout)${run.stderr.trim() === '' ? '' : ` (stderr: ${run.stderr.trim().split('\n')[0]})`}`);
9334
+ }
9335
+ if (probed === null) {
9336
+ return failRun('probe-failed', `no candidate model answered the liveness probe — ${probeNotes.join('; ')}`, null);
9337
+ }
9338
+ // ── the review call ──
9339
+ const reviewArgs = claudeReviewArgs(probed);
9340
+ if (reviewArgs === null)
9341
+ return failRun('probe-failed', `the probed id ${probed} failed id validation on the review path`, probed);
9342
+ const started = Date.now();
9343
+ const review = await runClaudeBridge(binOpt, reviewArgs, prompt, timeoutS * 1000, isolatedCwd);
9344
+ const elapsedMs = Date.now() - started;
9345
+ if (review.spawnError !== null) {
9346
+ const enoent = /ENOENT|not found|no such file/i.test(review.spawnError);
9347
+ return failRun(enoent ? 'claude-not-found' : 'exit-nonzero', review.spawnError, probed, { stdout: review.stdout, stderr: review.stderr });
9348
+ }
9349
+ if (review.timedOut) {
9350
+ return failRun('timeout', `the review call exceeded --timeout ${timeoutS}s and the child was killed (timeout-${timeoutS}s)`, probed, { stdout: review.stdout, stderr: review.stderr });
9351
+ }
9352
+ if (review.exitCode !== 0) {
9353
+ const reason = classifyClaudeStderr(review.stderr);
9354
+ return failRun(reason, `\`${binOpt}\` exited ${String(review.exitCode)}${review.stderr.trim() === '' ? ' with no stderr' : `: ${review.stderr.trim().split('\n')[0]}`}` +
9355
+ (reason === 'exit-nonzero' ? ' — the cause is NOT classified: from outside the process a limit-exhaustion death and a crash look alike, so the raw evidence is saved instead of a guess.' : ''), probed, { stdout: review.stdout, stderr: review.stderr });
9356
+ }
9357
+ if (review.stdout.trim() === '') {
9358
+ return failRun('empty-output', `the review call exited 0 with no output (${review.stdout.length} chars) — silence is not a clean review`, probed, { stdout: review.stdout, stderr: review.stderr });
9359
+ }
9360
+ // ── PARSE, never synthesize ──
9361
+ const emittedAt = new Date().toISOString();
9362
+ const parsed = parseBridgeOutput(review.stdout, { slug, coderFamily, model: probed, elapsedMs, promptSha256, emittedAt });
9363
+ if (!parsed.ok) {
9364
+ // No `?? <some named reason>` fallback: a state the parser did not name is its OWN failure
9365
+ // (round-2 MAJOR M5 — laundering an unknown state into `no-grade-marker` reads like a verdict
9366
+ // about the reviewer's text when it is really a verdict about our own code).
9367
+ const reason = parsed.reason;
9368
+ return failRun(reason, parsed.detail, probed, { stdout: review.stdout, stderr: review.stderr });
9369
+ }
9370
+ const signoff = parsed.signoff;
9371
+ // ── landing (R3-3 ordering): AUDIT FIRST, then the report, then the truth about the report ──
9372
+ //
9373
+ // Round 2 wrote the report BEFORE the record, so a crash between the two left a report on disk
9374
+ // and a record that said `reportWritten:false` — metadata that lies in the direction of "no
9375
+ // review happened" while a review report sits next to it. The order below can only ever
9376
+ // understate: the record exists first saying false, the report lands, then the record is
9377
+ // corrected. A crash at any point leaves a record that is true or pessimistic, never optimistic.
9378
+ let rawStdoutFile;
9379
+ try {
9380
+ rawStdoutFile = retainRaw(review.stdout, 'signoff');
9381
+ }
9382
+ catch (error) {
9383
+ return failRun('audit-write-failed', `the review was PARSED (grade ${signoff.grade}) but its raw stdout could not be retained: ${String(error)} — an unauditable success is not a success, so the run FAILS rather than shipping a verdict nobody can re-derive`, probed, { stdout: review.stdout, stderr: review.stderr });
9384
+ }
9385
+ const recordText = (reportWritten) => `${JSON.stringify(buildBridgeSignoffRecord(signoff, {
9386
+ runId,
9387
+ claudeBin: resolvedBin,
9388
+ binOverride,
9389
+ requestedOut,
9390
+ reportWritten,
9391
+ rawStdoutFile,
9392
+ promptSha256,
9393
+ ...(parsed.channels === undefined ? {} : { channels: parsed.channels }),
9394
+ }), null, 2)}\n`;
9395
+ let signoffPath;
9396
+ try {
9397
+ signoffPath = uniquePath(join(stateDir, `signoff-${runId}`), '.json');
9398
+ writeNewFileOrThrow(signoffPath, recordText(false));
9399
+ }
9400
+ catch (error) {
9401
+ return failRun('audit-write-failed', `the review was PARSED (grade ${signoff.grade}) but the signoff record could not be written: ${String(error)} — the verdict exists and cannot be persisted, so the run FAILS rather than reporting an unrecorded success`, probed, { stdout: review.stdout, stderr: review.stderr });
9402
+ }
9403
+ let reportError = null;
9404
+ try {
9405
+ writeNewFileOrThrow(outPath, renderBridgeReport(signoff), 0o600);
9406
+ }
9407
+ catch (error) {
9408
+ reportError = error;
9409
+ }
9410
+ if (reportError === null) {
9411
+ // the ONLY moment `reportWritten:true` may appear: after the report is on disk
9412
+ try {
9413
+ // ATOMIC (R4-1): write a sibling temp file, then rename() over the original. On the same
9414
+ // filesystem rename is atomic, so a reader — or a crash — sees the OLD complete record or
9415
+ // the NEW complete record, never a truncated one. Round 3 truncated and rewrote in place,
9416
+ // which made the "a crash leaves a record that is true or pessimistic" claim untrue in the
9417
+ // one case it was about.
9418
+ const tmpPath = `${signoffPath}.tmp.${process.pid}`;
9419
+ writeNewFileOrThrow(tmpPath, recordText(true));
9420
+ if (process.env[QE_BRIDGE_FAILPOINT_ENV] === 'hang-before-rename') {
9421
+ // test-only: stop dead INSIDE the window, so a SIGKILL can prove the property
9422
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 600_000);
9423
+ }
9424
+ renameSync(tmpPath, signoffPath);
9425
+ chmodSync(signoffPath, RECORD_FILE_MODE);
9426
+ }
9427
+ catch (error) {
9428
+ return failRun('audit-write-failed', `the report landed at ${requestedOut} but the signoff record could not be updated to say so: ${String(error)} — the record on disk understates (reportWritten:false); re-run rather than trusting a record that disagrees with the tree`, probed, { stdout: review.stdout, stderr: review.stderr });
9429
+ }
9430
+ }
9431
+ else {
9432
+ const detail = `the review was PARSED (grade ${signoff.grade}) but ${requestedOut} could not be written: ${String(reportError)} — prior evidence is never overwritten and a symlinked target is never followed; the verdict is preserved at ${relative(root, signoffPath)} with reportWritten:false`;
9433
+ return failRun('report-write-failed', detail, probed, { stdout: '', stderr: '' });
9434
+ }
9435
+ if (json) {
9436
+ write(JSON.stringify({
9437
+ ok: true,
9438
+ grade: signoff.grade,
9439
+ gradedBy: signoff.gradedBy,
9440
+ coderFamily: signoff.coderFamily,
9441
+ coderFamilySource,
9442
+ findings: signoff.findings.length,
9443
+ report: requestedOut,
9444
+ reportWritten: true,
9445
+ signoff: relative(root, signoffPath),
9446
+ rawStdout: rawStdoutFile,
9447
+ runId,
9448
+ binOverride,
9449
+ claudeBin: resolvedBin,
9450
+ elapsedMs,
9451
+ promptChars: prompt.length,
9452
+ promptSha256,
9453
+ channels: parsed.channels,
9454
+ exitCode: 0,
9455
+ }));
9456
+ }
9457
+ else {
9458
+ write(`dz qe-bridge: GRADE ${signoff.grade} from claude/${signoff.gradedBy.model} — ${signoff.findings.length} finding(s) in ${Math.round(elapsedMs / 1000)}s`);
9459
+ if (binOverride)
9460
+ write(` ⚠ reviewer executable OVERRIDDEN via ${QE_BRIDGE_CLAUDE_BIN_ENV}: ${resolvedBin} (recorded as binOverride:true — this signoff does not prove Anthropic's runtime answered)`);
9461
+ write(` report: ${requestedOut}`);
9462
+ write(` signoff: ${relative(root, signoffPath)}`);
9463
+ write(` settle: dz reqe --slug ${slug} --done --report ${requestedOut}`);
9464
+ write(' the bridge REPORTS (any grade exits 0); gating stays with dz reqe and the host pipeline.');
9465
+ }
9466
+ return 0;
9467
+ }
9468
+ finally {
9469
+ cleanupIsolated();
9470
+ }
9471
+ }
7866
9472
  function cmdScore(options, flags, cwd, write) {
7867
9473
  const json = flags.has('json');
7868
9474
  if (flags.has('help')) {
@@ -9722,6 +11328,9 @@ export async function runCli(argv, io = {}) {
9722
11328
  case 'scout':
9723
11329
  return await cmdScout(options, flags, cwd, write);
9724
11330
  case 'workflow':
11331
+ // `run` is ASYNC (it drives child processes); every other subcommand stays sync.
11332
+ if ((options.get('_positional_0') ?? '') === 'run')
11333
+ return await cmdWorkflowRun(options, optionLists, flags, cwd, write);
9725
11334
  return cmdWorkflow(options, flags, cwd, write);
9726
11335
  case 'workflow-lint':
9727
11336
  return cmdWorkflowLint(options, flags, cwd, write);
@@ -9829,6 +11438,8 @@ export async function runCli(argv, io = {}) {
9829
11438
  return cmdScore(options, flags, cwd, write);
9830
11439
  case 'reqe':
9831
11440
  return cmdReqe(options, flags, cwd, write);
11441
+ case 'qe-bridge':
11442
+ return await cmdQeBridge(options, flags, cwd, write);
9832
11443
  case 'backlog':
9833
11444
  return await cmdBacklog(options, flags, cwd, write);
9834
11445
  case 'routing':