@dzhechkov/harness-cli 0.4.6 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dz-manifest.json +125 -21
- package/LICENSE +21 -0
- package/README.md +501 -30
- package/dist/bin.d.ts +10 -0
- package/dist/bin.d.ts.map +1 -1
- package/dist/bin.js +45 -12
- package/dist/bin.js.map +1 -1
- package/dist/cli.d.ts +110 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +2206 -195
- package/dist/cli.js.map +1 -1
- package/dist/core-compat.d.ts +106 -0
- package/dist/core-compat.d.ts.map +1 -0
- package/dist/core-compat.js +252 -0
- package/dist/core-compat.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/package.json +12 -12
- package/sbom.json +280 -20
- package/src/bin.ts +43 -12
- package/src/cli.ts +2315 -184
- package/src/core-compat.ts +240 -0
- package/src/index.ts +4 -0
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,
|
|
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, 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
|
-
assembleCandidates, renderPromotionReport, renderPromotionAdr, normalizePromotionState, nextPromotionState, globMatch, promotionAdrRelPath, DEFAULT_WINDOW_DAYS, DEFAULT_PERIODS, MAX_CONTENT_FETCHES, BUILTIN_COVERAGE, decideProvenance, isInsideTree, signManifest, verifyManifest, listSignablePackFiles, assertKeyOutsideTree, decidePublishGate, collectPackageFacts, planReleaseGates, selectAffectedPackages, classifyGateExecutions, buildFailureIssue, buildReleaseNotes, releaseTagName, firstOutputLine, formatPublishError, MANIFEST_NAME, SBOM_NAME, buildArchitectureMap, renderMapHuman, findArchitectureDrift, renderDriftReport, scanWorkspacePackages, loadSubsystemManifest, loadProductVision, checkFeatureAgainstArchitecture, renderArchCheck, planProjectSkills, guidanceForStage, renderInjectionReport, analyzeCorpus, renderRakeReport, renderCriticSection, rakeAsLesson, rakeReward, DEFAULT_RAKE_THRESHOLDS, streamSessionEvents, findLatestTranscript, detectProcessRakes, buildRetro, renderRetro, retroLessonText, PROCESS_SIGNATURES, RETRO_DOMAIN, scanForSetup, buildSetupPlan, scaffoldFromSpec, renderScaffoldPreview, readExistingForScaffold, assembleChallengeContext, buildChallengeBrief, planDiscriminationCheck, classifyDiscrimination, pickAdversaryModel, CHALLENGE_QUESTIONS, loadOutcomes, renderOutcomes, statsForKey, selectAutoCost, recordProvisional, finalizeOutcome, harvestStageOutcomes, recommendModels, planFeed, GRADE_SUCCESS_FLOOR, COST_LADDER, splitScenarios, budgetPlan, selectWinner, proseScopeOk, renderProseDiff, readScenarioIds, DEFAULT_MAX_JUDGE_RUNS, collectDeliveryFacts, planDeliveryCheck, renderDeliveryBrief, classifyDelivery, isUsablePlaneResult, renderDeliveryReview, scanSkillsLayout, declaredPluginSurface, parseInitFacts, verifyRegistration, buildContentProbePrompt, classifyContentProbe, renderContentProbe, findNonRegistrableSkillDirs, assembleCompoundingReport,
|
|
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,
|
|
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)
|
|
@@ -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)
|
|
@@ -110,6 +115,8 @@ Usage:
|
|
|
110
115
|
(build-time: reconcile project grants vs installed skills' declared capabilities; dz never enforces — the host does)
|
|
111
116
|
dz sync-upstream [--package <dir>] [--list] [--all]
|
|
112
117
|
dz drift-check [--json] [--project <dir>] (CI gate: exit 1 if any shared skill drifted between its monorepo copies)
|
|
118
|
+
dz agents-sync [--project <dir>] [--check] [--json] (sync/verify the always-on policy fence in root AGENTS.md; exit 0 synced/written, 1 drift, 3 inconclusive)
|
|
119
|
+
dz hooks-sync --target codex [--check] [--verify] [--remove] [--json] (install/verify the dz veto + recall hooks in $CODEX_HOME/hooks.json; exit 0 armed+trusted, 1 not armed/drift, 3 inconclusive)
|
|
113
120
|
dz sync-canonical <skill> [--check] [--from <dir>] [--auto] [--project <dir>] (heal every copy from skills-meta/<skill> or --from; no canonical + --check = compare copies to each other (exit 1 on drift); no canonical + write = refuse unless --auto (LOUD, picks most-complete copy); --check writes nothing)
|
|
114
121
|
dz plugin [--version <ver>]
|
|
115
122
|
dz downloads
|
|
@@ -121,6 +128,18 @@ Usage:
|
|
|
121
128
|
|
|
122
129
|
Global: --version | -v [--json] (prints this CLI's own semver on one line, exit 0; "unknown" + exit 1 when unresolvable)
|
|
123
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
|
+
|
|
124
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)
|
|
125
144
|
|
|
126
145
|
Targets: ${TARGET_NAMES.join(', ')}
|
|
@@ -208,9 +227,11 @@ async function installSkills(opts) {
|
|
|
208
227
|
written += s.written;
|
|
209
228
|
skipped += s.skipped;
|
|
210
229
|
}
|
|
211
|
-
return { results, dirsSearched: skillsDirs.length, written, skipped, missing: [...report.missing] };
|
|
230
|
+
return { results, dirsSearched: skillsDirs.length, written, skipped, missing: [...report.missing], failures: [...report.failures], applyFailures: [...report.applyFailures] };
|
|
212
231
|
}
|
|
213
232
|
const results = [];
|
|
233
|
+
const failures = [];
|
|
234
|
+
const applyFailures = [];
|
|
214
235
|
for (const skillsDir of skillsDirs) {
|
|
215
236
|
const r = await runInit({
|
|
216
237
|
target,
|
|
@@ -223,6 +244,8 @@ async function installSkills(opts) {
|
|
|
223
244
|
for (const skill of r.skills) {
|
|
224
245
|
results.push({ id: skill.id, written: skill.written.length, skipped: skill.skipped.length });
|
|
225
246
|
}
|
|
247
|
+
failures.push(...r.failures);
|
|
248
|
+
applyFailures.push(...r.applyFailures);
|
|
226
249
|
}
|
|
227
250
|
let written = 0;
|
|
228
251
|
let skipped = 0;
|
|
@@ -232,7 +255,7 @@ async function installSkills(opts) {
|
|
|
232
255
|
}
|
|
233
256
|
const installed = new Set(results.map((s) => s.id));
|
|
234
257
|
const missing = select !== undefined ? [...select].filter((id) => !installed.has(id)) : [];
|
|
235
|
-
return { results, dirsSearched: skillsDirs.length, written, skipped, missing };
|
|
258
|
+
return { results, dirsSearched: skillsDirs.length, written, skipped, missing, failures, applyFailures };
|
|
236
259
|
}
|
|
237
260
|
/** Warn about preset/select ids that weren't found in any installed pack. */
|
|
238
261
|
function writeMissingSkillsHint(write, missing, presetName) {
|
|
@@ -248,12 +271,24 @@ function writeMissingSkillsHint(write, missing, presetName) {
|
|
|
248
271
|
write(` Install their packs first (e.g. dz install @dzhechkov/skills-<pack>) or check the ids.`);
|
|
249
272
|
}
|
|
250
273
|
}
|
|
251
|
-
async function cmdInit(options, flags, cwd, write) {
|
|
252
|
-
const
|
|
253
|
-
if (
|
|
254
|
-
|
|
274
|
+
async function cmdInit(options, flags, cwd, write, writeErr) {
|
|
275
|
+
const targetOpt = options.get('target');
|
|
276
|
+
if (targetOpt === undefined) {
|
|
277
|
+
// A missing `--target` is the same accusation as an unresolvable one, so it takes
|
|
278
|
+
// the same channel: diagnostics on stderr, stdout stays a data channel (ADR-002
|
|
279
|
+
// §Decision 2 / driver D6; fix round 1, QE F2).
|
|
280
|
+
writeErr(`dz init: --target must be one of: ${TARGET_NAMES_SORTED.join(', ')}`);
|
|
255
281
|
return 1;
|
|
256
282
|
}
|
|
283
|
+
const resolution = resolveTargetName(targetOpt);
|
|
284
|
+
if (resolution.kind === 'unknown') {
|
|
285
|
+
for (const line of formatTargetProblem('dz init', resolution))
|
|
286
|
+
writeErr(line);
|
|
287
|
+
return 1;
|
|
288
|
+
}
|
|
289
|
+
const target = resolution.target;
|
|
290
|
+
if (resolution.via === 'alias')
|
|
291
|
+
writeErr(formatTargetAliasNote('dz init', targetOpt, target));
|
|
257
292
|
const explicitSkillsDir = options.get('skills-dir');
|
|
258
293
|
const projectRoot = resolve(cwd, options.get('project') ?? '.');
|
|
259
294
|
const presetName = options.get('preset');
|
|
@@ -289,18 +324,62 @@ async function cmdInit(options, flags, cwd, write) {
|
|
|
289
324
|
write(` (searched ${r.dirsSearched} skill directories)`);
|
|
290
325
|
}
|
|
291
326
|
writeMissingSkillsHint(write, r.missing, presetName);
|
|
292
|
-
|
|
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
|
+
}
|
|
341
|
+
// Skip-and-collect must not become skip-and-SILENCE: a skill that failed to load is
|
|
342
|
+
// named on stderr and the command exits 1 (it exited 1 before too — by throwing).
|
|
343
|
+
if (r.failures.length > 0 || r.applyFailures.length > 0) {
|
|
344
|
+
// Counts first, then the named block — the same shape `dz list` uses. The block's
|
|
345
|
+
// own header already says "N skipped", so this line carries what it cannot: how many
|
|
346
|
+
// DID install, so a reader can tell a mostly-fine install from a mostly-broken one.
|
|
347
|
+
//
|
|
348
|
+
// The two failure kinds are counted and rendered SEPARATELY (fix round 1, QE F4):
|
|
349
|
+
// an unwritable target directory is not a broken pack, and printing it as one names
|
|
350
|
+
// the wrong file.
|
|
351
|
+
const parts = [`${r.results.length} installed`];
|
|
352
|
+
if (r.failures.length > 0)
|
|
353
|
+
parts.push(`${r.failures.length} skipped`);
|
|
354
|
+
if (r.applyFailures.length > 0)
|
|
355
|
+
parts.push(`${r.applyFailures.length} failed to write`);
|
|
356
|
+
writeErr(`dz init: ${parts.join(', ')}`);
|
|
357
|
+
for (const line of formatSkillLoadFailures(r.failures))
|
|
358
|
+
writeErr(line);
|
|
359
|
+
for (const line of formatSkillApplyFailures(r.applyFailures))
|
|
360
|
+
writeErr(line);
|
|
361
|
+
return 1;
|
|
362
|
+
}
|
|
363
|
+
return codexHooksOk ? 0 : 1;
|
|
293
364
|
}
|
|
294
|
-
async function cmdVerify(options, cwd, write) {
|
|
365
|
+
async function cmdVerify(options, cwd, write, writeErr) {
|
|
295
366
|
const skillsDir = resolve(cwd, options.get('skills-dir') ?? '.claude/skills');
|
|
296
367
|
const targetOpt = options.get('target');
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
368
|
+
let target;
|
|
369
|
+
if (targetOpt !== undefined) {
|
|
370
|
+
const resolution = resolveTargetName(targetOpt);
|
|
371
|
+
if (resolution.kind === 'unknown') {
|
|
372
|
+
for (const line of formatTargetProblem('dz verify', resolution))
|
|
373
|
+
writeErr(line);
|
|
374
|
+
return 1;
|
|
375
|
+
}
|
|
376
|
+
target = resolution.target;
|
|
377
|
+
if (resolution.via === 'alias')
|
|
378
|
+
writeErr(formatTargetAliasNote('dz verify', targetOpt, target));
|
|
300
379
|
}
|
|
301
380
|
const report = await runVerify({
|
|
302
381
|
skillsDir,
|
|
303
|
-
...(
|
|
382
|
+
...(target !== undefined ? { target } : {}),
|
|
304
383
|
});
|
|
305
384
|
write(`dz verify (${report.target}): ${report.valid}/${report.total} skill(s) valid`);
|
|
306
385
|
for (const skill of report.skills) {
|
|
@@ -309,7 +388,7 @@ async function cmdVerify(options, cwd, write) {
|
|
|
309
388
|
}
|
|
310
389
|
return report.valid === report.total ? 0 : 1;
|
|
311
390
|
}
|
|
312
|
-
async function cmdSync(options, flags, cwd, write) {
|
|
391
|
+
async function cmdSync(options, flags, cwd, write, writeErr) {
|
|
313
392
|
const projectRoot = resolve(cwd, options.get('project') ?? '.');
|
|
314
393
|
const canonicalArg = options.get('canonical');
|
|
315
394
|
// Auto-discover all skills-* packs, or use explicit --canonical
|
|
@@ -333,6 +412,15 @@ async function cmdSync(options, flags, cwd, write) {
|
|
|
333
412
|
});
|
|
334
413
|
const { total, inSync, missing, drift } = report.summary;
|
|
335
414
|
write(`dz sync${report.dryRun ? ' --dry-run' : ''}: ${inSync}/${total} in sync, ${missing} missing, ${drift} drift`);
|
|
415
|
+
// Skip-and-collect (D1): the broken canonical skills are NAMED on stderr, and their
|
|
416
|
+
// presence keeps the exit code non-zero — a partial sync is not a clean sync.
|
|
417
|
+
if (report.failures.length > 0) {
|
|
418
|
+
// See `cmdInit` above: counts here, names in the block below.
|
|
419
|
+
writeErr(`dz sync: ${report.skills.length} compared, ${report.failures.length} skipped`);
|
|
420
|
+
for (const line of formatSkillLoadFailures(report.failures))
|
|
421
|
+
writeErr(line);
|
|
422
|
+
return 1;
|
|
423
|
+
}
|
|
336
424
|
return missing === 0 && drift === 0 ? 0 : 1;
|
|
337
425
|
}
|
|
338
426
|
function cmdCreateSkill(options, flags, cwd, write) {
|
|
@@ -374,19 +462,44 @@ function cmdCreateSkill(options, flags, cwd, write) {
|
|
|
374
462
|
}
|
|
375
463
|
return 0;
|
|
376
464
|
}
|
|
377
|
-
|
|
465
|
+
/**
|
|
466
|
+
* `dz list` — skip-and-collect (feature dz-cli-defects, D1).
|
|
467
|
+
*
|
|
468
|
+
* One unparseable `SKILL.md` used to discard the ENTIRE listing with a message naming
|
|
469
|
+
* neither the file nor the count. Now the parseable skills list on stdout and the
|
|
470
|
+
* broken ones are named on stderr. The whole emit contract, in one place:
|
|
471
|
+
*
|
|
472
|
+
* | valid | skipped | stdout | stderr | exit |
|
|
473
|
+
* |-------|---------|--------|--------|------|
|
|
474
|
+
* | >0 | 0 | listing | *empty* | 0 |
|
|
475
|
+
* | >0 | >0 | listing of the valid ones | named summary | 1 |
|
|
476
|
+
* | 0 | >0 | *nothing* | named summary | 1 |
|
|
477
|
+
* | 0 | 0 | *nothing* | `no skills found in <dir>` | 1 |
|
|
478
|
+
*
|
|
479
|
+
* The last row is the ONE intentional departure from byte-identical output: that line
|
|
480
|
+
* used to go to stdout. Moving it keeps *stdout is data, stderr is diagnosis* whole —
|
|
481
|
+
* the invariant that makes `dz list > out.txt` trustworthy.
|
|
482
|
+
*/
|
|
483
|
+
function cmdList(options, cwd, write, writeErr) {
|
|
378
484
|
const skillsDir = resolve(cwd, options.get('skills-dir') ?? '.claude/skills');
|
|
379
|
-
const skills =
|
|
380
|
-
if (skills.length === 0) {
|
|
381
|
-
|
|
485
|
+
const { skills, failures } = listSkillsDetailed(skillsDir);
|
|
486
|
+
if (skills.length === 0 && failures.length === 0) {
|
|
487
|
+
writeErr(`dz list: no skills found in ${skillsDir}`);
|
|
382
488
|
return 1;
|
|
383
489
|
}
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
const
|
|
387
|
-
|
|
490
|
+
if (skills.length > 0) {
|
|
491
|
+
write(`${skills.length} skill(s) in ${skillsDir}:\n`);
|
|
492
|
+
for (const skill of skills) {
|
|
493
|
+
const desc = skill.description.length > 80 ? skill.description.slice(0, 77) + '...' : skill.description;
|
|
494
|
+
write(` ${skill.id.padEnd(35)} ${desc}`);
|
|
495
|
+
}
|
|
388
496
|
}
|
|
389
|
-
|
|
497
|
+
if (failures.length === 0)
|
|
498
|
+
return 0;
|
|
499
|
+
writeErr(`dz list: ${skills.length} listed, ${failures.length} skipped in ${skillsDir}`);
|
|
500
|
+
for (const line of formatSkillLoadFailures(failures))
|
|
501
|
+
writeErr(line);
|
|
502
|
+
return 1;
|
|
390
503
|
}
|
|
391
504
|
function cmdInfo(options, args, cwd, write) {
|
|
392
505
|
const skillsDir = resolve(cwd, options.get('skills-dir') ?? '.claude/skills');
|
|
@@ -1217,17 +1330,22 @@ function cmdBundle(options, flags, cwd, write) {
|
|
|
1217
1330
|
}
|
|
1218
1331
|
return 0;
|
|
1219
1332
|
}
|
|
1220
|
-
async function cmdInstall(options, flags, cwd, write, installRunner) {
|
|
1333
|
+
async function cmdInstall(options, flags, cwd, write, writeErr, installRunner) {
|
|
1221
1334
|
const pkg = options.get('_positional_0');
|
|
1222
1335
|
if (!pkg) {
|
|
1223
1336
|
write('dz install: package name required (e.g., dz install @dzhechkov/skills-devops)');
|
|
1224
1337
|
return 1;
|
|
1225
1338
|
}
|
|
1226
1339
|
const targetOpt = options.get('target') ?? 'claude-code';
|
|
1227
|
-
|
|
1228
|
-
|
|
1340
|
+
const targetResolution = resolveTargetName(targetOpt);
|
|
1341
|
+
if (targetResolution.kind === 'unknown') {
|
|
1342
|
+
for (const line of formatTargetProblem('dz install', targetResolution))
|
|
1343
|
+
writeErr(line);
|
|
1229
1344
|
return 1;
|
|
1230
1345
|
}
|
|
1346
|
+
const target = targetResolution.target;
|
|
1347
|
+
if (targetResolution.via === 'alias')
|
|
1348
|
+
writeErr(formatTargetAliasNote('dz install', targetOpt, target));
|
|
1231
1349
|
const projectRoot = resolve(cwd, options.get('project') ?? '.');
|
|
1232
1350
|
// Step 1: npm install the package (installRunner is the CliIo test seam — unset in production)
|
|
1233
1351
|
write(`Installing ${pkg}...`);
|
|
@@ -1261,7 +1379,7 @@ async function cmdInstall(options, flags, cwd, write, installRunner) {
|
|
|
1261
1379
|
const root = roots[0];
|
|
1262
1380
|
// Step 3: Use dz init with the resolved skills root as source
|
|
1263
1381
|
const report = await runInit({
|
|
1264
|
-
target
|
|
1382
|
+
target,
|
|
1265
1383
|
skillsDir: root.dir,
|
|
1266
1384
|
projectRoot,
|
|
1267
1385
|
force: flags.has('force'),
|
|
@@ -1278,20 +1396,38 @@ async function cmdInstall(options, flags, cwd, write, installRunner) {
|
|
|
1278
1396
|
if (root.layout === 'npx-template' && root.hasCompanionAssets) {
|
|
1279
1397
|
write(` note: ${pkg} also ships commands/hooks/agents — \`npx -y ${pkg} init\` installs the full kit.`);
|
|
1280
1398
|
}
|
|
1399
|
+
// Skip-and-collect at install time (D1 / the report's D2 amendment): the offending
|
|
1400
|
+
// SKILL.md came out of the DOWNLOADED TARBALL, so the path is rendered relative to
|
|
1401
|
+
// the package root (a `node_modules/**` absolute path is not actionable) and the
|
|
1402
|
+
// message says whose defect it is. Exit 1 — a pack that shipped an unloadable skill
|
|
1403
|
+
// did not fully install.
|
|
1404
|
+
if (report.failures.length > 0) {
|
|
1405
|
+
writeErr(`dz install: ${pkg} ships ${report.failures.length} unparseable skill(s) —`);
|
|
1406
|
+
for (const line of formatSkillLoadFailures(report.failures, { relativeTo: pkgDir }))
|
|
1407
|
+
writeErr(line);
|
|
1408
|
+
writeErr('This is a defect in the package, not in your project.');
|
|
1409
|
+
writeErr(`Workaround: npx -y ${pkg} init`);
|
|
1410
|
+
return 1;
|
|
1411
|
+
}
|
|
1281
1412
|
return 0;
|
|
1282
1413
|
}
|
|
1283
|
-
function cmdCompose(options, cwd, write) {
|
|
1414
|
+
function cmdCompose(options, cwd, write, writeErr) {
|
|
1284
1415
|
const combo = options.get('_positional_0');
|
|
1285
1416
|
if (!combo) {
|
|
1286
1417
|
write('dz compose: preset combination required (e.g., dz compose devops+mcp+web3)');
|
|
1287
1418
|
return 1;
|
|
1288
1419
|
}
|
|
1289
1420
|
// --target is documented; honor it in the suggested install command (was hardcoded claude-code).
|
|
1290
|
-
const
|
|
1291
|
-
|
|
1292
|
-
|
|
1421
|
+
const targetOpt = options.get('target') ?? 'claude-code';
|
|
1422
|
+
const composeResolution = resolveTargetName(targetOpt);
|
|
1423
|
+
if (composeResolution.kind === 'unknown') {
|
|
1424
|
+
for (const line of formatTargetProblem('dz compose', composeResolution))
|
|
1425
|
+
writeErr(line);
|
|
1293
1426
|
return 1;
|
|
1294
1427
|
}
|
|
1428
|
+
const target = composeResolution.target;
|
|
1429
|
+
if (composeResolution.via === 'alias')
|
|
1430
|
+
writeErr(formatTargetAliasNote('dz compose', targetOpt, target));
|
|
1295
1431
|
const presetNames = combo.split('+').map((s) => s.trim());
|
|
1296
1432
|
const allSkills = new Set();
|
|
1297
1433
|
const resolved = [];
|
|
@@ -3489,12 +3625,21 @@ async function cmdBrain(options, flags, cwd, write, readStdin) {
|
|
|
3489
3625
|
write(BRAIN_USAGE);
|
|
3490
3626
|
return sub === undefined ? 0 : 1;
|
|
3491
3627
|
}
|
|
3492
|
-
async function cmdSetup(options, flags, cwd, write) {
|
|
3493
|
-
const
|
|
3494
|
-
if (!
|
|
3495
|
-
|
|
3628
|
+
async function cmdSetup(options, flags, cwd, write, writeErr) {
|
|
3629
|
+
const targetOpt = options.get('target');
|
|
3630
|
+
if (!targetOpt) {
|
|
3631
|
+
writeErr(`dz setup: --target required (${TARGET_NAMES_SORTED.join(', ')})`);
|
|
3632
|
+
return 1;
|
|
3633
|
+
}
|
|
3634
|
+
const setupResolution = resolveTargetName(targetOpt);
|
|
3635
|
+
if (setupResolution.kind === 'unknown') {
|
|
3636
|
+
for (const line of formatTargetProblem('dz setup', setupResolution))
|
|
3637
|
+
writeErr(line);
|
|
3496
3638
|
return 1;
|
|
3497
3639
|
}
|
|
3640
|
+
const target = setupResolution.target;
|
|
3641
|
+
if (setupResolution.via === 'alias')
|
|
3642
|
+
writeErr(formatTargetAliasNote('dz setup', targetOpt, target));
|
|
3498
3643
|
const projectRoot = resolve(cwd, options.get('project') ?? '.');
|
|
3499
3644
|
const presetName = options.get('preset');
|
|
3500
3645
|
write(`\n╔══════════════════════════════════════════════════════╗`);
|
|
@@ -3540,6 +3685,18 @@ async function cmdSetup(options, flags, cwd, write) {
|
|
|
3540
3685
|
else {
|
|
3541
3686
|
write(`║ (no skills resolved) ║`);
|
|
3542
3687
|
}
|
|
3688
|
+
// Step 5 (ADR-001 §8): DELIVER the codex hooks and verify them live. Non-aborting — the rest of
|
|
3689
|
+
// setup has already run and the summary still prints; only the exit code carries the failure.
|
|
3690
|
+
let codexHooksOk = true;
|
|
3691
|
+
if (target === 'codex' && !flags.has('no-hooks')) {
|
|
3692
|
+
write(`║ 5. Delivering codex hooks (live verify)... ║`);
|
|
3693
|
+
const delivery = deliverCodexHooks({ project: projectRoot, verify: !flags.has('no-verify') }, undefined, 'dz setup');
|
|
3694
|
+
codexHooksOk = delivery.ok;
|
|
3695
|
+
for (const line of delivery.stdout)
|
|
3696
|
+
write(line);
|
|
3697
|
+
for (const line of delivery.stderr)
|
|
3698
|
+
writeErr(line);
|
|
3699
|
+
}
|
|
3543
3700
|
write(`╠══════════════════════════════════════════════════════╣`);
|
|
3544
3701
|
write(`║ Setup: ${String(setupResult.completed).padStart(2)} done, ${String(setupResult.skipped).padStart(2)} skipped ║`);
|
|
3545
3702
|
// Honest label derived from the ACTUAL wiring (runSetup's 'agentdb wiring' invariant check),
|
|
@@ -3558,7 +3715,9 @@ async function cmdSetup(options, flags, cwd, write) {
|
|
|
3558
3715
|
+ (install.dirsSearched > 1 ? ` (searched ${install.dirsSearched} skill dirs)` : ''));
|
|
3559
3716
|
writeMissingSkillsHint(write, install.missing, selectArg !== undefined ? undefined : preset);
|
|
3560
3717
|
}
|
|
3561
|
-
|
|
3718
|
+
// A hook that was written but never witnessed firing is NOT a completed setup (ADR-002 §5): the
|
|
3719
|
+
// step is reported failed, the process was not aborted.
|
|
3720
|
+
return codexHooksOk ? 0 : 1;
|
|
3562
3721
|
}
|
|
3563
3722
|
function cmdPretrain(options, cwd, write) {
|
|
3564
3723
|
const projectRoot = resolve(cwd, options.get('project') ?? '.');
|
|
@@ -3659,12 +3818,19 @@ function cmdRecommend(options, cwd, write) {
|
|
|
3659
3818
|
write(` Get it: dz init --target claude-code --select skill-advisor (or --preset meta)`);
|
|
3660
3819
|
return 0;
|
|
3661
3820
|
}
|
|
3662
|
-
function cmdUpgrade(options, flags, cwd, write) {
|
|
3821
|
+
function cmdUpgrade(options, flags, cwd, write, writeErr) {
|
|
3663
3822
|
const targetOpt = options.get('target') ?? 'claude-code';
|
|
3664
|
-
|
|
3665
|
-
|
|
3823
|
+
const upgradeResolution = resolveTargetName(targetOpt);
|
|
3824
|
+
if (upgradeResolution.kind === 'unknown') {
|
|
3825
|
+
for (const line of formatTargetProblem('dz upgrade', upgradeResolution))
|
|
3826
|
+
writeErr(line);
|
|
3666
3827
|
return 1;
|
|
3667
3828
|
}
|
|
3829
|
+
// The dir map is keyed by the RESOLVED name — keying it by the raw `--target` would
|
|
3830
|
+
// let an alias validate and then miss the map.
|
|
3831
|
+
const upgradeTarget = upgradeResolution.target;
|
|
3832
|
+
if (upgradeResolution.via === 'alias')
|
|
3833
|
+
writeErr(formatTargetAliasNote('dz upgrade', targetOpt, upgradeTarget));
|
|
3668
3834
|
const projectRoot = resolve(cwd, options.get('project') ?? '.');
|
|
3669
3835
|
const targetDirMap = {
|
|
3670
3836
|
'claude-code': '.claude/skills', codex: '.agents/skills', opencode: '.opencode/skills',
|
|
@@ -3672,9 +3838,9 @@ function cmdUpgrade(options, flags, cwd, write) {
|
|
|
3672
3838
|
'agents-md': 'AGENTS.md', cursor: '.cursor/rules', gemini: 'GEMINI.md',
|
|
3673
3839
|
windsurf: '.windsurf/rules',
|
|
3674
3840
|
};
|
|
3675
|
-
const mappedDir = targetDirMap[
|
|
3841
|
+
const mappedDir = targetDirMap[upgradeTarget];
|
|
3676
3842
|
if (mappedDir === undefined) {
|
|
3677
|
-
write(`dz upgrade: no skills directory mapping for target ${
|
|
3843
|
+
write(`dz upgrade: no skills directory mapping for target ${upgradeTarget}`);
|
|
3678
3844
|
return 1;
|
|
3679
3845
|
}
|
|
3680
3846
|
const targetDir = join(projectRoot, mappedDir);
|
|
@@ -3689,14 +3855,14 @@ function cmdUpgrade(options, flags, cwd, write) {
|
|
|
3689
3855
|
}
|
|
3690
3856
|
}
|
|
3691
3857
|
const report = checkUpgrades(targetDir, canonicalDirs);
|
|
3692
|
-
write(`\ndz upgrade — ${
|
|
3858
|
+
write(`\ndz upgrade — ${upgradeTarget} (${targetDir})`);
|
|
3693
3859
|
write(` Installed: ${report.installed} Needs update: ${report.needsUpdate} Up-to-date: ${report.upToDate} Custom: ${report.notInCanonical}\n`);
|
|
3694
3860
|
for (const check of report.skills) {
|
|
3695
3861
|
const icon = check.needsUpdate ? '△' : check.canonicalSize === undefined ? '?' : '✓';
|
|
3696
3862
|
write(` ${icon} ${check.id.padEnd(25)} ${check.reason}`);
|
|
3697
3863
|
}
|
|
3698
3864
|
if (report.needsUpdate > 0) {
|
|
3699
|
-
write(`\n${report.needsUpdate} skill(s) need update. Run: dz init --target ${
|
|
3865
|
+
write(`\n${report.needsUpdate} skill(s) need update. Run: dz init --target ${upgradeTarget} --force`);
|
|
3700
3866
|
}
|
|
3701
3867
|
// ADR-001 (verify-apply-leg): verify what we just left on disk. A TAMPERED pack aborts.
|
|
3702
3868
|
const sigFatal = reportPackVerification(projectRoot, options.get('pubkey'), flags.has('require-signing'), write);
|
|
@@ -4249,7 +4415,7 @@ function cmdPublish(options, flags, cwd, write) {
|
|
|
4249
4415
|
/* dz parity — the honest feature×target map (target-parity-matrix, */
|
|
4250
4416
|
/* ADR-001): computed from the declarative model, never hand-written */
|
|
4251
4417
|
/* ------------------------------------------------------------------ */
|
|
4252
|
-
function cmdParity(options, flags, write) {
|
|
4418
|
+
function cmdParity(options, flags, write, writeErr) {
|
|
4253
4419
|
const json = flags.has('json');
|
|
4254
4420
|
if (flags.has('help')) {
|
|
4255
4421
|
write('dz parity [--target <name>] [--json] — the computed feature×target map (never hand-written)');
|
|
@@ -4278,34 +4444,81 @@ function cmdParity(options, flags, write) {
|
|
|
4278
4444
|
}
|
|
4279
4445
|
}
|
|
4280
4446
|
const matrix = buildParityMatrix();
|
|
4281
|
-
|
|
4282
|
-
|
|
4283
|
-
|
|
4284
|
-
|
|
4285
|
-
|
|
4286
|
-
|
|
4287
|
-
|
|
4447
|
+
// EVIDENCE staleness, folded into the report (fix round 2, R2-3). Derived from the records
|
|
4448
|
+
// themselves — no `codex --version`, no subprocess, so `dz parity` stays a deterministic function
|
|
4449
|
+
// of the model. A cell whose deciding form rests on a transcript that is older than the newest
|
|
4450
|
+
// recording for the SAME target is reported `inconclusive`, never `full`: the round-1 gate could
|
|
4451
|
+
// already tell, and nothing a user runs was asking it.
|
|
4452
|
+
const staleEvidence = findStaleTranscriptEvidence();
|
|
4453
|
+
const staleByTarget = new Map();
|
|
4454
|
+
for (const s of staleEvidence)
|
|
4455
|
+
staleByTarget.set(s.target, [...(staleByTarget.get(s.target) ?? []), s.capability]);
|
|
4456
|
+
const reportCell = (feature, t, cell) => downgradeForStaleEvidence(feature, cell, staleByTarget.get(t) ?? []);
|
|
4457
|
+
const staleNote = (t) => staleEvidence
|
|
4458
|
+
.filter((s) => s.target === t)
|
|
4459
|
+
.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 ?? ''})`);
|
|
4460
|
+
// Site 8 of the D3 rewiring, closed in fix round 1 (QE F1). It shipped spelling its
|
|
4461
|
+
// own bare guard `TARGET_NAMES.includes(...)` and was therefore invisible to the AM-2
|
|
4462
|
+
// grep-guard, which searched for the token `isTargetName(` — a PRESENCE check on one
|
|
4463
|
+
// spelling where the property was "no call site bypasses the resolver". The guard in
|
|
4464
|
+
// `test/target-alias-cli.test.ts` now checks the class, and the sweep list is derived
|
|
4465
|
+
// from the help text so a ninth command cannot be missed the same way.
|
|
4466
|
+
const targetOpt = options.get('target');
|
|
4467
|
+
let target;
|
|
4468
|
+
if (targetOpt !== undefined) {
|
|
4469
|
+
const parityResolution = resolveTargetName(targetOpt);
|
|
4470
|
+
if (parityResolution.kind === 'unknown') {
|
|
4471
|
+
// Both forms go to stderr: an error is not data, and `dz parity --json | jq`
|
|
4472
|
+
// must not be fed a diagnostic (ADR-002 §Decision 2 / driver D6).
|
|
4473
|
+
if (json) {
|
|
4474
|
+
writeErr(JSON.stringify({ error: `unknown target ${JSON.stringify(targetOpt)}`, suggestion: parityResolution.suggestion, targets: TARGET_NAMES_SORTED, exitCode: 1 }, null, 2));
|
|
4475
|
+
}
|
|
4476
|
+
else {
|
|
4477
|
+
for (const line of formatTargetProblem('dz parity', parityResolution))
|
|
4478
|
+
writeErr(line);
|
|
4479
|
+
}
|
|
4480
|
+
return 1;
|
|
4481
|
+
}
|
|
4482
|
+
target = parityResolution.target;
|
|
4483
|
+
if (parityResolution.via === 'alias')
|
|
4484
|
+
writeErr(formatTargetAliasNote('dz parity', targetOpt, target));
|
|
4288
4485
|
}
|
|
4289
4486
|
if (json) {
|
|
4290
|
-
const
|
|
4291
|
-
|
|
4292
|
-
|
|
4293
|
-
|
|
4294
|
-
|
|
4487
|
+
const shown = target !== undefined ? [target] : TARGET_NAMES;
|
|
4488
|
+
const rows = matrix.map((r) => {
|
|
4489
|
+
const cells = {};
|
|
4490
|
+
for (const t of shown)
|
|
4491
|
+
cells[t] = reportCell(r.feature, t, r.cells[t]);
|
|
4492
|
+
return { id: r.feature.id, title: r.feature.title, cells };
|
|
4493
|
+
});
|
|
4295
4494
|
// A filtered response stays internally consistent: capabilities are filtered too (Codex QE gap 9).
|
|
4296
4495
|
const caps = target !== undefined ? { [target]: TARGET_CAPABILITIES[target] } : TARGET_CAPABILITIES;
|
|
4297
|
-
write(JSON.stringify({
|
|
4496
|
+
write(JSON.stringify({
|
|
4497
|
+
targets: shown,
|
|
4498
|
+
capabilities: caps,
|
|
4499
|
+
// The evidence axis travels WITH the matrix: a consumer that reads `level` must be able to
|
|
4500
|
+
// read why a cell is inconclusive without a second command.
|
|
4501
|
+
staleEvidence: staleEvidence.filter((sv) => shown.includes(sv.target)),
|
|
4502
|
+
features: rows,
|
|
4503
|
+
}, null, 2));
|
|
4298
4504
|
return 0;
|
|
4299
4505
|
}
|
|
4300
4506
|
if (target !== undefined) {
|
|
4301
4507
|
const t = target;
|
|
4302
4508
|
write(`\ndz parity — ${t} (capabilities: ${TARGET_CAPABILITIES[t].join(', ')})\n`);
|
|
4303
4509
|
for (const r of matrix) {
|
|
4304
|
-
const c = r.cells[t];
|
|
4305
|
-
const icon = c.level === 'full' ? '✓' : c.level === 'manual' ? '◐' : '—';
|
|
4306
|
-
|
|
4307
|
-
|
|
4308
|
-
|
|
4510
|
+
const c = reportCell(r.feature, t, r.cells[t]);
|
|
4511
|
+
const icon = c.level === 'full' ? '✓' : c.level === 'manual' ? '◐' : c.level === 'inconclusive' ? '?' : '—';
|
|
4512
|
+
const detail = c.level === 'none'
|
|
4513
|
+
? 'not available on this target'
|
|
4514
|
+
: c.level === 'inconclusive'
|
|
4515
|
+
? `via ${c.via ?? ''} — INCONCLUSIVE: stale evidence for ${(c.staleEvidence ?? []).join(', ')}`
|
|
4516
|
+
: `via ${c.via ?? ''}`;
|
|
4517
|
+
write(` ${icon} ${r.feature.title.padEnd(58)} ${detail}`);
|
|
4518
|
+
}
|
|
4519
|
+
write('\n ✓ full (the complete experience) ◐ manual (works, you drive it by hand) ? evidence stale (re-probe) — not available');
|
|
4520
|
+
for (const line of staleNote(t))
|
|
4521
|
+
write(line);
|
|
4309
4522
|
return 0;
|
|
4310
4523
|
}
|
|
4311
4524
|
// Grid: one column per target, short header, one row per feature. Computed, never hand-written;
|
|
@@ -4315,8 +4528,8 @@ function cmdParity(options, flags, write) {
|
|
|
4315
4528
|
write(` ${'feature'.padEnd(52)} ${TARGET_NAMES.map((t) => (short[t] ?? t).padStart(4)).join('')}`);
|
|
4316
4529
|
for (const r of matrix) {
|
|
4317
4530
|
const cells = TARGET_NAMES.map((t) => {
|
|
4318
|
-
const c = r.cells[t];
|
|
4319
|
-
return (c.level === 'full' ? '✓' : c.level === 'manual' ? '◐' : '—').padStart(4);
|
|
4531
|
+
const c = reportCell(r.feature, t, r.cells[t]);
|
|
4532
|
+
return (c.level === 'full' ? '✓' : c.level === 'manual' ? '◐' : c.level === 'inconclusive' ? '?' : '—').padStart(4);
|
|
4320
4533
|
}).join('');
|
|
4321
4534
|
write(` ${r.feature.title.slice(0, 52).padEnd(52)} ${cells}`);
|
|
4322
4535
|
}
|
|
@@ -5273,6 +5486,331 @@ function readDriftAllowlist(root) {
|
|
|
5273
5486
|
return [];
|
|
5274
5487
|
}
|
|
5275
5488
|
}
|
|
5489
|
+
/** Refresh or verify the root AGENTS.md bearing-policy projection. */
|
|
5490
|
+
/**
|
|
5491
|
+
* `dz hooks-sync --target codex` (`crossrt-2-codex-hooks`, AM-14).
|
|
5492
|
+
*
|
|
5493
|
+
* ONE verb in the existing target vocabulary (`parity`, `delivery-check`, `--target`), extensible to
|
|
5494
|
+
* a future runtime without a third surface. **No alias** — `dz codex-hooks` resolves to nothing.
|
|
5495
|
+
*
|
|
5496
|
+
* Exit map (ADR-002 §5, pinned by test):
|
|
5497
|
+
* 0 = `armed` AND `trust: 'trusted'` — the ONLY outcome that may print a success word (AM-17)
|
|
5498
|
+
* 1 = not armed, armed-but-trust-pending, drift, or a refusal
|
|
5499
|
+
* 3 = inconclusive (including "no codex binary on PATH")
|
|
5500
|
+
*/
|
|
5501
|
+
function cmdHooksSync(options, flags, cwd, write, writeErr) {
|
|
5502
|
+
const json = flags.has('json');
|
|
5503
|
+
const usage = 'dz hooks-sync --target codex [--check] [--verify] [--remove] [--json] [--project <dir>] [--no-verify]';
|
|
5504
|
+
if (flags.has('help')) {
|
|
5505
|
+
write(`${usage} — install/verify the dz veto + recall hooks in $CODEX_HOME/hooks.json`);
|
|
5506
|
+
return 0;
|
|
5507
|
+
}
|
|
5508
|
+
for (const flag of flags) {
|
|
5509
|
+
if (!['check', 'verify', 'no-verify', 'remove', 'json', 'help'].includes(flag)) {
|
|
5510
|
+
const message = `dz hooks-sync: unknown option --${flag}\n${usage}`;
|
|
5511
|
+
(json ? write : writeErr)(json ? JSON.stringify({ error: `unknown option --${flag}`, exitCode: 1 }) : message);
|
|
5512
|
+
return 1;
|
|
5513
|
+
}
|
|
5514
|
+
}
|
|
5515
|
+
for (const key of options.keys()) {
|
|
5516
|
+
if (key !== 'target' && key !== 'project' && key !== 'codex-home') {
|
|
5517
|
+
const message = key.startsWith('_positional_') ? `unexpected argument ${JSON.stringify(options.get(key))}` : `unknown option --${key}`;
|
|
5518
|
+
(json ? write : writeErr)(json ? JSON.stringify({ error: message, exitCode: 1 }) : `dz hooks-sync: ${message}\n${usage}`);
|
|
5519
|
+
return 1;
|
|
5520
|
+
}
|
|
5521
|
+
}
|
|
5522
|
+
// Every `--target` read in this CLI goes through resolveTargetName (alias support + one spelling
|
|
5523
|
+
// of the unknown-target message), pinned by `everyTargetGuardUsesResolveTargetName`.
|
|
5524
|
+
const targetOpt = options.get('target');
|
|
5525
|
+
if (targetOpt === undefined) {
|
|
5526
|
+
const message = '--target is required';
|
|
5527
|
+
(json ? write : writeErr)(json ? JSON.stringify({ error: message, exitCode: 1 }) : `dz hooks-sync: ${message}\n${usage}`);
|
|
5528
|
+
return 1;
|
|
5529
|
+
}
|
|
5530
|
+
const resolution = resolveTargetName(targetOpt);
|
|
5531
|
+
if (resolution.kind === 'unknown') {
|
|
5532
|
+
if (json) {
|
|
5533
|
+
write(JSON.stringify({ error: `unknown target ${targetOpt}`, exitCode: 1 }));
|
|
5534
|
+
}
|
|
5535
|
+
else {
|
|
5536
|
+
for (const line of formatTargetProblem('dz hooks-sync', resolution))
|
|
5537
|
+
writeErr(line);
|
|
5538
|
+
}
|
|
5539
|
+
return 1;
|
|
5540
|
+
}
|
|
5541
|
+
const target = resolution.target;
|
|
5542
|
+
if (resolution.via === 'alias')
|
|
5543
|
+
writeErr(formatTargetAliasNote('dz hooks-sync', targetOpt, target));
|
|
5544
|
+
if (target !== 'codex') {
|
|
5545
|
+
// Deliberately narrow: only Codex has a hook carrier today. Naming the reason keeps a future
|
|
5546
|
+
// reader from assuming the other nine are simply unimplemented here.
|
|
5547
|
+
const message = `unsupported --target ${target} (only "codex" has a hook carrier today)`;
|
|
5548
|
+
(json ? write : writeErr)(json ? JSON.stringify({ error: message, exitCode: 1 }) : `dz hooks-sync: ${message}\n${usage}`);
|
|
5549
|
+
return 1;
|
|
5550
|
+
}
|
|
5551
|
+
const codexHome = options.get('codex-home');
|
|
5552
|
+
const projectOpt = options.get('project');
|
|
5553
|
+
// `--no-verify` wins over `--verify`: an explicit refusal to measure is never overridden by the
|
|
5554
|
+
// flag that asks for a measurement.
|
|
5555
|
+
const report = runSyncCodexHooksGuarded(codexHooksSyncOptions({
|
|
5556
|
+
...(codexHome !== undefined ? { codexHome } : {}),
|
|
5557
|
+
...(projectOpt !== undefined ? { project: resolve(cwd, projectOpt) } : {}),
|
|
5558
|
+
check: flags.has('check'),
|
|
5559
|
+
remove: flags.has('remove'),
|
|
5560
|
+
verify: !flags.has('no-verify'),
|
|
5561
|
+
}));
|
|
5562
|
+
if (json) {
|
|
5563
|
+
write(JSON.stringify({ ...report, exitCode: report.exitCode }));
|
|
5564
|
+
return report.exitCode;
|
|
5565
|
+
}
|
|
5566
|
+
for (const err of report.errors)
|
|
5567
|
+
writeErr(`dz hooks-sync: ${err}`);
|
|
5568
|
+
for (const warn of report.warnings)
|
|
5569
|
+
writeErr(`dz hooks-sync: warning: ${warn}`);
|
|
5570
|
+
// SILENT in a home that never opted in — the leg-1 F12 lesson: a --check that chatters in every
|
|
5571
|
+
// unrelated project trains its reader to ignore it.
|
|
5572
|
+
if (flags.has('check') && !report.installed && report.errors.length === 0)
|
|
5573
|
+
return report.exitCode;
|
|
5574
|
+
if (flags.has('remove')) {
|
|
5575
|
+
write(`dz hooks-sync: removed ${report.removed} managed entr(ies) from ${report.registryPath}`);
|
|
5576
|
+
return report.exitCode;
|
|
5577
|
+
}
|
|
5578
|
+
// AM-17 / G-G: the success word is reachable ONLY from `report.ready` — armed AND trusted AND
|
|
5579
|
+
// WITNESSED blocking by a live, non-bypassed probe. The pre-fix version printed it off
|
|
5580
|
+
// `exitCode === 0 && trust && installed`, none of which is evidence that the guard fires.
|
|
5581
|
+
const summary = codexHooksSummary(report);
|
|
5582
|
+
for (const line of summary.stdout)
|
|
5583
|
+
write(line);
|
|
5584
|
+
for (const line of summary.stderr)
|
|
5585
|
+
writeErr(line);
|
|
5586
|
+
return report.exitCode;
|
|
5587
|
+
}
|
|
5588
|
+
/**
|
|
5589
|
+
* The argv → operation mapping, extracted so it can be PINNED.
|
|
5590
|
+
*
|
|
5591
|
+
* It is the mapping that was broken: `--verify`, `--no-verify` and `--project` were parsed,
|
|
5592
|
+
* validated, listed in the usage line — and then never reached `runSyncCodexHooks`, so the CRITICAL
|
|
5593
|
+
* finding (a `ready` with no live proof behind it) lived entirely in three missing object keys.
|
|
5594
|
+
* A function that returns the options object is testable without a codex binary; an inline literal
|
|
5595
|
+
* is not.
|
|
5596
|
+
*/
|
|
5597
|
+
export function codexHooksSyncOptions(input) {
|
|
5598
|
+
return {
|
|
5599
|
+
...(input.codexHome !== undefined ? { codexHome: input.codexHome } : {}),
|
|
5600
|
+
...(input.project !== undefined ? { project: input.project } : {}),
|
|
5601
|
+
check: input.check === true,
|
|
5602
|
+
remove: input.remove === true,
|
|
5603
|
+
verify: input.verify !== false,
|
|
5604
|
+
};
|
|
5605
|
+
}
|
|
5606
|
+
/**
|
|
5607
|
+
* What the user is told about a sync report — the ONE place the success word can be printed.
|
|
5608
|
+
*
|
|
5609
|
+
* `report.ready` is the whole gate: installed ∧ executable ∧ trusted ∧ a live, non-bypassed probe
|
|
5610
|
+
* that WITNESSED our block. Nothing else may print "ready" (AM-17 / G-G), and `--no-verify` never
|
|
5611
|
+
* can, because it never measured.
|
|
5612
|
+
*/
|
|
5613
|
+
export function codexHooksSummary(report, label = 'dz hooks-sync') {
|
|
5614
|
+
const stdout = [];
|
|
5615
|
+
const stderr = [];
|
|
5616
|
+
if (report.ready) {
|
|
5617
|
+
stdout.push(`${label}: codex hooks installed and ARMED (trust: ${report.trust}) — VERIFIED by a live veto probe — ready`);
|
|
5618
|
+
return { ok: true, stdout, stderr };
|
|
5619
|
+
}
|
|
5620
|
+
if (report.installed) {
|
|
5621
|
+
const verdict = report.verify === null ? 'not verified (no live probe ran)' : `${report.verify.verdict} — ${report.verify.reason}`;
|
|
5622
|
+
// Say what the report ESTABLISHED, not a hopeful summary of it: `installed+trusted` used to
|
|
5623
|
+
// print verbatim even when the same line went on to report `trust: unknown` (the re-QE's
|
|
5624
|
+
// non-closure note). A message that argues with its own parenthesis teaches the reader to skip
|
|
5625
|
+
// the parenthesis.
|
|
5626
|
+
const established = report.trust === 'trusted' ? 'installed+trusted' : `installed, trust ${report.trust}`;
|
|
5627
|
+
stderr.push(`${label}: ${established}, NOT verified — ARMED = NO (trust: ${report.trust}, executable: ${report.executable}, verify: ${verdict})`);
|
|
5628
|
+
stderr.push('→ open an interactive Codex session in this directory, approve the two dz hooks, then re-run `dz hooks-sync --target codex --verify`');
|
|
5629
|
+
}
|
|
5630
|
+
else {
|
|
5631
|
+
stderr.push(`${label}: ARMED = NO — the managed entries are not present in the registry`);
|
|
5632
|
+
}
|
|
5633
|
+
return { ok: false, stdout, stderr };
|
|
5634
|
+
}
|
|
5635
|
+
/**
|
|
5636
|
+
* ADR-001 §8: `dz setup` and `dz init --target codex` DELIVER the hooks and verify them.
|
|
5637
|
+
*
|
|
5638
|
+
* Non-aborting by contract (ADR-002 D6): the caller keeps going and folds `ok` into its own exit
|
|
5639
|
+
* code. Before this round no production path called `runSyncCodexHooks` at all — the operation, its
|
|
5640
|
+
* classifier and its exit map existed and were reachable only from the dedicated command
|
|
5641
|
+
* (independent review, finding 2).
|
|
5642
|
+
*/
|
|
5643
|
+
/**
|
|
5644
|
+
* Serialize dz's own `hooks.json` read-merge-write behind the `codex-hooks` named lock
|
|
5645
|
+
* (feature qe-bridge-claude, ADR-001 D4-A — the exit condition of the accepted degradation in
|
|
5646
|
+
* `architecture/degradations.md`).
|
|
5647
|
+
*
|
|
5648
|
+
* The lock lives BESIDE the registry it guards (`$CODEX_HOME/.dz/locks/codex-hooks.lock`), not in
|
|
5649
|
+
* this repo: two dz processes running from two different worktrees share a `CODEX_HOME`, not a
|
|
5650
|
+
* project root, so a lock under the project would serialize nothing.
|
|
5651
|
+
*
|
|
5652
|
+
* READ-ONLY runs (`--check`) are NOT locked: they write nothing, and a check that can be blocked by
|
|
5653
|
+
* a writer would be a new failure mode in exchange for no guarantee.
|
|
5654
|
+
*
|
|
5655
|
+
* HONEST LIMIT: this is an ADVISORY lock. It serializes dz-side writers only; a foreign installer
|
|
5656
|
+
* (ruvnet-brain ships its own Codex hooks bundle) never takes it. For that case the pre-existing
|
|
5657
|
+
* mitigations remain the backstop — foreign entries preserved byte-for-byte, a timestamped backup
|
|
5658
|
+
* before every modifying write, and atomic temp+rename so no reader sees a partial file.
|
|
5659
|
+
*/
|
|
5660
|
+
function runSyncCodexHooksLocked(options = {}) {
|
|
5661
|
+
if (options?.check === true)
|
|
5662
|
+
return runSyncCodexHooks(options);
|
|
5663
|
+
const codexHome = resolveCodexHome(options?.codexHome);
|
|
5664
|
+
// AM-35a is a SHIPPED property with a test: a run that refuses (no `codex` on PATH) must leave
|
|
5665
|
+
// CODEX_HOME untouched — dz does not create user-global state for a runtime that is not there.
|
|
5666
|
+
// Taking the lock creates `<codexHome>/.dz/locks/`, so when the guarded operation turned out to
|
|
5667
|
+
// write nothing, the lock scaffolding is removed again (empty-dir removals only: a directory that
|
|
5668
|
+
// still holds another process's live lock simply refuses to go).
|
|
5669
|
+
const preexisting = existsSync(join(codexHome, '.dz'));
|
|
5670
|
+
const tidyLockScaffold = (report) => {
|
|
5671
|
+
if (preexisting || report.written || report.writes.length > 0)
|
|
5672
|
+
return;
|
|
5673
|
+
for (const dir of [join(codexHome, '.dz', 'locks'), join(codexHome, '.dz')]) {
|
|
5674
|
+
try {
|
|
5675
|
+
rmdirSync(dir);
|
|
5676
|
+
}
|
|
5677
|
+
catch { /* non-empty (someone else's lock) or gone — leave it */ }
|
|
5678
|
+
}
|
|
5679
|
+
};
|
|
5680
|
+
// ROUND-2 C2: the lock now wraps ONLY the registry read-plan-write transaction, passed in as the
|
|
5681
|
+
// operation's `criticalSection` seam. It used to wrap the WHOLE operation, including a live veto
|
|
5682
|
+
// probe that can block for ~300s — ten times the 30s stale threshold, after which a waiter is
|
|
5683
|
+
// entitled to break the lock and the holder is no longer excluding anyone. The probes mutate
|
|
5684
|
+
// nothing shared, so they run unlocked by design.
|
|
5685
|
+
let lockError = null;
|
|
5686
|
+
const report = runSyncCodexHooks({
|
|
5687
|
+
...options,
|
|
5688
|
+
criticalSection: (fn) => {
|
|
5689
|
+
try {
|
|
5690
|
+
return withNamedLockSync(codexHome, 'codex-hooks', fn);
|
|
5691
|
+
}
|
|
5692
|
+
catch (error) {
|
|
5693
|
+
if (error instanceof NamedLockTimeoutError || error instanceof NamedLockCompromisedError) {
|
|
5694
|
+
lockError = error;
|
|
5695
|
+
throw error;
|
|
5696
|
+
}
|
|
5697
|
+
throw error;
|
|
5698
|
+
}
|
|
5699
|
+
},
|
|
5700
|
+
});
|
|
5701
|
+
tidyLockScaffold(report);
|
|
5702
|
+
if (lockError !== null)
|
|
5703
|
+
throw lockError;
|
|
5704
|
+
return report;
|
|
5705
|
+
}
|
|
5706
|
+
/** Wrap the delivery so a lock refusal becomes a REPORT (loud, nothing written), not a stack trace. */
|
|
5707
|
+
function runSyncCodexHooksGuarded(options = {}) {
|
|
5708
|
+
const codexHome = resolveCodexHome(options?.codexHome);
|
|
5709
|
+
try {
|
|
5710
|
+
return runSyncCodexHooksLocked(options);
|
|
5711
|
+
}
|
|
5712
|
+
catch (error) {
|
|
5713
|
+
if (error instanceof NamedLockTimeoutError || error instanceof NamedLockCompromisedError) {
|
|
5714
|
+
const why = error instanceof NamedLockTimeoutError
|
|
5715
|
+
? `another dz process is writing ${join(codexHome, 'hooks.json')} (${error.message}) — NOTHING was written; retry once it finishes`
|
|
5716
|
+
: `the codex-hooks lock was broken while this run held it (${error.message}) — the registry write may have raced; re-run and re-verify`;
|
|
5717
|
+
return {
|
|
5718
|
+
codexHome,
|
|
5719
|
+
registryPath: join(codexHome, 'hooks.json'),
|
|
5720
|
+
installed: false,
|
|
5721
|
+
executable: false,
|
|
5722
|
+
written: false,
|
|
5723
|
+
removed: 0,
|
|
5724
|
+
foreignPreserved: 0,
|
|
5725
|
+
unattributable: 0,
|
|
5726
|
+
drift: [],
|
|
5727
|
+
trust: 'unknown',
|
|
5728
|
+
codexVersion: null,
|
|
5729
|
+
writes: [],
|
|
5730
|
+
verify: null,
|
|
5731
|
+
verified: false,
|
|
5732
|
+
ready: false,
|
|
5733
|
+
exitCode: 1,
|
|
5734
|
+
warnings: [],
|
|
5735
|
+
errors: [why],
|
|
5736
|
+
};
|
|
5737
|
+
}
|
|
5738
|
+
throw error;
|
|
5739
|
+
}
|
|
5740
|
+
}
|
|
5741
|
+
export function deliverCodexHooks(input, sync = runSyncCodexHooksGuarded, label = 'dz setup') {
|
|
5742
|
+
const report = sync(codexHooksSyncOptions(input));
|
|
5743
|
+
const summary = codexHooksSummary(report, label);
|
|
5744
|
+
const stderr = [...report.errors.map((e) => `${label}: ${e}`), ...report.warnings.map((w) => `${label}: warning: ${w}`), ...summary.stderr];
|
|
5745
|
+
return { ok: summary.ok, stdout: summary.stdout, stderr, report };
|
|
5746
|
+
}
|
|
5747
|
+
function cmdAgentsSync(options, flags, cwd, write, writeErr) {
|
|
5748
|
+
const json = flags.has('json');
|
|
5749
|
+
const usage = 'dz agents-sync [--project <dir>] [--check] [--json]';
|
|
5750
|
+
if (flags.has('help')) {
|
|
5751
|
+
write(`${usage} — sync/verify the dz:policies fence in root AGENTS.md`);
|
|
5752
|
+
return 0;
|
|
5753
|
+
}
|
|
5754
|
+
for (const flag of flags) {
|
|
5755
|
+
if (!['check', 'json', 'help'].includes(flag)) {
|
|
5756
|
+
const message = `dz agents-sync: unknown option --${flag}\n${usage}`;
|
|
5757
|
+
(json ? write : writeErr)(json ? JSON.stringify({ error: `unknown option --${flag}`, exitCode: 1 }) : message);
|
|
5758
|
+
return 1;
|
|
5759
|
+
}
|
|
5760
|
+
}
|
|
5761
|
+
for (const key of options.keys()) {
|
|
5762
|
+
if (key !== 'project') {
|
|
5763
|
+
const message = key.startsWith('_positional_') ? `unexpected argument ${JSON.stringify(options.get(key))}` : `unknown option --${key}`;
|
|
5764
|
+
(json ? write : writeErr)(json ? JSON.stringify({ error: message, exitCode: 1 }) : `dz agents-sync: ${message}\n${usage}`);
|
|
5765
|
+
return 1;
|
|
5766
|
+
}
|
|
5767
|
+
}
|
|
5768
|
+
const root = resolve(cwd, options.get('project') ?? '.');
|
|
5769
|
+
try {
|
|
5770
|
+
const report = runSyncAgentsPolicy({ projectRoot: root, check: flags.has('check') });
|
|
5771
|
+
const drifted = report.drift.filter((finding) => finding.status !== 'ok');
|
|
5772
|
+
const inconclusive = report.missing.length > 0;
|
|
5773
|
+
const failed = flags.has('check')
|
|
5774
|
+
? report.changed || report.budget.overflow || drifted.length > 0
|
|
5775
|
+
: !report.inSync;
|
|
5776
|
+
const exitCode = inconclusive ? 3 : failed ? 1 : 0;
|
|
5777
|
+
if (json) {
|
|
5778
|
+
write(JSON.stringify({ ...report, sections: report.blocks, exitCode }));
|
|
5779
|
+
return exitCode;
|
|
5780
|
+
}
|
|
5781
|
+
if (inconclusive) {
|
|
5782
|
+
writeErr(`dz agents-sync: INCONCLUSIVE — unreadable or unanchored policy source(s): ${report.missing.join(', ')}`);
|
|
5783
|
+
writeErr('→ heal with: restore the named source anchors, then run dz agents-sync');
|
|
5784
|
+
return 3;
|
|
5785
|
+
}
|
|
5786
|
+
if (failed) {
|
|
5787
|
+
const effect = flags.has('check') ? 'AGENTS.md would change' : 'AGENTS.md was not rewritten';
|
|
5788
|
+
writeErr(`dz agents-sync: DRIFT — ${drifted.length} stale/missing section(s); ${effect}`);
|
|
5789
|
+
for (const finding of drifted)
|
|
5790
|
+
writeErr(` ${finding.id}: ${finding.file} (${finding.status})`);
|
|
5791
|
+
if (drifted.some((finding) => finding.id === 'dz:policies')) {
|
|
5792
|
+
writeErr('→ heal with: repair duplicate/unmatched dz:policies markers, then run dz agents-sync');
|
|
5793
|
+
}
|
|
5794
|
+
else {
|
|
5795
|
+
writeErr('→ heal with: dz agents-sync');
|
|
5796
|
+
}
|
|
5797
|
+
return 1;
|
|
5798
|
+
}
|
|
5799
|
+
const verb = report.written ? 'wrote' : 'in sync';
|
|
5800
|
+
write(`dz agents-sync: ${verb} — ${report.blocks.length} policy section(s), ${report.budget.bytes} bytes (${report.budget.pct}% of ${report.budget.cap})`);
|
|
5801
|
+
for (const warning of report.warnings)
|
|
5802
|
+
writeErr(`dz agents-sync: warning: ${warning}`);
|
|
5803
|
+
return 0;
|
|
5804
|
+
}
|
|
5805
|
+
catch (error) {
|
|
5806
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
5807
|
+
if (json)
|
|
5808
|
+
write(JSON.stringify({ error: message, exitCode: 1 }));
|
|
5809
|
+
else
|
|
5810
|
+
writeErr(`dz agents-sync: ${message}`);
|
|
5811
|
+
return 1;
|
|
5812
|
+
}
|
|
5813
|
+
}
|
|
5276
5814
|
function cmdDriftCheck(options, flags, cwd, write) {
|
|
5277
5815
|
const root = resolve(cwd, options.get('project') ?? '.');
|
|
5278
5816
|
// Default scope = PUBLISHED packages only: the `.claude/skills` dogfood copies legitimately lag the
|
|
@@ -5380,6 +5918,38 @@ function gatherReadmeCounts(root) {
|
|
|
5380
5918
|
function gatherGuardFacts(op, root, text, storeCap) {
|
|
5381
5919
|
const facts = { op };
|
|
5382
5920
|
if (op === 'publish') {
|
|
5921
|
+
// agents-md-policy-sync: fixed registry, no tree walk. The pure detector
|
|
5922
|
+
// recomputes every expected hash from current source text; this gatherer
|
|
5923
|
+
// only supplies bytes. Any unexpected gather failure omits the fact, and
|
|
5924
|
+
// evaluateGuard records that advisory coverage gap in `notes`.
|
|
5925
|
+
try {
|
|
5926
|
+
const policyFiles = new Map();
|
|
5927
|
+
for (const file of new Set(POLICY_SOURCES.map((source) => source.file))) {
|
|
5928
|
+
try {
|
|
5929
|
+
policyFiles.set(file, readFileSync(join(root, file), 'utf8'));
|
|
5930
|
+
}
|
|
5931
|
+
catch {
|
|
5932
|
+
policyFiles.set(file, null);
|
|
5933
|
+
}
|
|
5934
|
+
}
|
|
5935
|
+
let agentsMd = null;
|
|
5936
|
+
try {
|
|
5937
|
+
agentsMd = readFileSync(join(root, 'AGENTS.md'), 'utf8');
|
|
5938
|
+
}
|
|
5939
|
+
catch { /* missing stamp evidence */ }
|
|
5940
|
+
const policyDrift = detectPolicyDrift(policyFiles, agentsMd, POLICY_SOURCES);
|
|
5941
|
+
facts['policyDrift'] = {
|
|
5942
|
+
applicable: policyDrift.applicable,
|
|
5943
|
+
// Did this repo OPT IN? A `dz:policies` fence in AGENTS.md is the only durable signal that
|
|
5944
|
+
// someone ran `dz agents-sync` here. Without it the advisory rule is out of scope and stays
|
|
5945
|
+
// silent; with it, unreadable sources become a loud note instead of a silent skip.
|
|
5946
|
+
fenced: hasPolicyFence(agentsMd),
|
|
5947
|
+
drifted: policyDrift.findings
|
|
5948
|
+
.filter((finding) => finding.status !== 'ok')
|
|
5949
|
+
.map((finding) => `${finding.id}:${finding.file}:${finding.status}`),
|
|
5950
|
+
};
|
|
5951
|
+
}
|
|
5952
|
+
catch { /* unexpected gather failure — omission becomes a visible guard note */ }
|
|
5383
5953
|
const manifests = [];
|
|
5384
5954
|
const located = [];
|
|
5385
5955
|
try {
|
|
@@ -6494,7 +7064,7 @@ async function cmdRetro(options, flags, cwd, write) {
|
|
|
6494
7064
|
* --from-spec <spec.json> preview the scaffold (create / augment per file); the SKILL fills the spec
|
|
6495
7065
|
* --apply with --from-spec: WRITE (create missing, AUGMENT existing — never clobber)
|
|
6496
7066
|
*/
|
|
6497
|
-
function cmdFeatureAdrSetup(options, flags, cwd, write) {
|
|
7067
|
+
function cmdFeatureAdrSetup(options, flags, cwd, write, writeErr) {
|
|
6498
7068
|
let repoRoot = cwd;
|
|
6499
7069
|
try {
|
|
6500
7070
|
repoRoot = execSync('git rev-parse --show-toplevel', { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || cwd;
|
|
@@ -6514,11 +7084,25 @@ function cmdFeatureAdrSetup(options, flags, cwd, write) {
|
|
|
6514
7084
|
// Its "runnable here" list is computed for --target (default agents-md, the AGENTS.md-class target class).
|
|
6515
7085
|
const wantGates = flags.has('gates');
|
|
6516
7086
|
const targetOpt = options.get('target');
|
|
6517
|
-
|
|
6518
|
-
|
|
6519
|
-
|
|
7087
|
+
// Sites 7 AND 8 of the D3 rewiring. Site 8 (the coercion below) is NOT a guard — it
|
|
7088
|
+
// is a silent fallback, and a mechanical "replace isTargetName with resolveTargetName"
|
|
7089
|
+
// pass would miss it. With aliasing in place and the coercion left alone,
|
|
7090
|
+
// `dz feature-adr-setup --gates --target claude` would pass validation and then emit
|
|
7091
|
+
// for **agents-md**. So the coercion CONSUMES the resolution computed once, above;
|
|
7092
|
+
// `agents-md` is the default only when `--target` is ABSENT.
|
|
7093
|
+
let gatesTarget = 'agents-md';
|
|
7094
|
+
if (targetOpt !== undefined) {
|
|
7095
|
+
const gatesResolution = resolveTargetName(targetOpt);
|
|
7096
|
+
if (gatesResolution.kind === 'unknown') {
|
|
7097
|
+
for (const line of formatTargetProblem('dz feature-adr-setup', gatesResolution))
|
|
7098
|
+
writeErr(line);
|
|
7099
|
+
return 1;
|
|
7100
|
+
}
|
|
7101
|
+
gatesTarget = gatesResolution.target;
|
|
7102
|
+
if (gatesResolution.via === 'alias') {
|
|
7103
|
+
writeErr(formatTargetAliasNote('dz feature-adr-setup', targetOpt, gatesTarget));
|
|
7104
|
+
}
|
|
6520
7105
|
}
|
|
6521
|
-
const gatesTarget = isTargetName(targetOpt ?? '') ? targetOpt : 'agents-md';
|
|
6522
7106
|
const specPath = options.get('from-spec');
|
|
6523
7107
|
if (specPath === undefined && !wantGuards && !wantGates) {
|
|
6524
7108
|
// default + --plan: the read-only "which documents, and where?" answer.
|
|
@@ -6652,7 +7236,15 @@ function cmdChallenge(options, flags, cwd, write) {
|
|
|
6652
7236
|
* --base <ref> the base ref to fail against (default HEAD)
|
|
6653
7237
|
* --name '<filter>' optional -t test-name filter applied to every target
|
|
6654
7238
|
* --runner '<cmd>' test runner (default `npx vitest run`)
|
|
6655
|
-
* --
|
|
7239
|
+
* --timeout <ms> per-run timeout (default 300000; a timed-out run is CANNOT_ISOLATE)
|
|
7240
|
+
* --json machine-readable {plan, results, tipTree, perTest, aggregate,
|
|
7241
|
+
* findings, measurementValid, primaryAction}
|
|
7242
|
+
*
|
|
7243
|
+
* This executor is THIN by design (house style: pure classifier + thin executor). It performs exactly the
|
|
7244
|
+
* I/O the pure gate cannot — stat, worktree, run, capture — and hands OBSERVATIONS back. It no longer
|
|
7245
|
+
* interprets anything: the pre-epoch load-error regex that lived here (`/cannot find module|failed to
|
|
7246
|
+
* load|.../i`) is DELETED, because a regex over a runner's stderr, written in the executor, is exactly the
|
|
7247
|
+
* probabilistic channel that minted `DISCRIMINATES` for `--runner false`.
|
|
6656
7248
|
*
|
|
6657
7249
|
* NEVER auto-aborts: a non-discriminating (false-green) test is reported as a HIGH finding for the owner to
|
|
6658
7250
|
* decide (dz's rule — a false gate kills trust). Exit code is 0 on a clean run regardless of verdict; 2 only on
|
|
@@ -6673,6 +7265,9 @@ function cmdDiscriminationCheck(options, flags, cwd, write) {
|
|
|
6673
7265
|
const propertyTests = testArg.split(',').map((s) => s.trim()).filter(Boolean).map((file) => nameFilter !== undefined && nameFilter.trim() !== '' ? { file, name: nameFilter.trim() } : { file });
|
|
6674
7266
|
const baseRef = options.get('base') ?? 'HEAD';
|
|
6675
7267
|
const runnerOpt = options.get('runner');
|
|
7268
|
+
// R11: a hung runner is a loud non-answer, never a pass. Same default + parse shape as mutation-gate.
|
|
7269
|
+
const timeoutOpt = Number(options.get('timeout') ?? '300000');
|
|
7270
|
+
const timeoutMs = Number.isFinite(timeoutOpt) && timeoutOpt > 0 ? timeoutOpt : 300000;
|
|
6676
7271
|
const plan = planDiscriminationCheck(runnerOpt !== undefined ? { baseRef, propertyTests, runner: runnerOpt } : { baseRef, propertyTests });
|
|
6677
7272
|
if (!plan.runnable) {
|
|
6678
7273
|
// No safe target to run → this is the existing "property untested" finding (empty propertyTests classify).
|
|
@@ -6687,113 +7282,218 @@ function cmdDiscriminationCheck(options, flags, cwd, write) {
|
|
|
6687
7282
|
write(` → ${result.finding?.detail ?? 'no property test to check'}`);
|
|
6688
7283
|
return 0;
|
|
6689
7284
|
}
|
|
6690
|
-
//
|
|
6691
|
-
//
|
|
6692
|
-
|
|
6693
|
-
|
|
6694
|
-
|
|
6695
|
-
|
|
6696
|
-
|
|
6697
|
-
|
|
6698
|
-
|
|
6699
|
-
|
|
6700
|
-
|
|
6701
|
-
|
|
6702
|
-
const
|
|
6703
|
-
|
|
6704
|
-
|
|
6705
|
-
|
|
6706
|
-
|
|
6707
|
-
|
|
6708
|
-
|
|
6709
|
-
|
|
6710
|
-
|
|
6711
|
-
|
|
6712
|
-
|
|
6713
|
-
|
|
6714
|
-
|
|
6715
|
-
|
|
6716
|
-
|
|
6717
|
-
|
|
6718
|
-
|
|
6719
|
-
|
|
6720
|
-
|
|
6721
|
-
|
|
7285
|
+
// ── (1) stat + isFile, BEFORE the worktree (AM-6 / FR-A1) ──────────────────────────────────
|
|
7286
|
+
// R12: `stat` FOLLOWS symlinks on purpose. A dangling symlink lstat-exists but has no readable
|
|
7287
|
+
// content — that IS absence of the named check. A directory stat-exists but is not a regular
|
|
7288
|
+
// file. Pre-epoch both reached the copy step, threw, and were caught into `outcome:'error'`,
|
|
7289
|
+
// which minted the near-pass DISCRIMINATES_VIA_ERROR (MEASURED — acid A1 / the dangling-symlink
|
|
7290
|
+
// and directory rows of features/wave1-instrument-repair/07_code_changes/acid-red-runs.md).
|
|
7291
|
+
const absent = [];
|
|
7292
|
+
const present = [];
|
|
7293
|
+
for (const t of plan.targets) {
|
|
7294
|
+
let isRegular = false;
|
|
7295
|
+
let isDirectory = false;
|
|
7296
|
+
try {
|
|
7297
|
+
const st = statSync(resolve(repoRoot, t.file));
|
|
7298
|
+
isRegular = st.isFile();
|
|
7299
|
+
isDirectory = st.isDirectory();
|
|
7300
|
+
}
|
|
7301
|
+
catch { /* ENOENT / dangling symlink / permission — absence, either way */ }
|
|
7302
|
+
if (isRegular) {
|
|
7303
|
+
present.push(t.name !== undefined ? { file: t.file, name: t.name } : { file: t.file });
|
|
7304
|
+
continue;
|
|
7305
|
+
}
|
|
7306
|
+
const row = t.name !== undefined ? { file: t.file, name: t.name, outcome: 'absent' } : { file: t.file, outcome: 'absent' };
|
|
7307
|
+
// Out-of-band detail channel: TEST_FILE_ABSENT is evidence-EXEMPT (its evidence is the stat
|
|
7308
|
+
// itself), and absent rows never consult the evidence gate — so this object cannot degrade the
|
|
7309
|
+
// row. It exists only so the finding can tell the operator WHY the path is not a test file.
|
|
7310
|
+
absent.push(isDirectory
|
|
7311
|
+
? { ...row, evidence: { exitCode: null, runner: 'unrecognised', failureKind: 'unrecognised', testsExecuted: null, targetSeen: false, evidenceLine: 'not-a-regular-file' } }
|
|
7312
|
+
: row);
|
|
7313
|
+
}
|
|
7314
|
+
const results = [...absent];
|
|
7315
|
+
let tipTree = null;
|
|
7316
|
+
// Confirmation 12: with nothing present there is nothing to run — no worktree is built at all.
|
|
7317
|
+
if (present.length > 0) {
|
|
7318
|
+
const runner = runnerOpt !== undefined && plan.commands.some((c) => c.includes(runnerOpt)) ? runnerOpt : 'npx vitest run';
|
|
7319
|
+
// Execute the plan in a temp worktree WE own; substitute {{WORKTREE}} and always clean up.
|
|
7320
|
+
// `git worktree add` must CREATE the path, so compute a fresh non-existent one (do NOT mkdtemp it).
|
|
7321
|
+
const worktree = join(mkdtempSync(join(tmpdir(), 'dz-disc-')), 'wt');
|
|
7322
|
+
try {
|
|
7323
|
+
// 1) add the detached worktree at base (git creates `worktree`; its parent already exists).
|
|
7324
|
+
const addCmd = plan.commands[0].replace(/\{\{WORKTREE\}\}/g, worktree);
|
|
7325
|
+
execSync(addCmd, { cwd: repoRoot, stdio: 'pipe', encoding: 'utf-8' });
|
|
7326
|
+
// 1b) a fresh worktree has NO node_modules — without this, every test fails to load (runner + deps
|
|
7327
|
+
// unresolvable) and the gate collapses to always-VIA_ERROR, blind to false greens. Absolute-path
|
|
7328
|
+
// symlinks point back at the main checkout's already-installed trees, robust across pnpm's layout.
|
|
7329
|
+
const linkNodeModules = (relDir) => {
|
|
7330
|
+
const srcNm = join(repoRoot, relDir, 'node_modules');
|
|
7331
|
+
if (!existsSync(srcNm))
|
|
7332
|
+
return;
|
|
7333
|
+
const dstNm = join(worktree, relDir, 'node_modules');
|
|
7334
|
+
if (existsSync(dstNm))
|
|
7335
|
+
return;
|
|
7336
|
+
try {
|
|
7337
|
+
mkdirSync(dirname(dstNm), { recursive: true });
|
|
7338
|
+
symlinkSync(srcNm, dstNm, 'dir');
|
|
7339
|
+
}
|
|
7340
|
+
catch { /* best effort */ }
|
|
7341
|
+
};
|
|
7342
|
+
linkNodeModules('.'); // root (hoisted deps + .bin)
|
|
7343
|
+
const pkgDirs = new Set();
|
|
7344
|
+
for (const t of present) {
|
|
7345
|
+
let d = dirname(t.file);
|
|
7346
|
+
while (d && d !== '.' && d !== sep) {
|
|
7347
|
+
if (existsSync(join(repoRoot, d, 'package.json'))) {
|
|
7348
|
+
pkgDirs.add(d);
|
|
7349
|
+
break;
|
|
7350
|
+
}
|
|
7351
|
+
d = dirname(d);
|
|
6722
7352
|
}
|
|
6723
|
-
d = dirname(d);
|
|
6724
7353
|
}
|
|
6725
|
-
|
|
6726
|
-
|
|
6727
|
-
|
|
6728
|
-
|
|
6729
|
-
|
|
6730
|
-
|
|
6731
|
-
|
|
6732
|
-
|
|
6733
|
-
|
|
7354
|
+
for (const d of pkgDirs)
|
|
7355
|
+
linkNodeModules(d);
|
|
7356
|
+
// 2) copy each property test into the base worktree, then 3) run it and record the OBSERVATION.
|
|
7357
|
+
for (const t of present) {
|
|
7358
|
+
try {
|
|
7359
|
+
const src = resolve(repoRoot, t.file);
|
|
7360
|
+
// containment guard (defense in depth beyond planDiscriminationCheck's path sanitation).
|
|
7361
|
+
if (!resolve(src).startsWith(resolve(repoRoot) + sep)) {
|
|
7362
|
+
results.push(nameFor(t, 'error'));
|
|
7363
|
+
continue;
|
|
7364
|
+
}
|
|
7365
|
+
const dst = join(worktree, t.file);
|
|
7366
|
+
mkdirSync(dirname(dst), { recursive: true });
|
|
7367
|
+
writeFileSync(dst, readFileSync(src));
|
|
7368
|
+
}
|
|
7369
|
+
catch {
|
|
7370
|
+
// the file STAT-PASSED and the copy still failed: degrade LOUDLY as an error with NO
|
|
7371
|
+
// evidence (the gate reads it as CANNOT_ISOLATE), never as absence and never as a pass.
|
|
6734
7372
|
results.push(nameFor(t, 'error'));
|
|
6735
7373
|
continue;
|
|
6736
7374
|
}
|
|
6737
|
-
|
|
6738
|
-
|
|
6739
|
-
|
|
7375
|
+
// t.file + t.name already passed the engine's strict sanitation (no quotes/metacharacters/leading-dash);
|
|
7376
|
+
// still quote + `--` so a path can never be read as a runner option or split a word.
|
|
7377
|
+
const nameArg = t.name ? ` -t '${t.name}'` : '';
|
|
7378
|
+
const cmd = `${runner}${nameArg} -- '${t.file}'`;
|
|
7379
|
+
const base = runCapturedTest(cmd, worktree, timeoutMs);
|
|
7380
|
+
const evidence = classifyExecutionEvidence(base.output, base.exitCode, t.file);
|
|
7381
|
+
const outcome = discriminationOutcomeOf(base.exitCode, evidence);
|
|
7382
|
+
const row = t.name !== undefined
|
|
7383
|
+
? { file: t.file, name: t.name, outcome, evidence }
|
|
7384
|
+
: { file: t.file, outcome, evidence };
|
|
7385
|
+
// 4) TIP CONTROL (FR-A2 + Confirmation 17). Run it for ALL non-assertion redness — file-load
|
|
7386
|
+
// redness (the matrix's EVIDENCED-error rows) AND unrecognised redness (so the invocation
|
|
7387
|
+
// ledger can prove the tip was REACHED). The CLASSIFIER still ignores the tip for unevidenced
|
|
7388
|
+
// base rows per the matrix; running it is cheap and only ever on an already-broken path.
|
|
7389
|
+
// Do NOT "simplify" this to evidenced-error-only — that silently breaks Confirmation 17.
|
|
7390
|
+
if (base.exitCode !== null && base.exitCode !== 0 && evidence.failureKind !== 'assertions') {
|
|
7391
|
+
const tip = runCapturedTest(cmd, repoRoot, timeoutMs);
|
|
7392
|
+
const tipEvidence = classifyExecutionEvidence(tip.output, tip.exitCode, t.file);
|
|
7393
|
+
row['tipOutcome'] = discriminationOutcomeOf(tip.exitCode, tipEvidence);
|
|
7394
|
+
row['tipEvidence'] = tipEvidence;
|
|
7395
|
+
// R15, named honestly: the base run is isolated in a worktree, but the tip runs in the LIVE
|
|
7396
|
+
// tree, where a concurrent writer can flip the observation mid-gate. No lock is taken
|
|
7397
|
+
// (deferred to backlog 9520e506); instead every tip-derived reading carries the tree
|
|
7398
|
+
// CONDITIONS it was taken under, so a surprising verdict can be re-read against them.
|
|
7399
|
+
if (tipTree === null)
|
|
7400
|
+
tipTree = readTipTreeConditions(repoRoot);
|
|
7401
|
+
}
|
|
7402
|
+
results.push(row);
|
|
6740
7403
|
}
|
|
6741
|
-
|
|
6742
|
-
|
|
6743
|
-
|
|
7404
|
+
}
|
|
7405
|
+
catch (e) {
|
|
7406
|
+
if (flags.has('json')) {
|
|
7407
|
+
write(JSON.stringify({ plan, error: 'worktree-setup-failed', detail: String(e.message).slice(0, 300) }, null, 2));
|
|
6744
7408
|
}
|
|
6745
|
-
|
|
6746
|
-
|
|
6747
|
-
|
|
6748
|
-
|
|
7409
|
+
else
|
|
7410
|
+
write(`discrimination-check: could not create worktree at ${baseRef}: ${String(e.message).slice(0, 200)}`);
|
|
7411
|
+
return 2;
|
|
7412
|
+
}
|
|
7413
|
+
finally {
|
|
6749
7414
|
try {
|
|
6750
|
-
execSync(
|
|
6751
|
-
results.push(nameFor(t, 'pass')); // exit 0 → test PASSED at base → false green
|
|
7415
|
+
execSync(`git worktree remove --force ${worktree}`, { cwd: repoRoot, stdio: 'pipe' });
|
|
6752
7416
|
}
|
|
6753
|
-
catch
|
|
6754
|
-
|
|
6755
|
-
|
|
6756
|
-
|
|
6757
|
-
const isLoadError = /cannot find module|failed to load|no test (files )?found|error: cannot|transform failed|esbuild/i.test(out);
|
|
6758
|
-
results.push(nameFor(t, isLoadError ? 'error' : 'fail'));
|
|
7417
|
+
catch { /* fall through to rm */ }
|
|
7418
|
+
// remove the whole mkdtemp parent (worktree is `<mkdtemp>/wt`), so nothing leaks under tmp even on error.
|
|
7419
|
+
try {
|
|
7420
|
+
rmSync(dirname(worktree), { recursive: true, force: true });
|
|
6759
7421
|
}
|
|
7422
|
+
catch { /* best effort */ }
|
|
7423
|
+
try {
|
|
7424
|
+
execSync('git worktree prune', { cwd: repoRoot, stdio: 'pipe' });
|
|
7425
|
+
}
|
|
7426
|
+
catch { /* best effort */ }
|
|
6760
7427
|
}
|
|
6761
7428
|
}
|
|
6762
|
-
catch (e) {
|
|
6763
|
-
if (flags.has('json')) {
|
|
6764
|
-
write(JSON.stringify({ plan, error: 'worktree-setup-failed', detail: String(e.message).slice(0, 300) }, null, 2));
|
|
6765
|
-
}
|
|
6766
|
-
else
|
|
6767
|
-
write(`discrimination-check: could not create worktree at ${baseRef}: ${String(e.message).slice(0, 200)}`);
|
|
6768
|
-
return 2;
|
|
6769
|
-
}
|
|
6770
|
-
finally {
|
|
6771
|
-
try {
|
|
6772
|
-
execSync(`git worktree remove --force ${worktree}`, { cwd: repoRoot, stdio: 'pipe' });
|
|
6773
|
-
}
|
|
6774
|
-
catch { /* fall through to rm */ }
|
|
6775
|
-
// remove the whole mkdtemp parent (worktree is `<mkdtemp>/wt`), so nothing leaks under tmp even on error.
|
|
6776
|
-
try {
|
|
6777
|
-
rmSync(dirname(worktree), { recursive: true, force: true });
|
|
6778
|
-
}
|
|
6779
|
-
catch { /* best effort */ }
|
|
6780
|
-
try {
|
|
6781
|
-
execSync('git worktree prune', { cwd: repoRoot, stdio: 'pipe' });
|
|
6782
|
-
}
|
|
6783
|
-
catch { /* best effort */ }
|
|
6784
|
-
}
|
|
6785
7429
|
const result = classifyDiscrimination({ propertyTests, results });
|
|
6786
7430
|
if (flags.has('json')) {
|
|
6787
|
-
write(JSON.stringify({ plan, results, ...result }, null, 2));
|
|
7431
|
+
write(JSON.stringify({ plan, results, tipTree, ...result }, null, 2));
|
|
6788
7432
|
return 0;
|
|
6789
7433
|
}
|
|
6790
7434
|
write(`discrimination-check @ ${baseRef} — verdict: ${result.aggregate}`);
|
|
6791
|
-
for (const p of result.perTest)
|
|
6792
|
-
|
|
6793
|
-
|
|
6794
|
-
|
|
7435
|
+
for (const p of result.perTest) {
|
|
7436
|
+
// FR-A6: ✓ is reserved for the two ESTABLISHED trust verdicts. Every other value — including
|
|
7437
|
+
// every degraded reading — renders ✗, because a ✗ the operator investigates beats a ✓ that
|
|
7438
|
+
// silently meant "we could not tell".
|
|
7439
|
+
const mark = p.verdict === 'DISCRIMINATES' || p.verdict === 'DISCRIMINATES_VIA_ERROR' ? '✓' : '✗';
|
|
7440
|
+
write(` ${mark} ${p.file}${p.name ? ` (${p.name})` : ''}: ${p.verdict}${p.reason ? ` (reason: ${p.reason})` : ''}`);
|
|
7441
|
+
}
|
|
7442
|
+
write(` measurementValid: ${String(result.measurementValid)} · primaryAction: ${result.primaryAction}`);
|
|
7443
|
+
// ALL findings print, not just the worst: the scalar aggregate names one state, and a corpus with
|
|
7444
|
+
// a false green AND an absent file has two problems, each with its own operator action.
|
|
7445
|
+
for (const f of result.findings)
|
|
7446
|
+
write(`\n [${f.severity}] ${f.title}\n ${f.detail}`);
|
|
6795
7447
|
return 0;
|
|
6796
7448
|
}
|
|
7449
|
+
/**
|
|
7450
|
+
* Run one test command and CAPTURE the observation — output plus the exit code, including the
|
|
7451
|
+
* "no exit code at all" case. `execSync`'s timeout kills the child via signal and leaves
|
|
7452
|
+
* `status` null; a spawn failure does the same. That null is not an error to swallow, it is the
|
|
7453
|
+
* evidence (`CANNOT_ISOLATE` reason `'timeout'`), so it is returned as data.
|
|
7454
|
+
*/
|
|
7455
|
+
function runCapturedTest(cmd, cwd, timeoutMs) {
|
|
7456
|
+
try {
|
|
7457
|
+
const stdout = execSync(cmd, { cwd, stdio: 'pipe', encoding: 'utf-8', timeout: timeoutMs });
|
|
7458
|
+
return { output: String(stdout ?? ''), exitCode: 0 };
|
|
7459
|
+
}
|
|
7460
|
+
catch (e) {
|
|
7461
|
+
const err = e;
|
|
7462
|
+
return {
|
|
7463
|
+
output: String(err.stdout ?? '') + String(err.stderr ?? ''),
|
|
7464
|
+
exitCode: typeof err.status === 'number' ? err.status : null,
|
|
7465
|
+
};
|
|
7466
|
+
}
|
|
7467
|
+
}
|
|
7468
|
+
/**
|
|
7469
|
+
* The outcome VALUE for one captured run. The executor's whole remaining judgment, and it is
|
|
7470
|
+
* mechanical: exit 0 is a pass, no exit code is an error, and a non-zero exit is an error only when
|
|
7471
|
+
* the classifier RECOGNISED a file-load failure. An unrecognised red is deliberately recorded as a
|
|
7472
|
+
* `fail` VALUE whose evidence then degrades it — exactly acid A6's pinned shape, and the reason the
|
|
7473
|
+
* executor no longer owns a regex.
|
|
7474
|
+
*/
|
|
7475
|
+
function discriminationOutcomeOf(exitCode, evidence) {
|
|
7476
|
+
if (exitCode === null)
|
|
7477
|
+
return 'error';
|
|
7478
|
+
if (exitCode === 0)
|
|
7479
|
+
return 'pass';
|
|
7480
|
+
return evidence.failureKind === 'file-load' ? 'error' : 'fail';
|
|
7481
|
+
}
|
|
7482
|
+
/** The live tree's identity at tip-run time (R15). Best-effort: unknown conditions read as such. */
|
|
7483
|
+
function readTipTreeConditions(repoRoot) {
|
|
7484
|
+
let headSha = 'unknown';
|
|
7485
|
+
let dirtyFiles = -1;
|
|
7486
|
+
try {
|
|
7487
|
+
headSha = execSync('git rev-parse HEAD', { cwd: repoRoot, stdio: 'pipe', encoding: 'utf-8' }).trim();
|
|
7488
|
+
}
|
|
7489
|
+
catch { /* best effort */ }
|
|
7490
|
+
try {
|
|
7491
|
+
const porcelain = execSync('git status --porcelain', { cwd: repoRoot, stdio: 'pipe', encoding: 'utf-8' });
|
|
7492
|
+
dirtyFiles = String(porcelain).split('\n').filter((l) => l.trim() !== '').length;
|
|
7493
|
+
}
|
|
7494
|
+
catch { /* best effort */ }
|
|
7495
|
+
return { headSha, dirtyFiles };
|
|
7496
|
+
}
|
|
6797
7497
|
/** small helper: build a result row, omitting `name` when absent (exactOptionalPropertyTypes). */
|
|
6798
7498
|
function nameFor(t, outcome) {
|
|
6799
7499
|
return t.name !== undefined ? { file: t.file, name: t.name, outcome } : { file: t.file, outcome };
|
|
@@ -7194,28 +7894,621 @@ function cmdMutationGate(options, flags, cwd, write) {
|
|
|
7194
7894
|
* debt); an oversized or non-regular due-file counts as MALFORMED, never a silent skip (QE #8);
|
|
7195
7895
|
* a debt whose embedded slug differs from its directory is MALFORMED — identity is the directory,
|
|
7196
7896
|
* the JSON only confirms it (QE #4: an embedded foreign slug must not redirect settlement). */
|
|
7197
|
-
|
|
7198
|
-
|
|
7199
|
-
|
|
7200
|
-
|
|
7201
|
-
|
|
7897
|
+
/* ── `dz workflow run` — the impure half of the loop-plan executor (feature dz-workflow-run) ──────
|
|
7898
|
+
*
|
|
7899
|
+
* Everything DECIDABLE lives in harness-core's pure scheduler. This half owns exactly four things
|
|
7900
|
+
* core refuses to touch: the filesystem, the lock, the child processes, and the exit code. Keeping
|
|
7901
|
+
* that line sharp is what lets the whole feature be tested without a child process — so anything
|
|
7902
|
+
* added here that could have been a decision belongs upstream instead.
|
|
7903
|
+
*/
|
|
7904
|
+
/** The env TEST SEAM (the `DZ_QE_BRIDGE_CLAUDE_BIN` precedent: an env var, never a flag — a flag
|
|
7905
|
+
* invites production use). Recorded LOUDLY in run-state as `dispatcherOverride: true`, because a
|
|
7906
|
+
* test seam that leaves no trace in the artifact is indistinguishable from a real run. */
|
|
7907
|
+
const WF_RUN_DISPATCH_SCRIPT_ENV = 'DZ_WF_RUN_DISPATCH_SCRIPT';
|
|
7908
|
+
const WF_RUN_OWNER_FILE = 'run-owner.json';
|
|
7909
|
+
const WF_RUN_STATE_FILE = 'run-state.json';
|
|
7910
|
+
/**
|
|
7911
|
+
* THE ONE PLACE anything in the loop runner is signalled (Step-8 re-QE NEW-C4).
|
|
7912
|
+
*
|
|
7913
|
+
* Round 1 guarded `process.kill(-pid, …)` — the process-GROUP path — and left `child.kill(sig)`
|
|
7914
|
+
* unguarded beside it. A fake child reporting `pid: 0` therefore still received the signal through
|
|
7915
|
+
* the object method, which is the same defect wearing a different call shape. `process.kill(-0, …)`
|
|
7916
|
+
* signals the CALLER'S OWN process group; round 0 of this exact class took down the vitest worker
|
|
7917
|
+
* pool. Two call shapes meant two chances to forget, so now there is one.
|
|
7918
|
+
*
|
|
7919
|
+
* The guard is on the PID, not on the shape: an unsignalable pid (not an integer, or <= 1) reaches
|
|
7920
|
+
* NOTHING — neither `process.kill` nor `child.kill`. Returns whether a signal was actually sent, so
|
|
7921
|
+
* a caller can never mistake "refused" for "delivered".
|
|
7922
|
+
*/
|
|
7923
|
+
function signalChildSafely(child, signal, detached) {
|
|
7924
|
+
const raw = child?.pid;
|
|
7925
|
+
if (typeof raw !== 'number' || !Number.isInteger(raw) || raw <= 1)
|
|
7926
|
+
return false;
|
|
7202
7927
|
try {
|
|
7203
|
-
if (
|
|
7204
|
-
|
|
7205
|
-
|
|
7928
|
+
if (detached)
|
|
7929
|
+
process.kill(-raw, signal);
|
|
7930
|
+
else
|
|
7931
|
+
child?.kill?.(signal);
|
|
7932
|
+
return true;
|
|
7206
7933
|
}
|
|
7207
7934
|
catch {
|
|
7208
|
-
return
|
|
7209
|
-
}
|
|
7210
|
-
|
|
7211
|
-
|
|
7212
|
-
|
|
7213
|
-
|
|
7214
|
-
|
|
7215
|
-
|
|
7216
|
-
|
|
7217
|
-
|
|
7218
|
-
|
|
7935
|
+
return false; // already gone, or not ours — both mean nothing more to do
|
|
7936
|
+
}
|
|
7937
|
+
}
|
|
7938
|
+
/** Test seam for the chokepoint: NEW-C4's proof needs to call it with a hostile pid. */
|
|
7939
|
+
export function __wfSignalChildTestSeam(child, signal, detached) {
|
|
7940
|
+
return signalChildSafely(child, signal, detached);
|
|
7941
|
+
}
|
|
7942
|
+
/** Live children, keyed by pid, for the kill-group handlers (AM-10). */
|
|
7943
|
+
const wfLiveChildren = new Map();
|
|
7944
|
+
let wfHandlersInstalled = false;
|
|
7945
|
+
/** Kill the process GROUP of every live child. A detached child leads its own group, so killing the
|
|
7946
|
+
* leader alone would leave whatever it spawned running — that is the orphan class AM-10 closes.
|
|
7947
|
+
* NAMED RESIDUE: a SIGKILL of the runner itself runs no handler, so that case still orphans. */
|
|
7948
|
+
function wfKillLiveChildren(signal = 'SIGTERM') {
|
|
7949
|
+
const killed = [];
|
|
7950
|
+
for (const [pid, child] of [...wfLiveChildren.entries()]) {
|
|
7951
|
+
// ONE chokepoint: group first (a detached child leads its own), then the leader. Neither shape
|
|
7952
|
+
// is reachable without the pid guard.
|
|
7953
|
+
if (signalChildSafely(child, signal, true) || signalChildSafely(child, signal, false))
|
|
7954
|
+
killed.push(pid);
|
|
7955
|
+
wfLiveChildren.delete(pid);
|
|
7956
|
+
}
|
|
7957
|
+
return killed;
|
|
7958
|
+
}
|
|
7959
|
+
function wfInstallKillHandlers() {
|
|
7960
|
+
if (wfHandlersInstalled)
|
|
7961
|
+
return;
|
|
7962
|
+
wfHandlersInstalled = true;
|
|
7963
|
+
process.on('exit', () => { wfKillLiveChildren(); });
|
|
7964
|
+
process.on('SIGTERM', () => { wfKillLiveChildren(); process.exit(143); });
|
|
7965
|
+
process.on('SIGINT', () => { wfKillLiveChildren(); process.exit(130); });
|
|
7966
|
+
}
|
|
7967
|
+
/** Exposed for the unit test: the kill set must NAME every live child's pid. */
|
|
7968
|
+
export function __wfKillGroupTestSeam() {
|
|
7969
|
+
return {
|
|
7970
|
+
register: (pid, child) => { wfLiveChildren.set(pid, child); },
|
|
7971
|
+
killAll: () => wfKillLiveChildren(),
|
|
7972
|
+
size: () => wfLiveChildren.size,
|
|
7973
|
+
};
|
|
7974
|
+
}
|
|
7975
|
+
/** A `ChildRunner` over the generalized wrapper, registering every live child for the kill set. */
|
|
7976
|
+
/** A `ChildRunner` bound to ONE family, so its child can only ever receive that family's
|
|
7977
|
+
* credentials (re-QE H9). Two runners, two credential sets, one wrapper. */
|
|
7978
|
+
const wfChildRunnerFor = (family) => async (bin, argv, opts) => runChildBridge(bin, argv, {
|
|
7979
|
+
...opts,
|
|
7980
|
+
// a dispatched model gets a NAMED minimal environment, never the runner's whole one (HIGH-9)
|
|
7981
|
+
envMode: 'allowlist',
|
|
7982
|
+
envExtra: CHILD_ENV_BY_FAMILY[family],
|
|
7983
|
+
onSpawn: (child) => {
|
|
7984
|
+
if (typeof child.pid !== 'number')
|
|
7985
|
+
return;
|
|
7986
|
+
const pid = child.pid;
|
|
7987
|
+
wfLiveChildren.set(pid, child);
|
|
7988
|
+
child.on('close', () => { wfLiveChildren.delete(pid); });
|
|
7989
|
+
},
|
|
7990
|
+
});
|
|
7991
|
+
/** Build the SCRIPTED dispatcher from the env seam's JSON file (test-only). */
|
|
7992
|
+
function wfScriptedDispatcher(scriptPath, family) {
|
|
7993
|
+
const raw = JSON.parse(readFileSync(scriptPath, 'utf8'));
|
|
7994
|
+
const script = (raw['steps'] ?? raw);
|
|
7995
|
+
const probeId = typeof raw['probeId'] === 'string' ? raw['probeId'] : `${family}-scripted`;
|
|
7996
|
+
const consumed = new Map();
|
|
7997
|
+
return {
|
|
7998
|
+
probe: async (candidates) => ({ id: raw['probeFails'] === true ? null : probeId, wallMs: 1, detail: `scripted probe (${candidates.join(',') || 'defaults'})` }),
|
|
7999
|
+
dispatch: async (req) => {
|
|
8000
|
+
const key = req.itemKey === null ? req.stepId : `${req.stepId}:${req.itemKey}`;
|
|
8001
|
+
const entry = script[key] ?? script[req.stepId];
|
|
8002
|
+
const n = consumed.get(key) ?? 0;
|
|
8003
|
+
consumed.set(key, n + 1);
|
|
8004
|
+
const picked = Array.isArray(entry) ? entry[Math.min(n, entry.length - 1)] : entry;
|
|
8005
|
+
const base = {
|
|
8006
|
+
outcome: 'ok',
|
|
8007
|
+
text: `scripted:${key}`,
|
|
8008
|
+
family,
|
|
8009
|
+
modelUsed: req.resolvedModelId,
|
|
8010
|
+
wallMs: 1,
|
|
8011
|
+
tokensIn: null,
|
|
8012
|
+
tokensOut: null,
|
|
8013
|
+
tokensSource: null,
|
|
8014
|
+
};
|
|
8015
|
+
if (picked === undefined || picked === null)
|
|
8016
|
+
return base;
|
|
8017
|
+
// a scripted step may ask the runner to CREATE its declared writes (the landed-barrier leg)
|
|
8018
|
+
if (picked['writes'] === true) {
|
|
8019
|
+
for (const rel of req.expectedWrites) {
|
|
8020
|
+
// the seam re-checks containment too — a test double that could write outside the root
|
|
8021
|
+
// would be a hole in exactly the guard the real path is being tested for (CRITICAL-4)
|
|
8022
|
+
const abs = wfContainedPath(req.cwd, rel);
|
|
8023
|
+
if (abs === null)
|
|
8024
|
+
continue;
|
|
8025
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
8026
|
+
writeFileSync(abs, `scripted write for ${req.stepId} attempt ${req.attempt}\n`);
|
|
8027
|
+
}
|
|
8028
|
+
}
|
|
8029
|
+
return { ...base, ...picked };
|
|
8030
|
+
},
|
|
8031
|
+
};
|
|
8032
|
+
}
|
|
8033
|
+
/** Read a small JSON file, or null. */
|
|
8034
|
+
function wfReadJson(path) {
|
|
8035
|
+
try {
|
|
8036
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
8037
|
+
}
|
|
8038
|
+
catch {
|
|
8039
|
+
return null;
|
|
8040
|
+
}
|
|
8041
|
+
}
|
|
8042
|
+
/** Is a recorded pid still alive? `kill(pid, 0)` is the portable liveness probe. */
|
|
8043
|
+
function wfPidAlive(pid) {
|
|
8044
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
8045
|
+
return false;
|
|
8046
|
+
try {
|
|
8047
|
+
process.kill(pid, 0);
|
|
8048
|
+
return true;
|
|
8049
|
+
}
|
|
8050
|
+
catch (e) {
|
|
8051
|
+
return e.code === 'EPERM'; // alive, just not ours
|
|
8052
|
+
}
|
|
8053
|
+
}
|
|
8054
|
+
/** The fs `RunStore`. Every write that must be atomic is temp+rename; every file that must be NEW
|
|
8055
|
+
* is `wx`. Nothing here decides anything. */
|
|
8056
|
+
/**
|
|
8057
|
+
* Containment, re-checked at the LAST possible moment (Step-8 CRITICAL-4: "repeat containment
|
|
8058
|
+
* immediately before filesystem access").
|
|
8059
|
+
*
|
|
8060
|
+
* Preflight validated these paths minutes earlier, against a filesystem that has since been written
|
|
8061
|
+
* to — by the very models this run dispatched. A symlink planted between preflight and the probe is
|
|
8062
|
+
* not a hypothetical here; creating files is what the file-deliverable steps DO. Returns the
|
|
8063
|
+
* absolute path, or null when the path no longer resolves inside the root.
|
|
8064
|
+
*/
|
|
8065
|
+
function wfContainedPath(targetCwd, rel) {
|
|
8066
|
+
const check = containedUnderRoot(targetCwd, rel);
|
|
8067
|
+
return check.ok ? check.path : null;
|
|
8068
|
+
}
|
|
8069
|
+
function wfMakeStore(runDir, repoRoot, targetCwd) {
|
|
8070
|
+
const statePath = join(runDir, WF_RUN_STATE_FILE);
|
|
8071
|
+
const tracePath = join(runDir, 'trace.jsonl');
|
|
8072
|
+
const stateDir = join(runDir, '.fa-state');
|
|
8073
|
+
const ckptPath = join(stateDir, 'checkpoints.jsonl');
|
|
8074
|
+
const budgetPath = join(runDir, 'budget.jsonl');
|
|
8075
|
+
const duePath = join(stateDir, 'reqe-due.json');
|
|
8076
|
+
const ledgerPath = join(repoRoot, '.dz', 'feature-adr', 'run-cost-ledger.jsonl');
|
|
8077
|
+
const hashOf = (abs) => {
|
|
8078
|
+
try {
|
|
8079
|
+
return createHash('sha256').update(readFileSync(abs)).digest('hex');
|
|
8080
|
+
}
|
|
8081
|
+
catch {
|
|
8082
|
+
return null;
|
|
8083
|
+
}
|
|
8084
|
+
};
|
|
8085
|
+
return {
|
|
8086
|
+
runDirExists: () => existsSync(runDir),
|
|
8087
|
+
hasTrace: () => existsSync(tracePath),
|
|
8088
|
+
readTraceText: () => (existsSync(tracePath) ? readFileSync(tracePath, 'utf8') : null),
|
|
8089
|
+
readRunState: () => wfReadJson(statePath),
|
|
8090
|
+
writeRunState: (s) => {
|
|
8091
|
+
mkdirSync(runDir, { recursive: true });
|
|
8092
|
+
const tmp = statePath + '.tmp';
|
|
8093
|
+
writeFileSync(tmp, JSON.stringify(s, null, 2) + '\n');
|
|
8094
|
+
renameSync(tmp, statePath); // atomic: a half-written state is a foreign run forever
|
|
8095
|
+
},
|
|
8096
|
+
appendTraceLines: (lines) => {
|
|
8097
|
+
if (lines.length === 0)
|
|
8098
|
+
return;
|
|
8099
|
+
mkdirSync(runDir, { recursive: true });
|
|
8100
|
+
appendFileSync(tracePath, lines.join('\n') + '\n');
|
|
8101
|
+
},
|
|
8102
|
+
readCheckpointsText: () => (existsSync(ckptPath) ? readFileSync(ckptPath, 'utf8') : null),
|
|
8103
|
+
appendCheckpointLine: (line) => {
|
|
8104
|
+
mkdirSync(stateDir, { recursive: true });
|
|
8105
|
+
appendFileSync(ckptPath, line + '\n');
|
|
8106
|
+
},
|
|
8107
|
+
appendBudgetRow: (row) => {
|
|
8108
|
+
mkdirSync(runDir, { recursive: true });
|
|
8109
|
+
appendFileSync(budgetPath, JSON.stringify(row) + '\n');
|
|
8110
|
+
},
|
|
8111
|
+
appendLedgerLine: (line) => {
|
|
8112
|
+
try {
|
|
8113
|
+
mkdirSync(dirname(ledgerPath), { recursive: true });
|
|
8114
|
+
appendFileSync(ledgerPath, line + '\n');
|
|
8115
|
+
}
|
|
8116
|
+
catch {
|
|
8117
|
+
/* telemetry is SECONDARY: a ledger failure never fails a run */
|
|
8118
|
+
}
|
|
8119
|
+
},
|
|
8120
|
+
probeArtifact: (rel) => {
|
|
8121
|
+
const abs = wfContainedPath(targetCwd, rel);
|
|
8122
|
+
// a path that no longer resolves inside the root is NOT LANDED, whatever is at the other end
|
|
8123
|
+
return abs !== null && existsSync(abs);
|
|
8124
|
+
},
|
|
8125
|
+
// re-QE R3-A: the same realpath + symlinked-ancestor discipline, for reads AND writes, at the
|
|
8126
|
+
// moment before the dispatch grants filesystem access
|
|
8127
|
+
pathContainmentOk: (rel) => wfContainedPath(targetCwd, rel) !== null,
|
|
8128
|
+
snapshotWrites: (rels) => {
|
|
8129
|
+
const out = {};
|
|
8130
|
+
for (const rel of rels) {
|
|
8131
|
+
const abs = wfContainedPath(targetCwd, rel);
|
|
8132
|
+
out[rel] = abs === null ? null : hashOf(abs);
|
|
8133
|
+
}
|
|
8134
|
+
return out;
|
|
8135
|
+
},
|
|
8136
|
+
writeReqeDebt: (record) => {
|
|
8137
|
+
mkdirSync(stateDir, { recursive: true });
|
|
8138
|
+
writeFileSync(duePath, JSON.stringify(record, null, 2) + '\n');
|
|
8139
|
+
},
|
|
8140
|
+
};
|
|
8141
|
+
}
|
|
8142
|
+
/**
|
|
8143
|
+
* `dz workflow run <plan.json>` — INTERPRET the plan (ADR-001). Registered inside the existing
|
|
8144
|
+
* `case 'workflow':` branch when `_positional_0 === 'run'`, BEFORE the sync `cmdWorkflow`.
|
|
8145
|
+
*
|
|
8146
|
+
* Exit codes (AM-11): `0` completed · `1` failed (named) · `2` usage / invalid plan ·
|
|
8147
|
+
* `75` typed pause (sysexits EX_TEMPFAIL). NOT `3`: that collides with workflow-lint's
|
|
8148
|
+
* inconclusive and reads ignorable, while a pause strands resumable progress.
|
|
8149
|
+
*/
|
|
8150
|
+
async function cmdWorkflowRun(options, optionLists, flags, cwd, write) {
|
|
8151
|
+
const json = flags.has('json');
|
|
8152
|
+
const usage = 'dz workflow run <plan.json> [--run-id <id>] [--resume <runId>] [--arg k=v]… '
|
|
8153
|
+
+ '[--coder-family codex|claude] [--default-family codex|claude] [--budget <n>] [--max-wall-clock <s>] '
|
|
8154
|
+
+ '[--stage-timeout <s>] [--budget-extra <n>] [--wall-clock-extra <s>] [--run-dir <dir>] '
|
|
8155
|
+
+ '[--allow-same-family-qe] [--json]';
|
|
8156
|
+
const usageError = (message) => {
|
|
8157
|
+
write(json ? JSON.stringify({ ok: false, reason: 'plan-invalid', error: message, exitCode: 2 }) : `dz workflow run: ${message}\n${usage}`);
|
|
8158
|
+
return 2;
|
|
8159
|
+
};
|
|
8160
|
+
if (flags.has('help')) {
|
|
8161
|
+
write(usage);
|
|
8162
|
+
write('');
|
|
8163
|
+
write('EXIT CODES — `dz workflow run` and `dz workflow-lint` have DIFFERENT tables (AM-11):');
|
|
8164
|
+
write(' run 0 completed · 1 failed (named reason) · 2 usage/invalid plan · 75 typed pause (EX_TEMPFAIL)');
|
|
8165
|
+
write(' lint 0 clean · 1 findings · 3 inconclusive');
|
|
8166
|
+
write(' 75 is NOT 3: 3 reads ignorable and collides with lint, while a pause strands resumable work.');
|
|
8167
|
+
write('On a pause the LAST stdout line is a `wf-pause-envelope/1` JSON object; a FAILURE emits none,');
|
|
8168
|
+
write('so a wrapper can tell the two apart from stdout + exit code alone, without parsing prose.');
|
|
8169
|
+
return 0;
|
|
8170
|
+
}
|
|
8171
|
+
// ── closed allowlists (the cmdQeBridge discipline: an unknown flag is a usage error, never a
|
|
8172
|
+
// silently-ignored intention) ──
|
|
8173
|
+
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']);
|
|
8174
|
+
const FLAGS = new Set(['allow-same-family-qe', 'json', 'help']);
|
|
8175
|
+
for (const k of options.keys())
|
|
8176
|
+
if (!OPTS.has(k))
|
|
8177
|
+
return usageError(`unknown option --${k}`);
|
|
8178
|
+
for (const f of flags)
|
|
8179
|
+
if (!FLAGS.has(f))
|
|
8180
|
+
return usageError(`unknown flag --${f}`);
|
|
8181
|
+
// ── SINGLETONS (Step-8 HIGH-8 — the recurring class) ──
|
|
8182
|
+
//
|
|
8183
|
+
// `parseArgs` keeps every occurrence in `optionLists` but the main map is LAST-WINS, so
|
|
8184
|
+
// `--coder-family codex --coder-family claude` was accepted and only `claude` reached preflight.
|
|
8185
|
+
// For a SAFETY option that is a bypass: the same-family guard compares the coder family against a
|
|
8186
|
+
// qe step's family, and whoever supplies the last occurrence chooses the answer. Every option here
|
|
8187
|
+
// is a singleton BY MEANING — a run has one coder family, one budget, one run directory — so a
|
|
8188
|
+
// second occurrence is not a preference, it is an ambiguity, and the only honest answer is to
|
|
8189
|
+
// refuse. `--arg` is deliberately absent: it is the one genuinely repeatable option.
|
|
8190
|
+
const SINGLETON_OPTS = ['run-id', 'resume', 'coder-family', 'default-family', 'budget', 'max-wall-clock', 'stage-timeout', 'budget-extra', 'wall-clock-extra', 'run-dir', 'project'];
|
|
8191
|
+
for (const key of SINGLETON_OPTS) {
|
|
8192
|
+
const occurrences = optionLists.get(key) ?? [];
|
|
8193
|
+
if (occurrences.length > 1) {
|
|
8194
|
+
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. `
|
|
8195
|
+
+ 'Stacking a safety option would let the LAST value decide what the first one refused.');
|
|
8196
|
+
}
|
|
8197
|
+
}
|
|
8198
|
+
const planPath = options.get('_positional_1') ?? '';
|
|
8199
|
+
if (planPath === '')
|
|
8200
|
+
return usageError('a plan.json positional is required');
|
|
8201
|
+
const root = resolve(cwd, options.get('project') ?? '.');
|
|
8202
|
+
const absPlan = resolve(cwd, planPath);
|
|
8203
|
+
if (!existsSync(absPlan))
|
|
8204
|
+
return usageError(`no such plan file: ${absPlan}`);
|
|
8205
|
+
let rawPlan;
|
|
8206
|
+
try {
|
|
8207
|
+
rawPlan = JSON.parse(readFileSync(absPlan, 'utf8'));
|
|
8208
|
+
}
|
|
8209
|
+
catch (e) {
|
|
8210
|
+
return usageError(`unparseable plan JSON — ${e instanceof Error ? e.message : String(e)}`);
|
|
8211
|
+
}
|
|
8212
|
+
const parsed = parsePlan(rawPlan);
|
|
8213
|
+
if (isParseErrors(parsed)) {
|
|
8214
|
+
if (json)
|
|
8215
|
+
write(JSON.stringify({ ok: false, reason: 'plan-invalid', parseErrors: parsed, exitCode: 2 }));
|
|
8216
|
+
else
|
|
8217
|
+
for (const e of parsed)
|
|
8218
|
+
write(`PARSE ${e.path}: ${e.message}`);
|
|
8219
|
+
return 2;
|
|
8220
|
+
}
|
|
8221
|
+
const diags = validatePlan(parsed);
|
|
8222
|
+
if (diags.length > 0) {
|
|
8223
|
+
if (json)
|
|
8224
|
+
write(JSON.stringify({ ok: false, reason: 'plan-invalid', diagnostics: diags, exitCode: 2 }));
|
|
8225
|
+
else
|
|
8226
|
+
for (const d of diags)
|
|
8227
|
+
write(`${d.invariant} ${d.path}: ${d.message}`);
|
|
8228
|
+
return 2;
|
|
8229
|
+
}
|
|
8230
|
+
// ── numeric options ride the Number.isFinite clamp (every numeric config clamp needs it) ──
|
|
8231
|
+
const num = (key, scale = 1) => {
|
|
8232
|
+
const raw = options.get(key);
|
|
8233
|
+
if (raw === undefined)
|
|
8234
|
+
return { ok: true, value: null };
|
|
8235
|
+
const n = Number(raw);
|
|
8236
|
+
if (!Number.isFinite(n) || n < 0)
|
|
8237
|
+
return { ok: false, why: `--${key} must be a finite non-negative number (got ${JSON.stringify(raw)})` };
|
|
8238
|
+
return { ok: true, value: Math.floor(n * scale) };
|
|
8239
|
+
};
|
|
8240
|
+
const nums = {};
|
|
8241
|
+
for (const [key, scale] of [['budget', 1], ['max-wall-clock', 1000], ['stage-timeout', 1000], ['budget-extra', 1], ['wall-clock-extra', 1000]]) {
|
|
8242
|
+
const r = num(key, scale);
|
|
8243
|
+
if (!r.ok)
|
|
8244
|
+
return usageError(r.why);
|
|
8245
|
+
nums[key] = r.value;
|
|
8246
|
+
}
|
|
8247
|
+
const familyOpt = (key) => {
|
|
8248
|
+
const raw = options.get(key);
|
|
8249
|
+
if (raw === undefined)
|
|
8250
|
+
return { ok: true, value: null };
|
|
8251
|
+
if (raw !== 'codex' && raw !== 'openai' && raw !== 'claude')
|
|
8252
|
+
return { ok: false, why: `--${key} must be codex or claude (got ${JSON.stringify(raw)})` };
|
|
8253
|
+
return { ok: true, value: modelFamily(raw) };
|
|
8254
|
+
};
|
|
8255
|
+
const coder = familyOpt('coder-family');
|
|
8256
|
+
if (!coder.ok)
|
|
8257
|
+
return usageError(coder.why);
|
|
8258
|
+
const dflt = familyOpt('default-family');
|
|
8259
|
+
if (!dflt.ok)
|
|
8260
|
+
return usageError(dflt.why);
|
|
8261
|
+
const resumeArgs = {};
|
|
8262
|
+
for (const kv of optionLists.get('arg') ?? []) {
|
|
8263
|
+
const eq = kv.indexOf('=');
|
|
8264
|
+
if (eq <= 0)
|
|
8265
|
+
return usageError(`--arg must be k=v (got ${JSON.stringify(kv)})`);
|
|
8266
|
+
resumeArgs[kv.slice(0, eq)] = kv.slice(eq + 1);
|
|
8267
|
+
}
|
|
8268
|
+
const resumeId = options.get('resume') ?? null;
|
|
8269
|
+
const runId = options.get('run-id') ?? resumeId ?? `${parsed.name}-${randomBytes(2).toString('hex')}`;
|
|
8270
|
+
if (!TRACE_RUNID_RE.test(runId))
|
|
8271
|
+
return usageError(`runId ${JSON.stringify(runId)} fails ${String(TRACE_RUNID_RE)}`);
|
|
8272
|
+
if (resumeId !== null && options.get('run-id') !== undefined && options.get('run-id') !== resumeId) {
|
|
8273
|
+
return usageError('--run-id and --resume name different runs — a resume continues the run it names');
|
|
8274
|
+
}
|
|
8275
|
+
const runDirOpt = options.get('run-dir');
|
|
8276
|
+
let runDir;
|
|
8277
|
+
if (runDirOpt !== undefined) {
|
|
8278
|
+
const contained = containedUnderRoot(root, runDirOpt);
|
|
8279
|
+
if (!contained.ok)
|
|
8280
|
+
return usageError(`--run-dir ${contained.why}`);
|
|
8281
|
+
runDir = contained.path;
|
|
8282
|
+
}
|
|
8283
|
+
else {
|
|
8284
|
+
runDir = join(root, '.dz', 'loop-trace', runId); // the addressing `dz workflow-trace --run <id>` already uses
|
|
8285
|
+
}
|
|
8286
|
+
// The scripted-seam marker is established BEFORE the first refusal can emit (re-QE MINOR): an
|
|
8287
|
+
// EARLY refusal is still a run that would have dispatched to no real model, and a reader of that
|
|
8288
|
+
// envelope has the same right to know as a reader of a completed one.
|
|
8289
|
+
const seamScriptPath = process.env[WF_RUN_DISPATCH_SCRIPT_ENV];
|
|
8290
|
+
const dispatcherOverride = typeof seamScriptPath === 'string' && seamScriptPath !== '';
|
|
8291
|
+
const emit = (payload) => {
|
|
8292
|
+
write(JSON.stringify(dispatcherOverride ? { ...payload, dispatcherOverride: true } : payload));
|
|
8293
|
+
};
|
|
8294
|
+
// ── AM-7 ownership, ATOMICALLY (Step-8 HIGH-6) ──
|
|
8295
|
+
//
|
|
8296
|
+
// The previous shape read the owner marker, decided, and wrote it later — a window in which two
|
|
8297
|
+
// processes both saw "no live owner" and both proceeded. And the write itself was wrapped in a
|
|
8298
|
+
// swallowing try, so a run could execute while its durable claim silently did not exist.
|
|
8299
|
+
//
|
|
8300
|
+
// Now: check and claim happen INSIDE the named lock, the claim is `wx` (create-or-fail, so the
|
|
8301
|
+
// filesystem itself arbitrates), a STALE marker is only replaced under that same lock, and a
|
|
8302
|
+
// failure to claim FAILS THE RUN. One writer per run is not a convention here; it is an atomic
|
|
8303
|
+
// filesystem operation.
|
|
8304
|
+
const ownerPath = join(runDir, WF_RUN_OWNER_FILE);
|
|
8305
|
+
const startedMarker = new Date().toISOString();
|
|
8306
|
+
const RUN_ARTIFACTS = [WF_RUN_STATE_FILE, 'trace.jsonl', 'budget.jsonl', join('.fa-state', 'checkpoints.jsonl')];
|
|
8307
|
+
const claim = withNamedLockSync(root, `wf-run-${runId}`, () => {
|
|
8308
|
+
mkdirSync(runDir, { recursive: true });
|
|
8309
|
+
// ORDER MATTERS. A LIVE owner is `run-locked` whichever kind of invocation this is — that is
|
|
8310
|
+
// the precise fact, and it outranks "the directory has files in it". Only then does a FRESH run
|
|
8311
|
+
// refuse a directory that already holds a run's artifacts.
|
|
8312
|
+
const existingOwner = wfReadJson(ownerPath);
|
|
8313
|
+
if (existingOwner !== null && typeof existingOwner.pid === 'number' && wfPidAlive(existingOwner.pid)) {
|
|
8314
|
+
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` };
|
|
8315
|
+
}
|
|
8316
|
+
if (resumeId === null) {
|
|
8317
|
+
// a FRESH run may not write into a directory that already holds ANY artifact of a run — not
|
|
8318
|
+
// merely one with a readable state file (a HALF-written run is exactly the dangerous case).
|
|
8319
|
+
// The owner marker is excluded: a dead one is stale residue, handled by the claim below.
|
|
8320
|
+
const found = RUN_ARTIFACTS.filter((rel) => existsSync(join(runDir, rel)));
|
|
8321
|
+
if (found.length > 0) {
|
|
8322
|
+
return {
|
|
8323
|
+
ok: false,
|
|
8324
|
+
reason: 'run-exists',
|
|
8325
|
+
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`,
|
|
8326
|
+
};
|
|
8327
|
+
}
|
|
8328
|
+
}
|
|
8329
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
8330
|
+
try {
|
|
8331
|
+
writeFileSync(ownerPath, JSON.stringify({ host: WF_RUN_OWNER_HOST, pid: process.pid, runnerVersion: dzOwnVersion(), startedMarker }, null, 2) + '\n', { flag: 'wx' });
|
|
8332
|
+
return { ok: true };
|
|
8333
|
+
}
|
|
8334
|
+
catch (e) {
|
|
8335
|
+
if (e.code !== 'EEXIST') {
|
|
8336
|
+
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` };
|
|
8337
|
+
}
|
|
8338
|
+
const held = wfReadJson(ownerPath);
|
|
8339
|
+
if (held !== null && typeof held.pid === 'number' && wfPidAlive(held.pid)) {
|
|
8340
|
+
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` };
|
|
8341
|
+
}
|
|
8342
|
+
// STALE: the recorded owner is gone. Replacing it is safe HERE and only here, because this
|
|
8343
|
+
// whole block holds the named lock, so no concurrent claimant can be mid-decision.
|
|
8344
|
+
try {
|
|
8345
|
+
unlinkSync(ownerPath);
|
|
8346
|
+
}
|
|
8347
|
+
catch {
|
|
8348
|
+
/* someone else just cleared it; the retry's `wx` decides */
|
|
8349
|
+
}
|
|
8350
|
+
}
|
|
8351
|
+
}
|
|
8352
|
+
return { ok: false, reason: 'run-locked', detail: `could not claim ${ownerPath} after replacing a stale marker — another writer is racing for this run` };
|
|
8353
|
+
});
|
|
8354
|
+
if (!claim.ok) {
|
|
8355
|
+
if (json)
|
|
8356
|
+
emit({ schema: 'wf-run-result/1', runId, status: 'failed', reason: claim.reason, exitCode: 1 });
|
|
8357
|
+
else
|
|
8358
|
+
write(`dz workflow run: ${claim.reason} — ${claim.detail}`);
|
|
8359
|
+
return 1;
|
|
8360
|
+
}
|
|
8361
|
+
// ── NEW-H (re-QE): from HERE to the end, every exit path — return, throw, or completion — runs
|
|
8362
|
+
// the cleanup. Round 1 opened the try only around `runWorkflow`, so a `usageError` return or a
|
|
8363
|
+
// throw while CONSTRUCTING the dispatchers (a malformed seam file is enough) left `run-owner.json`
|
|
8364
|
+
// behind: a durable claim held by a process that had already exited.
|
|
8365
|
+
try {
|
|
8366
|
+
const targetCwd = root;
|
|
8367
|
+
const inputs = {
|
|
8368
|
+
plan: parsed,
|
|
8369
|
+
runId,
|
|
8370
|
+
coderFamily: coder.value ?? 'claude',
|
|
8371
|
+
allowSameFamilyQe: flags.has('allow-same-family-qe'),
|
|
8372
|
+
defaultFamily: dflt.value,
|
|
8373
|
+
budgetOverride: nums['budget'] ?? null,
|
|
8374
|
+
maxWallClockMsOverride: nums['max-wall-clock'] ?? null,
|
|
8375
|
+
stageTimeoutMsOverride: nums['stage-timeout'] ?? null,
|
|
8376
|
+
resume: resumeId,
|
|
8377
|
+
resumeArgs,
|
|
8378
|
+
budgetExtra: nums['budget-extra'] ?? null,
|
|
8379
|
+
wallClockExtraMs: nums['wall-clock-extra'] ?? null,
|
|
8380
|
+
runnerVersion: dzOwnVersion(),
|
|
8381
|
+
cwdRoot: targetCwd,
|
|
8382
|
+
};
|
|
8383
|
+
const pre = preflight(inputs, {
|
|
8384
|
+
realpath: (p) => { try {
|
|
8385
|
+
return realpathSync(p);
|
|
8386
|
+
}
|
|
8387
|
+
catch {
|
|
8388
|
+
return null;
|
|
8389
|
+
} },
|
|
8390
|
+
exists: (p) => existsSync(p),
|
|
8391
|
+
});
|
|
8392
|
+
if (!pre.ok) {
|
|
8393
|
+
if (json)
|
|
8394
|
+
emit({ schema: 'wf-run-result/1', runId, status: 'failed', reason: pre.reason, exitCode: 1 });
|
|
8395
|
+
else
|
|
8396
|
+
write(`dz workflow run: ${pre.reason} — ${pre.detail}`);
|
|
8397
|
+
return 1;
|
|
8398
|
+
}
|
|
8399
|
+
// ── dispatchers: the real adapters, or the scripted env TEST SEAM ──
|
|
8400
|
+
const scriptPath = seamScriptPath;
|
|
8401
|
+
let dispatchers;
|
|
8402
|
+
if (dispatcherOverride) {
|
|
8403
|
+
if (!existsSync(scriptPath))
|
|
8404
|
+
return usageError(`${WF_RUN_DISPATCH_SCRIPT_ENV}=${String(scriptPath)} does not exist`);
|
|
8405
|
+
dispatchers = { claude: wfScriptedDispatcher(scriptPath, 'claude'), openai: wfScriptedDispatcher(scriptPath, 'openai') };
|
|
8406
|
+
}
|
|
8407
|
+
else {
|
|
8408
|
+
wfInstallKillHandlers();
|
|
8409
|
+
const isolated = mkdtempSync(join(tmpdir(), 'dz-wf-run-'));
|
|
8410
|
+
const monotonicMs = () => Number(process.hrtime.bigint() / 1000000n);
|
|
8411
|
+
dispatchers = {
|
|
8412
|
+
claude: makeClaudePDispatcher(wfChildRunnerFor('claude'), { isolatedCwd: () => isolated, monotonicMs }),
|
|
8413
|
+
openai: makeCodexExecDispatcher(wfChildRunnerFor('openai'), { isolatedCwd: () => isolated, monotonicMs }),
|
|
8414
|
+
};
|
|
8415
|
+
}
|
|
8416
|
+
const store = wfMakeStore(runDir, root, targetCwd);
|
|
8417
|
+
const deps = {
|
|
8418
|
+
store,
|
|
8419
|
+
dispatchers,
|
|
8420
|
+
lock: (fn) => withNamedLockSync(root, `wf-run-${runId}`, fn),
|
|
8421
|
+
now: () => new Date().toISOString(),
|
|
8422
|
+
monotonicMs: () => Number(process.hrtime.bigint() / 1000000n),
|
|
8423
|
+
dispatcherOverride,
|
|
8424
|
+
planPath: relative(root, absPlan) || planPath,
|
|
8425
|
+
slug: parsed.name,
|
|
8426
|
+
// the envelope must point at THIS run's state file and reproduce THIS run's flags (HIGH-7)
|
|
8427
|
+
runStatePath: relative(root, join(runDir, WF_RUN_STATE_FILE)) || join(runDir, WF_RUN_STATE_FILE),
|
|
8428
|
+
runDirArg: runDirOpt ?? null,
|
|
8429
|
+
// the run-state owner records the process that actually holds the claim (HIGH-6) — `pid: 0`
|
|
8430
|
+
// was a durable record of a process that never existed
|
|
8431
|
+
ownerPid: process.pid,
|
|
8432
|
+
ownerStartedMarker: startedMarker,
|
|
8433
|
+
};
|
|
8434
|
+
const outcome = await runWorkflow(inputs, pre, deps);
|
|
8435
|
+
// A run that dispatched to NO REAL MODEL says so on every channel (Step-8 MEDIUM-11): the
|
|
8436
|
+
// result/pause envelope carries `dispatcherOverride`, and the human line says it in words. A seam
|
|
8437
|
+
// visible only inside a state file is a seam a reader of the output cannot know about.
|
|
8438
|
+
const seamNote = dispatcherOverride ? ' [SCRIPTED DISPATCHER — no real model ran]' : '';
|
|
8439
|
+
if (outcome.kind === 'paused') {
|
|
8440
|
+
if (!json)
|
|
8441
|
+
write(`dz workflow run: PAUSED (${outcome.envelope.pauseState})${seamNote} — resume with: ${outcome.envelope.resumeCmd}`);
|
|
8442
|
+
// the envelope is the LAST stdout line, ALWAYS (AM-16)
|
|
8443
|
+
emit({ ...outcome.envelope, ...(dispatcherOverride ? { dispatcherOverride: true } : {}) });
|
|
8444
|
+
return 75;
|
|
8445
|
+
}
|
|
8446
|
+
if (outcome.kind === 'failed') {
|
|
8447
|
+
if (!json)
|
|
8448
|
+
write(`dz workflow run: ${outcome.reason}${seamNote} — ${outcome.detail}`);
|
|
8449
|
+
emit(outcome.result); // a wf-run-result/1 line — and NEVER a pause envelope
|
|
8450
|
+
return 1;
|
|
8451
|
+
}
|
|
8452
|
+
if (!json) {
|
|
8453
|
+
const terminal = outcome.result.terminalRoute === undefined ? '' : ` via the plan's terminal route ${outcome.result.terminalRoute}`;
|
|
8454
|
+
write(`dz workflow run: completed (${runId})${terminal}${seamNote} — trace at ${join(relative(root, runDir) || '.', 'trace.jsonl')}`);
|
|
8455
|
+
}
|
|
8456
|
+
emit(outcome.result);
|
|
8457
|
+
return 0;
|
|
8458
|
+
}
|
|
8459
|
+
finally {
|
|
8460
|
+
// the claim is released exactly once, on EVERY path out of the claimed region
|
|
8461
|
+
try {
|
|
8462
|
+
unlinkSync(ownerPath);
|
|
8463
|
+
}
|
|
8464
|
+
catch { /* already gone */ }
|
|
8465
|
+
wfKillLiveChildren();
|
|
8466
|
+
}
|
|
8467
|
+
}
|
|
8468
|
+
function scanReqeDebts(root) {
|
|
8469
|
+
const out = [];
|
|
8470
|
+
let malformed = 0;
|
|
8471
|
+
/**
|
|
8472
|
+
* TWO scan roots (K1 — feature dz-workflow-run):
|
|
8473
|
+
* `features/<slug>/.fa-state/` — the feature-adr home, the original root;
|
|
8474
|
+
* `.dz/loop-trace/<runId>/.fa-state/` — the DEFAULT home of a `dz workflow run` (ADR-003).
|
|
8475
|
+
* Without the second root the ADR-002 waiver promise ("a waived run's debt is surfaced by
|
|
8476
|
+
* `dz reqe`") is FALSE for every default-homed loop run — the record would be written to a
|
|
8477
|
+
* directory nothing ever reads. The scan's own rules are unchanged: symlinked containers are
|
|
8478
|
+
* skipped, an oversize or non-plain file is NAMED as malformed rather than silently dropped, and
|
|
8479
|
+
* a debt whose `slug` disagrees with its directory is malformed too.
|
|
8480
|
+
*/
|
|
8481
|
+
const scanRoots = [
|
|
8482
|
+
{ base: join(root, 'features'), keyMatchesDir: true },
|
|
8483
|
+
// a loop run's directory is its runId; the debt's `slug` is the PLAN's name, so the two need
|
|
8484
|
+
// not agree — the identity check that applies under features/ does not apply here
|
|
8485
|
+
{ base: join(root, '.dz', 'loop-trace'), keyMatchesDir: false },
|
|
8486
|
+
];
|
|
8487
|
+
for (const scanRoot of scanRoots)
|
|
8488
|
+
scanOneReqeRoot(scanRoot.base, scanRoot.keyMatchesDir, out, (n) => { malformed += n; });
|
|
8489
|
+
return { debts: out, malformed };
|
|
8490
|
+
}
|
|
8491
|
+
/** One scan root's walk — extracted verbatim from the original single-root body (K1). */
|
|
8492
|
+
function scanOneReqeRoot(featuresDir, keyMatchesDir, out, addMalformed) {
|
|
8493
|
+
let malformed = 0;
|
|
8494
|
+
let slugs = [];
|
|
8495
|
+
try {
|
|
8496
|
+
if (lstatSync(featuresDir).isSymbolicLink())
|
|
8497
|
+
return; // r2 #4: the container itself
|
|
8498
|
+
slugs = readdirSync(featuresDir);
|
|
8499
|
+
}
|
|
8500
|
+
catch {
|
|
8501
|
+
return;
|
|
8502
|
+
}
|
|
8503
|
+
for (const slug of slugs.sort()) {
|
|
8504
|
+
const dir = join(featuresDir, slug);
|
|
8505
|
+
const stateDir = join(dir, '.fa-state');
|
|
8506
|
+
const duePath = join(stateDir, 'reqe-due.json');
|
|
8507
|
+
try {
|
|
8508
|
+
if (lstatSync(dir).isSymbolicLink() || lstatSync(stateDir).isSymbolicLink())
|
|
8509
|
+
continue;
|
|
8510
|
+
}
|
|
8511
|
+
catch {
|
|
7219
8512
|
continue; // no feature dir / no state dir — nothing to scan
|
|
7220
8513
|
}
|
|
7221
8514
|
let st;
|
|
@@ -7231,7 +8524,7 @@ function scanReqeDebts(root) {
|
|
|
7231
8524
|
}
|
|
7232
8525
|
try {
|
|
7233
8526
|
const debt = parseReqeDebt(readFileSync(duePath, 'utf-8'));
|
|
7234
|
-
if (debt && debt.slug === slug)
|
|
8527
|
+
if (debt && (!keyMatchesDir || debt.slug === slug))
|
|
7235
8528
|
out.push({ debt, duePath, dir });
|
|
7236
8529
|
else
|
|
7237
8530
|
malformed++;
|
|
@@ -7240,7 +8533,7 @@ function scanReqeDebts(root) {
|
|
|
7240
8533
|
malformed++;
|
|
7241
8534
|
}
|
|
7242
8535
|
}
|
|
7243
|
-
|
|
8536
|
+
addMalformed(malformed);
|
|
7244
8537
|
}
|
|
7245
8538
|
/**
|
|
7246
8539
|
* `dz reqe` — the re-QE debt ledger (backlog 6b40e667): list usage-switched same-family QE debts,
|
|
@@ -7396,6 +8689,711 @@ function cmdReqe(options, flags, cwd, write) {
|
|
|
7396
8689
|
write(json ? JSON.stringify({ settled: true, grade: settlement.grade, exitCode: 0 }) : `dz reqe: ${okMsg}`);
|
|
7397
8690
|
return 0;
|
|
7398
8691
|
}
|
|
8692
|
+
/* -------------------------------------------------------------------------- */
|
|
8693
|
+
/* `dz qe-bridge` — the reverse QE bridge (feature qe-bridge-claude, ADR-001) */
|
|
8694
|
+
/* -------------------------------------------------------------------------- */
|
|
8695
|
+
/** Review timeout default: an adversarial QE pass legitimately takes minutes (NFR-3). */
|
|
8696
|
+
const QE_BRIDGE_DEFAULT_TIMEOUT_S = 600;
|
|
8697
|
+
const QE_BRIDGE_MIN_TIMEOUT_S = 30;
|
|
8698
|
+
const QE_BRIDGE_MAX_TIMEOUT_S = 3600;
|
|
8699
|
+
/** Probe timeout — the mirror of `codexProbeCommand`'s `timeout 60`. */
|
|
8700
|
+
const QE_BRIDGE_PROBE_TIMEOUT_MS = 60_000;
|
|
8701
|
+
/**
|
|
8702
|
+
* Run one `claude` call with the prompt on STDIN. Spawn-injectable, and NEVER throws: a missing
|
|
8703
|
+
* binary, a crash and a hang all come back as DATA, because the taxonomy above them can only name
|
|
8704
|
+
* a failure it is handed. (The first draft of this function let the ENOENT escape as an uncaught
|
|
8705
|
+
* exception and the command never settled — the acid A1 red, quoted in red-green.md.)
|
|
8706
|
+
*
|
|
8707
|
+
* Mirrors `probeContent`'s settled-flag + SIGTERM deadline shape (`cli.ts` probes) and scrubs
|
|
8708
|
+
* `PROBE_SCRUB_ENV`, so a bridge launched from inside a nested Claude session cannot inherit the
|
|
8709
|
+
* parent's session identity (SEC-4).
|
|
8710
|
+
*/
|
|
8711
|
+
export async function runClaudeBridge(bin, argv, promptStdin, timeoutMs, cwd = process.cwd(), spawnImpl = spawn) {
|
|
8712
|
+
// A THIN WRAPPER over runChildBridge since the loop runner needed the same machinery with two
|
|
8713
|
+
// extra knobs. This signature is consumed by the qe-bridge suites and MUST NOT change.
|
|
8714
|
+
return runChildBridge(bin, argv, { stdinText: promptStdin, timeoutMs, cwd, detached: false, spawnImpl });
|
|
8715
|
+
}
|
|
8716
|
+
/** How long a child's process group gets to honour SIGTERM before SIGKILL (Step-8 MEDIUM-14). */
|
|
8717
|
+
const CHILD_SIGKILL_GRACE_MS = 2000;
|
|
8718
|
+
/**
|
|
8719
|
+
* The MINIMAL environment a dispatched child gets (Step-8 HIGH-9).
|
|
8720
|
+
*
|
|
8721
|
+
* A deny-list removes what somebody remembered; an allow-list carries what the child needs and
|
|
8722
|
+
* nothing else. The named set is deliberately boring — enough for a binary to find itself, resolve
|
|
8723
|
+
* a home directory, write a temp file and talk to a proxy — plus each runtime's own credential
|
|
8724
|
+
* variables, which are listed because they are REQUIRED, not because they happened to be present.
|
|
8725
|
+
* Anything a future adapter needs is added HERE, visibly, with a reason.
|
|
8726
|
+
*/
|
|
8727
|
+
const CHILD_ENV_BASE = [
|
|
8728
|
+
'PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL', 'LANG', 'LC_ALL', 'TZ',
|
|
8729
|
+
'TMPDIR', 'TEMP', 'TMP',
|
|
8730
|
+
'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy',
|
|
8731
|
+
// node itself, for a child that IS node
|
|
8732
|
+
'NODE_EXTRA_CA_CERTS',
|
|
8733
|
+
];
|
|
8734
|
+
/**
|
|
8735
|
+
* CREDENTIALS ARE PER FAMILY (Step-8 re-QE H9 — the round-1 allowlist shipped BOTH sets to BOTH
|
|
8736
|
+
* runtimes, which is a shorter list of the same mistake).
|
|
8737
|
+
*
|
|
8738
|
+
* A codex dispatch has no business holding an Anthropic key, and vice versa. The two runtimes are
|
|
8739
|
+
* separate blast radii precisely because the cross-model rule makes them review each other: if one
|
|
8740
|
+
* is compromised or simply misbehaves, it must not be carrying the other's credentials. The base
|
|
8741
|
+
* above is boring on purpose — enough to find a binary, a home directory and a proxy — and nothing
|
|
8742
|
+
* in it authenticates anything.
|
|
8743
|
+
*/
|
|
8744
|
+
const CHILD_ENV_BY_FAMILY = {
|
|
8745
|
+
claude: ['ANTHROPIC_API_KEY', 'CLAUDE_CONFIG_DIR'],
|
|
8746
|
+
openai: ['OPENAI_API_KEY', 'CODEX_HOME'],
|
|
8747
|
+
};
|
|
8748
|
+
function buildAllowlistEnv(extra, parent = process.env) {
|
|
8749
|
+
const out = {};
|
|
8750
|
+
for (const key of [...CHILD_ENV_BASE, ...extra]) {
|
|
8751
|
+
const v = parent[key];
|
|
8752
|
+
if (typeof v === 'string')
|
|
8753
|
+
out[key] = v;
|
|
8754
|
+
}
|
|
8755
|
+
return out;
|
|
8756
|
+
}
|
|
8757
|
+
/** Test seam for H9: the exact environment ONE family's child would receive. */
|
|
8758
|
+
export function __wfChildEnvTestSeam(family, parent) {
|
|
8759
|
+
return buildAllowlistEnv(CHILD_ENV_BY_FAMILY[family], parent);
|
|
8760
|
+
}
|
|
8761
|
+
/**
|
|
8762
|
+
* THE child-process wrapper both the qe-bridge and the loop runner ride (ADR-002 O1: ONE impure
|
|
8763
|
+
* wrapper, not two). Generalized from `runClaudeBridge` with the same guarantees — a settled flag so
|
|
8764
|
+
* no path resolves twice, a deadline timer that SIGTERMs, the `PROBE_SCRUB_ENV` scrub so a bridge
|
|
8765
|
+
* launched from inside a nested Claude session cannot inherit it, and an injectable `spawnImpl` —
|
|
8766
|
+
* plus the two knobs the generalization adds:
|
|
8767
|
+
*
|
|
8768
|
+
* • `stdinText: null` ⇒ `stdio[0] = 'ignore'`. MEASURED: codex-cli 0.148.0 prints
|
|
8769
|
+
* `Reading additional input from stdin...` and WAITS when stdin is left open. Passing an empty
|
|
8770
|
+
* string is not the same thing as closing it.
|
|
8771
|
+
* • `detached: true` ⇒ the child leads its OWN process group, so the runner can kill the whole
|
|
8772
|
+
* group (AM-10). `onSpawn` hands the live child to the caller's registry at the only moment the
|
|
8773
|
+
* pid is knowable.
|
|
8774
|
+
*
|
|
8775
|
+
* Never throws: a spawn failure resolves with `spawnError` set, exactly like the original.
|
|
8776
|
+
*/
|
|
8777
|
+
export async function runChildBridge(bin, argv, opts) {
|
|
8778
|
+
const spawnImpl = opts.spawnImpl ?? spawn;
|
|
8779
|
+
return new Promise((resolveRun) => {
|
|
8780
|
+
const env = opts.envMode === 'allowlist'
|
|
8781
|
+
? buildAllowlistEnv(opts.envExtra ?? [])
|
|
8782
|
+
: { ...process.env };
|
|
8783
|
+
if (opts.envMode !== 'allowlist')
|
|
8784
|
+
for (const key of PROBE_SCRUB_ENV)
|
|
8785
|
+
delete env[key];
|
|
8786
|
+
let out = '';
|
|
8787
|
+
let err = '';
|
|
8788
|
+
let settled = false;
|
|
8789
|
+
let timedOut = false;
|
|
8790
|
+
let child;
|
|
8791
|
+
const finish = (spawnError, exitCode) => {
|
|
8792
|
+
if (settled)
|
|
8793
|
+
return;
|
|
8794
|
+
settled = true;
|
|
8795
|
+
clearTimeout(timer);
|
|
8796
|
+
// A DETACHED child leads its own group, so kill the GROUP — killing the leader alone leaves
|
|
8797
|
+
// whatever it spawned running (the orphan class AM-10 exists to close). SIGTERM is a REQUEST;
|
|
8798
|
+
// a group that ignores it would outlive the runner, so a bounded grace period later the same
|
|
8799
|
+
// group gets SIGKILL, which is not a request (Step-8 MEDIUM-14).
|
|
8800
|
+
// EVERY termination goes through the one guarded chokepoint (re-QE NEW-C4) — there is no
|
|
8801
|
+
// second call shape here to forget to guard.
|
|
8802
|
+
const sent = signalChildSafely(child, 'SIGTERM', opts.detached);
|
|
8803
|
+
if (sent) {
|
|
8804
|
+
const escalation = setTimeout(() => {
|
|
8805
|
+
signalChildSafely(child, 'SIGKILL', opts.detached);
|
|
8806
|
+
}, CHILD_SIGKILL_GRACE_MS);
|
|
8807
|
+
escalation.unref?.(); // the grace timer must never hold the runner's event loop open
|
|
8808
|
+
}
|
|
8809
|
+
resolveRun({ stdout: out, stderr: err, exitCode, timedOut, spawnError });
|
|
8810
|
+
};
|
|
8811
|
+
const timer = setTimeout(() => {
|
|
8812
|
+
timedOut = true;
|
|
8813
|
+
finish(null, null);
|
|
8814
|
+
}, opts.timeoutMs);
|
|
8815
|
+
try {
|
|
8816
|
+
child = spawnImpl(bin, argv, {
|
|
8817
|
+
cwd: opts.cwd,
|
|
8818
|
+
env,
|
|
8819
|
+
detached: opts.detached,
|
|
8820
|
+
stdio: [opts.stdinText === null ? 'ignore' : 'pipe', 'pipe', 'pipe'],
|
|
8821
|
+
});
|
|
8822
|
+
}
|
|
8823
|
+
catch (error) {
|
|
8824
|
+
finish(`cannot run \`${bin}\`: ${error instanceof Error ? error.message : String(error)}`, null);
|
|
8825
|
+
return;
|
|
8826
|
+
}
|
|
8827
|
+
opts.onSpawn?.(child);
|
|
8828
|
+
child.on('error', (error) => finish(`cannot run \`${bin}\`: ${error.message}`, null));
|
|
8829
|
+
child.stdout?.on('data', (c) => { out += c.toString(); });
|
|
8830
|
+
child.stderr?.on('data', (c) => { err += c.toString(); });
|
|
8831
|
+
child.on('close', (code) => finish(null, code));
|
|
8832
|
+
// EPIPE when the child died before reading: already reported through 'error'/'close'.
|
|
8833
|
+
child.stdin?.on('error', () => { });
|
|
8834
|
+
if (opts.stdinText !== null) {
|
|
8835
|
+
try {
|
|
8836
|
+
child.stdin?.write(opts.stdinText);
|
|
8837
|
+
child.stdin?.end();
|
|
8838
|
+
}
|
|
8839
|
+
catch {
|
|
8840
|
+
/* the close/error handlers decide the outcome */
|
|
8841
|
+
}
|
|
8842
|
+
}
|
|
8843
|
+
});
|
|
8844
|
+
}
|
|
8845
|
+
/** stderr wording that PROVES a login problem. Anything else stays unclassified: a guessed reason
|
|
8846
|
+
* is a small lie, and the failure record carries the raw evidence instead (ADR-001 D3-A). */
|
|
8847
|
+
function classifyClaudeStderr(stderr) {
|
|
8848
|
+
return /invalid api key|not logged in|please run \/login|unauthorized|authentication (failed|error)|oauth token (has )?expired/i.test(stderr)
|
|
8849
|
+
? 'claude-not-logged-in'
|
|
8850
|
+
: 'exit-nonzero';
|
|
8851
|
+
}
|
|
8852
|
+
/** Test seam AND escape hatch for a non-standard install: the executable the bridge spawns.
|
|
8853
|
+
* Deliberately an ENV VAR and not a flag — a reviewer's identity should not be something a caller
|
|
8854
|
+
* can redirect with a casual command-line switch, and every record says loudly when it was used
|
|
8855
|
+
* (round-2 M3). */
|
|
8856
|
+
const QE_BRIDGE_CLAUDE_BIN_ENV = 'DZ_QE_BRIDGE_CLAUDE_BIN';
|
|
8857
|
+
/**
|
|
8858
|
+
* CRASH FAILPOINT (round-4 R4-1) — test-only, and the ONLY thing it can do is stop. Set
|
|
8859
|
+
* `DZ_QE_BRIDGE_FAILPOINT=hang-before-rename` and the process blocks after the temp record is
|
|
8860
|
+
* written and before the rename, so a test can SIGKILL it exactly inside the window the atomic
|
|
8861
|
+
* update exists to close. Unset (the normal case) it is one string comparison and no behaviour.
|
|
8862
|
+
* A crash-window property that no test can enter is a claim, not a guarantee.
|
|
8863
|
+
*/
|
|
8864
|
+
const QE_BRIDGE_FAILPOINT_ENV = 'DZ_QE_BRIDGE_FAILPOINT';
|
|
8865
|
+
/** Files under `.fa-state/qe-bridge/` may quote reviewed source and reviewer prose: owner-only. */
|
|
8866
|
+
const RECORD_FILE_MODE = 0o600;
|
|
8867
|
+
const RECORD_DIR_MODE = 0o700;
|
|
8868
|
+
/**
|
|
8869
|
+
* Write a NEW file, never through a symlink, never over an existing one, and never world-readable.
|
|
8870
|
+
* `wx` gives O_EXCL (no overwrite, no symlink follow at the final component); the explicit chmods
|
|
8871
|
+
* defeat the process umask, which `mode:` alone does not (MEASURED: under umask 022 the round-1
|
|
8872
|
+
* writes landed 0644/0755 — round-2 MAJOR M6).
|
|
8873
|
+
*/
|
|
8874
|
+
function writeNewFileOrThrow(path, content, mode = RECORD_FILE_MODE) {
|
|
8875
|
+
const dir = dirname(path);
|
|
8876
|
+
mkdirSync(dir, { recursive: true, mode: RECORD_DIR_MODE });
|
|
8877
|
+
try {
|
|
8878
|
+
chmodSync(dir, RECORD_DIR_MODE);
|
|
8879
|
+
}
|
|
8880
|
+
catch { /* not ours to tighten (a pre-existing shared dir) — the file mode below still applies */ }
|
|
8881
|
+
writeFileSync(path, content, { flag: 'wx', mode });
|
|
8882
|
+
chmodSync(path, mode);
|
|
8883
|
+
}
|
|
8884
|
+
/**
|
|
8885
|
+
* Path containment that survives a symlinked PARENT (round-2 M6). Lexical `startsWith` is not
|
|
8886
|
+
* containment: `features/x/` can be a symlink to `/etc`, and `wx` only refuses a symlink at the
|
|
8887
|
+
* FINAL component. So: walk from the deepest EXISTING ancestor, realpath it, and require the result
|
|
8888
|
+
* to stay under the realpath of the root — and refuse outright if any existing component on the way
|
|
8889
|
+
* is a symlink.
|
|
8890
|
+
*/
|
|
8891
|
+
function containedUnderRoot(root, target) {
|
|
8892
|
+
let realRoot;
|
|
8893
|
+
try {
|
|
8894
|
+
realRoot = realpathSync(root);
|
|
8895
|
+
}
|
|
8896
|
+
catch {
|
|
8897
|
+
return { ok: false, why: `the project root ${root} does not resolve` };
|
|
8898
|
+
}
|
|
8899
|
+
const abs = resolve(root, target);
|
|
8900
|
+
const rel = relative(realRoot, abs);
|
|
8901
|
+
if (rel === '' || rel.startsWith('..') || isAbsolute(rel))
|
|
8902
|
+
return { ok: false, why: `${target} resolves outside the project root` };
|
|
8903
|
+
// deepest existing ancestor, with every existing component checked for a symlink
|
|
8904
|
+
const parts = rel.split(sep).filter((p) => p !== '');
|
|
8905
|
+
let walked = realRoot;
|
|
8906
|
+
for (const part of parts) {
|
|
8907
|
+
const next = join(walked, part);
|
|
8908
|
+
let st;
|
|
8909
|
+
try {
|
|
8910
|
+
st = lstatSync(next);
|
|
8911
|
+
}
|
|
8912
|
+
catch {
|
|
8913
|
+
break; // this component does not exist yet: nothing below it can be a symlink either
|
|
8914
|
+
}
|
|
8915
|
+
if (st.isSymbolicLink())
|
|
8916
|
+
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)` };
|
|
8917
|
+
walked = next;
|
|
8918
|
+
}
|
|
8919
|
+
let realWalked;
|
|
8920
|
+
try {
|
|
8921
|
+
realWalked = realpathSync(walked);
|
|
8922
|
+
}
|
|
8923
|
+
catch {
|
|
8924
|
+
return { ok: false, why: `${target} has an unresolvable parent` };
|
|
8925
|
+
}
|
|
8926
|
+
const realRel = relative(realRoot, realWalked);
|
|
8927
|
+
if (realRel.startsWith('..') || isAbsolute(realRel))
|
|
8928
|
+
return { ok: false, why: `${target} escapes the project root through its parent directories` };
|
|
8929
|
+
return { ok: true, path: abs };
|
|
8930
|
+
}
|
|
8931
|
+
/**
|
|
8932
|
+
* `dz qe-bridge --family claude` — run a Claude reviewer over Step-8-scoped inputs and land a
|
|
8933
|
+
* PARSED signoff (feature qe-bridge-claude, ADR-001).
|
|
8934
|
+
*
|
|
8935
|
+
* The point of the command: when CODEX hosts the run there is no Claude agent plane to dispatch
|
|
8936
|
+
* from, so the cross-family QE rule has no vehicle — `buildReqeBrief` hands the human a `null`
|
|
8937
|
+
* command template for exactly this case. This is that vehicle, callable from a plain shell.
|
|
8938
|
+
*
|
|
8939
|
+
* ISOLATION (round-2 CRITICAL C1): both calls run from an EMPTY temporary directory with the
|
|
8940
|
+
* runtime's own `--safe-mode --strict-mcp-config --tools '' --no-session-persistence`, and the
|
|
8941
|
+
* verdict is read out of the `--output-format json` result envelope. Without that, the reviewer is a
|
|
8942
|
+
* fully customized session running INSIDE the repository under review, and hooks/plugins can print a
|
|
8943
|
+
* complete signoff onto the same stdout the parser reads (MEASURED — see red-green.md).
|
|
8944
|
+
*
|
|
8945
|
+
* Exit codes: 0 = a signoff was PARSED (any grade — the bridge reports, `dz reqe` gates),
|
|
8946
|
+
* 1 = a NAMED failure (record in `.fa-state/qe-bridge/failed-*.json`, raw output beside it,
|
|
8947
|
+
* never at `--out`), 2 = a usage error (nothing spawned, nothing written).
|
|
8948
|
+
*/
|
|
8949
|
+
async function cmdQeBridge(options, flags, cwd, write) {
|
|
8950
|
+
const json = flags.has('json');
|
|
8951
|
+
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]';
|
|
8952
|
+
const usageError = (message) => {
|
|
8953
|
+
write(json ? JSON.stringify({ ok: false, error: message, exitCode: 2 }) : `dz qe-bridge: ${message}\n${usage}`);
|
|
8954
|
+
return 2;
|
|
8955
|
+
};
|
|
8956
|
+
if (flags.has('help')) {
|
|
8957
|
+
if (json) {
|
|
8958
|
+
write(JSON.stringify({ help: usage, exitCode: 0 }));
|
|
8959
|
+
return 0;
|
|
8960
|
+
}
|
|
8961
|
+
write(usage);
|
|
8962
|
+
write(' Runs a CLAUDE reviewer over a feature’s Step-8 artifacts from ANY host (a Codex session included)');
|
|
8963
|
+
write(' and writes a parsed SIGNOFF. Exit 0 = a signoff was parsed (ANY grade — the bridge reports, it does');
|
|
8964
|
+
write(' not gate); 1 = a named failure (see features/<slug>/.fa-state/qe-bridge/failed-*.json); 2 = usage.');
|
|
8965
|
+
write(' --family codex is reserved: the forward bridge is `codex exec` (see .claude/rules/feature-adr-conventions.md).');
|
|
8966
|
+
write(' Default report: features/<slug>/08b_reqe_report.md — settle it with');
|
|
8967
|
+
write(' dz reqe --slug <feature> --done --report features/<feature>/08b_reqe_report.md');
|
|
8968
|
+
write(' DISCLOSURE: the bridge sends the extracts you scope (--files, plus the feature’s manifest/ADR/QE report)');
|
|
8969
|
+
write(' to the Claude runtime. It cannot classify secrets — scoping the content you scope is YOUR decision (SEC-5).');
|
|
8970
|
+
return 0;
|
|
8971
|
+
}
|
|
8972
|
+
const ALLOWED_FLAGS = new Set(['json', 'help', 'allow-same-family']);
|
|
8973
|
+
for (const flag of flags) {
|
|
8974
|
+
if (!ALLOWED_FLAGS.has(flag)) {
|
|
8975
|
+
return usageError(`unknown option --${flag}` + (['model', 'slug', 'family', 'out', 'files', 'timeout', 'coder-family', 'project'].includes(flag) ? ` (it takes a value: --${flag} <value>)` : ''));
|
|
8976
|
+
}
|
|
8977
|
+
}
|
|
8978
|
+
const ALLOWED_OPTIONS = new Set(['family', 'slug', 'coder-family', 'model', 'files', 'out', 'timeout', 'project']);
|
|
8979
|
+
for (const key of options.keys()) {
|
|
8980
|
+
if (key.startsWith('_positional_'))
|
|
8981
|
+
return usageError(`unexpected argument "${options.get(key)}"`);
|
|
8982
|
+
if (key === 'claude-bin')
|
|
8983
|
+
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.`);
|
|
8984
|
+
if (!ALLOWED_OPTIONS.has(key))
|
|
8985
|
+
return usageError(`unknown option --${key}`);
|
|
8986
|
+
}
|
|
8987
|
+
// ── family (the reserved codex direction errors with a pointer, never a silent alias) ──
|
|
8988
|
+
const family = options.get('family');
|
|
8989
|
+
if (family === undefined)
|
|
8990
|
+
return usageError('--family claude is required');
|
|
8991
|
+
if (family === 'codex' || family === 'openai') {
|
|
8992
|
+
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)');
|
|
8993
|
+
}
|
|
8994
|
+
if (family !== 'claude')
|
|
8995
|
+
return usageError(`unsupported --family ${family} (this leg ships "claude" only)`);
|
|
8996
|
+
const slug = options.get('slug') ?? '';
|
|
8997
|
+
if (!isSafeSlug(slug))
|
|
8998
|
+
return usageError('a kebab-case --slug <feature> is required (no path separators, max 40 chars)');
|
|
8999
|
+
const root = resolve(cwd, options.get('project') ?? '.');
|
|
9000
|
+
const featureDir = join(root, 'features', slug);
|
|
9001
|
+
if (!existsSync(featureDir))
|
|
9002
|
+
return usageError(`no feature directory at features/${slug} — the bridge reviews an existing feature’s artifacts`);
|
|
9003
|
+
// ── coder family: the RECORDED DEBT is the authority; the flag may only fill a gap ──
|
|
9004
|
+
//
|
|
9005
|
+
// Round-2 MAJOR M3: round 1 let `--coder-family codex` override a debt that said `claude`, which
|
|
9006
|
+
// turns the loud `--allow-same-family` escape into an optional formality — a Claude-coded feature
|
|
9007
|
+
// could be Claude-reviewed by mis-declaring one flag. The debt is written by the pipeline; the
|
|
9008
|
+
// flag is written by whoever is running the command.
|
|
9009
|
+
let coderFamily = null;
|
|
9010
|
+
let coderFamilySource = 'flag';
|
|
9011
|
+
let recordedDebtFamily = null;
|
|
9012
|
+
const duePath = join(featureDir, '.fa-state', 'reqe-due.json');
|
|
9013
|
+
if (existsSync(duePath)) {
|
|
9014
|
+
try {
|
|
9015
|
+
const debt = parseReqeDebt(readFileSync(duePath, 'utf-8'));
|
|
9016
|
+
if (debt)
|
|
9017
|
+
recordedDebtFamily = debt.coderFamily;
|
|
9018
|
+
}
|
|
9019
|
+
catch { /* unreadable debt: treated as absent, and the flag must then be given */ }
|
|
9020
|
+
}
|
|
9021
|
+
const coderOpt = options.get('coder-family');
|
|
9022
|
+
if (coderOpt !== undefined) {
|
|
9023
|
+
// The FLAG surface stays a closed allowlist (the cmdQeBridge discipline — a flag is not a
|
|
9024
|
+
// place to accept whatever parses); the FAMILY behind it comes from the ONE mapper the loop
|
|
9025
|
+
// runner also uses for its same-family comparison (ADR-002 W20/AM-17). A second normalization
|
|
9026
|
+
// here is how a codex-coded run comes to be reviewed by codex under a claude label — the
|
|
9027
|
+
// agreement between the two call sites is pinned by a test, not by care.
|
|
9028
|
+
const asked = coderOpt === 'codex' || coderOpt === 'openai' || coderOpt === 'claude' ? modelFamily(coderOpt) : null;
|
|
9029
|
+
if (asked === null)
|
|
9030
|
+
return usageError(`--coder-family must be codex or claude (got "${coderOpt}")`);
|
|
9031
|
+
if (recordedDebtFamily !== null && recordedDebtFamily !== asked) {
|
|
9032
|
+
return usageError(`--coder-family ${coderOpt} contradicts the recorded debt at features/${slug}/.fa-state/reqe-due.json, which says the coder family was ${recordedDebtFamily}. ` +
|
|
9033
|
+
'The debt is the authority: it was written by the run being reviewed, the flag by whoever is invoking this command. ' +
|
|
9034
|
+
'Refusing rather than letting a flag re-label who wrote the code — that label is what decides whether this review is cross-family. ' +
|
|
9035
|
+
'Fix the flag, or correct the debt file if IT is wrong.');
|
|
9036
|
+
}
|
|
9037
|
+
coderFamily = asked;
|
|
9038
|
+
}
|
|
9039
|
+
else if (recordedDebtFamily !== null) {
|
|
9040
|
+
coderFamily = recordedDebtFamily;
|
|
9041
|
+
coderFamilySource = 'reqe-due.json';
|
|
9042
|
+
}
|
|
9043
|
+
if (coderFamily === null) {
|
|
9044
|
+
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`);
|
|
9045
|
+
}
|
|
9046
|
+
// ── --out: under the repo, no traversal, no control characters, no symlinked parents ──
|
|
9047
|
+
const outOpt = options.get('out') ?? join('features', slug, '08b_reqe_report.md');
|
|
9048
|
+
if (hasUnsafePathChars(outOpt) || hasDotDotSegment(outOpt))
|
|
9049
|
+
return usageError('--out must not contain control characters or ".." segments');
|
|
9050
|
+
const outCheck = containedUnderRoot(root, outOpt);
|
|
9051
|
+
if (!outCheck.ok)
|
|
9052
|
+
return usageError(`--out ${outCheck.why}`);
|
|
9053
|
+
const outPath = outCheck.path;
|
|
9054
|
+
// ── timeouts: Number.isFinite-safe clamp (the numeric-clamp lesson) ──
|
|
9055
|
+
let timeoutS = QE_BRIDGE_DEFAULT_TIMEOUT_S;
|
|
9056
|
+
const timeoutRaw = options.get('timeout');
|
|
9057
|
+
if (timeoutRaw !== undefined) {
|
|
9058
|
+
const n = Number(timeoutRaw);
|
|
9059
|
+
if (!Number.isFinite(n))
|
|
9060
|
+
return usageError(`--timeout must be a number of seconds (got "${timeoutRaw}")`);
|
|
9061
|
+
timeoutS = Math.min(QE_BRIDGE_MAX_TIMEOUT_S, Math.max(QE_BRIDGE_MIN_TIMEOUT_S, Math.floor(n)));
|
|
9062
|
+
}
|
|
9063
|
+
// ── model candidates: an allowlist says a name is spellable, only the probe says it answers ──
|
|
9064
|
+
const modelOpt = options.get('model');
|
|
9065
|
+
if (modelOpt !== undefined && !isSafeClaudeId(modelOpt)) {
|
|
9066
|
+
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)`);
|
|
9067
|
+
}
|
|
9068
|
+
const candidates = modelOpt !== undefined ? [modelOpt] : Object.keys(KNOWN_CLAUDE);
|
|
9069
|
+
const binRaw = process.env[QE_BRIDGE_CLAUDE_BIN_ENV];
|
|
9070
|
+
const binOverride = typeof binRaw === 'string' && binRaw.trim() !== '';
|
|
9071
|
+
const binOpt = binOverride ? binRaw.trim() : 'claude';
|
|
9072
|
+
if (hasUnsafePathChars(binOpt))
|
|
9073
|
+
return usageError(`${QE_BRIDGE_CLAUDE_BIN_ENV} must not contain control characters`);
|
|
9074
|
+
let resolvedBin = binOpt;
|
|
9075
|
+
if (binOverride) {
|
|
9076
|
+
try {
|
|
9077
|
+
resolvedBin = realpathSync(binOpt);
|
|
9078
|
+
}
|
|
9079
|
+
catch {
|
|
9080
|
+
resolvedBin = binOpt; // unresolvable: recorded as given, and the spawn will name the failure
|
|
9081
|
+
}
|
|
9082
|
+
}
|
|
9083
|
+
// ── extracts: SCOPED, never a repo dump ──
|
|
9084
|
+
const extracts = [];
|
|
9085
|
+
const pushIfPresent = (rel, label) => {
|
|
9086
|
+
const p = join(root, rel);
|
|
9087
|
+
try {
|
|
9088
|
+
if (!lstatSync(p).isFile())
|
|
9089
|
+
return;
|
|
9090
|
+
extracts.push({ label, text: readFileSync(p, 'utf-8') });
|
|
9091
|
+
}
|
|
9092
|
+
catch { /* absent: the brief says so by omission */ }
|
|
9093
|
+
};
|
|
9094
|
+
pushIfPresent(join('features', slug, '07_code_changes', 'change_manifest.md'), `features/${slug}/07_code_changes/change_manifest.md`);
|
|
9095
|
+
const adrDir = join(featureDir, '03_adr');
|
|
9096
|
+
if (existsSync(adrDir)) {
|
|
9097
|
+
for (const f of readdirSync(adrDir).filter((n) => n.endsWith('.md')).sort()) {
|
|
9098
|
+
pushIfPresent(join('features', slug, '03_adr', f), `features/${slug}/03_adr/${f}`);
|
|
9099
|
+
}
|
|
9100
|
+
}
|
|
9101
|
+
pushIfPresent(join('features', slug, '08_qe_report.md'), `features/${slug}/08_qe_report.md (the review ON RECORD — judge it, do not inherit it)`);
|
|
9102
|
+
const filesOpt = options.get('files');
|
|
9103
|
+
for (const rel of (filesOpt ?? '').split(',').map((s) => s.trim()).filter((s) => s !== '')) {
|
|
9104
|
+
if (hasUnsafePathChars(rel) || hasDotDotSegment(rel) || isAbsolute(rel)) {
|
|
9105
|
+
return usageError(`--files entry "${rel}" must be a repo-relative path with no ".." segments and no control characters`);
|
|
9106
|
+
}
|
|
9107
|
+
const check = containedUnderRoot(root, rel);
|
|
9108
|
+
if (!check.ok)
|
|
9109
|
+
return usageError(`--files entry "${check.why}"`);
|
|
9110
|
+
let st;
|
|
9111
|
+
try {
|
|
9112
|
+
st = lstatSync(check.path);
|
|
9113
|
+
}
|
|
9114
|
+
catch {
|
|
9115
|
+
return usageError(`--files entry "${rel}" does not exist`);
|
|
9116
|
+
}
|
|
9117
|
+
if (!st.isFile())
|
|
9118
|
+
return usageError(`--files entry "${rel}" is not a regular file (symlinks are refused)`);
|
|
9119
|
+
extracts.push({ label: rel, text: readFileSync(check.path, 'utf-8') });
|
|
9120
|
+
}
|
|
9121
|
+
if (extracts.length === 0) {
|
|
9122
|
+
return usageError(`nothing to review: features/${slug} has no change manifest, ADR or QE report, and no --files were given`);
|
|
9123
|
+
}
|
|
9124
|
+
// ── run identity + the audit bundle (round-2 M7) ──
|
|
9125
|
+
//
|
|
9126
|
+
// R3-2: the state directory is checked with the SAME containment walk as `--out`, BEFORE anything
|
|
9127
|
+
// is created in it. Round 2 contained the record PATHS but not the directory they live in, so a
|
|
9128
|
+
// symlinked `.fa-state/qe-bridge` (or `.fa-state`) silently redirected every write — and every
|
|
9129
|
+
// chmod — outside the repository. A guard that covers the leaves but not the branch is not a guard.
|
|
9130
|
+
const stateDirRel = join('features', slug, '.fa-state', 'qe-bridge');
|
|
9131
|
+
const stateCheck = containedUnderRoot(root, stateDirRel);
|
|
9132
|
+
if (!stateCheck.ok)
|
|
9133
|
+
return usageError(`the audit state directory ${stateCheck.why}`);
|
|
9134
|
+
const stateDir = stateCheck.path;
|
|
9135
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
9136
|
+
const runId = `${stamp}-${randomBytes(4).toString('hex')}`;
|
|
9137
|
+
const requestedOut = relative(root, outPath);
|
|
9138
|
+
const uniquePath = (base, ext) => {
|
|
9139
|
+
let candidate = `${base}${ext}`;
|
|
9140
|
+
for (let n = 2; existsSync(candidate); n += 1)
|
|
9141
|
+
candidate = `${base}-${n}${ext}`;
|
|
9142
|
+
return candidate;
|
|
9143
|
+
};
|
|
9144
|
+
/** Retain the raw reviewer stdout. THROWS on failure (R3-3): round 2 swallowed the error and
|
|
9145
|
+
* recorded `rawStdoutFile: null`, which turns "we could not keep the evidence" into a field nobody
|
|
9146
|
+
* reads. On the success path an unretainable stdout fails the run; on a failure path the forensics
|
|
9147
|
+
* are best-effort, because the run is already failing for a named reason. */
|
|
9148
|
+
const retainRaw = (stdout, kind) => {
|
|
9149
|
+
if (stdout === '')
|
|
9150
|
+
return null;
|
|
9151
|
+
const p = uniquePath(join(stateDir, `${kind}-${runId}`), '.stdout.txt');
|
|
9152
|
+
writeNewFileOrThrow(p, stdout);
|
|
9153
|
+
return relative(root, p);
|
|
9154
|
+
};
|
|
9155
|
+
const retainRawBestEffort = (stdout, kind) => {
|
|
9156
|
+
try {
|
|
9157
|
+
return retainRaw(stdout, kind);
|
|
9158
|
+
}
|
|
9159
|
+
catch {
|
|
9160
|
+
return null;
|
|
9161
|
+
}
|
|
9162
|
+
};
|
|
9163
|
+
let promptSha256 = null;
|
|
9164
|
+
const failRun = (reason, detail, model, forensics) => {
|
|
9165
|
+
const emittedAt = new Date().toISOString();
|
|
9166
|
+
const rawStdoutFile = forensics === undefined ? null : retainRawBestEffort(forensics.stdout, 'failed');
|
|
9167
|
+
const record = buildBridgeFailureRecord(reason, detail, {
|
|
9168
|
+
slug,
|
|
9169
|
+
model,
|
|
9170
|
+
emittedAt,
|
|
9171
|
+
runId,
|
|
9172
|
+
claudeBin: resolvedBin,
|
|
9173
|
+
binOverride,
|
|
9174
|
+
requestedOut,
|
|
9175
|
+
reportWritten: false,
|
|
9176
|
+
rawStdoutFile,
|
|
9177
|
+
promptSha256,
|
|
9178
|
+
});
|
|
9179
|
+
let recordPath = '';
|
|
9180
|
+
try {
|
|
9181
|
+
recordPath = uniquePath(join(stateDir, `failed-${runId}`), '.json');
|
|
9182
|
+
writeNewFileOrThrow(recordPath, `${JSON.stringify(record, null, 2)}\n`);
|
|
9183
|
+
if (forensics && forensics.stderr.trim() !== '')
|
|
9184
|
+
writeNewFileOrThrow(uniquePath(join(stateDir, `failed-${runId}`), '.stderr.txt'), forensics.stderr);
|
|
9185
|
+
}
|
|
9186
|
+
catch (primaryError) {
|
|
9187
|
+
// R3-1: the one condition that breaks the record medium ITSELF (`audit-write-failed`) must
|
|
9188
|
+
// still leave a named record behind, or the closed taxonomy has a member nothing can evidence.
|
|
9189
|
+
// Fall back ONE level up, inside the same per-feature state plane — not to an invented path.
|
|
9190
|
+
try {
|
|
9191
|
+
recordPath = uniquePath(join(featureDir, '.fa-state', `qe-bridge-fallback-${runId}`), '.json');
|
|
9192
|
+
writeNewFileOrThrow(recordPath, `${JSON.stringify({ ...record, fallbackFrom: relative(root, stateDir), fallbackReason: String(primaryError) }, null, 2)}\n`);
|
|
9193
|
+
}
|
|
9194
|
+
catch (fallbackError) {
|
|
9195
|
+
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)})`);
|
|
9196
|
+
return 1;
|
|
9197
|
+
}
|
|
9198
|
+
}
|
|
9199
|
+
if (json)
|
|
9200
|
+
write(JSON.stringify({ ok: false, reason, detail, record: relative(root, recordPath), runId, reportWritten: false, requestedOut, exitCode: 1 }));
|
|
9201
|
+
else {
|
|
9202
|
+
write(`dz qe-bridge: FAILED — ${reason}`);
|
|
9203
|
+
write(` ${detail}`);
|
|
9204
|
+
write(` record: ${relative(root, recordPath)}`);
|
|
9205
|
+
write(` no report was written at ${requestedOut} — an unparseable or absent review is never a passing one.`);
|
|
9206
|
+
}
|
|
9207
|
+
return 1;
|
|
9208
|
+
};
|
|
9209
|
+
// ── the prompt (built BEFORE any model call: a same-family refusal must cost nothing) ──
|
|
9210
|
+
const built = buildBridgePrompt({ slug, coderFamily, allowSameFamily: flags.has('allow-same-family'), extracts });
|
|
9211
|
+
if (!built.ok)
|
|
9212
|
+
return failRun(built.reason, built.detail, null);
|
|
9213
|
+
const prompt = built.prompt;
|
|
9214
|
+
promptSha256 = createHash('sha256').update(prompt).digest('hex');
|
|
9215
|
+
// ── the isolated working directory: an EMPTY dir, so project-scoped discovery finds nothing ──
|
|
9216
|
+
let isolatedCwd;
|
|
9217
|
+
try {
|
|
9218
|
+
isolatedCwd = mkdtempSync(join(tmpdir(), 'dz-qe-bridge-iso-'));
|
|
9219
|
+
}
|
|
9220
|
+
catch (error) {
|
|
9221
|
+
return failRun('probe-failed', `could not create an isolated working directory for the reviewer: ${String(error)}`, null);
|
|
9222
|
+
}
|
|
9223
|
+
const cleanupIsolated = () => {
|
|
9224
|
+
try {
|
|
9225
|
+
rmSync(isolatedCwd, { recursive: true, force: true });
|
|
9226
|
+
}
|
|
9227
|
+
catch { /* a leftover empty temp dir is not worth failing a review over */ }
|
|
9228
|
+
};
|
|
9229
|
+
try {
|
|
9230
|
+
// ── probe: the allowlist is a search order, the probe is the answer ──
|
|
9231
|
+
let probed = null;
|
|
9232
|
+
const probeNotes = [];
|
|
9233
|
+
for (const id of candidates) {
|
|
9234
|
+
const probeArgs = claudeProbeArgs(id);
|
|
9235
|
+
if (probeArgs === null) {
|
|
9236
|
+
probeNotes.push(`${id}: unsafe id, never spawned`);
|
|
9237
|
+
continue;
|
|
9238
|
+
}
|
|
9239
|
+
const run = await runClaudeBridge(binOpt, probeArgs, '', QE_BRIDGE_PROBE_TIMEOUT_MS, isolatedCwd);
|
|
9240
|
+
if (run.spawnError !== null) {
|
|
9241
|
+
const enoent = /ENOENT|not found|no such file/i.test(run.spawnError);
|
|
9242
|
+
return failRun(enoent ? 'claude-not-found' : 'probe-failed', enoent
|
|
9243
|
+
? `\`${binOpt}\` is not runnable (${run.spawnError}) — install/authenticate the Claude CLI, or point ${QE_BRIDGE_CLAUDE_BIN_ENV} at it`
|
|
9244
|
+
: run.spawnError, null);
|
|
9245
|
+
}
|
|
9246
|
+
if (run.timedOut) {
|
|
9247
|
+
probeNotes.push(`${id}: probe timed out after ${QE_BRIDGE_PROBE_TIMEOUT_MS / 1000}s`);
|
|
9248
|
+
continue;
|
|
9249
|
+
}
|
|
9250
|
+
if (interpretClaudeProbe({ stdout: run.stdout, exitCode: run.exitCode ?? 1 })) {
|
|
9251
|
+
probed = id;
|
|
9252
|
+
break;
|
|
9253
|
+
}
|
|
9254
|
+
const loginish = classifyClaudeStderr(run.stderr) === 'claude-not-logged-in';
|
|
9255
|
+
if (loginish) {
|
|
9256
|
+
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 });
|
|
9257
|
+
}
|
|
9258
|
+
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]})`}`);
|
|
9259
|
+
}
|
|
9260
|
+
if (probed === null) {
|
|
9261
|
+
return failRun('probe-failed', `no candidate model answered the liveness probe — ${probeNotes.join('; ')}`, null);
|
|
9262
|
+
}
|
|
9263
|
+
// ── the review call ──
|
|
9264
|
+
const reviewArgs = claudeReviewArgs(probed);
|
|
9265
|
+
if (reviewArgs === null)
|
|
9266
|
+
return failRun('probe-failed', `the probed id ${probed} failed id validation on the review path`, probed);
|
|
9267
|
+
const started = Date.now();
|
|
9268
|
+
const review = await runClaudeBridge(binOpt, reviewArgs, prompt, timeoutS * 1000, isolatedCwd);
|
|
9269
|
+
const elapsedMs = Date.now() - started;
|
|
9270
|
+
if (review.spawnError !== null) {
|
|
9271
|
+
const enoent = /ENOENT|not found|no such file/i.test(review.spawnError);
|
|
9272
|
+
return failRun(enoent ? 'claude-not-found' : 'exit-nonzero', review.spawnError, probed, { stdout: review.stdout, stderr: review.stderr });
|
|
9273
|
+
}
|
|
9274
|
+
if (review.timedOut) {
|
|
9275
|
+
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 });
|
|
9276
|
+
}
|
|
9277
|
+
if (review.exitCode !== 0) {
|
|
9278
|
+
const reason = classifyClaudeStderr(review.stderr);
|
|
9279
|
+
return failRun(reason, `\`${binOpt}\` exited ${String(review.exitCode)}${review.stderr.trim() === '' ? ' with no stderr' : `: ${review.stderr.trim().split('\n')[0]}`}` +
|
|
9280
|
+
(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 });
|
|
9281
|
+
}
|
|
9282
|
+
if (review.stdout.trim() === '') {
|
|
9283
|
+
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 });
|
|
9284
|
+
}
|
|
9285
|
+
// ── PARSE, never synthesize ──
|
|
9286
|
+
const emittedAt = new Date().toISOString();
|
|
9287
|
+
const parsed = parseBridgeOutput(review.stdout, { slug, coderFamily, model: probed, elapsedMs, promptSha256, emittedAt });
|
|
9288
|
+
if (!parsed.ok) {
|
|
9289
|
+
// No `?? <some named reason>` fallback: a state the parser did not name is its OWN failure
|
|
9290
|
+
// (round-2 MAJOR M5 — laundering an unknown state into `no-grade-marker` reads like a verdict
|
|
9291
|
+
// about the reviewer's text when it is really a verdict about our own code).
|
|
9292
|
+
const reason = parsed.reason;
|
|
9293
|
+
return failRun(reason, parsed.detail, probed, { stdout: review.stdout, stderr: review.stderr });
|
|
9294
|
+
}
|
|
9295
|
+
const signoff = parsed.signoff;
|
|
9296
|
+
// ── landing (R3-3 ordering): AUDIT FIRST, then the report, then the truth about the report ──
|
|
9297
|
+
//
|
|
9298
|
+
// Round 2 wrote the report BEFORE the record, so a crash between the two left a report on disk
|
|
9299
|
+
// and a record that said `reportWritten:false` — metadata that lies in the direction of "no
|
|
9300
|
+
// review happened" while a review report sits next to it. The order below can only ever
|
|
9301
|
+
// understate: the record exists first saying false, the report lands, then the record is
|
|
9302
|
+
// corrected. A crash at any point leaves a record that is true or pessimistic, never optimistic.
|
|
9303
|
+
let rawStdoutFile;
|
|
9304
|
+
try {
|
|
9305
|
+
rawStdoutFile = retainRaw(review.stdout, 'signoff');
|
|
9306
|
+
}
|
|
9307
|
+
catch (error) {
|
|
9308
|
+
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 });
|
|
9309
|
+
}
|
|
9310
|
+
const recordText = (reportWritten) => `${JSON.stringify(buildBridgeSignoffRecord(signoff, {
|
|
9311
|
+
runId,
|
|
9312
|
+
claudeBin: resolvedBin,
|
|
9313
|
+
binOverride,
|
|
9314
|
+
requestedOut,
|
|
9315
|
+
reportWritten,
|
|
9316
|
+
rawStdoutFile,
|
|
9317
|
+
promptSha256,
|
|
9318
|
+
...(parsed.channels === undefined ? {} : { channels: parsed.channels }),
|
|
9319
|
+
}), null, 2)}\n`;
|
|
9320
|
+
let signoffPath;
|
|
9321
|
+
try {
|
|
9322
|
+
signoffPath = uniquePath(join(stateDir, `signoff-${runId}`), '.json');
|
|
9323
|
+
writeNewFileOrThrow(signoffPath, recordText(false));
|
|
9324
|
+
}
|
|
9325
|
+
catch (error) {
|
|
9326
|
+
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 });
|
|
9327
|
+
}
|
|
9328
|
+
let reportError = null;
|
|
9329
|
+
try {
|
|
9330
|
+
writeNewFileOrThrow(outPath, renderBridgeReport(signoff), 0o600);
|
|
9331
|
+
}
|
|
9332
|
+
catch (error) {
|
|
9333
|
+
reportError = error;
|
|
9334
|
+
}
|
|
9335
|
+
if (reportError === null) {
|
|
9336
|
+
// the ONLY moment `reportWritten:true` may appear: after the report is on disk
|
|
9337
|
+
try {
|
|
9338
|
+
// ATOMIC (R4-1): write a sibling temp file, then rename() over the original. On the same
|
|
9339
|
+
// filesystem rename is atomic, so a reader — or a crash — sees the OLD complete record or
|
|
9340
|
+
// the NEW complete record, never a truncated one. Round 3 truncated and rewrote in place,
|
|
9341
|
+
// which made the "a crash leaves a record that is true or pessimistic" claim untrue in the
|
|
9342
|
+
// one case it was about.
|
|
9343
|
+
const tmpPath = `${signoffPath}.tmp.${process.pid}`;
|
|
9344
|
+
writeNewFileOrThrow(tmpPath, recordText(true));
|
|
9345
|
+
if (process.env[QE_BRIDGE_FAILPOINT_ENV] === 'hang-before-rename') {
|
|
9346
|
+
// test-only: stop dead INSIDE the window, so a SIGKILL can prove the property
|
|
9347
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 600_000);
|
|
9348
|
+
}
|
|
9349
|
+
renameSync(tmpPath, signoffPath);
|
|
9350
|
+
chmodSync(signoffPath, RECORD_FILE_MODE);
|
|
9351
|
+
}
|
|
9352
|
+
catch (error) {
|
|
9353
|
+
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 });
|
|
9354
|
+
}
|
|
9355
|
+
}
|
|
9356
|
+
else {
|
|
9357
|
+
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`;
|
|
9358
|
+
return failRun('report-write-failed', detail, probed, { stdout: '', stderr: '' });
|
|
9359
|
+
}
|
|
9360
|
+
if (json) {
|
|
9361
|
+
write(JSON.stringify({
|
|
9362
|
+
ok: true,
|
|
9363
|
+
grade: signoff.grade,
|
|
9364
|
+
gradedBy: signoff.gradedBy,
|
|
9365
|
+
coderFamily: signoff.coderFamily,
|
|
9366
|
+
coderFamilySource,
|
|
9367
|
+
findings: signoff.findings.length,
|
|
9368
|
+
report: requestedOut,
|
|
9369
|
+
reportWritten: true,
|
|
9370
|
+
signoff: relative(root, signoffPath),
|
|
9371
|
+
rawStdout: rawStdoutFile,
|
|
9372
|
+
runId,
|
|
9373
|
+
binOverride,
|
|
9374
|
+
claudeBin: resolvedBin,
|
|
9375
|
+
elapsedMs,
|
|
9376
|
+
promptChars: prompt.length,
|
|
9377
|
+
promptSha256,
|
|
9378
|
+
channels: parsed.channels,
|
|
9379
|
+
exitCode: 0,
|
|
9380
|
+
}));
|
|
9381
|
+
}
|
|
9382
|
+
else {
|
|
9383
|
+
write(`dz qe-bridge: GRADE ${signoff.grade} from claude/${signoff.gradedBy.model} — ${signoff.findings.length} finding(s) in ${Math.round(elapsedMs / 1000)}s`);
|
|
9384
|
+
if (binOverride)
|
|
9385
|
+
write(` ⚠ reviewer executable OVERRIDDEN via ${QE_BRIDGE_CLAUDE_BIN_ENV}: ${resolvedBin} (recorded as binOverride:true — this signoff does not prove Anthropic's runtime answered)`);
|
|
9386
|
+
write(` report: ${requestedOut}`);
|
|
9387
|
+
write(` signoff: ${relative(root, signoffPath)}`);
|
|
9388
|
+
write(` settle: dz reqe --slug ${slug} --done --report ${requestedOut}`);
|
|
9389
|
+
write(' the bridge REPORTS (any grade exits 0); gating stays with dz reqe and the host pipeline.');
|
|
9390
|
+
}
|
|
9391
|
+
return 0;
|
|
9392
|
+
}
|
|
9393
|
+
finally {
|
|
9394
|
+
cleanupIsolated();
|
|
9395
|
+
}
|
|
9396
|
+
}
|
|
7399
9397
|
function cmdScore(options, flags, cwd, write) {
|
|
7400
9398
|
const json = flags.has('json');
|
|
7401
9399
|
if (flags.has('help')) {
|
|
@@ -9195,6 +11193,8 @@ async function cmdImportEcc(options, flags, cwd, write) {
|
|
|
9195
11193
|
export async function runCli(argv, io = {}) {
|
|
9196
11194
|
const cwd = io.cwd ?? process.cwd();
|
|
9197
11195
|
const write = io.write ?? ((line) => { console.log(line); });
|
|
11196
|
+
// Diagnostics go to stderr so `dz <cmd> > out.txt` yields clean data (feature dz-cli-defects).
|
|
11197
|
+
const writeErr = io.writeErr ?? ((line) => { console.error(line); });
|
|
9198
11198
|
// Lazy STDIN reader — only `dz brain ground` reads it, and only when no positional prompt is
|
|
9199
11199
|
// given. Never blocks: injected `io.stdin` wins; else read fd 0 synchronously, but bail to '' on
|
|
9200
11200
|
// a TTY (nothing piped) or any read error. Grounding must never hang waiting on an empty pipe.
|
|
@@ -9238,14 +11238,14 @@ export async function runCli(argv, io = {}) {
|
|
|
9238
11238
|
try {
|
|
9239
11239
|
switch (command) {
|
|
9240
11240
|
case 'init':
|
|
9241
|
-
return await cmdInit(options, flags, cwd, write);
|
|
11241
|
+
return await cmdInit(options, flags, cwd, write, writeErr);
|
|
9242
11242
|
case 'verify':
|
|
9243
|
-
return await cmdVerify(options, cwd, write);
|
|
11243
|
+
return await cmdVerify(options, cwd, write, writeErr);
|
|
9244
11244
|
case 'sync':
|
|
9245
11245
|
case 'update':
|
|
9246
|
-
return await cmdSync(options, flags, cwd, write);
|
|
11246
|
+
return await cmdSync(options, flags, cwd, write, writeErr);
|
|
9247
11247
|
case 'list':
|
|
9248
|
-
return cmdList(options, cwd, write);
|
|
11248
|
+
return cmdList(options, cwd, write, writeErr);
|
|
9249
11249
|
case 'create-skill':
|
|
9250
11250
|
return cmdCreateSkill(options, flags, cwd, write);
|
|
9251
11251
|
case 'info':
|
|
@@ -9253,6 +11253,9 @@ export async function runCli(argv, io = {}) {
|
|
|
9253
11253
|
case 'scout':
|
|
9254
11254
|
return await cmdScout(options, flags, cwd, write);
|
|
9255
11255
|
case 'workflow':
|
|
11256
|
+
// `run` is ASYNC (it drives child processes); every other subcommand stays sync.
|
|
11257
|
+
if ((options.get('_positional_0') ?? '') === 'run')
|
|
11258
|
+
return await cmdWorkflowRun(options, optionLists, flags, cwd, write);
|
|
9256
11259
|
return cmdWorkflow(options, flags, cwd, write);
|
|
9257
11260
|
case 'workflow-lint':
|
|
9258
11261
|
return cmdWorkflowLint(options, flags, cwd, write);
|
|
@@ -9263,7 +11266,7 @@ export async function runCli(argv, io = {}) {
|
|
|
9263
11266
|
case 'doctor':
|
|
9264
11267
|
return await cmdDoctor(options, flags, cwd, write);
|
|
9265
11268
|
case 'install':
|
|
9266
|
-
return await cmdInstall(options, flags, cwd, write, io.installRunner);
|
|
11269
|
+
return await cmdInstall(options, flags, cwd, write, writeErr, io.installRunner);
|
|
9267
11270
|
case 'bundle':
|
|
9268
11271
|
return cmdBundle(options, flags, cwd, write);
|
|
9269
11272
|
case 'teach':
|
|
@@ -9291,17 +11294,17 @@ export async function runCli(argv, io = {}) {
|
|
|
9291
11294
|
case 'verify-pack':
|
|
9292
11295
|
return cmdVerifyPack(options, flags, cwd, write);
|
|
9293
11296
|
case 'setup':
|
|
9294
|
-
return await cmdSetup(options, flags, cwd, write);
|
|
11297
|
+
return await cmdSetup(options, flags, cwd, write, writeErr);
|
|
9295
11298
|
case 'pretrain':
|
|
9296
11299
|
return cmdPretrain(options, cwd, write);
|
|
9297
11300
|
case 'compose':
|
|
9298
|
-
return cmdCompose(options, cwd, write);
|
|
11301
|
+
return cmdCompose(options, cwd, write, writeErr);
|
|
9299
11302
|
case 'diff':
|
|
9300
11303
|
return cmdDiff(options, cwd, write);
|
|
9301
11304
|
case 'recommend':
|
|
9302
11305
|
return cmdRecommend(options, cwd, write);
|
|
9303
11306
|
case 'upgrade':
|
|
9304
|
-
return cmdUpgrade(options, flags, cwd, write);
|
|
11307
|
+
return cmdUpgrade(options, flags, cwd, write, writeErr);
|
|
9305
11308
|
case 'auto-canonicalize':
|
|
9306
11309
|
return await cmdAutoCanonicalize(options, cwd, write);
|
|
9307
11310
|
case 'publish':
|
|
@@ -9309,7 +11312,7 @@ export async function runCli(argv, io = {}) {
|
|
|
9309
11312
|
case 'release':
|
|
9310
11313
|
return cmdRelease(options, flags, cwd, write, io.releaseRunner);
|
|
9311
11314
|
case 'parity':
|
|
9312
|
-
return cmdParity(options, flags, write);
|
|
11315
|
+
return cmdParity(options, flags, write, writeErr);
|
|
9313
11316
|
case 'registry':
|
|
9314
11317
|
return cmdRegistry(options, cwd, write);
|
|
9315
11318
|
case 'benchmark':
|
|
@@ -9320,6 +11323,10 @@ export async function runCli(argv, io = {}) {
|
|
|
9320
11323
|
return await cmdSyncUpstream(options, flags, cwd, write);
|
|
9321
11324
|
case 'drift-check':
|
|
9322
11325
|
return cmdDriftCheck(options, flags, cwd, write);
|
|
11326
|
+
case 'hooks-sync':
|
|
11327
|
+
return cmdHooksSync(options, flags, cwd, write, writeErr);
|
|
11328
|
+
case 'agents-sync':
|
|
11329
|
+
return cmdAgentsSync(options, flags, cwd, write, writeErr);
|
|
9323
11330
|
case 'sync-canonical':
|
|
9324
11331
|
return cmdSyncCanonical(options, flags, cwd, write);
|
|
9325
11332
|
case 'plugin':
|
|
@@ -9337,7 +11344,7 @@ export async function runCli(argv, io = {}) {
|
|
|
9337
11344
|
case 'retro':
|
|
9338
11345
|
return await cmdRetro(options, flags, cwd, write);
|
|
9339
11346
|
case 'feature-adr-setup':
|
|
9340
|
-
return cmdFeatureAdrSetup(options, flags, cwd, write);
|
|
11347
|
+
return cmdFeatureAdrSetup(options, flags, cwd, write, writeErr);
|
|
9341
11348
|
case 'challenge':
|
|
9342
11349
|
return cmdChallenge(options, flags, cwd, write);
|
|
9343
11350
|
case 'discrimination-check':
|
|
@@ -9356,6 +11363,8 @@ export async function runCli(argv, io = {}) {
|
|
|
9356
11363
|
return cmdScore(options, flags, cwd, write);
|
|
9357
11364
|
case 'reqe':
|
|
9358
11365
|
return cmdReqe(options, flags, cwd, write);
|
|
11366
|
+
case 'qe-bridge':
|
|
11367
|
+
return await cmdQeBridge(options, flags, cwd, write);
|
|
9359
11368
|
case 'backlog':
|
|
9360
11369
|
return await cmdBacklog(options, flags, cwd, write);
|
|
9361
11370
|
case 'routing':
|
|
@@ -9375,7 +11384,9 @@ export async function runCli(argv, io = {}) {
|
|
|
9375
11384
|
}
|
|
9376
11385
|
}
|
|
9377
11386
|
catch (error) {
|
|
9378
|
-
|
|
11387
|
+
// stderr, not stdout: an uncaught failure is a diagnostic, and routing it through
|
|
11388
|
+
// `write` is what made `dz list > skills.txt` write the error into the data file.
|
|
11389
|
+
writeErr(`dz: ${error instanceof Error ? error.message : String(error)}`);
|
|
9379
11390
|
return 1;
|
|
9380
11391
|
}
|
|
9381
11392
|
}
|