@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/.dz-manifest.json +144 -36
- package/README.md +24 -1
- package/dist/amendment-trace.d.ts +98 -0
- package/dist/amendment-trace.d.ts.map +1 -0
- package/dist/amendment-trace.js +275 -0
- package/dist/amendment-trace.js.map +1 -0
- package/dist/feature-adr-checkpoints.d.ts +51 -0
- package/dist/feature-adr-checkpoints.d.ts.map +1 -1
- package/dist/feature-adr-checkpoints.js +63 -1
- package/dist/feature-adr-checkpoints.js.map +1 -1
- package/dist/feature-adr-routing.d.ts +437 -4
- package/dist/feature-adr-routing.d.ts.map +1 -1
- package/dist/feature-adr-routing.js +719 -6
- package/dist/feature-adr-routing.js.map +1 -1
- package/dist/index.d.ts +8 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -3
- package/dist/index.js.map +1 -1
- package/dist/loop-blobs.generated.js +5 -5
- package/dist/loop-blobs.generated.js.map +1 -1
- package/dist/patterns.d.ts.map +1 -1
- package/dist/patterns.js +4 -1
- package/dist/patterns.js.map +1 -1
- package/dist/run-records.d.ts +66 -0
- package/dist/run-records.d.ts.map +1 -0
- package/dist/run-records.js +169 -0
- package/dist/run-records.js.map +1 -0
- package/package.json +5 -5
- package/sbom.json +311 -41
- package/src/amendment-trace.ts +348 -0
- package/src/feature-adr-checkpoints.ts +74 -1
- package/src/feature-adr-routing.ts +851 -6
- package/src/index.ts +76 -1
- package/src/loop-blobs.generated.ts +5 -5
- package/src/patterns.ts +4 -1
- package/src/run-records.ts +220 -0
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
|
|
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: "
|
|
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:
|
|
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: "
|
|
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
|
-
|
|
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
|
+
}
|