@dzhechkov/harness-core 0.7.11 → 0.8.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 +287 -87
- package/README.md +60 -1
- package/dist/amendment-trace.d.ts +12 -1
- package/dist/amendment-trace.d.ts.map +1 -1
- package/dist/amendment-trace.js +22 -4
- package/dist/amendment-trace.js.map +1 -1
- package/dist/book-kb.d.ts.map +1 -1
- package/dist/book-kb.js +17 -0
- package/dist/book-kb.js.map +1 -1
- package/dist/feature-adr-checkpoints.d.ts +61 -0
- package/dist/feature-adr-checkpoints.d.ts.map +1 -1
- package/dist/feature-adr-checkpoints.js +116 -2
- package/dist/feature-adr-checkpoints.js.map +1 -1
- package/dist/feature-adr-routing.d.ts +69 -12
- package/dist/feature-adr-routing.d.ts.map +1 -1
- package/dist/feature-adr-routing.js +117 -7
- package/dist/feature-adr-routing.js.map +1 -1
- package/dist/index.d.ts +23 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +23 -4
- package/dist/index.js.map +1 -1
- package/dist/learning-backend.d.ts +39 -0
- package/dist/learning-backend.d.ts.map +1 -1
- package/dist/learning-backend.js +31 -11
- package/dist/learning-backend.js.map +1 -1
- package/dist/lesson-bandit.d.ts +116 -0
- package/dist/lesson-bandit.d.ts.map +1 -0
- package/dist/lesson-bandit.js +235 -0
- package/dist/lesson-bandit.js.map +1 -0
- package/dist/lesson-payoff.d.ts +260 -0
- package/dist/lesson-payoff.d.ts.map +1 -0
- package/dist/lesson-payoff.js +597 -0
- package/dist/lesson-payoff.js.map +1 -0
- package/dist/loop-blobs.generated.js +2 -2
- package/dist/loop-blobs.generated.js.map +1 -1
- package/dist/mutation-gate.d.ts +31 -8
- package/dist/mutation-gate.d.ts.map +1 -1
- package/dist/mutation-gate.js +57 -16
- package/dist/mutation-gate.js.map +1 -1
- package/dist/named-lock.d.ts.map +1 -1
- package/dist/named-lock.js +11 -9
- package/dist/named-lock.js.map +1 -1
- package/dist/native-dep-probe.d.ts +11 -0
- package/dist/native-dep-probe.d.ts.map +1 -0
- package/dist/native-dep-probe.js +100 -0
- package/dist/native-dep-probe.js.map +1 -0
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +82 -1
- package/dist/operations.js.map +1 -1
- package/dist/parser-safe-region.d.ts +47 -0
- package/dist/parser-safe-region.d.ts.map +1 -0
- package/dist/parser-safe-region.js +47 -0
- package/dist/parser-safe-region.js.map +1 -0
- package/dist/patterns.d.ts +21 -0
- package/dist/patterns.d.ts.map +1 -1
- package/dist/patterns.js +52 -4
- package/dist/patterns.js.map +1 -1
- package/dist/profile.d.ts +202 -0
- package/dist/profile.d.ts.map +1 -0
- package/dist/profile.js +536 -0
- package/dist/profile.js.map +1 -0
- package/dist/project-skills-root.d.ts +44 -0
- package/dist/project-skills-root.d.ts.map +1 -0
- package/dist/project-skills-root.js +62 -0
- package/dist/project-skills-root.js.map +1 -0
- package/dist/publish.d.ts +5 -3
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +46 -8
- package/dist/publish.js.map +1 -1
- package/dist/qe-rounds.d.ts +75 -0
- package/dist/qe-rounds.d.ts.map +1 -0
- package/dist/qe-rounds.js +174 -0
- package/dist/qe-rounds.js.map +1 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +5 -1
- package/dist/registry.js.map +1 -1
- package/dist/run-records.d.ts.map +1 -1
- package/dist/run-records.js +8 -1
- package/dist/run-records.js.map +1 -1
- package/dist/setup.d.ts +35 -4
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +86 -23
- package/dist/setup.js.map +1 -1
- package/dist/sign.d.ts +29 -24
- package/dist/sign.d.ts.map +1 -1
- package/dist/sign.js +183 -61
- package/dist/sign.js.map +1 -1
- package/dist/store-location.d.ts +56 -0
- package/dist/store-location.d.ts.map +1 -0
- package/dist/store-location.js +56 -0
- package/dist/store-location.js.map +1 -0
- package/dist/store-lock.d.ts +1 -0
- package/dist/store-lock.d.ts.map +1 -1
- package/dist/store-lock.js +7 -1
- package/dist/store-lock.js.map +1 -1
- package/dist/store-merge.d.ts +55 -0
- package/dist/store-merge.d.ts.map +1 -0
- package/dist/store-merge.js +77 -0
- package/dist/store-merge.js.map +1 -0
- package/dist/teach-target.d.ts +61 -0
- package/dist/teach-target.d.ts.map +1 -0
- package/dist/teach-target.js +105 -0
- package/dist/teach-target.js.map +1 -0
- package/dist/vector-tier.d.ts +30 -0
- package/dist/vector-tier.d.ts.map +1 -1
- package/dist/vector-tier.js +178 -20
- package/dist/vector-tier.js.map +1 -1
- package/package.json +13 -13
- package/sbom.json +591 -87
- package/src/amendment-trace.ts +34 -4
- package/src/book-kb.ts +17 -0
- package/src/feature-adr-checkpoints.ts +111 -2
- package/src/feature-adr-routing.ts +146 -7
- package/src/index.ts +55 -3
- package/src/learning-backend.ts +62 -11
- package/src/lesson-bandit.ts +279 -0
- package/src/lesson-payoff.ts +728 -0
- package/src/loop-blobs.generated.ts +2 -2
- package/src/mutation-gate.ts +74 -17
- package/src/named-lock.ts +10 -8
- package/src/native-dep-probe.ts +118 -0
- package/src/operations.ts +77 -1
- package/src/parser-safe-region.ts +65 -0
- package/src/patterns.ts +76 -6
- package/src/profile.ts +593 -0
- package/src/project-skills-root.ts +63 -0
- package/src/publish.ts +43 -7
- package/src/qe-rounds.ts +226 -0
- package/src/registry.ts +5 -1
- package/src/run-records.ts +9 -1
- package/src/setup.ts +87 -23
- package/src/sign.ts +180 -60
- package/src/store-location.ts +76 -0
- package/src/store-lock.ts +8 -1
- package/src/store-merge.ts +91 -0
- package/src/teach-target.ts +126 -0
- package/src/vector-tier.ts +236 -21
|
@@ -52,11 +52,11 @@ export const BLOBS: Record<string, LoopBlob> = {
|
|
|
52
52
|
"training-pairs": {
|
|
53
53
|
name: "training-pairs",
|
|
54
54
|
version: "1.1.0",
|
|
55
|
-
contentHash: "
|
|
55
|
+
contentHash: "199eaaa8ce5dba4f54744b4a4f34eea32a3c33d4de8dbbddd3d119b70459361d",
|
|
56
56
|
sourcePath: "packages/@dzhechkov/harness-core/src/feature-adr-checkpoints.ts",
|
|
57
57
|
requires: ["checkpoints"],
|
|
58
58
|
exports: ["TRAINPAIR_SCHEMA_VERSION","TRAINPAIR_MAX_IO_CHARS","trainingPairFamily","trainingPairPath","TRAINPAIR_PRIVACY_NOTE","buildTrainingPair","serializeTrainingPair","trainingPairAppendCmd","decideCaptureMode","captureFailureRecord","trainingPairBackfillCmd","TP_BACKFILL_OK","TP_BACKFILL_SKIP"],
|
|
59
|
-
code: "function decideCaptureMode(opts) {\n if (!opts.enabled)\n return 'skip-disabled';\n if (!Number.isInteger(opts.recordCount) || opts.recordCount <= 0)\n return 'skip-empty';\n return opts.resumed ? 'backfill' : 'capture';\n}\nfunction captureFailureRecord(stage, mode, reason, detail) {\n const normalizedStage = typeof stage === 'string' && stage.trim() !== '' ? stage : 'unknown';\n const normalizedMode = mode === 'capture' || mode === 'backfill' || mode === 'skip-disabled' || mode === 'skip-empty'\n ? mode\n : null;\n const normalizedReason = reason === 'threw' || reason === 'unserializable' || reason === 'unverified' || reason === 'backfill-unverified' || reason === 'empty-output'\n ? reason\n : 'threw';\n let normalizedDetail = null;\n if (detail !== null && detail !== undefined) {\n try {\n const text = String(detail);\n if (text !== '')\n normalizedDetail = text.length > 500 ? text.slice(0, 500) + '…' : text;\n }\n catch {\n normalizedDetail = null;\n }\n }\n return { stage: normalizedStage, mode: normalizedMode, reason: normalizedReason, detail: normalizedDetail };\n}\nconst TRAINPAIR_SCHEMA_VERSION = 'fa-trainpair-2';\nconst TRAINPAIR_MAX_IO_CHARS = 48000;\nfunction trainingPairFamily(spec) {\n return /codex|gpt|openai/i.test(String(spec ?? '')) ? 'codex' : 'claude';\n}\nfunction trainingPairPath(slug, stage) {\n return '.dz/fa-training/' + slug + '/' + stage + '.jsonl';\n}\nconst TRAINPAIR_PRIVACY_NOTE = \"feature-adr TRAINING PAIRS (backlog 70e0f083): per-stage SFT records - STAGE INPUT (full prompt/context) -> STAGE OUTPUT (artifact/result) -> EVALUATION (QE grade + injected lessons) with model+family provenance; one JSONL file per stage per slug. PRIVACY: pairs may contain TARGET-REPO CODE and full prompts. This directory is NOT gitignored yet by explicit owner decision - review contents before sharing or publishing anything that embeds it. ts is the CAPTURE time. On a record with captureMode: 'backfill' that is the RECONSTRUCTION time, NOT the stage's observation time — the original stage's timing lives in that run's .fa-state checkpoint.\";\nfunction coerceText(v) {\n if (typeof v === 'string')\n return v;\n if (v === null || v === undefined)\n return '';\n try {\n const s = JSON.stringify(v);\n return typeof s === 'string' ? s : String(v);\n }\n catch {\n return String(v);\n }\n}\nfunction buildTrainingPair(opts) {\n let input = coerceText(opts.input);\n let output = coerceText(opts.output);\n let truncated = null;\n if (input.length + output.length > TRAINPAIR_MAX_IO_CHARS) {\n truncated = { inputChars: input.length, outputChars: output.length, inputHash: fnv1a64(input), outputHash: fnv1a64(output) };\n const half = Math.floor(TRAINPAIR_MAX_IO_CHARS / 2);\n let inKeep = input.length;\n let outKeep = output.length;\n if (outKeep <= half)\n inKeep = TRAINPAIR_MAX_IO_CHARS - outKeep;\n else if (inKeep <= half)\n outKeep = TRAINPAIR_MAX_IO_CHARS - inKeep;\n else {\n inKeep = half;\n outKeep = TRAINPAIR_MAX_IO_CHARS - half;\n }\n if (inKeep < input.length)\n input = input.slice(0, inKeep) + '\\n…[TRUNCATED ' + (truncated.inputChars - inKeep) + ' chars — full-text fnv1a64=' + truncated.inputHash + ']';\n if (outKeep < output.length)\n output = output.slice(0, outKeep) + '\\n…[TRUNCATED ' + (truncated.outputChars - outKeep) + ' chars — full-text fnv1a64=' + truncated.outputHash + ']';\n }\n const ev = opts.evaluation || {};\n const pv = opts.provenance || {};\n return {\n schema: TRAINPAIR_SCHEMA_VERSION,\n slug: opts.slug,\n stage: opts.stage,\n ts: opts.ts === undefined ? null : opts.ts,\n input,\n output,\n evaluation: {\n grade: typeof ev.grade === 'string' && ev.grade.trim() !== '' ? ev.grade : null,\n gradedBy: typeof ev.gradedBy === 'string' && ev.gradedBy !== '' ? ev.gradedBy : null,\n lessonsInjected: Array.isArray(ev.lessonsInjected) ? ev.lessonsInjected.filter((s) => typeof s === 'string' && s !== '') : [],\n },\n provenance: {\n model: typeof pv.model === 'string' && pv.model !== '' ? pv.model : 'unknown',\n family: pv.family === 'claude' || pv.family === 'codex' ? pv.family : trainingPairFamily(pv.model),\n role: typeof pv.role === 'string' && pv.role !== '' ? pv.role : 'unknown',\n tokens: typeof pv.tokens === 'number' && Number.isFinite(pv.tokens) ? pv.tokens : null,\n minutes: typeof pv.minutes === 'number' && Number.isFinite(pv.minutes) ? pv.minutes : null,\n },\n truncated,\n captureMode: opts.captureMode === 'backfill' ? 'backfill' : 'capture',\n resumed: opts.resumed === true,\n };\n}\nfunction serializeTrainingPair(pair) {\n try {\n const line = JSON.stringify(pair);\n return typeof line === 'string' ? line : null;\n }\n catch {\n return null;\n }\n}\nfunction trainingPairAppendCmd(repoAbs, slug, stage, line) {\n const dirAbs = repoAbs + '/.dz/fa-training/' + slug;\n const readmeAbs = repoAbs + '/.dz/fa-training/README.md';\n const fileAbs = dirAbs + '/' + stage + '.jsonl';\n return ('mkdir -p ' + shellQuote(dirAbs) +\n ' && { [ -f ' + shellQuote(readmeAbs) + ' ] || printf \\'%s\\\\n\\' ' + shellQuote(TRAINPAIR_PRIVACY_NOTE) + ' > ' + shellQuote(readmeAbs) + '; }' +\n \" && printf '%s\\\\n' \" + shellQuote(line) + ' >> ' + shellQuote(fileAbs));\n}\nconst TP_BACKFILL_OK = 'TP-BACKFILL-OK';\nconst TP_BACKFILL_SKIP = 'TP-BACKFILL-SKIP';\nconst TP_BACKFILL_DUP = 'TP-BACKFILL-DUP';\nfunction trainingPairBackfillCmd(repoAbs, slug, stage, lines, markKey) {\n if (typeof repoAbs !== 'string' || repoAbs === '')\n return null;\n if (typeof slug !== 'string' || slug === '')\n return null;\n if (typeof stage !== 'string' || stage === '')\n return null;\n if (!Array.isArray(lines) || lines.length === 0 || !lines.every(line => typeof line === 'string' && line !== ''))\n return null;\n const dirAbs = repoAbs + '/.dz/fa-training/' + slug;\n const readmeAbs = repoAbs + '/.dz/fa-training/README.md';\n const fileAbs = dirAbs + '/' + stage + '.jsonl';\n const markDir = repoAbs + '/.dz/fa-training/.backfill-marks';\n const markStage = stage.replace(/\\.\\./g, '_').replace(/\\//g, '_');\n const resolvedMarkKey = markKey === undefined ? fnv1a64(stage + '\\0' + lines.join('\\n')) : markKey;\n const markPath = markDir + '/' + markStage + '-' + resolvedMarkKey;\n const appends = lines\n .map(line => \"printf '%s\\\\n' \" + shellQuote(line) + ' >> ' + shellQuote(fileAbs))\n .join(' && ');\n return ('mkdir -p ' + shellQuote(dirAbs) +\n ' && { [ -f ' + shellQuote(readmeAbs) + ' ] || printf \\'%s\\\\n\\' ' + shellQuote(TRAINPAIR_PRIVACY_NOTE) + ' > ' + shellQuote(readmeAbs) + '; }' +\n ' && mkdir -p ' + shellQuote(markDir) +\n ' && if mkdir ' + shellQuote(markPath) + ' 2>/dev/null; then ' +\n 'if [ -f ' + shellQuote(fileAbs) + ' ]; then echo ' + shellQuote(TP_BACKFILL_SKIP) +\n '; else { ' + appends + ' && echo ' + shellQuote(TP_BACKFILL_OK) + '; } || { rmdir ' + shellQuote(markPath) + ' 2>/dev/null; false; }; fi' +\n '; else echo ' + shellQuote(TP_BACKFILL_DUP) + '; fi');\n}",
|
|
59
|
+
code: "function decideCaptureMode(opts) {\n if (!opts.enabled)\n return 'skip-disabled';\n if (!Number.isInteger(opts.recordCount) || opts.recordCount <= 0)\n return 'skip-empty';\n return opts.resumed ? 'backfill' : 'capture';\n}\nfunction captureFailureRecord(stage, mode, reason, detail) {\n const normalizedStage = typeof stage === 'string' && stage.trim() !== '' ? stage : 'unknown';\n const normalizedMode = mode === 'capture' || mode === 'backfill' || mode === 'skip-disabled' || mode === 'skip-empty'\n ? mode\n : null;\n const normalizedReason = reason === 'threw' || reason === 'unserializable' || reason === 'unverified' || reason === 'backfill-unverified' || reason === 'empty-output'\n ? reason\n : 'threw';\n let normalizedDetail = null;\n if (detail !== null && detail !== undefined) {\n try {\n const text = String(detail);\n if (text !== '')\n normalizedDetail = text.length > 500 ? text.slice(0, 500) + '…' : text;\n }\n catch {\n normalizedDetail = null;\n }\n }\n return { stage: normalizedStage, mode: normalizedMode, reason: normalizedReason, detail: normalizedDetail };\n}\nconst TRAINPAIR_SCHEMA_VERSION = 'fa-trainpair-2';\nconst TRAINPAIR_MAX_IO_CHARS = 48000;\nfunction trainingPairFamily(spec) {\n return /codex|gpt|openai/i.test(String(spec ?? '')) ? 'codex' : 'claude';\n}\nfunction trainingPairPath(slug, stage) {\n return '.dz/fa-training/' + slug + '/' + stage + '.jsonl';\n}\nconst TRAINPAIR_PRIVACY_NOTE = \"feature-adr TRAINING PAIRS (backlog 70e0f083): per-stage SFT records - STAGE INPUT (full prompt/context) -> STAGE OUTPUT (artifact/result) -> EVALUATION (QE grade + injected lessons) with model+family provenance; one JSONL file per stage per slug. PRIVACY: pairs may contain TARGET-REPO CODE and full prompts. This directory is NOT gitignored yet by explicit owner decision - review contents before sharing or publishing anything that embeds it. ts is the CAPTURE time. On a record with captureMode: 'backfill' that is the RECONSTRUCTION time, NOT the stage's observation time — the original stage's timing lives in that run's .fa-state checkpoint.\";\nconst TP_PROFILE_MARKER_START = '<!-- dz:profile:start -->';\nconst TP_PROFILE_MARKER_END = '<!-- dz:profile:end -->';\nconst TP_PROFILE_REDACTED = '[dz:profile REDACTED]';\nfunction redactProfileBlock(text) {\n if (typeof text !== 'string' || text === '')\n return typeof text === 'string' ? text : '';\n let out = '';\n let rest = text;\n for (;;) {\n const start = rest.indexOf(TP_PROFILE_MARKER_START);\n if (start === -1)\n return out + rest;\n out += rest.slice(0, start) + TP_PROFILE_REDACTED;\n const end = rest.indexOf(TP_PROFILE_MARKER_END, start + TP_PROFILE_MARKER_START.length);\n if (end === -1)\n return out;\n rest = rest.slice(end + TP_PROFILE_MARKER_END.length);\n }\n}\nfunction coerceText(v) {\n if (typeof v === 'string')\n return v;\n if (v === null || v === undefined)\n return '';\n try {\n const s = JSON.stringify(v);\n return typeof s === 'string' ? s : String(v);\n }\n catch {\n return String(v);\n }\n}\nfunction buildTrainingPair(opts) {\n let input = redactProfileBlock(coerceText(opts.input));\n let output = redactProfileBlock(coerceText(opts.output));\n let truncated = null;\n if (input.length + output.length > TRAINPAIR_MAX_IO_CHARS) {\n truncated = { inputChars: input.length, outputChars: output.length, inputHash: fnv1a64(input), outputHash: fnv1a64(output) };\n const half = Math.floor(TRAINPAIR_MAX_IO_CHARS / 2);\n let inKeep = input.length;\n let outKeep = output.length;\n if (outKeep <= half)\n inKeep = TRAINPAIR_MAX_IO_CHARS - outKeep;\n else if (inKeep <= half)\n outKeep = TRAINPAIR_MAX_IO_CHARS - inKeep;\n else {\n inKeep = half;\n outKeep = TRAINPAIR_MAX_IO_CHARS - half;\n }\n if (inKeep < input.length)\n input = input.slice(0, inKeep) + '\\n…[TRUNCATED ' + (truncated.inputChars - inKeep) + ' chars — full-text fnv1a64=' + truncated.inputHash + ']';\n if (outKeep < output.length)\n output = output.slice(0, outKeep) + '\\n…[TRUNCATED ' + (truncated.outputChars - outKeep) + ' chars — full-text fnv1a64=' + truncated.outputHash + ']';\n }\n const ev = opts.evaluation || {};\n const pv = opts.provenance || {};\n return {\n schema: TRAINPAIR_SCHEMA_VERSION,\n slug: opts.slug,\n stage: opts.stage,\n ts: opts.ts === undefined ? null : opts.ts,\n input,\n output,\n evaluation: {\n grade: typeof ev.grade === 'string' && ev.grade.trim() !== '' ? ev.grade : null,\n gradedBy: typeof ev.gradedBy === 'string' && ev.gradedBy !== '' ? ev.gradedBy : null,\n lessonsInjected: Array.isArray(ev.lessonsInjected) ? ev.lessonsInjected.filter((s) => typeof s === 'string' && s !== '') : [],\n },\n provenance: {\n model: typeof pv.model === 'string' && pv.model !== '' ? pv.model : 'unknown',\n family: pv.family === 'claude' || pv.family === 'codex' ? pv.family : trainingPairFamily(pv.model),\n role: typeof pv.role === 'string' && pv.role !== '' ? pv.role : 'unknown',\n tokens: typeof pv.tokens === 'number' && Number.isFinite(pv.tokens) ? pv.tokens : null,\n minutes: typeof pv.minutes === 'number' && Number.isFinite(pv.minutes) ? pv.minutes : null,\n },\n truncated,\n captureMode: opts.captureMode === 'backfill' ? 'backfill' : 'capture',\n resumed: opts.resumed === true,\n };\n}\nfunction serializeTrainingPair(pair) {\n try {\n const line = JSON.stringify(pair);\n return typeof line === 'string' ? line : null;\n }\n catch {\n return null;\n }\n}\nfunction trainingPairAppendCmd(repoAbs, slug, stage, line) {\n const dirAbs = repoAbs + '/.dz/fa-training/' + slug;\n const readmeAbs = repoAbs + '/.dz/fa-training/README.md';\n const fileAbs = dirAbs + '/' + stage + '.jsonl';\n return ('mkdir -p ' + shellQuote(dirAbs) +\n ' && { [ -f ' + shellQuote(readmeAbs) + ' ] || printf \\'%s\\\\n\\' ' + shellQuote(TRAINPAIR_PRIVACY_NOTE) + ' > ' + shellQuote(readmeAbs) + '; }' +\n \" && printf '%s\\\\n' \" + shellQuote(line) + ' >> ' + shellQuote(fileAbs));\n}\nconst TP_BACKFILL_OK = 'TP-BACKFILL-OK';\nconst TP_BACKFILL_SKIP = 'TP-BACKFILL-SKIP';\nconst TP_BACKFILL_DUP = 'TP-BACKFILL-DUP';\nfunction trainingPairBackfillCmd(repoAbs, slug, stage, lines, markKey) {\n if (typeof repoAbs !== 'string' || repoAbs === '')\n return null;\n if (typeof slug !== 'string' || slug === '')\n return null;\n if (typeof stage !== 'string' || stage === '')\n return null;\n if (!Array.isArray(lines) || lines.length === 0 || !lines.every(line => typeof line === 'string' && line !== ''))\n return null;\n const dirAbs = repoAbs + '/.dz/fa-training/' + slug;\n const readmeAbs = repoAbs + '/.dz/fa-training/README.md';\n const fileAbs = dirAbs + '/' + stage + '.jsonl';\n const markDir = repoAbs + '/.dz/fa-training/.backfill-marks';\n const markStage = stage.replace(/\\.\\./g, '_').replace(/\\//g, '_');\n const resolvedMarkKey = markKey === undefined ? fnv1a64(stage + '\\0' + lines.join('\\n')) : markKey;\n const markPath = markDir + '/' + markStage + '-' + resolvedMarkKey;\n const appends = lines\n .map(line => \"printf '%s\\\\n' \" + shellQuote(line) + ' >> ' + shellQuote(fileAbs))\n .join(' && ');\n return ('mkdir -p ' + shellQuote(dirAbs) +\n ' && { [ -f ' + shellQuote(readmeAbs) + ' ] || printf \\'%s\\\\n\\' ' + shellQuote(TRAINPAIR_PRIVACY_NOTE) + ' > ' + shellQuote(readmeAbs) + '; }' +\n ' && mkdir -p ' + shellQuote(markDir) +\n ' && if mkdir ' + shellQuote(markPath) + ' 2>/dev/null; then ' +\n 'if [ -f ' + shellQuote(fileAbs) + ' ]; then echo ' + shellQuote(TP_BACKFILL_SKIP) +\n '; else { ' + appends + ' && echo ' + shellQuote(TP_BACKFILL_OK) + '; } || { rmdir ' + shellQuote(markPath) + ' 2>/dev/null; false; }; fi' +\n '; else echo ' + shellQuote(TP_BACKFILL_DUP) + '; fi');\n}",
|
|
60
60
|
},
|
|
61
61
|
"model-resolver": {
|
|
62
62
|
name: "model-resolver",
|
package/src/mutation-gate.ts
CHANGED
|
@@ -45,12 +45,15 @@ export interface MutationRegistryEntry {
|
|
|
45
45
|
export interface MutationRegistry {
|
|
46
46
|
/** optional suite command override for the whole registry (default `npm test`). */
|
|
47
47
|
readonly testCommand?: string;
|
|
48
|
+
/** opt-in proof that the suite harness reached its clean completion path. */
|
|
49
|
+
readonly requireCompletionReceipt?: boolean;
|
|
48
50
|
readonly entries: readonly MutationRegistryEntry[];
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
export type MutationVerdict =
|
|
52
54
|
| 'PROVEN' // mutation applied, suite went red, failing count (when known) >= minFailing
|
|
53
55
|
| 'UNDEFENDED' // mutation applied, suite stayed GREEN — the property has no discriminating test
|
|
56
|
+
| 'RECEIPT_MISMATCH' // the suite harness declared a receipt error, or an opted-in completion receipt was absent — neither the exit code nor failing count may be read as discrimination
|
|
54
57
|
| 'NOT_APPLIED' // `find` occurred 0 or >1 times — code drifted; nothing was tested (rule 1)
|
|
55
58
|
| 'BELOW_MIN' // suite red, but a RELIABLE count came in under the entry's own minFailing contract
|
|
56
59
|
| 'MUTATION_UNPARSEABLE' // the MUTATED file no longer parses — a registry/setup error: whole-suite redness from a load failure proves nothing (route a)
|
|
@@ -89,12 +92,22 @@ export interface MutationObservation {
|
|
|
89
92
|
* failure), NEVER PROVEN: redness that cannot be attributed proves nothing.
|
|
90
93
|
*/
|
|
91
94
|
readonly outputUnrecognised?: string;
|
|
95
|
+
/**
|
|
96
|
+
* set to the marker text when the suite harness declares its own execution-receipt contract
|
|
97
|
+
* violated, or to the missing-receipt detail for an opted-in registry. Either condition
|
|
98
|
+
* invalidates both green and count-based readings of the same run.
|
|
99
|
+
*/
|
|
100
|
+
readonly receiptMismatch?: string;
|
|
101
|
+
/** named reason from spawnSync when no exit code exists (timeout, signal, maxBuffer, spawn). */
|
|
102
|
+
readonly runFailureReason?: string;
|
|
92
103
|
/**
|
|
93
104
|
* exit code of the suite re-run on the RESTORED tree after this entry (the attribution check
|
|
94
105
|
* for flaky neighbours). undefined = not run; 0 = reproducibly green; any other value (or null)
|
|
95
106
|
* = the "green" baseline is not reproducible, so the red under mutation is not attributable.
|
|
96
107
|
*/
|
|
97
108
|
readonly rebaselineExitCode?: number | null;
|
|
109
|
+
/** named no-exit reason for the restored-tree attribution run, when it produced none. */
|
|
110
|
+
readonly rebaselineFailureReason?: string;
|
|
98
111
|
}
|
|
99
112
|
|
|
100
113
|
export interface MutationEntryResult {
|
|
@@ -135,7 +148,7 @@ export interface ParsedRegistry {
|
|
|
135
148
|
readonly errors: readonly string[];
|
|
136
149
|
}
|
|
137
150
|
|
|
138
|
-
/** Parse + validate a registry JSON text. Accepts a bare array
|
|
151
|
+
/** Parse + validate a registry JSON text. Accepts a bare array or `{testCommand?, requireCompletionReceipt?, entries}`. */
|
|
139
152
|
export function parseMutationRegistry(text: string): ParsedRegistry {
|
|
140
153
|
let raw: unknown;
|
|
141
154
|
try {
|
|
@@ -146,10 +159,11 @@ export function parseMutationRegistry(text: string): ParsedRegistry {
|
|
|
146
159
|
|
|
147
160
|
let entriesRaw: unknown;
|
|
148
161
|
let testCommand: string | undefined;
|
|
162
|
+
let requireCompletionReceipt: boolean | undefined;
|
|
149
163
|
if (Array.isArray(raw)) {
|
|
150
164
|
entriesRaw = raw;
|
|
151
165
|
} else if (raw && typeof raw === 'object') {
|
|
152
|
-
const obj = raw as { testCommand?: unknown; entries?: unknown };
|
|
166
|
+
const obj = raw as { testCommand?: unknown; requireCompletionReceipt?: unknown; entries?: unknown };
|
|
153
167
|
entriesRaw = obj.entries;
|
|
154
168
|
if (obj.testCommand !== undefined) {
|
|
155
169
|
if (typeof obj.testCommand !== 'string' || obj.testCommand.trim() === '') {
|
|
@@ -157,9 +171,15 @@ export function parseMutationRegistry(text: string): ParsedRegistry {
|
|
|
157
171
|
}
|
|
158
172
|
testCommand = obj.testCommand.trim();
|
|
159
173
|
}
|
|
174
|
+
if (obj.requireCompletionReceipt !== undefined) {
|
|
175
|
+
if (typeof obj.requireCompletionReceipt !== 'boolean') {
|
|
176
|
+
return { registry: null, errors: ['requireCompletionReceipt must be a boolean when present'] };
|
|
177
|
+
}
|
|
178
|
+
requireCompletionReceipt = obj.requireCompletionReceipt;
|
|
179
|
+
}
|
|
160
180
|
}
|
|
161
181
|
if (!Array.isArray(entriesRaw)) {
|
|
162
|
-
return { registry: null, errors: ['registry must be an array of entries or {testCommand?, entries: [...]}'] };
|
|
182
|
+
return { registry: null, errors: ['registry must be an array of entries or {testCommand?, requireCompletionReceipt?, entries: [...]}'] };
|
|
163
183
|
}
|
|
164
184
|
if (entriesRaw.length === 0) {
|
|
165
185
|
// An empty registry "passes" by testing nothing — the same silent hole as a skipped mutation.
|
|
@@ -237,9 +257,14 @@ export function parseMutationRegistry(text: string): ParsedRegistry {
|
|
|
237
257
|
});
|
|
238
258
|
|
|
239
259
|
if (errors.length > 0) return { registry: null, errors };
|
|
240
|
-
return
|
|
241
|
-
|
|
242
|
-
|
|
260
|
+
return {
|
|
261
|
+
registry: {
|
|
262
|
+
...(testCommand !== undefined ? { testCommand } : {}),
|
|
263
|
+
...(requireCompletionReceipt !== undefined ? { requireCompletionReceipt } : {}),
|
|
264
|
+
entries,
|
|
265
|
+
},
|
|
266
|
+
errors: [],
|
|
267
|
+
};
|
|
243
268
|
}
|
|
244
269
|
|
|
245
270
|
// ── Mutation application — exact text surgery, exactly once (rule 1) ──────────────────────────
|
|
@@ -315,6 +340,21 @@ function stripSgr(s: string): string {
|
|
|
315
340
|
return s.replace(SGR, '');
|
|
316
341
|
}
|
|
317
342
|
|
|
343
|
+
/** Return the first suite-harness receipt error at column 0, with SGR removed. */
|
|
344
|
+
export function detectSuiteReceiptMismatch(rawOutput: string): string | undefined {
|
|
345
|
+
const match = /^mutation-suite-receipt-error:[^\S\r\n]*(.*)$/m.exec(stripSgr(rawOutput));
|
|
346
|
+
return match === null ? undefined : (match[1] ?? '').trim();
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Return the first suite-harness clean-completion receipt at column 0, with SGR removed. */
|
|
350
|
+
export function detectSuiteCompletionReceipt(rawOutput: string): { lanes: number; names: number } | undefined {
|
|
351
|
+
const match = /^mutation-suite-receipt-ok:[^\S\r\n]*lanes=(\d+)[^\S\r\n]+names=(\d+)[^\S\r\n]*$/m.exec(stripSgr(rawOutput));
|
|
352
|
+
if (match === null) return undefined;
|
|
353
|
+
const lanes = Number(match[1]);
|
|
354
|
+
const names = Number(match[2]);
|
|
355
|
+
return Number.isSafeInteger(lanes) && Number.isSafeInteger(names) ? { lanes, names } : undefined;
|
|
356
|
+
}
|
|
357
|
+
|
|
318
358
|
export type RunnerKind = 'node-test' | 'vitest' | 'unknown';
|
|
319
359
|
|
|
320
360
|
/**
|
|
@@ -464,10 +504,10 @@ export interface BaselineResult {
|
|
|
464
504
|
* A RED baseline in the scratch copy is a SETUP error, never a mutation result: every subsequent
|
|
465
505
|
* "red under mutation" would be noise, and every "green" a lie about an unrunnable copy.
|
|
466
506
|
*/
|
|
467
|
-
export function classifyBaseline(exitCode: number | null): BaselineResult {
|
|
507
|
+
export function classifyBaseline(exitCode: number | null, runFailureReason?: string): BaselineResult {
|
|
468
508
|
if (exitCode === 0) return { ok: true, detail: 'baseline suite green in the scratch copy' };
|
|
469
509
|
if (exitCode === null) {
|
|
470
|
-
return { ok: false, detail:
|
|
510
|
+
return { ok: false, detail: `baseline suite produced no exit code (${runFailureReason ?? 'unknown timeout/spawn failure'}) — the copy is not runnable; fix the copy, do not read this as a mutation result` };
|
|
471
511
|
}
|
|
472
512
|
return { ok: false, detail: `baseline suite RED (exit ${exitCode}) in the UNMUTATED scratch copy — a broken copy cannot prove anything; fix the copy (node_modules link? path-dependent test?) before trusting any verdict` };
|
|
473
513
|
}
|
|
@@ -489,13 +529,16 @@ export function classifyBaseline(exitCode: number | null): BaselineResult {
|
|
|
489
529
|
* the flaky-rebaseline check (a structurally-broken run needs no
|
|
490
530
|
* attribution analysis);
|
|
491
531
|
* 4. INCONCLUSIVE (no exit) — the run produced nothing to classify at all;
|
|
492
|
-
* 5.
|
|
532
|
+
* 5. RECEIPT_MISMATCH — the harness declared its own receipt contract broken, which
|
|
533
|
+
* invalidates BOTH the green reading and every count-based reading
|
|
534
|
+
* of the same run;
|
|
535
|
+
* 6. UNDEFENDED (exit 0) — the disjoint GREEN arm: mutually exclusive with every red-based
|
|
493
536
|
* verdict below;
|
|
494
|
-
*
|
|
537
|
+
* 7. INCONCLUSIVE (unrecognised output) — red, but the shape is unreadable: counts parsed out of
|
|
495
538
|
* unrecognised output must not reach BELOW_MIN/OVER_FAILING/PROVEN;
|
|
496
|
-
*
|
|
497
|
-
*
|
|
498
|
-
*
|
|
539
|
+
* 8. INCONCLUSIVE (flaky rebaseline) — red, readable, but not attributable;
|
|
540
|
+
* 9. BELOW_MIN → 10. OVER_FAILING — reliable-count contract checks, both failing;
|
|
541
|
+
* 11. PROVEN — applied, red, behavioural, attributable, within bounds.
|
|
499
542
|
*
|
|
500
543
|
* THE DROP DECISION (SPEC §Reporting, decided here + justified): `failing < observed` but still
|
|
501
544
|
* `>= minFailing` is a LOUD WARNING, not a failure. Two reasons, both load-bearing:
|
|
@@ -568,7 +611,18 @@ export function classifyMutationOutcome(obs: MutationObservation): MutationEntry
|
|
|
568
611
|
applied: true,
|
|
569
612
|
verdict: 'INCONCLUSIVE',
|
|
570
613
|
drop: false,
|
|
571
|
-
detail:
|
|
614
|
+
detail: `suite produced NO exit code under the mutation (${obs.runFailureReason ?? 'unknown timeout / spawn failure'}) — inconclusive is a FAILURE, never a pass`,
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
if (obs.receiptMismatch !== undefined) {
|
|
619
|
+
const boundedReceipt = Array.from(obs.receiptMismatch).slice(0, 220).join('');
|
|
620
|
+
return {
|
|
621
|
+
...base,
|
|
622
|
+
applied: true,
|
|
623
|
+
verdict: 'RECEIPT_MISMATCH',
|
|
624
|
+
drop: false,
|
|
625
|
+
detail: `the suite harness declared its own receipt contract violated (${boundedReceipt}) — neither this run's exit code nor its failing count may be read as discrimination; RECEIPT_MISMATCH is a FAILURE, never PROVEN`,
|
|
572
626
|
};
|
|
573
627
|
}
|
|
574
628
|
|
|
@@ -604,7 +658,7 @@ export function classifyMutationOutcome(obs: MutationObservation): MutationEntry
|
|
|
604
658
|
applied: true,
|
|
605
659
|
verdict: 'INCONCLUSIVE',
|
|
606
660
|
drop: false,
|
|
607
|
-
detail: `suite red under the mutation BUT the restored baseline did not reproduce green (${obs.rebaselineExitCode === null ?
|
|
661
|
+
detail: `suite red under the mutation BUT the restored baseline did not reproduce green (${obs.rebaselineExitCode === null ? `no exit code: ${obs.rebaselineFailureReason ?? 'unknown timeout / spawn failure'}` : `exit ${obs.rebaselineExitCode}`}) — the suite is flaky; the redness is not attributable to the protection and may be an unrelated neighbour`,
|
|
608
662
|
};
|
|
609
663
|
}
|
|
610
664
|
|
|
@@ -645,7 +699,7 @@ export function classifyMutationOutcome(obs: MutationObservation): MutationEntry
|
|
|
645
699
|
}
|
|
646
700
|
|
|
647
701
|
/** Verdicts that fail the gate. INCONCLUSIVE and NOT_APPLIED fail (inconclusive ≠ pass). */
|
|
648
|
-
const FAILING_VERDICTS: ReadonlySet<MutationVerdict> = new Set(['UNDEFENDED', 'NOT_APPLIED', 'BELOW_MIN', 'MUTATION_UNPARSEABLE', 'MUTATION_LOAD_FATAL', 'OVER_FAILING', 'INCONCLUSIVE']);
|
|
702
|
+
const FAILING_VERDICTS: ReadonlySet<MutationVerdict> = new Set(['UNDEFENDED', 'RECEIPT_MISMATCH', 'NOT_APPLIED', 'BELOW_MIN', 'MUTATION_UNPARSEABLE', 'MUTATION_LOAD_FATAL', 'OVER_FAILING', 'INCONCLUSIVE']);
|
|
649
703
|
|
|
650
704
|
/** Exit contract: 0 all proven · 1 any entry failed (or red baseline) · (2 = usage/setup, CLI-side). */
|
|
651
705
|
export function mutationGateExitCode(results: readonly MutationEntryResult[], baselineOk: boolean): number {
|
|
@@ -660,6 +714,7 @@ export interface MutationGateSummary {
|
|
|
660
714
|
readonly total: number;
|
|
661
715
|
readonly proven: number;
|
|
662
716
|
readonly undefended: number;
|
|
717
|
+
readonly receiptMismatch: number;
|
|
663
718
|
readonly notApplied: number;
|
|
664
719
|
readonly belowMin: number;
|
|
665
720
|
readonly unparseable: number;
|
|
@@ -678,6 +733,7 @@ export function summarizeMutationResults(results: readonly MutationEntryResult[]
|
|
|
678
733
|
total: results.length,
|
|
679
734
|
proven: results.filter((r) => r.verdict === 'PROVEN').length,
|
|
680
735
|
undefended: results.filter((r) => r.verdict === 'UNDEFENDED').length,
|
|
736
|
+
receiptMismatch: results.filter((r) => r.verdict === 'RECEIPT_MISMATCH').length,
|
|
681
737
|
notApplied: results.filter((r) => r.verdict === 'NOT_APPLIED').length,
|
|
682
738
|
belowMin: results.filter((r) => r.verdict === 'BELOW_MIN').length,
|
|
683
739
|
unparseable: results.filter((r) => r.verdict === 'MUTATION_UNPARSEABLE').length,
|
|
@@ -692,6 +748,7 @@ export function summarizeMutationResults(results: readonly MutationEntryResult[]
|
|
|
692
748
|
const VERDICT_MARK: Record<MutationVerdict, string> = {
|
|
693
749
|
PROVEN: '✓',
|
|
694
750
|
UNDEFENDED: '✗',
|
|
751
|
+
RECEIPT_MISMATCH: '✗',
|
|
695
752
|
NOT_APPLIED: '✗',
|
|
696
753
|
BELOW_MIN: '✗',
|
|
697
754
|
MUTATION_UNPARSEABLE: '✗',
|
|
@@ -715,7 +772,7 @@ export function renderMutationReport(
|
|
|
715
772
|
if (r.verdict !== 'PROVEN' || r.drop) lines.push(` ${r.detail}`);
|
|
716
773
|
}
|
|
717
774
|
const s = summarizeMutationResults(results);
|
|
718
|
-
lines.push(` summary: ${s.proven}/${s.total} proven · ${s.undefended} undefended · ${s.notApplied} not-applied · ${s.belowMin} below-min · ${s.unparseable} unparseable · ${s.loadFatal} load-fatal · ${s.overFailing} over-failing · ${s.inconclusive} inconclusive · ${s.drops} coverage drop(s) among ${s.dropComparable}/${s.total} observed-anchored entries (a drop is undetectable without an \`observed\` anchor)`);
|
|
775
|
+
lines.push(` summary: ${s.proven}/${s.total} proven · ${s.undefended} undefended · ${s.receiptMismatch} receipt-mismatch · ${s.notApplied} not-applied · ${s.belowMin} below-min · ${s.unparseable} unparseable · ${s.loadFatal} load-fatal · ${s.overFailing} over-failing · ${s.inconclusive} inconclusive · ${s.drops} coverage drop(s) among ${s.dropComparable}/${s.total} observed-anchored entries (a drop is undetectable without an \`observed\` anchor)`);
|
|
719
776
|
lines.push(mutationGateExitCode(results, baseline.ok) === 0
|
|
720
777
|
? ' verdict: PASS — every named protection has a test that goes red when the protection is deleted'
|
|
721
778
|
: ' verdict: FAIL — at least one named protection is undefended, unmutable, or unproven');
|
package/src/named-lock.ts
CHANGED
|
@@ -33,7 +33,7 @@ import { createRequire } from 'node:module';
|
|
|
33
33
|
import { join, resolve } from 'node:path';
|
|
34
34
|
import { lockSync } from 'proper-lockfile';
|
|
35
35
|
|
|
36
|
-
import { LOCK_TIMEOUT_MS,
|
|
36
|
+
import { LOCK_TIMEOUT_MS, resolveStaleMs } from './store-lock.js';
|
|
37
37
|
import type { StoreLockOptions } from './store-lock.js';
|
|
38
38
|
|
|
39
39
|
/** `proper-lockfile` silently clamps `stale` up to this minimum. */
|
|
@@ -99,7 +99,7 @@ const sleepSync = (ms: number): void => {
|
|
|
99
99
|
|
|
100
100
|
function resolveOpts(opts: StoreLockOptions): { staleMs: number; timeoutMs: number; pollMs: number } {
|
|
101
101
|
return {
|
|
102
|
-
staleMs: Math.max(opts.staleMs ??
|
|
102
|
+
staleMs: Math.max(opts.staleMs ?? resolveStaleMs(), MIN_STALE_MS),
|
|
103
103
|
timeoutMs: opts.timeoutMs ?? defaultTimeoutMs(),
|
|
104
104
|
pollMs: opts.pollMs ?? 25,
|
|
105
105
|
};
|
|
@@ -206,12 +206,14 @@ function tryAcquire(
|
|
|
206
206
|
// NEVER remove a lock directory we cannot prove is ours — including at process exit.
|
|
207
207
|
return disarmExitRemoval(resourceKey) ? 'stolen' : 'stolen-undisarmed';
|
|
208
208
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
} catch { /* ERELEASED after a compromise — the lock is no longer ours to remove */ }
|
|
209
|
+
// Load-bearing order: remove our marker while the directory still excludes waiters. If we
|
|
210
|
+
// unlock first, a successor can acquire and publish its marker before our cleanup runs.
|
|
212
211
|
try {
|
|
213
212
|
rmSync(ownerMarkerPath(lockPath), { force: true });
|
|
214
213
|
} catch { /* best effort: a stale marker is overwritten by the next holder anyway */ }
|
|
214
|
+
try {
|
|
215
|
+
release();
|
|
216
|
+
} catch { /* ERELEASED after a compromise — the lock is no longer ours to remove */ }
|
|
215
217
|
return 'released';
|
|
216
218
|
},
|
|
217
219
|
};
|
|
@@ -257,9 +259,9 @@ export function withNamedLockSync<T>(
|
|
|
257
259
|
lockPath,
|
|
258
260
|
compromised ??
|
|
259
261
|
new Error(
|
|
260
|
-
'the lock directory no longer carries this holder\u2019s ownership token
|
|
261
|
-
'
|
|
262
|
-
'lock
|
|
262
|
+
'the lock directory no longer carries this holder\u2019s ownership token; a waiter may have broken it ' +
|
|
263
|
+
'as stale, or another holder\u2019s release may have removed it. The current holder left the observed ' +
|
|
264
|
+
'lock untouched; THIS run must be treated as having raced and retried.' +
|
|
263
265
|
(outcome === 'stolen-undisarmed'
|
|
264
266
|
? ' WARNING: proper-lockfile\u2019s exit-time lock removal could NOT be disarmed (its getLocks() accessor ' +
|
|
265
267
|
'is unavailable in this version), so this process may still remove the successor\u2019s lock when it exits.'
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export type NativeDepState = 'usable' | 'absent' | 'unusable';
|
|
5
|
+
|
|
6
|
+
export interface NativeDepVerdict {
|
|
7
|
+
readonly state: NativeDepState;
|
|
8
|
+
readonly pkg: string;
|
|
9
|
+
readonly reason?: string;
|
|
10
|
+
readonly path?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const verdictCache = new Map<string, NativeDepVerdict>();
|
|
14
|
+
const exerciseIds = new WeakMap<Function, string>();
|
|
15
|
+
let nextExerciseId = 1;
|
|
16
|
+
|
|
17
|
+
function exerciseIdentity(exercise: unknown): string {
|
|
18
|
+
if (typeof exercise !== 'function') return `invalid-${typeof exercise}`;
|
|
19
|
+
const existing = exerciseIds.get(exercise);
|
|
20
|
+
if (existing) return existing;
|
|
21
|
+
const identity = `function-${nextExerciseId++}`;
|
|
22
|
+
exerciseIds.set(exercise, identity);
|
|
23
|
+
return identity;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function formatError(error: unknown): string {
|
|
27
|
+
try {
|
|
28
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
29
|
+
const oneLine = message.replace(/\s+/g, ' ').trim() || 'unknown error';
|
|
30
|
+
return oneLine.length > 400 ? `${oneLine.slice(0, 397)}...` : oneLine;
|
|
31
|
+
} catch {
|
|
32
|
+
return 'unknown error';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function exerciseSqliteOpen(mod: unknown): void {
|
|
37
|
+
const defaultExport =
|
|
38
|
+
typeof mod === 'object' && mod !== null && 'default' in mod
|
|
39
|
+
? (mod as { default: unknown }).default
|
|
40
|
+
: mod;
|
|
41
|
+
const Database = defaultExport as new (filename: string) => { close(): void };
|
|
42
|
+
const database = new Database(':memory:');
|
|
43
|
+
database.close();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function probeNativeDep(
|
|
47
|
+
projectRoot: string,
|
|
48
|
+
pkg: string,
|
|
49
|
+
exercise?: (mod: unknown) => unknown,
|
|
50
|
+
exerciseId?: string,
|
|
51
|
+
): NativeDepVerdict {
|
|
52
|
+
let cacheKey: string;
|
|
53
|
+
try {
|
|
54
|
+
const resolvedExerciseId =
|
|
55
|
+
exerciseId ?? (exercise === undefined ? undefined : exerciseIdentity(exercise));
|
|
56
|
+
cacheKey = `${resolve(projectRoot)}::${pkg}::${resolvedExerciseId ?? 'none'}`;
|
|
57
|
+
} catch {
|
|
58
|
+
return { state: 'absent', pkg };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const cached = verdictCache.get(cacheKey);
|
|
62
|
+
if (cached) return cached;
|
|
63
|
+
|
|
64
|
+
let resolvedPath: string;
|
|
65
|
+
let projectRequire: ReturnType<typeof createRequire>;
|
|
66
|
+
try {
|
|
67
|
+
projectRequire = createRequire(join(projectRoot, 'package.json'));
|
|
68
|
+
resolvedPath = projectRequire.resolve(pkg);
|
|
69
|
+
} catch {
|
|
70
|
+
const verdict: NativeDepVerdict = { state: 'absent', pkg };
|
|
71
|
+
verdictCache.set(cacheKey, verdict);
|
|
72
|
+
return verdict;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let verdict: NativeDepVerdict;
|
|
76
|
+
try {
|
|
77
|
+
const mod = projectRequire(resolvedPath);
|
|
78
|
+
const exerciseResult = exercise?.(mod);
|
|
79
|
+
const then =
|
|
80
|
+
exerciseResult !== null &&
|
|
81
|
+
(typeof exerciseResult === 'object' || typeof exerciseResult === 'function')
|
|
82
|
+
? (exerciseResult as { then?: unknown }).then
|
|
83
|
+
: undefined;
|
|
84
|
+
if (typeof then === 'function') {
|
|
85
|
+
void Promise.resolve(exerciseResult).catch(() => {});
|
|
86
|
+
verdict = {
|
|
87
|
+
state: 'unusable',
|
|
88
|
+
pkg,
|
|
89
|
+
reason:
|
|
90
|
+
'exercise returned a promise; probeNativeDep is synchronous and cannot await it',
|
|
91
|
+
path: resolvedPath,
|
|
92
|
+
};
|
|
93
|
+
} else {
|
|
94
|
+
verdict = { state: 'usable', pkg, path: resolvedPath };
|
|
95
|
+
}
|
|
96
|
+
} catch (error) {
|
|
97
|
+
verdict = {
|
|
98
|
+
state: 'unusable',
|
|
99
|
+
pkg,
|
|
100
|
+
reason: formatError(error),
|
|
101
|
+
path: resolvedPath,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
verdictCache.set(cacheKey, verdict);
|
|
106
|
+
return verdict;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function describeNativeDep(verdict: NativeDepVerdict): string {
|
|
110
|
+
if (verdict.state === 'usable') return '';
|
|
111
|
+
if (verdict.state === 'absent') {
|
|
112
|
+
if (verdict.pkg === 'agentdb' || verdict.pkg === 'better-sqlite3') {
|
|
113
|
+
return `${verdict.pkg} not installed in project (run: dz setup --memory agentdb)`;
|
|
114
|
+
}
|
|
115
|
+
return `${verdict.pkg} not installed in project (run: npm i ${verdict.pkg})`;
|
|
116
|
+
}
|
|
117
|
+
return `${verdict.pkg} is installed at ${verdict.path ?? 'an unknown path'}, but its native part will not load on this Node: ${verdict.reason ?? 'unknown error'} (run: npm rebuild ${verdict.pkg})`;
|
|
118
|
+
}
|
package/src/operations.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
type HooksListHookMetadata,
|
|
28
28
|
type ManagedEntry,
|
|
29
29
|
} from './codex-hooks.js';
|
|
30
|
+
import { sweepSkillDrift } from './skill-drift.js';
|
|
30
31
|
import { generateCodexHelpers } from './codex-hooks-assets.js';
|
|
31
32
|
import {
|
|
32
33
|
classifyVetoProbe,
|
|
@@ -997,6 +998,40 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
|
|
|
997
998
|
detail: '.claude/mcp.json is NOT loaded by Claude Code — re-run dz setup to register agentdb in .mcp.json (project root)',
|
|
998
999
|
});
|
|
999
1000
|
}
|
|
1001
|
+
// 8x. agentdb MCP/writer store SEPARATION (ADR-001 agentdb-setup-shared-store-fix, 2026-08-26).
|
|
1002
|
+
// A shared store is a measured data-loss path (2026-07-09: 5 of 20 samples zero bytes, 4 torn) —
|
|
1003
|
+
// the same predicate setup.ts's `agentdb wiring` step uses, imported, never re-implemented.
|
|
1004
|
+
// Deliberately OUTSIDE the `.dz/agentdb-writer.mjs` branch below: a registration can be wrong
|
|
1005
|
+
// before any writer is deployed, and that is exactly when it is cheapest to fix.
|
|
1006
|
+
const mcpConfigPath = join(root, '.mcp.json');
|
|
1007
|
+
if (existsSync(mcpConfigPath)) {
|
|
1008
|
+
try {
|
|
1009
|
+
const { agentdbStoreSeparationProblem } = await import('./setup.js');
|
|
1010
|
+
const mcp = JSON.parse(readFileSync(mcpConfigPath, 'utf-8')) as {
|
|
1011
|
+
mcpServers?: Record<string, { env?: Record<string, string> }>;
|
|
1012
|
+
};
|
|
1013
|
+
const pinned = mcp.mcpServers?.['agentdb']?.env?.['AGENTDB_PATH'];
|
|
1014
|
+
// Only speak when an agentdb server is actually registered — a project without one keeps
|
|
1015
|
+
// its doctor output noise-free, exactly as before this check existed.
|
|
1016
|
+
// A REGISTERED agentdb server with NO AGENTDB_PATH is not a quiet project — it is the
|
|
1017
|
+
// protection DELETED. Without the pin the server resolves its store from its own cwd, which is
|
|
1018
|
+
// exactly the unpinned arrangement that produced an empty orphan store in this repo. The first
|
|
1019
|
+
// version of this check skipped that case, so removing the guard produced no finding at all
|
|
1020
|
+
// (cross-family QE, Codex gpt-5.6-sol). Silence on a deleted protection is the failure mode
|
|
1021
|
+
// this whole feature exists to end.
|
|
1022
|
+
const registered = mcp.mcpServers?.['agentdb'] !== undefined;
|
|
1023
|
+
if (registered && pinned === undefined) {
|
|
1024
|
+
checks.push({
|
|
1025
|
+
name: 'agentdb store separation',
|
|
1026
|
+
ok: false,
|
|
1027
|
+
detail: 'an agentdb MCP server is registered with NO AGENTDB_PATH — it will resolve its store from its own cwd, so nothing guarantees it stays off the session-hook writer store; re-run dz setup --memory agentdb',
|
|
1028
|
+
});
|
|
1029
|
+
} else if (pinned !== undefined) {
|
|
1030
|
+
const problem = agentdbStoreSeparationProblem(root, pinned);
|
|
1031
|
+
if (problem) checks.push({ name: 'agentdb store separation', ok: false, detail: problem });
|
|
1032
|
+
}
|
|
1033
|
+
} catch { /* doctor never throws on a diagnostic */ }
|
|
1034
|
+
}
|
|
1000
1035
|
// Flat dz hook entries (dz ≤0.3.43 emitted `{type,command}` without the matcher-group wrapper)
|
|
1001
1036
|
// are silently IGNORED by Claude Code — the writer never fires. Detect and point at the fix.
|
|
1002
1037
|
try {
|
|
@@ -1097,12 +1132,53 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
|
|
|
1097
1132
|
checks.push({
|
|
1098
1133
|
name: 'agentdb version drift',
|
|
1099
1134
|
ok: false,
|
|
1100
|
-
detail: `local agentdb@${localVer} != MCP pin ${pinned} (alpha schema drift risk
|
|
1135
|
+
detail: `local agentdb@${localVer} != MCP pin ${pinned} (alpha schema drift risk) — re-run dz setup`,
|
|
1101
1136
|
});
|
|
1102
1137
|
}
|
|
1103
1138
|
} catch { /* either side absent — covered by other checks */ }
|
|
1104
1139
|
}
|
|
1105
1140
|
|
|
1141
|
+
// 8a-bis. DEV-TREE SKILL LAG — advisory, never blocking.
|
|
1142
|
+
//
|
|
1143
|
+
// The `no-skill-drift` HARD rule sweeps scope 'installs', which DELIBERATELY excludes the
|
|
1144
|
+
// hand-edited `.claude/skills/` dev tree: those copies are allowed to lag a published bump, and
|
|
1145
|
+
// holding them to byte-identity would make the gate red-on-arrival. That reasoning is sound and
|
|
1146
|
+
// is NOT changed here.
|
|
1147
|
+
//
|
|
1148
|
+
// What was missing is that nobody SAW the lag. MEASURED 2026-08-26: `.claude/skills/idea2prd-manual`
|
|
1149
|
+
// was a month behind its package copy — missing the entire v3 modernization that landed in
|
|
1150
|
+
// 0.1.3/0.1.4 — and `.claude/skills/observability` had never received the 2026-08-24 tightening of
|
|
1151
|
+
// its dashboard gate. Those dev copies are exactly what the agents in THIS repo read, so "legitimately
|
|
1152
|
+
// lagging" quietly meant "the agents here have been running a stale skill for a month". A narrow
|
|
1153
|
+
// gate plus zero visibility is how a lag becomes a silent regression.
|
|
1154
|
+
//
|
|
1155
|
+
// So: report the DIFFERENCE between the wide sweep and the gated one — i.e. only skills whose sole
|
|
1156
|
+
// divergence is the dev tree. Never `ok:false` for a lag alone; the HARD rule keeps owning what
|
|
1157
|
+
// blocks. `dz drift-check --all` remains the detailed view.
|
|
1158
|
+
try {
|
|
1159
|
+
const allowlist = ((): string[] => {
|
|
1160
|
+
const ap = join(root, '.dz', 'drift-allowlist.json');
|
|
1161
|
+
if (!existsSync(ap)) return [];
|
|
1162
|
+
try {
|
|
1163
|
+
const raw = JSON.parse(readFileSync(ap, 'utf8')) as unknown;
|
|
1164
|
+
const arr = Array.isArray(raw) ? raw : Array.isArray((raw as { skills?: unknown })?.skills) ? (raw as { skills: unknown[] }).skills : [];
|
|
1165
|
+
return arr.map((e) => (typeof e === 'string' ? e : (e as { name?: string })?.name)).filter((n): n is string => typeof n === 'string' && n.length > 0);
|
|
1166
|
+
} catch { return []; }
|
|
1167
|
+
})();
|
|
1168
|
+
const wide = sweepSkillDrift(root, { scope: 'all', allowlist });
|
|
1169
|
+
const gated = new Set(sweepSkillDrift(root, { scope: 'installs', allowlist }).drifted.map((d) => d.name));
|
|
1170
|
+
// Only what the HARD rule does NOT already own: these differ ONLY because of the dev tree.
|
|
1171
|
+
const devOnly = wide.drifted.filter((d) => !gated.has(d.name));
|
|
1172
|
+
if (devOnly.length > 0) {
|
|
1173
|
+
const names = devOnly.slice(0, 5).map((d) => `${d.name} (${d.driftFiles} file(s))`).join(', ');
|
|
1174
|
+
checks.push({
|
|
1175
|
+
name: 'dev-tree skill lag',
|
|
1176
|
+
ok: true, // ADVISORY: the gated scope decides what blocks; this only makes the lag visible.
|
|
1177
|
+
detail: `${devOnly.length} skill(s) differ ONLY in the .claude/skills dev tree: ${names}${devOnly.length > 5 ? ', …' : ''} — a dev copy may legitimately lag a published bump, but it is what the agents here READ, so a month-old copy is a silent regression. Inspect with: dz drift-check --all`,
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
} catch { /* doctor never throws on a diagnostic */ }
|
|
1181
|
+
|
|
1106
1182
|
// 8b. EVIDENCE-CHAIN INTEGRITY (feature event-chain, ADR-001). `.dz/recall-usage.jsonl` and
|
|
1107
1183
|
// `.dz/guard-audit.jsonl` are what `dz compounding` and `dz guard promote` decide on; a rewrite
|
|
1108
1184
|
// that loses or duplicates a record there is a wrong verdict with no symptom. Deliberately OUTSIDE
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `feature-adr.js` workflow's PARSER-SAFE REGION — the shared predicate, not a re-implementation.
|
|
3
|
+
*
|
|
4
|
+
* The Workflow runtime's parser is STRICTER than `node --check`: inside the routing block that runs
|
|
5
|
+
* from `const MODELS =` to `const ROUTER =` a template literal is a hard parse error, so no backtick
|
|
6
|
+
* may appear there. That constraint used to be enforced by a `not.toContain('`')` assertion written
|
|
7
|
+
* inline in one test (`feature-adr-model-routing.test.ts:142`), which proves only that the SHIPPED
|
|
8
|
+
* file is currently clean — delete the assertion and nothing goes red.
|
|
9
|
+
*
|
|
10
|
+
* ADR-001 / AM-1 asked for the other half: a guard that is PROVEN to fire on a real violation. Making
|
|
11
|
+
* the predicate a shared export is what buys that — the same function decides the shipped file and the
|
|
12
|
+
* deliberately mutated copy, so a test can inject one backtick into the region and watch it refuse.
|
|
13
|
+
* Deleting the guard now deletes the thing the mutation test calls.
|
|
14
|
+
*
|
|
15
|
+
* Pure and dependency-free: string in, verdict out. No file I/O, no process state.
|
|
16
|
+
*
|
|
17
|
+
* @packageDocumentation
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Opening marker of the parser-safe region (the first line of the inlined routing block). */
|
|
21
|
+
export const PARSER_SAFE_REGION_START = 'const MODELS =';
|
|
22
|
+
/** Closing marker of the parser-safe region (the first declaration after the routing block). */
|
|
23
|
+
export const PARSER_SAFE_REGION_END = 'const ROUTER =';
|
|
24
|
+
|
|
25
|
+
/** Why a source could not be cleared — `null` when the region is clean. */
|
|
26
|
+
export type ParserSafeRegionRefusal =
|
|
27
|
+
/** a marker is missing or the two markers are out of order: the region cannot be located at all */
|
|
28
|
+
| 'region-not-found'
|
|
29
|
+
/** a backtick sits between the two markers — the Workflow parser rejects the file */
|
|
30
|
+
| 'backtick-in-region';
|
|
31
|
+
|
|
32
|
+
export interface ParserSafeRegionVerdict {
|
|
33
|
+
/** true only when the region was LOCATED and contains no backtick. */
|
|
34
|
+
readonly ok: boolean;
|
|
35
|
+
/** the refusal reason, or `null` on a clean verdict. */
|
|
36
|
+
readonly refusal: ParserSafeRegionRefusal | null;
|
|
37
|
+
/** absolute character offset of the first offending backtick, or `null`. */
|
|
38
|
+
readonly index: number | null;
|
|
39
|
+
/** 1-based line number of the first offending backtick, or `null`. */
|
|
40
|
+
readonly line: number | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Decide whether `source` respects the parser-safe region.
|
|
45
|
+
*
|
|
46
|
+
* A source whose markers cannot be located is NOT quietly cleared — an unlocatable region is
|
|
47
|
+
* `region-not-found`, never `ok`. That asymmetry is deliberate: a refactor that moves or renames the
|
|
48
|
+
* markers must be loud, because a silently-skipped check is indistinguishable from a passing one.
|
|
49
|
+
*/
|
|
50
|
+
export function checkParserSafeRegion(source: string): ParserSafeRegionVerdict {
|
|
51
|
+
const start = source.indexOf(PARSER_SAFE_REGION_START);
|
|
52
|
+
const end = source.indexOf(PARSER_SAFE_REGION_END);
|
|
53
|
+
if (start === -1 || end === -1 || end <= start) {
|
|
54
|
+
return { ok: false, refusal: 'region-not-found', index: null, line: null };
|
|
55
|
+
}
|
|
56
|
+
const offset = source.slice(start, end).indexOf('`');
|
|
57
|
+
if (offset === -1) return { ok: true, refusal: null, index: null, line: null };
|
|
58
|
+
const index = start + offset;
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
refusal: 'backtick-in-region',
|
|
62
|
+
index,
|
|
63
|
+
line: source.slice(0, index).split('\n').length,
|
|
64
|
+
};
|
|
65
|
+
}
|