@dzhechkov/harness-core 0.5.4 → 0.6.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/src/index.ts CHANGED
@@ -221,6 +221,7 @@ export {
221
221
  resumeMode,
222
222
  decideCheckpointResume,
223
223
  serializeCheckpoint,
224
+ decideCheckpointWrite,
224
225
  parseCheckpointRead,
225
226
  checkpointReadCmd,
226
227
  checkpointAppendCmd,
@@ -236,7 +237,33 @@ export {
236
237
  codeCheckpointPersistAllowed,
237
238
  codeStageResultShapeValid,
238
239
  } from './feature-adr-checkpoints.js';
239
- export type { CheckpointStage, CheckpointEntry, ResumeMode, ResumeDecision, ParsedCheckpointRead, TrainingPairFamily, TrainingPair, TrainingPairEvaluation, TrainingPairProvenance, TrainingPairTruncation } from './feature-adr-checkpoints.js';
240
+ export type { CheckpointStage, CheckpointEntry, ResumeMode, ResumeDecision, ParsedCheckpointRead, TrainingPairFamily, TrainingPair, TrainingPairEvaluation, TrainingPairProvenance, TrainingPairTruncation,
241
+ CheckpointWriteVerdict,
242
+ } from './feature-adr-checkpoints.js';
243
+
244
+ // amendment-traceability (ADR-001/002/003): the deterministic half of the Step-8 amendment gate.
245
+ export {
246
+ MIN_MATCHABLE_ID_LENGTH,
247
+ AMENDMENT_VACUITY_NOTE,
248
+ normalizeTestId,
249
+ amendmentSection,
250
+ planSaysNoAmendments,
251
+ parseAmendments,
252
+ resolveAmendments,
253
+ decideAmendmentOutcome,
254
+ amendmentVerdictLine,
255
+ amendmentsMissingFromPlan,
256
+ amendmentSubject,
257
+ extractTestTitles,
258
+ } from './amendment-trace.js';
259
+ export {
260
+ RECORD_MAX_LINE_CHARS,
261
+ decideRecordWrite,
262
+ decideReadBack,
263
+ recordVerdictLine,
264
+ } from './run-records.js';
265
+ export type { RecordKind, RecordVerdict, RecordDecision } from './run-records.js';
266
+ export type { AmendmentRow, AmendmentVerdict, AmendmentResolution, AmendmentOutcome, AmendmentDecision, PlanCoverageGap } from './amendment-trace.js';
240
267
  export {
241
268
  DOMAIN_LIFT_EXACT,
242
269
  DOMAIN_LIFT_RELATED,
@@ -516,6 +543,12 @@ export {
516
543
  resolveCoderSpec,
517
544
  coderIsCodex,
518
545
  resolveQeSpec,
546
+ crossFamilyQe,
547
+ decideModeBScope,
548
+ partitionReviewFindings,
549
+ parseHashProbe,
550
+ changedFromHashes,
551
+ changeSetProbeCmd,
519
552
  routingRequested,
520
553
  mergeOpts,
521
554
  DEFAULT_MODELS,
@@ -558,15 +591,48 @@ export {
558
591
  PLAN_GATE_SCRIPT,
559
592
  planCompletenessGateCmd,
560
593
  parsePlanGateVerdict,
594
+ // p16-non-js-portability: the gate-script search chain's operator note (ADR-002/AM-7) and the
595
+ // dzBin absolutization (ADR-003). Named for the same reason as the three above.
596
+ refusalNoteFor,
597
+ normalizeDzBin,
561
598
  // qe-bridge-claude: the bridge's path/slug hygiene reuses these rather than minting a second
562
599
  // definition of "safe" (ADR-001 D5-A).
563
600
  isSafeSlug,
564
601
  hasUnsafePathChars,
565
602
  hasDotDotSegment,
603
+ // qe-scoped-review: the two scoped Codex QE dispatch modes + the LOCKED decline taxonomy. NAMED
604
+ // one by one on purpose — an omission here silently drops the export from the published package
605
+ // while the suite stays green (the ADR-002 Confirmation-1 lesson, re-learnt on 2026-08-21 when a
606
+ // fixed export shipped behind two stale workflow mirrors).
607
+ CODEX_REVIEW_TIMEOUT_SECONDS,
608
+ CODEX_REVIEW_DEFAULT_EFFORT,
609
+ CODEX_TIMEOUT,
610
+ CODEX_QE_SIGNAL_PREFIX,
611
+ CODEX_QE_DECLINE_KINDS,
612
+ SCOPED_QE_MAX_FILES,
613
+ SCOPED_QE_MAX_QUESTIONS,
614
+ SCOPED_QE_MAX_PATH_CHARS,
615
+ SCOPED_QE_MAX_QUESTION_CHARS,
616
+ SCOPED_QE_PROMPT_MAX_CHARS,
617
+ isSafeCodexRef,
618
+ codexReviewCommand,
619
+ codexQeSignalCommand,
620
+ scopedQePrompt,
621
+ parseCodexReviewSignal,
622
+ parseCodexReviewFindings,
623
+ gradeFromReviewFindings,
624
+ classifyCodexQeOutcome,
625
+ codexQeDeclineReason,
626
+ codexDeclineReason,
627
+ parseCodexReviewResult,
566
628
  } from './feature-adr-routing.js';
567
629
  export type {
568
630
  StageOpts,
569
631
  RoutingEnv,
632
+ CrossFamilyQeReport,
633
+ ModeBScopeVerdict,
634
+ PartitionedFindings,
635
+ FileHashSnapshot,
570
636
  UsageSignal,
571
637
  UsageAction,
572
638
  CodeLandingStatus,
@@ -583,6 +649,15 @@ export type {
583
649
  ParsedBaselineCapture,
584
650
  ParsedLandingSignal,
585
651
  PlanGateVerdict,
652
+ PlanGateCmdOpts,
653
+ CodexReviewCommandInput,
654
+ CodexReviewCommandResult,
655
+ ScopedQePromptInput,
656
+ ParsedCodexQeSignal,
657
+ CodexQeFinding,
658
+ ClassifyCodexQeInput,
659
+ CodexQeDeclineDetail,
660
+ CodexReviewResult,
586
661
  } from './feature-adr-routing.js';
587
662
  export {
588
663
  CLAUDE_USAGE_MODELS,
@@ -43,11 +43,11 @@ export const BLOBS: Record<string, LoopBlob> = {
43
43
  "checkpoints": {
44
44
  name: "checkpoints",
45
45
  version: "1.1.0",
46
- contentHash: "d4dc6c956be3dd279b225bc5061ec4255babc44ffb5ca58646c9b6ea04a0c8c2",
46
+ contentHash: "a44560c6036fd143b7a3f125fec00fa8b91c3c06ac5b9e1ecfb83d27a5211e9b",
47
47
  sourcePath: "packages/@dzhechkov/harness-core/src/feature-adr-checkpoints.ts",
48
48
  requires: [],
49
- exports: ["CHECKPOINT_STAGES","STAGE_ARTIFACTS","CHECKPOINT_MAX_RESULT_CHARS","CKPT_SCHEMA_VERSION","fnv1a","fnv1a64","checkpointInputHash","resumeMode","decideCheckpointResume","serializeCheckpoint","CHECKPOINT_LS_SENTINEL","parseCheckpointRead","shellQuote","checkpointReadCmd","checkpointAppendCmd","DESIGN_SUBSTAGES","designStageKey","decideDesignFanResume","parseArtifactProbe"],
50
- code: "const CHECKPOINT_STAGES = ['router', 'design', 'plan', 'code', 'qe', 'fleet'];\nconst STAGE_ARTIFACTS = {\n router: null,\n design: '01_requirements.md',\n plan: '06_implementation_plan.md',\n code: '07_code_changes/change_manifest.md',\n qe: '08_qe_report.md',\n fleet: '09_fleet_qe_assessment.md',\n};\nconst CHECKPOINT_MAX_RESULT_CHARS = 12000;\nconst CKPT_SCHEMA_VERSION = 'fa-ckpt-3';\nfunction fnv1a(str) {\n let h = 0x811c9dc5;\n for (let i = 0; i < str.length; i++) {\n h ^= str.charCodeAt(i);\n h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;\n }\n return h.toString(16).padStart(8, '0');\n}\nfunction fnv1a64(str) {\n return fnv1a(str) + fnv1a('fa-ckpt-salt' + str);\n}\nfunction checkpointInputHash(stage, parts) {\n return fnv1a64(JSON.stringify([CKPT_SCHEMA_VERSION, stage, ...parts.map((p) => (p === undefined ? null : p))]));\n}\nfunction resumeMode(raw) {\n return raw === 'never' ? 'never' : raw === 'force' ? 'force' : 'auto';\n}\nconst DESIGN_SUBSTAGES = ['requirements', 'adr', 'qcsd', 'architecture'];\nfunction designStageKey(sub) {\n return 'design:' + sub;\n}\nfunction decideDesignFanResume(opts) {\n const missingSubstages = [];\n opts.required.forEach((sub, i) => {\n const r = opts.results[i];\n if (r === null || r === undefined)\n missingSubstages.push(sub);\n });\n const missingArtifacts = [];\n let probeMissing = false;\n if (opts.artifacts.length > 0) {\n if (opts.postRunListing === null)\n probeMissing = true;\n else\n for (const rel of opts.artifacts)\n if (!opts.postRunListing.has(rel))\n missingArtifacts.push(rel);\n }\n const reason = missingSubstages.length > 0 ? 'substage-missing'\n : probeMissing ? 'probe-not-established'\n : missingArtifacts.length > 0 ? 'artifact-missing'\n : 'ok';\n return { complete: reason === 'ok', missingSubstages, missingArtifacts, reason };\n}\nfunction decideCheckpointResume(opts) {\n if (opts.mode === 'never')\n return { resume: false, reason: 'mode-never' };\n if (!opts.entry || opts.entry.result === null || opts.entry.result === undefined) {\n return { resume: false, reason: 'no-checkpoint' };\n }\n if (opts.entry.inputHash !== opts.inputHash)\n return { resume: false, reason: 'stale-input' };\n if (opts.mode === 'force')\n return { resume: true, reason: 'resumed-force' };\n const required = opts.artifactRel === null ? [] : (typeof opts.artifactRel === 'string' ? [opts.artifactRel] : opts.artifactRel);\n for (const rel of required) {\n if (!opts.listing.has(rel))\n return { resume: false, reason: 'artifact-missing' };\n }\n return { resume: true, reason: 'resumed' };\n}\nfunction serializeCheckpoint(stage, inputHash, result) {\n if (result === null || result === undefined)\n return null;\n let line;\n try {\n line = JSON.stringify({ stage, inputHash, result });\n }\n catch {\n return null;\n }\n if (typeof line !== 'string' || line.length > CHECKPOINT_MAX_RESULT_CHARS)\n return null;\n return line;\n}\nconst CHECKPOINT_LS_SENTINEL = '---FA-CKPT-LS---';\nfunction parseCheckpointRead(text) {\n const out = { entries: {}, listing: new Set(), malformedLines: 0 };\n const raw = String(text ?? '');\n const lines = raw.split('\\n');\n const sentinelAt = lines.findIndex((l) => l.trim() === CHECKPOINT_LS_SENTINEL);\n const body = sentinelAt === -1 ? lines : lines.slice(0, sentinelAt);\n const ls = sentinelAt === -1 ? [] : lines.slice(sentinelAt + 1);\n for (const line of body) {\n const t = line.trim();\n if (t === '')\n continue;\n try {\n const e = JSON.parse(t);\n if (e && typeof e === 'object' && typeof e.stage === 'string' && typeof e.inputHash === 'string' && 'result' in e && e.result !== null && e.result !== undefined) {\n out.entries[e.stage] = e;\n }\n else {\n if (e && typeof e === 'object' && typeof e.stage === 'string')\n delete out.entries[e.stage];\n out.malformedLines++;\n }\n }\n catch {\n out.malformedLines++;\n }\n }\n for (const line of ls) {\n const t = line.trim();\n if (t !== '')\n out.listing.add(t);\n }\n return out;\n}\nfunction shellQuote(s) {\n return \"'\" + String(s).replace(/'/g, \"'\\\\''\") + \"'\";\n}\nfunction checkpointReadCmd(fdirAbs) {\n const q = shellQuote(fdirAbs);\n return ('cat ' + q + '/.fa-state/checkpoints.jsonl 2>/dev/null || true; ' +\n \"echo '\" + CHECKPOINT_LS_SENTINEL + \"'; \" +\n 'cd ' + q + ' 2>/dev/null && find . -maxdepth 2 -type f 2>/dev/null | sed \"s|^\\\\./||\" || true');\n}\nfunction checkpointAppendCmd(fdirAbs, line) {\n const dir = shellQuote(fdirAbs + '/.fa-state');\n const file = shellQuote(fdirAbs + '/.fa-state/checkpoints.jsonl');\n return 'mkdir -p ' + dir + \" && printf '%s\\\\n' \" + shellQuote(line) + ' >> ' + file;\n}\nfunction parseArtifactProbe(opts) {\n if (opts.stdout === null || opts.stdout === undefined)\n return null;\n const known = new Map();\n for (const rel of opts.required)\n known.set('HAVE:' + rel, rel);\n const found = new Set();\n let sentinels = 0;\n for (const raw of String(opts.stdout).split('\\n')) {\n const line = raw.trim();\n if (line === '')\n continue;\n if (line === opts.sentinel) {\n sentinels++;\n continue;\n }\n if (sentinels > 0)\n return null;\n const rel = known.get(line);\n if (rel === undefined)\n return null;\n found.add(rel);\n }\n if (sentinels !== 1)\n return null;\n return found;\n}",
49
+ exports: ["CHECKPOINT_STAGES","STAGE_ARTIFACTS","CHECKPOINT_MAX_RESULT_CHARS","CKPT_SCHEMA_VERSION","fnv1a","fnv1a64","checkpointInputHash","resumeMode","decideCheckpointResume","serializeCheckpoint","CHECKPOINT_LS_SENTINEL","parseCheckpointRead","shellQuote","checkpointReadCmd","checkpointAppendCmd","DESIGN_SUBSTAGES","designStageKey","decideDesignFanResume","parseArtifactProbe","ROUTER_CONTRACT_TOKEN"],
50
+ code: "const CHECKPOINT_STAGES = ['router', 'design', 'plan', 'code', 'qe', 'fleet'];\nconst STAGE_ARTIFACTS = {\n router: '00_complexity_assessment.md',\n design: '01_requirements.md',\n plan: '06_implementation_plan.md',\n code: '07_code_changes/change_manifest.md',\n qe: '08_qe_report.md',\n fleet: '09_fleet_qe_assessment.md',\n};\nconst CHECKPOINT_MAX_RESULT_CHARS = 12000;\nconst CKPT_SCHEMA_VERSION = 'fa-ckpt-3';\nconst ROUTER_CONTRACT_TOKEN = 'router-writes-00-v1';\nfunction fnv1a(str) {\n let h = 0x811c9dc5;\n for (let i = 0; i < str.length; i++) {\n h ^= str.charCodeAt(i);\n h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;\n }\n return h.toString(16).padStart(8, '0');\n}\nfunction fnv1a64(str) {\n return fnv1a(str) + fnv1a('fa-ckpt-salt' + str);\n}\nfunction checkpointInputHash(stage, parts) {\n return fnv1a64(JSON.stringify([CKPT_SCHEMA_VERSION, stage, ...parts.map((p) => (p === undefined ? null : p))]));\n}\nfunction resumeMode(raw) {\n return raw === 'never' ? 'never' : raw === 'force' ? 'force' : 'auto';\n}\nconst DESIGN_SUBSTAGES = ['requirements', 'adr', 'qcsd', 'architecture'];\nfunction designStageKey(sub) {\n return 'design:' + sub;\n}\nfunction decideDesignFanResume(opts) {\n const missingSubstages = [];\n opts.required.forEach((sub, i) => {\n const r = opts.results[i];\n if (r === null || r === undefined)\n missingSubstages.push(sub);\n });\n const missingArtifacts = [];\n let probeMissing = false;\n if (opts.artifacts.length > 0) {\n if (opts.postRunListing === null)\n probeMissing = true;\n else\n for (const rel of opts.artifacts)\n if (!opts.postRunListing.has(rel))\n missingArtifacts.push(rel);\n }\n const reason = missingSubstages.length > 0 ? 'substage-missing'\n : probeMissing ? 'probe-not-established'\n : missingArtifacts.length > 0 ? 'artifact-missing'\n : 'ok';\n return { complete: reason === 'ok', missingSubstages, missingArtifacts, reason };\n}\nfunction decideCheckpointResume(opts) {\n if (opts.mode === 'never')\n return { resume: false, reason: 'mode-never' };\n if (!opts.entry || opts.entry.result === null || opts.entry.result === undefined) {\n return { resume: false, reason: 'no-checkpoint' };\n }\n if (opts.entry.inputHash !== opts.inputHash)\n return { resume: false, reason: 'stale-input' };\n if (opts.mode === 'force')\n return { resume: true, reason: 'resumed-force' };\n const required = opts.artifactRel === null ? [] : (typeof opts.artifactRel === 'string' ? [opts.artifactRel] : opts.artifactRel);\n for (const rel of required) {\n if (!opts.listing.has(rel))\n return { resume: false, reason: 'artifact-missing' };\n }\n return { resume: true, reason: 'resumed' };\n}\nfunction serializeCheckpoint(stage, inputHash, result) {\n if (result === null || result === undefined)\n return null;\n let line;\n try {\n line = JSON.stringify({ stage, inputHash, result });\n }\n catch {\n return null;\n }\n if (typeof line !== 'string' || line.length > CHECKPOINT_MAX_RESULT_CHARS)\n return null;\n return line;\n}\nconst CHECKPOINT_LS_SENTINEL = '---FA-CKPT-LS---';\nfunction parseCheckpointRead(text) {\n const out = { entries: {}, listing: new Set(), malformedLines: 0 };\n const raw = String(text ?? '');\n const lines = raw.split('\\n');\n const sentinelAt = lines.findIndex((l) => l.trim() === CHECKPOINT_LS_SENTINEL);\n const body = sentinelAt === -1 ? lines : lines.slice(0, sentinelAt);\n const ls = sentinelAt === -1 ? [] : lines.slice(sentinelAt + 1);\n for (const line of body) {\n const t = line.trim();\n if (t === '')\n continue;\n try {\n const e = JSON.parse(t);\n if (e && typeof e === 'object' && typeof e.stage === 'string' && typeof e.inputHash === 'string' && 'result' in e && e.result !== null && e.result !== undefined) {\n out.entries[e.stage] = e;\n }\n else {\n if (e && typeof e === 'object' && typeof e.stage === 'string')\n delete out.entries[e.stage];\n out.malformedLines++;\n }\n }\n catch {\n out.malformedLines++;\n }\n }\n for (const line of ls) {\n const t = line.trim();\n if (t !== '')\n out.listing.add(t);\n }\n return out;\n}\nfunction shellQuote(s) {\n return \"'\" + String(s).replace(/'/g, \"'\\\\''\") + \"'\";\n}\nfunction checkpointReadCmd(fdirAbs) {\n const q = shellQuote(fdirAbs);\n return ('cat ' + q + '/.fa-state/checkpoints.jsonl 2>/dev/null || true; ' +\n \"echo '\" + CHECKPOINT_LS_SENTINEL + \"'; \" +\n 'cd ' + q + ' 2>/dev/null && find . -maxdepth 2 -type f 2>/dev/null | sed \"s|^\\\\./||\" || true');\n}\nfunction checkpointAppendCmd(fdirAbs, line) {\n const dir = shellQuote(fdirAbs + '/.fa-state');\n const file = shellQuote(fdirAbs + '/.fa-state/checkpoints.jsonl');\n return 'mkdir -p ' + dir + \" && printf '%s\\\\n' \" + shellQuote(line) + ' >> ' + file;\n}\nfunction parseArtifactProbe(opts) {\n if (opts.stdout === null || opts.stdout === undefined)\n return null;\n const known = new Map();\n for (const rel of opts.required)\n known.set('HAVE:' + rel, rel);\n const found = new Set();\n let sentinels = 0;\n for (const raw of String(opts.stdout).split('\\n')) {\n const line = raw.trim();\n if (line === '')\n continue;\n if (line === opts.sentinel) {\n sentinels++;\n continue;\n }\n if (sentinels > 0)\n return null;\n const rel = known.get(line);\n if (rel === undefined)\n return null;\n found.add(rel);\n }\n if (sentinels !== 1)\n return null;\n return found;\n}",
51
51
  },
52
52
  "training-pairs": {
53
53
  name: "training-pairs",
@@ -79,11 +79,11 @@ export const BLOBS: Record<string, LoopBlob> = {
79
79
  "codex-dispatch": {
80
80
  name: "codex-dispatch",
81
81
  version: "1.0.0",
82
- contentHash: "140da3714d2507683b0a27fcb01fa449ea3cb1619f1a2e181aaf24291397642d",
82
+ contentHash: "f203268da376fa993ce61d38b8fc6a24db4ac1e3edb6b90662f86ae4351a30e7",
83
83
  sourcePath: "packages/@dzhechkov/harness-core/src/feature-adr-routing.ts",
84
84
  requires: [],
85
85
  exports: ["codexDispatchMode","codexExecPlan","needsCodeLandedBarrier","decideCodeLanding"],
86
- code: "const CODE_LANDING_PIPELINE_PREFIXES = ['features/', '.dz/', '.agentic-qe/', 'roam/'];\nfunction codeLandingEmptySignal(seconds) {\n return 'changed=0 after ' + seconds + 's — genuinely not landed';\n}\nfunction needsCodeLandedBarrier(coderUsed) {\n return coderUsed === 'codex' || coderUsed === 'codex-fallback';\n}\nfunction stripCodeLandingPath(path) {\n let p = String(path || '').trim().replace(/\\\\/g, '/');\n while (p.indexOf('./') === 0)\n p = p.slice(2);\n return p.replace(/\\/+/g, '/');\n}\nfunction classifyCodeLandingPathReject(path) {\n const p = stripCodeLandingPath(path);\n if (!p)\n return 'empty-after-strip';\n if (p[0] === '/')\n return 'absolute-path';\n if (p === '..' || p.indexOf('../') === 0 || p.indexOf('/../') >= 0 || p.endsWith('/..'))\n return 'traversal';\n if (/[\\0\\r\\n\\t \"'\\x60$;&|<>*?()[\\]{}!]/.test(p))\n return 'not-a-path';\n if (p.endsWith('/'))\n return 'not-a-path';\n for (const prefix of CODE_LANDING_PIPELINE_PREFIXES) {\n const bare = prefix.slice(0, -1);\n if (p === bare || p.indexOf(prefix) === 0)\n return 'pipeline-artifact-path';\n }\n return null;\n}\nfunction normalizeCodeLandingPath(path) {\n return classifyCodeLandingPathReject(path) === null ? stripCodeLandingPath(path) : '';\n}\nfunction filterPollableCodePaths(paths) {\n const out = [];\n const seen = new Set();\n for (const path of paths || []) {\n const normalized = normalizeCodeLandingPath(path);\n if (!normalized || seen.has(normalized))\n continue;\n seen.add(normalized);\n out.push(normalized);\n }\n return out;\n}\nfunction isNewlyChanged(path, baseline, currentHashes) {\n if (!baseline || !baseline.ok)\n return false;\n let recorded = null;\n for (const entry of baseline.entries) {\n if (entry.path === path) {\n recorded = entry.hash;\n break;\n }\n }\n if (recorded === null)\n return true;\n const now = currentHashes ? currentHashes[path] : undefined;\n if (now === undefined)\n return false;\n return now !== recorded;\n}\nfunction decideCodeLanding(snapshot) {\n const maxWaitMs = Math.max(0, snapshot.maxWaitMs);\n const elapsedMs = Math.max(0, snapshot.elapsedMs);\n const elapsedSeconds = Math.floor(elapsedMs / 1000);\n const expectedPaths = filterPollableCodePaths(snapshot.expectedPaths);\n const changedPaths = filterPollableCodePaths(snapshot.changedEntries.map(function (entry) { return entry.path; }));\n if (expectedPaths.length === 0) {\n return {\n status: 'inconclusive',\n reason: 'empty-plan-block',\n changed: 0,\n elapsedMs: elapsedMs,\n elapsedSeconds: elapsedSeconds,\n expectedPaths: expectedPaths,\n matchedExpectedPaths: [],\n changedPaths: changedPaths,\n predicate: 'no-expected-targets',\n qeSignalLine: 'CODEX-LANDING-SIGNAL status=inconclusive predicate=no-expected-targets reason=empty-plan-block',\n };\n }\n const baseline = snapshot.baseline;\n if (!baseline || !baseline.ok) {\n const reason = baseline && baseline.reason ? baseline.reason : 'no-baseline';\n return {\n status: 'inconclusive',\n reason: reason,\n changed: 0,\n elapsedMs: elapsedMs,\n elapsedSeconds: elapsedSeconds,\n expectedPaths: expectedPaths,\n matchedExpectedPaths: [],\n changedPaths: changedPaths,\n predicate: 'newly-changed',\n qeSignalLine: 'CODEX-LANDING-SIGNAL status=inconclusive predicate=newly-changed reason=' + reason,\n };\n }\n const changed = new Set(changedPaths);\n const matchedExpectedPaths = expectedPaths.filter(function (path) {\n return changed.has(path) && isNewlyChanged(path, baseline, snapshot.currentHashes);\n });\n if (matchedExpectedPaths.length > 0) {\n return {\n status: 'landed',\n changed: matchedExpectedPaths.length,\n elapsedMs: elapsedMs,\n elapsedSeconds: elapsedSeconds,\n expectedPaths: expectedPaths,\n matchedExpectedPaths: matchedExpectedPaths,\n changedPaths: changedPaths,\n predicate: 'newly-changed',\n qeSignalLine: 'CODEX-LANDING-SIGNAL status=landed changed=' +\n matchedExpectedPaths.length +\n ' after=' +\n elapsedSeconds +\n 's predicate=newly-changed matched=' +\n matchedExpectedPaths.join(','),\n };\n }\n if (elapsedMs < maxWaitMs) {\n return {\n status: 'not-yet-flushed',\n changed: 0,\n elapsedMs: elapsedMs,\n elapsedSeconds: elapsedSeconds,\n expectedPaths: expectedPaths,\n matchedExpectedPaths: [],\n changedPaths: changedPaths,\n predicate: 'empty-before-timeout',\n qeSignalLine: 'CODEX-LANDING-SIGNAL status=not-yet-flushed changed=0 after ' + elapsedSeconds + 's — not yet flushed',\n };\n }\n const terminalSeconds = Math.ceil(maxWaitMs / 1000);\n return {\n status: 'genuinely-not-landed',\n changed: 0,\n elapsedMs: elapsedMs,\n elapsedSeconds: terminalSeconds,\n expectedPaths: expectedPaths,\n matchedExpectedPaths: [],\n changedPaths: changedPaths,\n predicate: 'empty-after-timeout',\n qeSignalLine: 'CODEX-LANDING-SIGNAL status=genuinely-not-landed ' + codeLandingEmptySignal(terminalSeconds),\n };\n}\nconst WRAPPER_STAGES = { code: 1, plan: 1 };\nfunction codexDispatchMode(stage) {\n return WRAPPER_STAGES[stage] ? 'wrapper' : 'exec';\n}\nconst CODEX_EXEC_PROMPT_CEILING_CHARS = 24000;\nfunction codexExecPlan(input) {\n if (codexDispatchMode(input.stage) === 'wrapper') {\n return { mode: 'wrapper', reason: 'deliverable is a file written out-of-band' };\n }\n if (!input.probedId) {\n return { mode: 'claude', reason: 'no codex model id answered the probe' };\n }\n if (input.promptChars > CODEX_EXEC_PROMPT_CEILING_CHARS) {\n return {\n mode: 'claude',\n reason: 'prompt is ' +\n input.promptChars +\n ' chars, over the ' +\n CODEX_EXEC_PROMPT_CEILING_CHARS +\n '-char codex exec ceiling (it would stall)',\n };\n }\n return { mode: 'exec', reason: 'codex exec on ' + input.probedId };\n}",
86
+ code: "const CODE_LANDING_PIPELINE_PREFIXES = ['features/', '.dz/', '.agentic-qe/', 'roam/'];\nfunction codeLandingEmptySignal(seconds) {\n return 'changed=0 after ' + seconds + 's — genuinely not landed';\n}\nfunction needsCodeLandedBarrier(coderUsed) {\n return coderUsed === 'codex' || coderUsed === 'codex-fallback';\n}\nfunction stripCodeLandingPath(path) {\n let p = String(path || '').trim().replace(/\\\\/g, '/');\n while (p.indexOf('./') === 0)\n p = p.slice(2);\n return p.replace(/\\/+/g, '/');\n}\nfunction classifyCodeLandingPathReject(path) {\n const p = stripCodeLandingPath(path);\n if (!p)\n return 'empty-after-strip';\n if (p[0] === '/')\n return 'absolute-path';\n if (p === '..' || p.indexOf('../') === 0 || p.indexOf('/../') >= 0 || p.endsWith('/..'))\n return 'traversal';\n if (/[\\0\\r\\n\\t \"'\\x60$;&|<>*?()[\\]{}!]/.test(p))\n return 'not-a-path';\n if (p.endsWith('/'))\n return 'not-a-path';\n for (const prefix of CODE_LANDING_PIPELINE_PREFIXES) {\n const bare = prefix.slice(0, -1);\n if (p === bare || p.indexOf(prefix) === 0)\n return 'pipeline-artifact-path';\n }\n return null;\n}\nfunction normalizeCodeLandingPath(path) {\n return classifyCodeLandingPathReject(path) === null ? stripCodeLandingPath(path) : '';\n}\nfunction filterPollableCodePaths(paths) {\n const out = [];\n const seen = new Set();\n for (const path of paths || []) {\n const normalized = normalizeCodeLandingPath(path);\n if (!normalized || seen.has(normalized))\n continue;\n seen.add(normalized);\n out.push(normalized);\n }\n return out;\n}\nfunction isNewlyChanged(path, baseline, currentHashes) {\n if (!baseline || !baseline.ok)\n return false;\n let recorded = null;\n for (const entry of baseline.entries) {\n if (entry.path === path) {\n recorded = entry.hash;\n break;\n }\n }\n if (recorded === null)\n return true;\n const now = currentHashes ? currentHashes[path] : undefined;\n if (now === undefined)\n return false;\n return now !== recorded;\n}\nfunction decideCodeLanding(snapshot) {\n const maxWaitMs = Math.max(0, snapshot.maxWaitMs);\n const elapsedMs = Math.max(0, snapshot.elapsedMs);\n const elapsedSeconds = Math.floor(elapsedMs / 1000);\n const expectedPaths = filterPollableCodePaths(snapshot.expectedPaths);\n const changedPaths = filterPollableCodePaths(snapshot.changedEntries.map(function (entry) { return entry.path; }));\n if (expectedPaths.length === 0) {\n return {\n status: 'inconclusive',\n reason: 'empty-plan-block',\n changed: 0,\n elapsedMs: elapsedMs,\n elapsedSeconds: elapsedSeconds,\n expectedPaths: expectedPaths,\n matchedExpectedPaths: [],\n changedPaths: changedPaths,\n predicate: 'no-expected-targets',\n qeSignalLine: 'CODEX-LANDING-SIGNAL status=inconclusive predicate=no-expected-targets reason=empty-plan-block',\n };\n }\n const baseline = snapshot.baseline;\n if (!baseline || !baseline.ok) {\n const reason = baseline && baseline.reason ? baseline.reason : 'no-baseline';\n return {\n status: 'inconclusive',\n reason: reason,\n changed: 0,\n elapsedMs: elapsedMs,\n elapsedSeconds: elapsedSeconds,\n expectedPaths: expectedPaths,\n matchedExpectedPaths: [],\n changedPaths: changedPaths,\n predicate: 'newly-changed',\n qeSignalLine: 'CODEX-LANDING-SIGNAL status=inconclusive predicate=newly-changed reason=' + reason,\n };\n }\n const changed = new Set(changedPaths);\n const matchedExpectedPaths = expectedPaths.filter(function (path) {\n return changed.has(path) && isNewlyChanged(path, baseline, snapshot.currentHashes);\n });\n if (matchedExpectedPaths.length > 0) {\n return {\n status: 'landed',\n changed: matchedExpectedPaths.length,\n elapsedMs: elapsedMs,\n elapsedSeconds: elapsedSeconds,\n expectedPaths: expectedPaths,\n matchedExpectedPaths: matchedExpectedPaths,\n changedPaths: changedPaths,\n predicate: 'newly-changed',\n qeSignalLine: 'CODEX-LANDING-SIGNAL status=landed changed=' +\n matchedExpectedPaths.length +\n ' after=' +\n elapsedSeconds +\n 's predicate=newly-changed matched=' +\n matchedExpectedPaths.join(','),\n };\n }\n if (elapsedMs < maxWaitMs) {\n return {\n status: 'not-yet-flushed',\n changed: 0,\n elapsedMs: elapsedMs,\n elapsedSeconds: elapsedSeconds,\n expectedPaths: expectedPaths,\n matchedExpectedPaths: [],\n changedPaths: changedPaths,\n predicate: 'empty-before-timeout',\n qeSignalLine: 'CODEX-LANDING-SIGNAL status=not-yet-flushed changed=0 after ' + elapsedSeconds + 's — not yet flushed',\n };\n }\n const terminalSeconds = Math.ceil(maxWaitMs / 1000);\n return {\n status: 'genuinely-not-landed',\n changed: 0,\n elapsedMs: elapsedMs,\n elapsedSeconds: terminalSeconds,\n expectedPaths: expectedPaths,\n matchedExpectedPaths: [],\n changedPaths: changedPaths,\n predicate: 'empty-after-timeout',\n qeSignalLine: 'CODEX-LANDING-SIGNAL status=genuinely-not-landed ' + codeLandingEmptySignal(terminalSeconds),\n };\n}\nconst WRAPPER_STAGES = { code: 1, plan: 1 };\nfunction codexDispatchMode(stage) {\n return WRAPPER_STAGES[stage] ? 'wrapper' : 'exec';\n}\nconst CODEX_EXEC_PROMPT_CEILING_CHARS = 24000;\nfunction codexExecPlan(input) {\n if (codexDispatchMode(input.stage) === 'wrapper') {\n return { mode: 'wrapper', reason: 'deliverable is a file written out-of-band' };\n }\n if (!input.probedId) {\n return { mode: 'claude', reason: 'no codex model id answered the probe' };\n }\n if (input.stage === 'qe' && input.scoped !== true) {\n return {\n mode: 'claude',\n reason: 'qe prompt is not SCOPED — an unscoped codex exec QE buys reconnaissance, not review ' +\n '(MEASURED 2026-08-21: 19038 chars, 280s, exit 124, no verdict)',\n };\n }\n if (input.promptChars > CODEX_EXEC_PROMPT_CEILING_CHARS) {\n return {\n mode: 'claude',\n reason: 'prompt is ' +\n input.promptChars +\n ' chars, over the ' +\n CODEX_EXEC_PROMPT_CEILING_CHARS +\n '-char codex exec ceiling (it would stall)',\n };\n }\n return { mode: 'exec', reason: 'codex exec on ' + input.probedId };\n}\nconst SCOPED_QE_MAX_FILES = 3;\nconst SCOPED_QE_MAX_QUESTIONS = 4;\nconst SCOPED_QE_MAX_PATH_CHARS = 200;\nconst SCOPED_QE_MAX_QUESTION_CHARS = 200;\nfunction scopedQePrompt(input) {\n const o = input || {};\n const rawFiles = Array.isArray(o.files) ? o.files : [];\n const files = [];\n for (const f of rawFiles) {\n const s = String(f === undefined || f === null ? '' : f).trim();\n if (s === '')\n continue;\n if (files.indexOf(s) !== -1)\n continue;\n files.push(s.slice(0, SCOPED_QE_MAX_PATH_CHARS));\n if (files.length >= SCOPED_QE_MAX_FILES)\n break;\n }\n if (files.length === 0)\n return '';\n const rawQuestions = Array.isArray(o.questions) ? o.questions : [];\n const questions = [];\n for (const q of rawQuestions) {\n const s = String(q === undefined || q === null ? '' : q).trim().replace(/\\s+/g, ' ');\n if (s === '')\n continue;\n questions.push(s.slice(0, SCOPED_QE_MAX_QUESTION_CHARS));\n if (questions.length >= SCOPED_QE_MAX_QUESTIONS)\n break;\n }\n if (questions.length === 0) {\n questions.push('Is this change correct, and does the test named by its ADR actually DISCRIMINATE (would it fail if the protection were deleted)?');\n }\n const slug = String(o.slug === undefined || o.slug === null ? '' : o.slug).trim().slice(0, 60);\n let out = 'Read ONLY these files: ' + files.join(', ') + '. Do NOT open any other file and do NOT explore the repository.';\n if (slug !== '')\n out += ' They are the changed files of feature ' + slug + '.';\n out += '\\n\\nAnswer these ' + questions.length + ' questions about them:\\n';\n for (let i = 0; i < questions.length; i++)\n out += i + 1 + '. ' + questions[i] + '\\n';\n out += '\\nFinish with a single final line: Grade: <A|B|C|D>';\n return out;\n}",
87
87
  },
88
88
  "challenge-panel": {
89
89
  name: "challenge-panel",
package/src/patterns.ts CHANGED
@@ -287,7 +287,10 @@ export function computePatternBoost(
287
287
  matched = true;
288
288
  } else {
289
289
  // medium: a meaningful word from the pattern appears in the skill haystack
290
- for (const word of text.split(/[^a-z0-9]+/)) {
290
+ // `\p{L}\p{N}`, not `a-z0-9`: the ASCII-only class made every non-Latin word invisible to
291
+ // the boost, so a Cyrillic pattern could never match a skill haystack. The `>= 5` rule below
292
+ // is this site's OWN threshold and is deliberately unchanged — only the alphabet moved.
293
+ for (const word of text.split(/[^\p{L}\p{N}]+/u)) {
291
294
  if (word.length >= 5 && haystack.includes(word)) {
292
295
  matched = true;
293
296
  break;
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Witnessed run records — the decision half of `dz feature-adr-record` (ADR-001 … ADR-003).
3
+ *
4
+ * Two durable writers in the /feature-adr workflow still handed a subagent a PRE-BAKED shell string
5
+ * carrying their payload: the run-cost ledger and the training-pair capture. That is the shape a
6
+ * security classifier blocked NINE times in one run — one entity instructing another to append state
7
+ * it never verified. The checkpoint writer was migrated for that reason; these two were left behind.
8
+ *
9
+ * The role change is the point: the subagent stops being a COURIER (handed a shell string, appends
10
+ * it) and becomes a CALLER (handed arguments; the command decides). A courier can neither refuse nor
11
+ * verify.
12
+ *
13
+ * Pure: payload in, verdict out. The CLI owns paths, the append, the read-back and the exit code.
14
+ */
15
+
16
+ export type RecordKind = 'ledger' | 'training-pair';
17
+
18
+ export type RecordVerdict =
19
+ /** the line was appended AND read back equal */
20
+ | 'written'
21
+ /** a mark shows another run got here first — nothing written, nothing wrong */
22
+ | 'duplicate'
23
+ /** the target already held this pair — nothing written, nothing wrong */
24
+ | 'skipped'
25
+ /** the payload was rejected before any write; the target is untouched */
26
+ | 'refused'
27
+ /** the append happened but the read-back disagreed — the caller MUST treat this as NOT written */
28
+ | 'not-verified';
29
+
30
+ export interface RecordDecision {
31
+ readonly verdict: RecordVerdict;
32
+ /** One mapping, never two: 0 written|duplicate|skipped · 2 refused · 3 not-verified. */
33
+ readonly exit: 0 | 2 | 3;
34
+ readonly reason: string;
35
+ /**
36
+ * ALWAYS false. A cost row and a training pair are observability, and observability must not take
37
+ * the run down with it (ADR-003). The field exists so the property is assertable rather than
38
+ * merely intended — a thrown refusal would turn bookkeeping into an outage.
39
+ */
40
+ readonly blocking: false;
41
+ /** The exact line to append, or null when nothing may be written. */
42
+ readonly line: string | null;
43
+ /** Set when a mark was found without its target — the previous holder died before writing. */
44
+ readonly staleMark?: boolean;
45
+ }
46
+
47
+ /** A serialised record line above this is refused rather than truncated (acid case A2). */
48
+ export const RECORD_MAX_LINE_CHARS = 24_000;
49
+
50
+ /** Fields every ledger row must carry before it is worth writing down. */
51
+ const LEDGER_REQUIRED = ['slug', 'stage'] as const;
52
+ /** Fields every training pair must carry — the dataset is worthless without input/output. */
53
+ const PAIR_REQUIRED = ['slug', 'stage', 'input', 'output'] as const;
54
+
55
+ const refuse = (reason: string): RecordDecision => ({ verdict: 'refused', exit: 2, reason, blocking: false, line: null });
56
+
57
+ /** `duplicate` and `skipped` both wrote nothing and both are fine — but they are DIFFERENT facts. */
58
+ const noop = (verdict: 'duplicate' | 'skipped', reason: string): RecordDecision => ({
59
+ verdict,
60
+ exit: 0,
61
+ reason,
62
+ blocking: false,
63
+ line: null,
64
+ });
65
+
66
+ function shapeMismatch(kind: RecordKind, payload: Record<string, unknown>): string | null {
67
+ // AM-1 FIRST, before the required-field sweep. A ledger row offered as a training pair fails BOTH
68
+ // checks, and the wrong-kind reason is the one that tells the caller what actually happened —
69
+ // "missing field `output`" sends them looking for a field they never meant to send.
70
+ if (kind === 'ledger' && 'input' in payload && 'output' in payload) {
71
+ // BOTH fields together are the training-pair signature. Either one alone is not: a ledger row may
72
+ // legitimately carry `input: {cached_tokens: 80}` (cross-family review, 2026-08-21) and refusing
73
+ // it would make the command reject honest data on a name collision.
74
+ return 'this payload carries BOTH `input` and `output` — it is a training pair, not a ledger row';
75
+ }
76
+ if (kind === 'training-pair' && 'tokens' in payload && !('input' in payload)) {
77
+ return 'this payload looks like a ledger row (`tokens` without `input`), not a training pair';
78
+ }
79
+ const required = kind === 'ledger' ? LEDGER_REQUIRED : PAIR_REQUIRED;
80
+ for (const field of required) {
81
+ const v = payload[field];
82
+ if (v === undefined || v === null) return `a ${kind} record is missing the required field \`${field}\``;
83
+ // EMPTY is not present. An earlier version checked only string-emptiness, so `input: []` and
84
+ // `output: {}` satisfied the requirement and an empty record reached the file — a pair with no
85
+ // content is worse than no pair, because it looks captured.
86
+ if (typeof v === 'string' && v.trim() === '') return `a ${kind} record has an EMPTY \`${field}\``;
87
+ if (Array.isArray(v) && v.length === 0) return `a ${kind} record has an EMPTY \`${field}\` (empty array)`;
88
+ if (typeof v === 'object' && !Array.isArray(v) && Object.keys(v as object).length === 0) {
89
+ return `a ${kind} record has an EMPTY \`${field}\` (empty object)`;
90
+ }
91
+ }
92
+ return null;
93
+ }
94
+
95
+ export function decideRecordWrite(input: {
96
+ kind: RecordKind;
97
+ /** The raw `--row` / `--pair` argument, exactly as the caller passed it. */
98
+ payloadRaw: string;
99
+ /** The stage this record belongs to; a record for a stage that produced nothing is refused. */
100
+ stage: string;
101
+ stageProducedResult?: boolean;
102
+ /** A backfill mark already present ⇒ another run got here first — unless the target is absent. */
103
+ markExists?: boolean;
104
+ /** Whether the target file exists; a mark without a target is a STALE mark, not a duplicate. */
105
+ targetExists?: boolean;
106
+ /** The target already holds this pair. */
107
+ targetHasPair?: boolean;
108
+ /** Stamped INTO the object before serialising — never rewritten in the shell afterwards (FR-7). */
109
+ timestamp?: string | null;
110
+ maxChars?: number;
111
+ }): RecordDecision {
112
+ const { kind, payloadRaw, stage } = input;
113
+ if (kind !== 'ledger' && kind !== 'training-pair') {
114
+ return refuse(`unknown --kind \`${String(kind)}\` — expected ledger or training-pair`);
115
+ }
116
+ if (typeof stage !== 'string' || stage.trim() === '') return refuse('--stage is required');
117
+ if (input.stageProducedResult === false) {
118
+ return refuse(`stage \`${stage}\` produced no result — there is nothing to record`);
119
+ }
120
+
121
+ let payload: unknown;
122
+ try {
123
+ payload = JSON.parse(payloadRaw);
124
+ } catch {
125
+ return refuse('the payload is not valid JSON — refused before any write, the target is untouched');
126
+ }
127
+ if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) {
128
+ return refuse('the payload must be a JSON object');
129
+ }
130
+ const obj = payload as Record<string, unknown>;
131
+
132
+ const mismatch = shapeMismatch(kind, obj);
133
+ if (mismatch !== null) return refuse(mismatch);
134
+
135
+ // The record is filed under `--stage`, and the payload carries its own. A disagreement means the
136
+ // row would land in the wrong stage's file (pairs) or under a wrong label (ledger) — both available
137
+ // here, so leaving them uncompared was a free check declined.
138
+ const payloadStage = obj['stage'];
139
+ if (typeof payloadStage === 'string' && payloadStage !== stage) {
140
+ return refuse(`the payload's stage \`${payloadStage}\` disagrees with --stage \`${stage}\` — the record would be filed under the wrong stage`);
141
+ }
142
+
143
+ // The no-write outcomes are checked AFTER the payload is validated: reporting `duplicate` for a
144
+ // malformed payload would hide a real defect behind a benign-looking verdict.
145
+ // A mark whose TARGET does not exist is STALE: a previous run took the mark and died before writing.
146
+ // Reporting `duplicate` there lets one crash lose the record forever — the silent-loss shape this
147
+ // whole feature removes (cross-family review, 2026-08-21). A stale mark does NOT stop the write; it
148
+ // is recorded on the decision so the caller can say why it proceeded anyway.
149
+ const staleMark = input.markExists === true && input.targetExists === false;
150
+ if (input.markExists === true && !staleMark) {
151
+ return noop('duplicate', 'a mark for this record already exists — another run captured it first');
152
+ }
153
+ if (input.targetHasPair === true) {
154
+ return noop('skipped', 'the target already holds this record — nothing to add');
155
+ }
156
+
157
+ // The timestamp goes in BEFORE serialisation. The shell `sed` this replaces rewrote `"date":null`
158
+ // inside an already-serialised document — text surgery on a structured value, and the exact place
159
+ // a payload containing that literal token could corrupt itself.
160
+ const stamped: Record<string, unknown> = { ...obj };
161
+ if (input.timestamp != null && input.timestamp !== '') {
162
+ // An EMPTY STRING is a gap, not a value. Stamping only over null/undefined let
163
+ // `"date":""` through as `written` (cross-family review, 2026-08-21) — a row that looks recorded
164
+ // and carries no date.
165
+ const isGap = (v: unknown): boolean => v === null || v === undefined || (typeof v === 'string' && v.trim() === '');
166
+ if (kind === 'ledger' && isGap(stamped['date'])) stamped['date'] = input.timestamp.slice(0, 10);
167
+ if (kind === 'training-pair' && isGap(stamped['ts'])) stamped['ts'] = input.timestamp;
168
+ }
169
+
170
+ let line: string;
171
+ try {
172
+ line = JSON.stringify(stamped);
173
+ } catch {
174
+ return refuse('the payload could not be serialised (circular or unsupported value)');
175
+ }
176
+ const cap = input.maxChars ?? RECORD_MAX_LINE_CHARS;
177
+ if (line.length > cap) {
178
+ return refuse(`the serialised record is ${line.length} chars, above the ${cap}-char cap — refused rather than truncated`);
179
+ }
180
+ if (line.includes('\n')) return refuse('the serialised record contains a newline — one record is one line');
181
+
182
+ return {
183
+ verdict: 'written',
184
+ exit: 0,
185
+ reason: staleMark
186
+ ? `${kind} record ready to append (a STALE mark was found — its target is absent, so a previous holder died before writing)`
187
+ : `${kind} record ready to append`,
188
+ blocking: false,
189
+ line,
190
+ staleMark,
191
+ };
192
+ }
193
+
194
+ /** The read-back verdict (ADR-002): equal bytes or NOT written. Never inferred from the absence of an error. */
195
+ export function decideReadBack(appended: string, lastLineOnDisk: string | null): RecordDecision {
196
+ if (lastLineOnDisk === null) {
197
+ return {
198
+ verdict: 'not-verified',
199
+ exit: 3,
200
+ reason: 'the record was appended but the file could not be read back — treat this as NOT written',
201
+ blocking: false,
202
+ line: appended,
203
+ };
204
+ }
205
+ if (lastLineOnDisk !== appended) {
206
+ return {
207
+ verdict: 'not-verified',
208
+ exit: 3,
209
+ reason: 'the last line on disk differs from what was appended — treat this as NOT written',
210
+ blocking: false,
211
+ line: appended,
212
+ };
213
+ }
214
+ return { verdict: 'written', exit: 0, reason: 'appended and verified by re-reading the tail', blocking: false, line: appended };
215
+ }
216
+
217
+ /** The one line every caller reads last, in the shape the other gates use. */
218
+ export function recordVerdictLine(kind: RecordKind, stage: string, d: RecordDecision): string {
219
+ return `feature-adr record (${kind}/${stage}): ${d.verdict.toUpperCase()} — ${d.reason}`;
220
+ }