@olegkoval/agent-skills 1.76.0 → 1.78.0

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.
Files changed (34) hide show
  1. package/adapters/claude/olko-github-pr/skills/lekker-review/SKILL.md +3 -1
  2. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/codex-usage.mjs +69 -0
  3. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/eval-runner.mjs +43 -4
  4. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/evidence-bundle.mjs +39 -0
  5. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/git-evidence.mjs +39 -0
  6. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/native-completion.mjs +39 -0
  7. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/revmux-adapter.mjs +1 -26
  8. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/revmux-normalization.mjs +27 -0
  9. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/run-log.mjs +5 -4
  10. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/setup-worktree.sh +51 -6
  11. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/validate-lint-receipt.mjs +33 -0
  12. package/package.json +1 -1
  13. package/plugins/olko-apple-kit/.claude-plugin/plugin.json +1 -1
  14. package/plugins/olko-creative/.claude-plugin/plugin.json +1 -1
  15. package/plugins/olko-garmin-kit/.claude-plugin/plugin.json +1 -1
  16. package/plugins/olko-git-tools/.claude-plugin/plugin.json +1 -1
  17. package/plugins/olko-github-pr/.claude-plugin/plugin.json +1 -1
  18. package/plugins/olko-github-pr/skills/lekker-review/SKILL.md +3 -1
  19. package/plugins/olko-github-pr/skills/lekker-review/scripts/codex-usage.mjs +69 -0
  20. package/plugins/olko-github-pr/skills/lekker-review/scripts/eval-runner.mjs +43 -4
  21. package/plugins/olko-github-pr/skills/lekker-review/scripts/evidence-bundle.mjs +39 -0
  22. package/plugins/olko-github-pr/skills/lekker-review/scripts/git-evidence.mjs +39 -0
  23. package/plugins/olko-github-pr/skills/lekker-review/scripts/native-completion.mjs +39 -0
  24. package/plugins/olko-github-pr/skills/lekker-review/scripts/revmux-adapter.mjs +1 -26
  25. package/plugins/olko-github-pr/skills/lekker-review/scripts/revmux-normalization.mjs +27 -0
  26. package/plugins/olko-github-pr/skills/lekker-review/scripts/run-log.mjs +5 -4
  27. package/plugins/olko-github-pr/skills/lekker-review/scripts/setup-worktree.sh +51 -6
  28. package/plugins/olko-github-pr/skills/lekker-review/scripts/validate-lint-receipt.mjs +33 -0
  29. package/plugins/olko-obsidian/.claude-plugin/plugin.json +1 -1
  30. package/plugins/olko-product/.claude-plugin/plugin.json +1 -1
  31. package/plugins/olko-reflection/.claude-plugin/plugin.json +1 -1
  32. package/plugins/olko-release/.claude-plugin/plugin.json +1 -1
  33. package/plugins/olko-skill-meta/.claude-plugin/plugin.json +1 -1
  34. package/plugins/olko-web-ops/.claude-plugin/plugin.json +1 -1
@@ -511,9 +511,11 @@ ${SKILL_ROOT}/scripts/setup-worktree.sh \
511
511
  On completion, read `worktree.json`. Keys emitted:
512
512
  `worktreePath`, `repoRoot`, `headSha`, `headShaShort`, `tscTail`,
513
513
  `tscChangedTail`, `tscErrorCount`, `eslintTail`, `eslintScope`, `eslintJson`,
514
- `changedFiles`, `baseRef`, `mergeBase`, `projectRules`, `deltaFile`, `notes`,
514
+ `eslintProvenance`, `changedFiles`, `baseRef`, `mergeBase`, `projectRules`, `deltaFile`, `notes`,
515
515
  `depsInstall`, `testRunner`.
516
516
 
517
+ `eslintProvenance` is the provenance-only receipt for `eslintJson` (base/head, tracked-worktree state, exit code, raw report hash).
518
+
517
519
  `testRunner` is `{runner, dirs, reason}` from `scripts/test-runner.mjs`:
518
520
  `runner` is `vitest|jest|mocha|node-test` only when its binary is installed
519
521
  in the worktree, `dirs` lists `{dir, runner}` pairs for each usable package
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env node
2
+ import { createReadStream, readFileSync } from 'node:fs';
3
+ import { createInterface } from 'node:readline';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const FIELDS = ['input_tokens', 'cached_input_tokens', 'cache_write_input_tokens', 'output_tokens', 'reasoning_output_tokens', 'total_tokens'];
7
+ const record = v => v && typeof v === 'object' && !Array.isArray(v);
8
+ const unavailable = (agentPath, reason) => ({ agentPath, status: 'unavailable', reason });
9
+
10
+ async function inspectSession(sessionFile, parentThreadId, agentPath) {
11
+ let sessionId = null, usage = null, complete = false, models = [];
12
+ try {
13
+ const input = createReadStream(sessionFile);
14
+ for await (const line of createInterface({ input, crlfDelay: Infinity })) {
15
+ if (!line.trim()) continue;
16
+ let row;
17
+ try { row = JSON.parse(line); } catch { return unavailable(agentPath, 'malformed JSONL'); }
18
+ if (row.type === 'session_meta') {
19
+ const spawn = row.payload?.source?.subagent?.thread_spawn;
20
+ if (sessionId || typeof row.payload?.id !== 'string' || !row.payload.id) return unavailable(agentPath, 'session metadata missing or duplicated');
21
+ if (spawn?.parent_thread_id !== parentThreadId || spawn?.agent_path !== agentPath) return unavailable(agentPath, 'parent thread or agent path mismatch');
22
+ sessionId = row.payload.id;
23
+ } else if (row.type === 'turn_context' && typeof row.payload?.model === 'string' && row.payload.model && !models.includes(row.payload.model)) {
24
+ models = [...models, row.payload.model];
25
+ } else if (row.type === 'event_msg' && row.payload?.type === 'task_started') complete = false;
26
+ else if (row.type === 'event_msg' && row.payload?.type === 'task_complete') complete = true;
27
+ else if (row.type === 'event_msg' && row.payload?.type === 'token_count') {
28
+ const raw = row.payload.info?.total_token_usage;
29
+ if (!record(raw) || FIELDS.some(k => !Number.isSafeInteger(raw[k]) || raw[k] < 0)) return unavailable(agentPath, 'token counters are malformed');
30
+ const next = Object.fromEntries(FIELDS.map(k => [k, raw[k]]));
31
+ if (usage && FIELDS.some(k => next[k] < usage[k])) return unavailable(agentPath, 'token counters decreased');
32
+ usage = next;
33
+ }
34
+ }
35
+ } catch { return unavailable(agentPath, 'session file could not be read'); }
36
+ if (!sessionId) return unavailable(agentPath, 'session metadata unavailable');
37
+ if (!complete) return unavailable(agentPath, 'task completion unavailable');
38
+ if (!usage) return unavailable(agentPath, 'token counters unavailable');
39
+ return { agentPath, status: 'available', sessionId, models, usage };
40
+ }
41
+
42
+ export async function collectUsage(manifest) {
43
+ if (!record(manifest) || typeof manifest.parentThreadId !== 'string' || !manifest.parentThreadId
44
+ || !Array.isArray(manifest.agents) || !manifest.agents.length
45
+ || manifest.agents.some(a => !record(a) || typeof a.agentPath !== 'string' || !a.agentPath || typeof a.sessionFile !== 'string' || !a.sessionFile)) {
46
+ return { available: false, agents: [], totals: null, reason: 'manifest needs parentThreadId and explicit agentPath/sessionFile entries' };
47
+ }
48
+ const paths = manifest.agents.map(a => a.agentPath), files = manifest.agents.map(a => a.sessionFile);
49
+ if (paths.some((p, i) => paths.indexOf(p) !== i) || files.some((p, i) => files.indexOf(p) !== i)) {
50
+ return { available: false, agents: [], totals: null, reason: 'manifest repeats an agent path or session file' };
51
+ }
52
+ let agents = [];
53
+ for (const agent of manifest.agents) agents = [...agents, await inspectSession(agent.sessionFile, manifest.parentThreadId, agent.agentPath)];
54
+ const available = agents.every(a => a.status !== 'unavailable');
55
+ const totals = available ? Object.fromEntries(FIELDS.map(k => [k, agents.reduce((n, a) => n + a.usage[k], 0)])) : null;
56
+ const safe = totals && FIELDS.every(k => Number.isSafeInteger(totals[k]));
57
+ return { available: Boolean(safe), agents, totals: safe ? totals : null, ...(available && !safe ? { reason: 'aggregate token counters exceed safe integer range' } : {}) };
58
+ }
59
+
60
+ async function main(argv) {
61
+ const i = argv.indexOf('--manifest'), path = i < 0 ? null : argv[i + 1];
62
+ if (!path || path.startsWith('--')) { console.error('usage: codex-usage.mjs --manifest <explicit-sessions.json>'); process.exitCode = 2; return; }
63
+ let result;
64
+ try { result = await collectUsage(JSON.parse(readFileSync(path, 'utf8'))); }
65
+ catch { result = { available: false, agents: [], totals: null, reason: 'manifest could not be read' }; process.exitCode = 2; }
66
+ console.log(JSON.stringify(result));
67
+ }
68
+
69
+ if (process.argv[1] === fileURLToPath(import.meta.url)) main(process.argv.slice(2));
@@ -2,7 +2,7 @@
2
2
  // Score lekker findings against a manifest of PRs with known outcomes.
3
3
  //
4
4
  // Usage:
5
- // node eval-runner.mjs --manifest evals/manifest.json --results <results.json> [--json]
5
+ // node eval-runner.mjs --manifest <manifest.json> (--results <results.json> | --archive <runs-dir>) [--json]
6
6
  //
7
7
  // manifest.json: { prs: [{ repo, pr, expect: "buggy"|"clean", knownBug?: "file:line + description", id?, sha? }] }
8
8
  // The case key is `id` when present, else "<repo>#<pr>@<sha>" when `sha` is
@@ -21,6 +21,7 @@
21
21
  // verdict-affecting finding (critical or important) is a false positive.
22
22
 
23
23
  import { readFileSync } from 'node:fs';
24
+ import { join } from 'node:path';
24
25
  import { fileURLToPath } from 'node:url';
25
26
 
26
27
  export const LINE_WINDOW = 10;
@@ -74,6 +75,7 @@ export function validateManifest(manifest) {
74
75
  return prs;
75
76
  }
76
77
 
78
+ /** Normalize archived review evidence, returning findings only when every identity check passes. */
77
79
  export function normalizeArchivedEvidence(entry, run, worktree, document) {
78
80
  const key = caseKey(entry);
79
81
  const unscored = (reason) => ({ key, reason });
@@ -98,6 +100,7 @@ export function normalizeArchivedEvidence(entry, run, worktree, document) {
98
100
  }
99
101
 
100
102
  // Validate archive references before reading any run directories.
103
+ /** Reject duplicate archive run IDs before archive paths are read. */
101
104
  export function validateArchiveRunIds(entries) {
102
105
  const duplicate = entries.find((entry, index) => entry.runId
103
106
  && entries.slice(0, index).some(previous => previous.runId === entry.runId));
@@ -105,6 +108,30 @@ export function validateArchiveRunIds(entries) {
105
108
  return entries;
106
109
  }
107
110
 
111
+ function readArchiveJson(path) {
112
+ try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; }
113
+ }
114
+
115
+ /** Load valid archived findings and retain a reason for each rejected run. */
116
+ function archivedResults(entries, runsDir) {
117
+ const results = {};
118
+ const reasons = [];
119
+ for (const entry of entries) {
120
+ if (typeof entry.runId !== 'string' || !RUN_ID.test(entry.runId)) {
121
+ reasons.push({ key: caseKey(entry), reason: 'unsafe or missing runId' });
122
+ continue;
123
+ }
124
+ const dir = join(runsDir, entry.runId);
125
+ const evidence = normalizeArchivedEvidence(entry,
126
+ readArchiveJson(join(dir, 'run.json')),
127
+ readArchiveJson(join(dir, 'worktree.json')),
128
+ readArchiveJson(join(dir, 'findings.json')));
129
+ if (evidence.reason) reasons.push({ key: evidence.key, reason: evidence.reason });
130
+ else results[evidence.key] = evidence.findings;
131
+ }
132
+ return { results, reasons };
133
+ }
134
+
108
135
  export function score(manifest, results) {
109
136
  const rows = validateManifest(manifest).map((e) => {
110
137
  const key = caseKey(e);
@@ -147,12 +174,24 @@ export function score(manifest, results) {
147
174
 
148
175
  function main(argv) {
149
176
  const flag = (n) => { const i = argv.indexOf('--' + n); return i === -1 ? null : argv[i + 1]; };
150
- if (!flag('manifest') || !flag('results')) {
151
- console.error('usage: eval-runner.mjs --manifest <manifest.json> --results <results.json> [--json]');
177
+ const results = flag('results');
178
+ const archiveIndex = argv.indexOf('--archive');
179
+ const archive = archiveIndex === -1 ? null : argv[archiveIndex + 1];
180
+ const resultsIndex = argv.indexOf('--results');
181
+ if (!flag('manifest') || (archiveIndex !== -1 && (!archive || archive.startsWith('--')))
182
+ || (resultsIndex !== -1 && (!results || results.startsWith('--')))
183
+ || Boolean(results) === Boolean(archive)) {
184
+ console.error('usage: eval-runner.mjs --manifest <manifest.json> (--results <results.json> | --archive <runs-dir>) [--json]');
152
185
  process.exit(2);
153
186
  }
154
- const out = score(JSON.parse(readFileSync(flag('manifest'), 'utf8')), JSON.parse(readFileSync(flag('results'), 'utf8')));
187
+ const manifest = JSON.parse(readFileSync(flag('manifest'), 'utf8'));
188
+ const source = archive
189
+ ? archivedResults(validateArchiveRunIds(validateManifest(manifest)), archive)
190
+ : { results: JSON.parse(readFileSync(results, 'utf8')), reasons: null };
191
+ const out = score(manifest, source.results);
192
+ if (source.reasons) out.unscoredReasons = source.reasons;
155
193
  if (argv.includes('--json')) { console.log(JSON.stringify(out)); return; }
194
+ for (const { key, reason } of source.reasons || []) console.error(`unscored ${key}: ${reason}`);
156
195
  const pct = (x) => (x === null ? 'n/a' : `${Math.round(x * 100)}%`);
157
196
  if (out.buggyRuns === undefined) {
158
197
  console.log(`recall ${pct(out.recall)} (${out.buggyScored} buggy scored), false positives ${out.falsePositives} (${out.cleanScored} clean scored), unscored ${out.unscored.length}`);
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { collectGitEvidence } from './git-evidence.mjs';
5
+ import { validateLintReceipt } from './validate-lint-receipt.mjs';
6
+
7
+ const record = value => value && typeof value === 'object' && !Array.isArray(value);
8
+ const unavailableLint = reason => ({ captureStatus: 'unavailable', reason });
9
+ const unavailableBundle = reason => ({ schema: 1,
10
+ git: { schema: 1, status: 'unavailable', reason }, lint: unavailableLint(reason) });
11
+
12
+ export function collectEvidence(worktreeMetadata) {
13
+ if (!record(worktreeMetadata)) return unavailableBundle('worktree metadata is missing or malformed');
14
+ const git = collectGitEvidence({ worktreePath: worktreeMetadata.worktreePath,
15
+ mergeBase: worktreeMetadata.mergeBase, headSha: worktreeMetadata.headSha });
16
+ if (git.status !== 'available') return { schema: 1, git, lint: unavailableLint(`Git evidence unavailable: ${git.reason}`) };
17
+ if (typeof worktreeMetadata.eslintJson !== 'string' || !worktreeMetadata.eslintJson) return { schema: 1, git, lint: unavailableLint('lint report path unavailable') };
18
+ if (typeof worktreeMetadata.eslintProvenance !== 'string' || !worktreeMetadata.eslintProvenance) return { schema: 1, git, lint: unavailableLint('lint provenance path unavailable') };
19
+ let reportBytes, receipt;
20
+ try { reportBytes = readFileSync(worktreeMetadata.eslintJson); } catch { return { schema: 1, git, lint: unavailableLint('lint report could not be read') }; }
21
+ try { receipt = JSON.parse(readFileSync(worktreeMetadata.eslintProvenance, 'utf8')); } catch { return { schema: 1, git, lint: unavailableLint('lint provenance is missing or malformed') }; }
22
+ const provenance = validateLintReceipt(receipt, { baseSha: git.provenance.baseSha,
23
+ headSha: git.provenance.headSha, trackedClean: git.provenance.trackedCleanBefore && git.provenance.trackedCleanAfter }, reportBytes);
24
+ if (provenance.status !== 'available') return { schema: 1, git, lint: unavailableLint(provenance.reason) };
25
+ let report;
26
+ try { report = JSON.parse(reportBytes.toString('utf8')); } catch { return { schema: 1, git, lint: unavailableLint('lint report is malformed JSON') }; }
27
+ if (!Array.isArray(report)) return { schema: 1, git, lint: unavailableLint('lint report is not a JSON array') };
28
+ return { schema: 1, git, lint: { captureStatus: 'available', exitCode: provenance.exitCode,
29
+ reportSha256: provenance.reportSha256, report } };
30
+ }
31
+
32
+ function main(argv) {
33
+ const i = argv.indexOf('--worktree'), path = i < 0 ? null : argv[i + 1];
34
+ if (!path || path.startsWith('--')) { console.error('usage: evidence-bundle.mjs --worktree <worktree.json>'); process.exitCode = 2; return; }
35
+ try { console.log(JSON.stringify(collectEvidence(JSON.parse(readFileSync(path, 'utf8'))))); }
36
+ catch { console.log(JSON.stringify(unavailableBundle('worktree metadata could not be read'))); process.exitCode = 2; }
37
+ }
38
+
39
+ if (process.argv[1] === fileURLToPath(import.meta.url)) main(process.argv.slice(2));
@@ -0,0 +1,39 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { TextDecoder } from 'node:util';
4
+ import { riskPaths } from './risk-paths.mjs';
5
+
6
+ const FULL_SHA = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i;
7
+ const DIFF_FLAGS = ['--no-ext-diff', '--no-textconv', '--no-color', '--binary', '--no-renames', '--src-prefix=a/', '--dst-prefix=b/', '--full-index', '--unified=3', '--inter-hunk-context=0', '--diff-algorithm=myers', '--indent-heuristic', '--no-relative', '--ignore-submodules=none', '--submodule=short', '-O/dev/null'];
8
+ const unavailable = reason => ({ schema: 1, status: 'unavailable', reason });
9
+ const git = (cwd, args) => execFileSync('git', ['--no-replace-objects', '-c', 'core.quotePath=true', '-c', 'diff.suppressBlankEmpty=false', '-C', cwd, ...args], { stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024, env: Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_'))) });
10
+ const fullSha = value => typeof value === 'string' && FULL_SHA.test(value);
11
+ const headOf = cwd => git(cwd, ['rev-parse', '--verify', 'HEAD^{commit}']).toString('ascii').trim().toLowerCase();
12
+ const trackedStatus = cwd => git(cwd, ['status', '--porcelain', '--untracked-files=no', '--ignore-submodules=none']);
13
+ const flaggedIndex = cwd => git(cwd, ['ls-files', '-v', '-z']).toString().split('\0').some(row => /^[a-z] |^S /.test(row));
14
+
15
+ export function collectGitEvidence({ worktreePath, mergeBase, headSha } = {}) {
16
+ if (typeof worktreePath !== 'string' || !worktreePath || !fullSha(mergeBase) || !fullSha(headSha)) return unavailable('worktree path or full commit SHA is invalid');
17
+ try {
18
+ const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
19
+ const baseSha = git(worktreePath, ['rev-parse', '--verify', `${mergeBase}^{commit}`]).toString('ascii').trim().toLowerCase();
20
+ if (baseSha !== mergeBase.toLowerCase()) return unavailable('merge base does not resolve to the pinned commit');
21
+ const headBefore = headOf(worktreePath);
22
+ if (headBefore !== headSha.toLowerCase()) return unavailable('worktree HEAD does not match pinned head');
23
+ if (flaggedIndex(worktreePath)) return unavailable('tracked index has assume-unchanged or skip-worktree entries before collection');
24
+ if (trackedStatus(worktreePath).length) return unavailable('tracked worktree is dirty before collection');
25
+ const rawDiff = git(worktreePath, ['diff', ...DIFF_FLAGS, baseSha, headBefore, '--']);
26
+ const rawPaths = git(worktreePath, ['diff', '--name-only', '-z', ...DIFF_FLAGS, baseSha, headBefore, '--']);
27
+ const headAfter = headOf(worktreePath);
28
+ if (headAfter !== headBefore) return unavailable('worktree HEAD changed during collection');
29
+ if (flaggedIndex(worktreePath)) return unavailable('tracked index gained assume-unchanged or skip-worktree entries during collection');
30
+ if (trackedStatus(worktreePath).length) return unavailable('tracked worktree became dirty during collection');
31
+ const changedPaths = rawPaths.toString('latin1').split('\0').filter(Boolean)
32
+ .map(path => decoder.decode(Buffer.from(path, 'latin1'))).sort();
33
+ const text = decoder.decode(rawDiff);
34
+ return { schema: 1, status: 'available', provenance: { baseSha, headSha: headAfter, trackedCleanBefore: true, trackedCleanAfter: true },
35
+ changedPaths, diff: { sha256: createHash('sha256').update(rawDiff).digest('hex'), text }, risk: riskPaths(changedPaths, text) };
36
+ } catch (error) {
37
+ return unavailable(error instanceof TypeError ? 'git evidence is not valid UTF-8' : 'git evidence command failed');
38
+ }
39
+ }
@@ -0,0 +1,39 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ const DIMENSIONS = {
4
+ deps: ['deps'],
5
+ scan: ['triage-quality', 'triage-logic', 'project-rules'],
6
+ medium: ['quality', 'implementation', 'simplification', 'conventions', 'test-quality', 'project-rules'],
7
+ deep: ['quality', 'implementation', 'simplification', 'conventions', 'test-quality', 'project-rules'],
8
+ };
9
+ const SHA = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i;
10
+ const HASH = /^[a-f0-9]{64}$/i;
11
+ const record = value => value && typeof value === 'object' && !Array.isArray(value);
12
+ const count = value => Number.isSafeInteger(value) && value >= 0;
13
+
14
+ /** Validate a native completion receipt against run policy and exact findings bytes. */
15
+ export function validateNativeCompletion(receipt, expected, findingsBytes) {
16
+ const unavailable = reason => ({ available: false, reason });
17
+ if (!record(expected) || !record(expected.eligible) || !record(receipt)) return unavailable('receipt or expected inputs are malformed');
18
+ if (!['deps', 'scan', 'medium', 'deep'].includes(expected.depth) || typeof expected.worktreeAvailable !== 'boolean'
19
+ || !count(expected.eligible.verify) || !count(expected.eligible.prove)) return unavailable('expected policy inputs are malformed');
20
+ if (typeof expected.runId !== 'string' || !expected.runId || typeof expected.headSha !== 'string' || !SHA.test(expected.headSha)) return unavailable('expected identity is malformed');
21
+ if (!(findingsBytes instanceof Uint8Array)) return unavailable('findings bytes are unavailable');
22
+ if (receipt.schema !== 1 || receipt.engine !== 'codex' || receipt.status !== 'complete') return unavailable('native completion is not complete');
23
+ if (receipt.runId !== expected.runId || receipt.depth !== expected.depth) return unavailable('run identity or depth mismatch');
24
+ if (typeof receipt.headSha !== 'string' || !SHA.test(receipt.headSha) || receipt.headSha.toLowerCase() !== expected.headSha.toLowerCase()) return unavailable('head SHA mismatch');
25
+ if (typeof receipt.findingsSha256 !== 'string' || !HASH.test(receipt.findingsSha256)
26
+ || createHash('sha256').update(findingsBytes).digest('hex') !== receipt.findingsSha256.toLowerCase()) return unavailable('findings hash mismatch');
27
+ const required = DIMENSIONS[expected.depth];
28
+ if (!Array.isArray(receipt.dimensions) || receipt.dimensions.length !== required.length) return unavailable('required review dimensions are missing or duplicated');
29
+ const dimensions = Array.from(receipt.dimensions);
30
+ if (dimensions.some(d => !record(d) || !required.includes(d.name) || d.status !== 'completed')) return unavailable('review dimensions are missing, unknown, or incomplete');
31
+ if (new Set(dimensions.map(d => d.name)).size !== required.length) return unavailable('review dimensions are duplicated');
32
+ const verify = expected.depth === 'deps' ? 0 : expected.eligible.verify;
33
+ const prove = ['medium', 'deep'].includes(expected.depth) && expected.worktreeAvailable ? Math.min(5, expected.eligible.prove) : 0;
34
+ const validStage = (stage, scheduled) => record(stage) && count(stage.scheduled) && count(stage.completed)
35
+ && count(stage.failed) && stage.scheduled === scheduled && stage.completed === scheduled && stage.failed === 0;
36
+ if (!record(receipt.stages) || !validStage(receipt.stages.verify, verify) || !validStage(receipt.stages.prove, prove)) return unavailable('verification or proof stage accounting is incomplete');
37
+ if (receipt.stages.critic !== (expected.depth === 'deep' ? 'completed' : 'not_required')) return unavailable('critic stage status does not match depth');
38
+ return { available: true, reason: null };
39
+ }
@@ -5,6 +5,7 @@
5
5
  import { readFileSync } from 'node:fs'
6
6
  import { dirname, resolve } from 'node:path'
7
7
  import { fileURLToPath } from 'node:url'
8
+ import { bugClassOf, extractBadCode, mapSeverity } from './revmux-normalization.mjs'
8
9
 
9
10
  const args = process.argv.slice(2)
10
11
  const reportPath = args[0]
@@ -97,32 +98,6 @@ function extractRule(title) {
97
98
  return m && HARD_RULES.has(m[1]) ? m[1] : undefined
98
99
  }
99
100
 
100
- // revmux reports one location per finding and no class, so the class is derived
101
- // from the rule tag or title and no other instances are claimed.
102
- function bugClassOf(rule, title) {
103
- const words = String(rule || title || '').toLowerCase().match(/[a-z0-9]+/g) || []
104
- return words.slice(0, 6).join('-') || 'unclassified'
105
- }
106
-
107
- function mapSeverity(f) {
108
- if (f.severity === 'critical') return 'critical'
109
- if (f.severity === 'major') return 'important'
110
- if (f.severity === 'minor') {
111
- return (f.lenses || []).includes('lekker-conventions') ? 'idiomatic' : 'observation'
112
- }
113
- return 'observation'
114
- }
115
-
116
- // Pull an inline code snippet out of the body so hardRuleCorroborated has
117
- // something to check against; revmux findings carry no separate badCode field.
118
- function extractBadCode(body) {
119
- const text = String(body || '')
120
- const backtick = /`([^`]+)`/.exec(text)
121
- if (backtick) return backtick[1]
122
- const dashCode = /--\s*(.+)$/.exec(text.split('\n')[0])
123
- return dashCode ? dashCode[1] : ''
124
- }
125
-
126
101
  const findings = []
127
102
  let droppedCount = 0
128
103
  let hardRuleCount = 0
@@ -0,0 +1,27 @@
1
+ // revmux reports one location per finding and no class, so the class is derived
2
+ // from the rule tag or title and no other instances are claimed.
3
+ function bugClassOf(rule, title) {
4
+ const words = String(rule || title || '').toLowerCase().match(/[a-z0-9]+/g) || []
5
+ return words.slice(0, 6).join('-') || 'unclassified'
6
+ }
7
+
8
+ function mapSeverity(f) {
9
+ if (f.severity === 'critical') return 'critical'
10
+ if (f.severity === 'major') return 'important'
11
+ if (f.severity === 'minor') {
12
+ return (f.lenses || []).includes('lekker-conventions') ? 'idiomatic' : 'observation'
13
+ }
14
+ return 'observation'
15
+ }
16
+
17
+ // Pull an inline code snippet out of the body so hardRuleCorroborated has
18
+ // something to check against; revmux findings carry no separate badCode field.
19
+ function extractBadCode(body) {
20
+ const text = String(body || '')
21
+ const backtick = /`([^`]+)`/.exec(text)
22
+ if (backtick) return backtick[1]
23
+ const dashCode = /--\s*(.+)$/.exec(text.split('\n')[0])
24
+ return dashCode ? dashCode[1] : ''
25
+ }
26
+
27
+ export { bugClassOf, extractBadCode, mapSeverity }
@@ -27,7 +27,7 @@
27
27
  // `finish` may be called more than once for one run (after the review, again
28
28
  // after fix mode); every call appends a fresh row and the newest one wins.
29
29
 
30
- import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, copyFileSync, readdirSync, statSync, rmSync } from 'node:fs';
30
+ import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, mkdtempSync, copyFileSync, readdirSync, statSync, rmSync } from 'node:fs';
31
31
  import { homedir } from 'node:os';
32
32
  import { basename, join } from 'node:path';
33
33
 
@@ -152,9 +152,10 @@ if (cmd === 'start') {
152
152
  const target = flag('target');
153
153
  if (!repo || !target) fail('start needs --repo and --target');
154
154
  const startedAt = new Date().toISOString();
155
- const runId = `${startedAt.replace(/[-:]/g, '').slice(0, 15)}-${slug(repo.split('/').pop())}-${slug(target)}`;
156
- const dir = runDir(runId);
157
- mkdirSync(dir, { recursive: true });
155
+ const prefix = `${startedAt.replace(/[-:]/g, '').slice(0, 15)}-${slug(repo.split('/').pop())}-${slug(target)}-`.slice(0, 249);
156
+ mkdirSync(RUNS_DIR, { recursive: true });
157
+ const dir = mkdtempSync(join(RUNS_DIR, prefix));
158
+ const runId = basename(dir);
158
159
  const meta = {
159
160
  schema: 1, runId, startedAt, repo, target,
160
161
  mode: flag('mode', 'pr'), depth: flag('depth'), engine: flag('engine', 'workflow'),
@@ -414,7 +414,9 @@ ESLINT_SCOPE_FILE="/tmp/lekker-eslint-scope-$$.txt"
414
414
  # temp files): lint-findings.mjs turns it into botPredicted findings, so the
415
415
  # review can fix what the bot reviewers would have charged for. Exported by
416
416
  # path as eslintJson.
417
- ESLINT_JSON_FILE="$(dirname "$OUT_JSON")/eslint-report.json"
417
+ ESLINT_ARTIFACT_DIR="$(mktemp -d "$(dirname "$OUT_JSON")/eslint.XXXXXX")"
418
+ ESLINT_JSON_FILE="$ESLINT_ARTIFACT_DIR/eslint-report.json"
419
+ ESLINT_PROVENANCE_FILE="$ESLINT_JSON_FILE.provenance.json"
418
420
  printf '' > "$TSC_TAIL_FILE"
419
421
  printf '' > "$TSC_CHANGED_FILE"
420
422
  printf '0' > "$TSC_COUNT_FILE"
@@ -422,6 +424,37 @@ printf '' > "$ESLINT_TAIL_FILE"
422
424
  printf 'skipped' > "$ESLINT_SCOPE_FILE"
423
425
  printf '' > "$ESLINT_JSON_FILE"
424
426
 
427
+ write_eslint_provenance() {
428
+ node - "$ESLINT_JSON_FILE" "$ESLINT_PROVENANCE_FILE" "$MERGE_BASE_VAL" "$@" <<'NODE' || rm -f "$ESLINT_PROVENANCE_FILE"
429
+ const { createHash } = require('node:crypto')
430
+ const fs = require('node:fs')
431
+ const [reportPath, receiptPath, baseSha, headBefore, headAfter, cleanBefore, cleanAfter, rawExit] = process.argv.slice(2)
432
+ const fullSha = value => /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(value || '')
433
+ let bytes = null, validReport = false
434
+ try {
435
+ bytes = fs.readFileSync(reportPath)
436
+ const report = JSON.parse(bytes.toString('utf8'))
437
+ validReport = bytes.length > 0 && Array.isArray(report) && report.length > 0
438
+ && report.every(file => file && typeof file.filePath === 'string' && file.filePath.trim() && Array.isArray(file.messages))
439
+ } catch {}
440
+ const exitCode = rawExit === '' ? null : Number(rawExit)
441
+ const reasons = []
442
+ if (!fullSha(baseSha)) reasons.push('invalid base SHA')
443
+ if (!fullSha(headBefore) || headBefore !== headAfter) reasons.push('HEAD missing or changed during lint')
444
+ if (cleanBefore !== 'true' || cleanAfter !== 'true') reasons.push('tracked worktree was dirty')
445
+ if (exitCode !== 0 && exitCode !== 1) reasons.push('ESLint did not complete with a result')
446
+ if (!validReport) reasons.push('ESLint report missing or malformed')
447
+ fs.writeFileSync(receiptPath, JSON.stringify({
448
+ schema: 1, status: reasons.length ? 'unavailable' : 'available',
449
+ ...(reasons.length ? { reason: reasons.join('; ') } : {}),
450
+ baseSha: baseSha === 'null' ? null : baseSha,
451
+ headShaBefore: headBefore || null, headShaAfter: headAfter || null,
452
+ trackedCleanBefore: cleanBefore === 'true', trackedCleanAfter: cleanAfter === 'true',
453
+ exitCode, reportSha256: bytes ? createHash('sha256').update(bytes).digest('hex') : null,
454
+ }, null, 2) + '\n')
455
+ NODE
456
+ }
457
+
425
458
  # Render a stylish-style tail from the JSON report. An empty or unparseable
426
459
  # report (eslint crashed, bad config) keeps whatever eslint wrote on stderr
427
460
  # instead of rendering "0 errors" from nothing.
@@ -473,7 +506,7 @@ run_tsc() {
473
506
  }
474
507
 
475
508
  run_eslint() {
476
- local has_config
509
+ local has_config head_before='' head_after='' clean_before=false clean_after=false lint_exit='' tracked_state
477
510
  has_config=false
478
511
  if [[ "$NM_LINKED" == "true" ]]; then
479
512
  (cd "$WORKTREE_PATH" && compgen -G '.eslintrc*' >/dev/null 2>&1) && has_config=true
@@ -486,8 +519,17 @@ run_eslint() {
486
519
  # One run, JSON formatter: the machine-readable report feeds
487
520
  # lint-findings.mjs and the human tail is rendered from the
488
521
  # same report, so the two can never disagree.
489
- (cd "$WORKTREE_PATH" && npx eslint --max-warnings=0 -f json -o "$ESLINT_JSON_FILE" "${ESLINT_TARGETS[@]}" 2> "$ESLINT_TAIL_FILE") || true
490
- render_eslint_tail
522
+ head_before="$(git -C "$WORKTREE_PATH" rev-parse HEAD 2>/dev/null || true)"
523
+ if tracked_state="$(git -C "$WORKTREE_PATH" status --porcelain=v1 --untracked-files=no 2>/dev/null)"; then
524
+ [[ -z "$tracked_state" ]] && clean_before=true
525
+ fi
526
+ lint_exit=0
527
+ (cd "$WORKTREE_PATH" && npx eslint --max-warnings=0 -f json -o "$ESLINT_JSON_FILE" "${ESLINT_TARGETS[@]}" 2> "$ESLINT_TAIL_FILE") || lint_exit=$?
528
+ head_after="$(git -C "$WORKTREE_PATH" rev-parse HEAD 2>/dev/null || true)"
529
+ if tracked_state="$(git -C "$WORKTREE_PATH" status --porcelain=v1 --untracked-files=no 2>/dev/null)"; then
530
+ [[ -z "$tracked_state" ]] && clean_after=true
531
+ fi
532
+ render_eslint_tail || true
491
533
  printf 'changed-files' > "$ESLINT_SCOPE_FILE"
492
534
  else
493
535
  printf 'no-changed-lintable-files\n' > "$ESLINT_TAIL_FILE"
@@ -508,6 +550,7 @@ run_eslint() {
508
550
  printf 'skipped\n' > "$ESLINT_TAIL_FILE"
509
551
  printf 'skipped' > "$ESLINT_SCOPE_FILE"
510
552
  fi
553
+ write_eslint_provenance "$head_before" "$head_after" "$clean_before" "$clean_after" "$lint_exit"
511
554
  }
512
555
 
513
556
  run_tsc &
@@ -582,6 +625,7 @@ python3 - \
582
625
  "$MERGE_BASE_VAL" \
583
626
  "$DEPS_INSTALL_FILE" \
584
627
  "$ESLINT_JSON_FILE" \
628
+ "$ESLINT_PROVENANCE_FILE" \
585
629
  "$TEST_RUNNER_JSON" \
586
630
  "$OUT_JSON" \
587
631
  "$BASE_RULES_FILE" \
@@ -592,8 +636,8 @@ import json, sys, os
592
636
  tsc_file, eslint_file, rules_file, delta_file, notes,
593
637
  changed_files_file, base_ref_val, eslint_scope_file,
594
638
  tsc_changed_file, tsc_count_file, merge_base_val,
595
- deps_install_file, eslint_json_file, test_runner_json, out_json,
596
- base_rules_file, base_rules_state) = sys.argv[1:22]
639
+ deps_install_file, eslint_json_file, eslint_provenance_file, test_runner_json, out_json,
640
+ base_rules_file, base_rules_state) = sys.argv[1:23]
597
641
 
598
642
  def parse_runner(raw):
599
643
  try:
@@ -665,6 +709,7 @@ data = {
665
709
  # no structured report (skipped, no config, crashed, or the repo-wide
666
710
  # fallback), so a consumer can tell "no lint report" from "no problems".
667
711
  "eslintJson": eslint_json_file if file_has_content(eslint_json_file) else None,
712
+ "eslintProvenance": eslint_provenance_file if file_has_content(eslint_provenance_file) else None,
668
713
  }
669
714
 
670
715
  for p in (tsc_file, eslint_file, rules_file, base_rules_file, changed_files_file,
@@ -0,0 +1,33 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ const FULL_SHA = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i
4
+ const HASH = /^[0-9a-f]{64}$/i
5
+ const unavailable = reason => ({ status: 'unavailable', reason })
6
+
7
+ export function validateLintReceipt(receipt, expected, reportBytes) {
8
+ if (!receipt || typeof receipt !== 'object' || Array.isArray(receipt)) return unavailable('receipt missing or malformed')
9
+ if (!expected || typeof expected !== 'object' || Array.isArray(expected)) return unavailable('expected provenance missing')
10
+ if (!(reportBytes instanceof Uint8Array)) return unavailable('raw lint report bytes unavailable')
11
+ if (typeof expected.baseSha !== 'string' || !FULL_SHA.test(expected.baseSha)
12
+ || typeof expected.headSha !== 'string' || !FULL_SHA.test(expected.headSha)) return unavailable('expected base/head must be full SHAs')
13
+ if (expected.trackedClean !== true) return unavailable('current tracked worktree is not clean')
14
+ if (receipt.schema !== 1) return unavailable('unsupported receipt schema')
15
+ if (receipt.status !== 'available') return unavailable(typeof receipt.reason === 'string' ? receipt.reason : 'receipt status is unavailable')
16
+ if (typeof receipt.baseSha !== 'string' || !FULL_SHA.test(receipt.baseSha)
17
+ || receipt.baseSha.toLowerCase() !== expected.baseSha.toLowerCase()) return unavailable('base SHA mismatch')
18
+ if (typeof receipt.headShaBefore !== 'string' || !FULL_SHA.test(receipt.headShaBefore)
19
+ || typeof receipt.headShaAfter !== 'string' || !FULL_SHA.test(receipt.headShaAfter)
20
+ || receipt.headShaBefore.toLowerCase() !== receipt.headShaAfter.toLowerCase()
21
+ || receipt.headShaBefore.toLowerCase() !== expected.headSha.toLowerCase()) return unavailable('head SHA mismatch or drift')
22
+ if (receipt.trackedCleanBefore !== true || receipt.trackedCleanAfter !== true) return unavailable('captured tracked worktree was not clean')
23
+ if (receipt.exitCode !== 0 && receipt.exitCode !== 1) return unavailable('invalid ESLint exit code')
24
+ if (typeof receipt.reportSha256 !== 'string' || !HASH.test(receipt.reportSha256)) return unavailable('report SHA-256 missing or malformed')
25
+ const reportSha256 = createHash('sha256').update(reportBytes).digest('hex')
26
+ if (reportSha256 !== receipt.reportSha256.toLowerCase()) return unavailable('lint report hash mismatch')
27
+ return {
28
+ status: 'available',
29
+ baseSha: receipt.baseSha, headSha: receipt.headShaBefore,
30
+ exitCode: receipt.exitCode, reportSha256,
31
+ trackedCleanBefore: receipt.trackedCleanBefore, trackedCleanAfter: receipt.trackedCleanAfter,
32
+ }
33
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olegkoval/agent-skills",
3
- "version": "1.76.0",
3
+ "version": "1.78.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "olko-apple-kit",
3
3
  "description": "Build and ship Apple platform apps: macOS menubar apps, App Store submissions.",
4
- "version": "1.76.0",
4
+ "version": "1.78.0",
5
5
  "author": {
6
6
  "name": "Oleg Koval"
7
7
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "olko-creative",
3
3
  "description": "Creative and personal projects: photo galleries, music players, listings, wiki editing.",
4
- "version": "1.76.0",
4
+ "version": "1.78.0",
5
5
  "author": {
6
6
  "name": "Oleg Koval"
7
7
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "olko-garmin-kit",
3
3
  "description": "Build, test and publish Garmin Connect IQ watch faces.",
4
- "version": "1.76.0",
4
+ "version": "1.78.0",
5
5
  "author": {
6
6
  "name": "Oleg Koval"
7
7
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "olko-git-tools",
3
3
  "description": "Everyday git and GitHub CLI operations: conventional commits, branch hygiene.",
4
- "version": "1.76.0",
4
+ "version": "1.78.0",
5
5
  "author": {
6
6
  "name": "Oleg Koval"
7
7
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "olko-github-pr",
3
3
  "description": "Drive GitHub pull requests to merge-ready: review-bot loops, CI fixes, descriptions, dependency triage.",
4
- "version": "1.76.0",
4
+ "version": "1.78.0",
5
5
  "author": {
6
6
  "name": "Oleg Koval"
7
7
  },
@@ -510,9 +510,11 @@ ${SKILL_ROOT}/scripts/setup-worktree.sh \
510
510
  On completion, read `worktree.json`. Keys emitted:
511
511
  `worktreePath`, `repoRoot`, `headSha`, `headShaShort`, `tscTail`,
512
512
  `tscChangedTail`, `tscErrorCount`, `eslintTail`, `eslintScope`, `eslintJson`,
513
- `changedFiles`, `baseRef`, `mergeBase`, `projectRules`, `deltaFile`, `notes`,
513
+ `eslintProvenance`, `changedFiles`, `baseRef`, `mergeBase`, `projectRules`, `deltaFile`, `notes`,
514
514
  `depsInstall`, `testRunner`.
515
515
 
516
+ `eslintProvenance` is the provenance-only receipt for `eslintJson` (base/head, tracked-worktree state, exit code, raw report hash).
517
+
516
518
  `testRunner` is `{runner, dirs, reason}` from `scripts/test-runner.mjs`:
517
519
  `runner` is `vitest|jest|mocha|node-test` only when its binary is installed
518
520
  in the worktree, `dirs` lists `{dir, runner}` pairs for each usable package
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env node
2
+ import { createReadStream, readFileSync } from 'node:fs';
3
+ import { createInterface } from 'node:readline';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const FIELDS = ['input_tokens', 'cached_input_tokens', 'cache_write_input_tokens', 'output_tokens', 'reasoning_output_tokens', 'total_tokens'];
7
+ const record = v => v && typeof v === 'object' && !Array.isArray(v);
8
+ const unavailable = (agentPath, reason) => ({ agentPath, status: 'unavailable', reason });
9
+
10
+ async function inspectSession(sessionFile, parentThreadId, agentPath) {
11
+ let sessionId = null, usage = null, complete = false, models = [];
12
+ try {
13
+ const input = createReadStream(sessionFile);
14
+ for await (const line of createInterface({ input, crlfDelay: Infinity })) {
15
+ if (!line.trim()) continue;
16
+ let row;
17
+ try { row = JSON.parse(line); } catch { return unavailable(agentPath, 'malformed JSONL'); }
18
+ if (row.type === 'session_meta') {
19
+ const spawn = row.payload?.source?.subagent?.thread_spawn;
20
+ if (sessionId || typeof row.payload?.id !== 'string' || !row.payload.id) return unavailable(agentPath, 'session metadata missing or duplicated');
21
+ if (spawn?.parent_thread_id !== parentThreadId || spawn?.agent_path !== agentPath) return unavailable(agentPath, 'parent thread or agent path mismatch');
22
+ sessionId = row.payload.id;
23
+ } else if (row.type === 'turn_context' && typeof row.payload?.model === 'string' && row.payload.model && !models.includes(row.payload.model)) {
24
+ models = [...models, row.payload.model];
25
+ } else if (row.type === 'event_msg' && row.payload?.type === 'task_started') complete = false;
26
+ else if (row.type === 'event_msg' && row.payload?.type === 'task_complete') complete = true;
27
+ else if (row.type === 'event_msg' && row.payload?.type === 'token_count') {
28
+ const raw = row.payload.info?.total_token_usage;
29
+ if (!record(raw) || FIELDS.some(k => !Number.isSafeInteger(raw[k]) || raw[k] < 0)) return unavailable(agentPath, 'token counters are malformed');
30
+ const next = Object.fromEntries(FIELDS.map(k => [k, raw[k]]));
31
+ if (usage && FIELDS.some(k => next[k] < usage[k])) return unavailable(agentPath, 'token counters decreased');
32
+ usage = next;
33
+ }
34
+ }
35
+ } catch { return unavailable(agentPath, 'session file could not be read'); }
36
+ if (!sessionId) return unavailable(agentPath, 'session metadata unavailable');
37
+ if (!complete) return unavailable(agentPath, 'task completion unavailable');
38
+ if (!usage) return unavailable(agentPath, 'token counters unavailable');
39
+ return { agentPath, status: 'available', sessionId, models, usage };
40
+ }
41
+
42
+ export async function collectUsage(manifest) {
43
+ if (!record(manifest) || typeof manifest.parentThreadId !== 'string' || !manifest.parentThreadId
44
+ || !Array.isArray(manifest.agents) || !manifest.agents.length
45
+ || manifest.agents.some(a => !record(a) || typeof a.agentPath !== 'string' || !a.agentPath || typeof a.sessionFile !== 'string' || !a.sessionFile)) {
46
+ return { available: false, agents: [], totals: null, reason: 'manifest needs parentThreadId and explicit agentPath/sessionFile entries' };
47
+ }
48
+ const paths = manifest.agents.map(a => a.agentPath), files = manifest.agents.map(a => a.sessionFile);
49
+ if (paths.some((p, i) => paths.indexOf(p) !== i) || files.some((p, i) => files.indexOf(p) !== i)) {
50
+ return { available: false, agents: [], totals: null, reason: 'manifest repeats an agent path or session file' };
51
+ }
52
+ let agents = [];
53
+ for (const agent of manifest.agents) agents = [...agents, await inspectSession(agent.sessionFile, manifest.parentThreadId, agent.agentPath)];
54
+ const available = agents.every(a => a.status !== 'unavailable');
55
+ const totals = available ? Object.fromEntries(FIELDS.map(k => [k, agents.reduce((n, a) => n + a.usage[k], 0)])) : null;
56
+ const safe = totals && FIELDS.every(k => Number.isSafeInteger(totals[k]));
57
+ return { available: Boolean(safe), agents, totals: safe ? totals : null, ...(available && !safe ? { reason: 'aggregate token counters exceed safe integer range' } : {}) };
58
+ }
59
+
60
+ async function main(argv) {
61
+ const i = argv.indexOf('--manifest'), path = i < 0 ? null : argv[i + 1];
62
+ if (!path || path.startsWith('--')) { console.error('usage: codex-usage.mjs --manifest <explicit-sessions.json>'); process.exitCode = 2; return; }
63
+ let result;
64
+ try { result = await collectUsage(JSON.parse(readFileSync(path, 'utf8'))); }
65
+ catch { result = { available: false, agents: [], totals: null, reason: 'manifest could not be read' }; process.exitCode = 2; }
66
+ console.log(JSON.stringify(result));
67
+ }
68
+
69
+ if (process.argv[1] === fileURLToPath(import.meta.url)) main(process.argv.slice(2));
@@ -2,7 +2,7 @@
2
2
  // Score lekker findings against a manifest of PRs with known outcomes.
3
3
  //
4
4
  // Usage:
5
- // node eval-runner.mjs --manifest evals/manifest.json --results <results.json> [--json]
5
+ // node eval-runner.mjs --manifest <manifest.json> (--results <results.json> | --archive <runs-dir>) [--json]
6
6
  //
7
7
  // manifest.json: { prs: [{ repo, pr, expect: "buggy"|"clean", knownBug?: "file:line + description", id?, sha? }] }
8
8
  // The case key is `id` when present, else "<repo>#<pr>@<sha>" when `sha` is
@@ -21,6 +21,7 @@
21
21
  // verdict-affecting finding (critical or important) is a false positive.
22
22
 
23
23
  import { readFileSync } from 'node:fs';
24
+ import { join } from 'node:path';
24
25
  import { fileURLToPath } from 'node:url';
25
26
 
26
27
  export const LINE_WINDOW = 10;
@@ -74,6 +75,7 @@ export function validateManifest(manifest) {
74
75
  return prs;
75
76
  }
76
77
 
78
+ /** Normalize archived review evidence, returning findings only when every identity check passes. */
77
79
  export function normalizeArchivedEvidence(entry, run, worktree, document) {
78
80
  const key = caseKey(entry);
79
81
  const unscored = (reason) => ({ key, reason });
@@ -98,6 +100,7 @@ export function normalizeArchivedEvidence(entry, run, worktree, document) {
98
100
  }
99
101
 
100
102
  // Validate archive references before reading any run directories.
103
+ /** Reject duplicate archive run IDs before archive paths are read. */
101
104
  export function validateArchiveRunIds(entries) {
102
105
  const duplicate = entries.find((entry, index) => entry.runId
103
106
  && entries.slice(0, index).some(previous => previous.runId === entry.runId));
@@ -105,6 +108,30 @@ export function validateArchiveRunIds(entries) {
105
108
  return entries;
106
109
  }
107
110
 
111
+ function readArchiveJson(path) {
112
+ try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; }
113
+ }
114
+
115
+ /** Load valid archived findings and retain a reason for each rejected run. */
116
+ function archivedResults(entries, runsDir) {
117
+ const results = {};
118
+ const reasons = [];
119
+ for (const entry of entries) {
120
+ if (typeof entry.runId !== 'string' || !RUN_ID.test(entry.runId)) {
121
+ reasons.push({ key: caseKey(entry), reason: 'unsafe or missing runId' });
122
+ continue;
123
+ }
124
+ const dir = join(runsDir, entry.runId);
125
+ const evidence = normalizeArchivedEvidence(entry,
126
+ readArchiveJson(join(dir, 'run.json')),
127
+ readArchiveJson(join(dir, 'worktree.json')),
128
+ readArchiveJson(join(dir, 'findings.json')));
129
+ if (evidence.reason) reasons.push({ key: evidence.key, reason: evidence.reason });
130
+ else results[evidence.key] = evidence.findings;
131
+ }
132
+ return { results, reasons };
133
+ }
134
+
108
135
  export function score(manifest, results) {
109
136
  const rows = validateManifest(manifest).map((e) => {
110
137
  const key = caseKey(e);
@@ -147,12 +174,24 @@ export function score(manifest, results) {
147
174
 
148
175
  function main(argv) {
149
176
  const flag = (n) => { const i = argv.indexOf('--' + n); return i === -1 ? null : argv[i + 1]; };
150
- if (!flag('manifest') || !flag('results')) {
151
- console.error('usage: eval-runner.mjs --manifest <manifest.json> --results <results.json> [--json]');
177
+ const results = flag('results');
178
+ const archiveIndex = argv.indexOf('--archive');
179
+ const archive = archiveIndex === -1 ? null : argv[archiveIndex + 1];
180
+ const resultsIndex = argv.indexOf('--results');
181
+ if (!flag('manifest') || (archiveIndex !== -1 && (!archive || archive.startsWith('--')))
182
+ || (resultsIndex !== -1 && (!results || results.startsWith('--')))
183
+ || Boolean(results) === Boolean(archive)) {
184
+ console.error('usage: eval-runner.mjs --manifest <manifest.json> (--results <results.json> | --archive <runs-dir>) [--json]');
152
185
  process.exit(2);
153
186
  }
154
- const out = score(JSON.parse(readFileSync(flag('manifest'), 'utf8')), JSON.parse(readFileSync(flag('results'), 'utf8')));
187
+ const manifest = JSON.parse(readFileSync(flag('manifest'), 'utf8'));
188
+ const source = archive
189
+ ? archivedResults(validateArchiveRunIds(validateManifest(manifest)), archive)
190
+ : { results: JSON.parse(readFileSync(results, 'utf8')), reasons: null };
191
+ const out = score(manifest, source.results);
192
+ if (source.reasons) out.unscoredReasons = source.reasons;
155
193
  if (argv.includes('--json')) { console.log(JSON.stringify(out)); return; }
194
+ for (const { key, reason } of source.reasons || []) console.error(`unscored ${key}: ${reason}`);
156
195
  const pct = (x) => (x === null ? 'n/a' : `${Math.round(x * 100)}%`);
157
196
  if (out.buggyRuns === undefined) {
158
197
  console.log(`recall ${pct(out.recall)} (${out.buggyScored} buggy scored), false positives ${out.falsePositives} (${out.cleanScored} clean scored), unscored ${out.unscored.length}`);
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { collectGitEvidence } from './git-evidence.mjs';
5
+ import { validateLintReceipt } from './validate-lint-receipt.mjs';
6
+
7
+ const record = value => value && typeof value === 'object' && !Array.isArray(value);
8
+ const unavailableLint = reason => ({ captureStatus: 'unavailable', reason });
9
+ const unavailableBundle = reason => ({ schema: 1,
10
+ git: { schema: 1, status: 'unavailable', reason }, lint: unavailableLint(reason) });
11
+
12
+ export function collectEvidence(worktreeMetadata) {
13
+ if (!record(worktreeMetadata)) return unavailableBundle('worktree metadata is missing or malformed');
14
+ const git = collectGitEvidence({ worktreePath: worktreeMetadata.worktreePath,
15
+ mergeBase: worktreeMetadata.mergeBase, headSha: worktreeMetadata.headSha });
16
+ if (git.status !== 'available') return { schema: 1, git, lint: unavailableLint(`Git evidence unavailable: ${git.reason}`) };
17
+ if (typeof worktreeMetadata.eslintJson !== 'string' || !worktreeMetadata.eslintJson) return { schema: 1, git, lint: unavailableLint('lint report path unavailable') };
18
+ if (typeof worktreeMetadata.eslintProvenance !== 'string' || !worktreeMetadata.eslintProvenance) return { schema: 1, git, lint: unavailableLint('lint provenance path unavailable') };
19
+ let reportBytes, receipt;
20
+ try { reportBytes = readFileSync(worktreeMetadata.eslintJson); } catch { return { schema: 1, git, lint: unavailableLint('lint report could not be read') }; }
21
+ try { receipt = JSON.parse(readFileSync(worktreeMetadata.eslintProvenance, 'utf8')); } catch { return { schema: 1, git, lint: unavailableLint('lint provenance is missing or malformed') }; }
22
+ const provenance = validateLintReceipt(receipt, { baseSha: git.provenance.baseSha,
23
+ headSha: git.provenance.headSha, trackedClean: git.provenance.trackedCleanBefore && git.provenance.trackedCleanAfter }, reportBytes);
24
+ if (provenance.status !== 'available') return { schema: 1, git, lint: unavailableLint(provenance.reason) };
25
+ let report;
26
+ try { report = JSON.parse(reportBytes.toString('utf8')); } catch { return { schema: 1, git, lint: unavailableLint('lint report is malformed JSON') }; }
27
+ if (!Array.isArray(report)) return { schema: 1, git, lint: unavailableLint('lint report is not a JSON array') };
28
+ return { schema: 1, git, lint: { captureStatus: 'available', exitCode: provenance.exitCode,
29
+ reportSha256: provenance.reportSha256, report } };
30
+ }
31
+
32
+ function main(argv) {
33
+ const i = argv.indexOf('--worktree'), path = i < 0 ? null : argv[i + 1];
34
+ if (!path || path.startsWith('--')) { console.error('usage: evidence-bundle.mjs --worktree <worktree.json>'); process.exitCode = 2; return; }
35
+ try { console.log(JSON.stringify(collectEvidence(JSON.parse(readFileSync(path, 'utf8'))))); }
36
+ catch { console.log(JSON.stringify(unavailableBundle('worktree metadata could not be read'))); process.exitCode = 2; }
37
+ }
38
+
39
+ if (process.argv[1] === fileURLToPath(import.meta.url)) main(process.argv.slice(2));
@@ -0,0 +1,39 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { TextDecoder } from 'node:util';
4
+ import { riskPaths } from './risk-paths.mjs';
5
+
6
+ const FULL_SHA = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i;
7
+ const DIFF_FLAGS = ['--no-ext-diff', '--no-textconv', '--no-color', '--binary', '--no-renames', '--src-prefix=a/', '--dst-prefix=b/', '--full-index', '--unified=3', '--inter-hunk-context=0', '--diff-algorithm=myers', '--indent-heuristic', '--no-relative', '--ignore-submodules=none', '--submodule=short', '-O/dev/null'];
8
+ const unavailable = reason => ({ schema: 1, status: 'unavailable', reason });
9
+ const git = (cwd, args) => execFileSync('git', ['--no-replace-objects', '-c', 'core.quotePath=true', '-c', 'diff.suppressBlankEmpty=false', '-C', cwd, ...args], { stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024, env: Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_'))) });
10
+ const fullSha = value => typeof value === 'string' && FULL_SHA.test(value);
11
+ const headOf = cwd => git(cwd, ['rev-parse', '--verify', 'HEAD^{commit}']).toString('ascii').trim().toLowerCase();
12
+ const trackedStatus = cwd => git(cwd, ['status', '--porcelain', '--untracked-files=no', '--ignore-submodules=none']);
13
+ const flaggedIndex = cwd => git(cwd, ['ls-files', '-v', '-z']).toString().split('\0').some(row => /^[a-z] |^S /.test(row));
14
+
15
+ export function collectGitEvidence({ worktreePath, mergeBase, headSha } = {}) {
16
+ if (typeof worktreePath !== 'string' || !worktreePath || !fullSha(mergeBase) || !fullSha(headSha)) return unavailable('worktree path or full commit SHA is invalid');
17
+ try {
18
+ const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
19
+ const baseSha = git(worktreePath, ['rev-parse', '--verify', `${mergeBase}^{commit}`]).toString('ascii').trim().toLowerCase();
20
+ if (baseSha !== mergeBase.toLowerCase()) return unavailable('merge base does not resolve to the pinned commit');
21
+ const headBefore = headOf(worktreePath);
22
+ if (headBefore !== headSha.toLowerCase()) return unavailable('worktree HEAD does not match pinned head');
23
+ if (flaggedIndex(worktreePath)) return unavailable('tracked index has assume-unchanged or skip-worktree entries before collection');
24
+ if (trackedStatus(worktreePath).length) return unavailable('tracked worktree is dirty before collection');
25
+ const rawDiff = git(worktreePath, ['diff', ...DIFF_FLAGS, baseSha, headBefore, '--']);
26
+ const rawPaths = git(worktreePath, ['diff', '--name-only', '-z', ...DIFF_FLAGS, baseSha, headBefore, '--']);
27
+ const headAfter = headOf(worktreePath);
28
+ if (headAfter !== headBefore) return unavailable('worktree HEAD changed during collection');
29
+ if (flaggedIndex(worktreePath)) return unavailable('tracked index gained assume-unchanged or skip-worktree entries during collection');
30
+ if (trackedStatus(worktreePath).length) return unavailable('tracked worktree became dirty during collection');
31
+ const changedPaths = rawPaths.toString('latin1').split('\0').filter(Boolean)
32
+ .map(path => decoder.decode(Buffer.from(path, 'latin1'))).sort();
33
+ const text = decoder.decode(rawDiff);
34
+ return { schema: 1, status: 'available', provenance: { baseSha, headSha: headAfter, trackedCleanBefore: true, trackedCleanAfter: true },
35
+ changedPaths, diff: { sha256: createHash('sha256').update(rawDiff).digest('hex'), text }, risk: riskPaths(changedPaths, text) };
36
+ } catch (error) {
37
+ return unavailable(error instanceof TypeError ? 'git evidence is not valid UTF-8' : 'git evidence command failed');
38
+ }
39
+ }
@@ -0,0 +1,39 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ const DIMENSIONS = {
4
+ deps: ['deps'],
5
+ scan: ['triage-quality', 'triage-logic', 'project-rules'],
6
+ medium: ['quality', 'implementation', 'simplification', 'conventions', 'test-quality', 'project-rules'],
7
+ deep: ['quality', 'implementation', 'simplification', 'conventions', 'test-quality', 'project-rules'],
8
+ };
9
+ const SHA = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i;
10
+ const HASH = /^[a-f0-9]{64}$/i;
11
+ const record = value => value && typeof value === 'object' && !Array.isArray(value);
12
+ const count = value => Number.isSafeInteger(value) && value >= 0;
13
+
14
+ /** Validate a native completion receipt against run policy and exact findings bytes. */
15
+ export function validateNativeCompletion(receipt, expected, findingsBytes) {
16
+ const unavailable = reason => ({ available: false, reason });
17
+ if (!record(expected) || !record(expected.eligible) || !record(receipt)) return unavailable('receipt or expected inputs are malformed');
18
+ if (!['deps', 'scan', 'medium', 'deep'].includes(expected.depth) || typeof expected.worktreeAvailable !== 'boolean'
19
+ || !count(expected.eligible.verify) || !count(expected.eligible.prove)) return unavailable('expected policy inputs are malformed');
20
+ if (typeof expected.runId !== 'string' || !expected.runId || typeof expected.headSha !== 'string' || !SHA.test(expected.headSha)) return unavailable('expected identity is malformed');
21
+ if (!(findingsBytes instanceof Uint8Array)) return unavailable('findings bytes are unavailable');
22
+ if (receipt.schema !== 1 || receipt.engine !== 'codex' || receipt.status !== 'complete') return unavailable('native completion is not complete');
23
+ if (receipt.runId !== expected.runId || receipt.depth !== expected.depth) return unavailable('run identity or depth mismatch');
24
+ if (typeof receipt.headSha !== 'string' || !SHA.test(receipt.headSha) || receipt.headSha.toLowerCase() !== expected.headSha.toLowerCase()) return unavailable('head SHA mismatch');
25
+ if (typeof receipt.findingsSha256 !== 'string' || !HASH.test(receipt.findingsSha256)
26
+ || createHash('sha256').update(findingsBytes).digest('hex') !== receipt.findingsSha256.toLowerCase()) return unavailable('findings hash mismatch');
27
+ const required = DIMENSIONS[expected.depth];
28
+ if (!Array.isArray(receipt.dimensions) || receipt.dimensions.length !== required.length) return unavailable('required review dimensions are missing or duplicated');
29
+ const dimensions = Array.from(receipt.dimensions);
30
+ if (dimensions.some(d => !record(d) || !required.includes(d.name) || d.status !== 'completed')) return unavailable('review dimensions are missing, unknown, or incomplete');
31
+ if (new Set(dimensions.map(d => d.name)).size !== required.length) return unavailable('review dimensions are duplicated');
32
+ const verify = expected.depth === 'deps' ? 0 : expected.eligible.verify;
33
+ const prove = ['medium', 'deep'].includes(expected.depth) && expected.worktreeAvailable ? Math.min(5, expected.eligible.prove) : 0;
34
+ const validStage = (stage, scheduled) => record(stage) && count(stage.scheduled) && count(stage.completed)
35
+ && count(stage.failed) && stage.scheduled === scheduled && stage.completed === scheduled && stage.failed === 0;
36
+ if (!record(receipt.stages) || !validStage(receipt.stages.verify, verify) || !validStage(receipt.stages.prove, prove)) return unavailable('verification or proof stage accounting is incomplete');
37
+ if (receipt.stages.critic !== (expected.depth === 'deep' ? 'completed' : 'not_required')) return unavailable('critic stage status does not match depth');
38
+ return { available: true, reason: null };
39
+ }
@@ -5,6 +5,7 @@
5
5
  import { readFileSync } from 'node:fs'
6
6
  import { dirname, resolve } from 'node:path'
7
7
  import { fileURLToPath } from 'node:url'
8
+ import { bugClassOf, extractBadCode, mapSeverity } from './revmux-normalization.mjs'
8
9
 
9
10
  const args = process.argv.slice(2)
10
11
  const reportPath = args[0]
@@ -97,32 +98,6 @@ function extractRule(title) {
97
98
  return m && HARD_RULES.has(m[1]) ? m[1] : undefined
98
99
  }
99
100
 
100
- // revmux reports one location per finding and no class, so the class is derived
101
- // from the rule tag or title and no other instances are claimed.
102
- function bugClassOf(rule, title) {
103
- const words = String(rule || title || '').toLowerCase().match(/[a-z0-9]+/g) || []
104
- return words.slice(0, 6).join('-') || 'unclassified'
105
- }
106
-
107
- function mapSeverity(f) {
108
- if (f.severity === 'critical') return 'critical'
109
- if (f.severity === 'major') return 'important'
110
- if (f.severity === 'minor') {
111
- return (f.lenses || []).includes('lekker-conventions') ? 'idiomatic' : 'observation'
112
- }
113
- return 'observation'
114
- }
115
-
116
- // Pull an inline code snippet out of the body so hardRuleCorroborated has
117
- // something to check against; revmux findings carry no separate badCode field.
118
- function extractBadCode(body) {
119
- const text = String(body || '')
120
- const backtick = /`([^`]+)`/.exec(text)
121
- if (backtick) return backtick[1]
122
- const dashCode = /--\s*(.+)$/.exec(text.split('\n')[0])
123
- return dashCode ? dashCode[1] : ''
124
- }
125
-
126
101
  const findings = []
127
102
  let droppedCount = 0
128
103
  let hardRuleCount = 0
@@ -0,0 +1,27 @@
1
+ // revmux reports one location per finding and no class, so the class is derived
2
+ // from the rule tag or title and no other instances are claimed.
3
+ function bugClassOf(rule, title) {
4
+ const words = String(rule || title || '').toLowerCase().match(/[a-z0-9]+/g) || []
5
+ return words.slice(0, 6).join('-') || 'unclassified'
6
+ }
7
+
8
+ function mapSeverity(f) {
9
+ if (f.severity === 'critical') return 'critical'
10
+ if (f.severity === 'major') return 'important'
11
+ if (f.severity === 'minor') {
12
+ return (f.lenses || []).includes('lekker-conventions') ? 'idiomatic' : 'observation'
13
+ }
14
+ return 'observation'
15
+ }
16
+
17
+ // Pull an inline code snippet out of the body so hardRuleCorroborated has
18
+ // something to check against; revmux findings carry no separate badCode field.
19
+ function extractBadCode(body) {
20
+ const text = String(body || '')
21
+ const backtick = /`([^`]+)`/.exec(text)
22
+ if (backtick) return backtick[1]
23
+ const dashCode = /--\s*(.+)$/.exec(text.split('\n')[0])
24
+ return dashCode ? dashCode[1] : ''
25
+ }
26
+
27
+ export { bugClassOf, extractBadCode, mapSeverity }
@@ -27,7 +27,7 @@
27
27
  // `finish` may be called more than once for one run (after the review, again
28
28
  // after fix mode); every call appends a fresh row and the newest one wins.
29
29
 
30
- import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, copyFileSync, readdirSync, statSync, rmSync } from 'node:fs';
30
+ import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, mkdtempSync, copyFileSync, readdirSync, statSync, rmSync } from 'node:fs';
31
31
  import { homedir } from 'node:os';
32
32
  import { basename, join } from 'node:path';
33
33
 
@@ -152,9 +152,10 @@ if (cmd === 'start') {
152
152
  const target = flag('target');
153
153
  if (!repo || !target) fail('start needs --repo and --target');
154
154
  const startedAt = new Date().toISOString();
155
- const runId = `${startedAt.replace(/[-:]/g, '').slice(0, 15)}-${slug(repo.split('/').pop())}-${slug(target)}`;
156
- const dir = runDir(runId);
157
- mkdirSync(dir, { recursive: true });
155
+ const prefix = `${startedAt.replace(/[-:]/g, '').slice(0, 15)}-${slug(repo.split('/').pop())}-${slug(target)}-`.slice(0, 249);
156
+ mkdirSync(RUNS_DIR, { recursive: true });
157
+ const dir = mkdtempSync(join(RUNS_DIR, prefix));
158
+ const runId = basename(dir);
158
159
  const meta = {
159
160
  schema: 1, runId, startedAt, repo, target,
160
161
  mode: flag('mode', 'pr'), depth: flag('depth'), engine: flag('engine', 'workflow'),
@@ -414,7 +414,9 @@ ESLINT_SCOPE_FILE="/tmp/lekker-eslint-scope-$$.txt"
414
414
  # temp files): lint-findings.mjs turns it into botPredicted findings, so the
415
415
  # review can fix what the bot reviewers would have charged for. Exported by
416
416
  # path as eslintJson.
417
- ESLINT_JSON_FILE="$(dirname "$OUT_JSON")/eslint-report.json"
417
+ ESLINT_ARTIFACT_DIR="$(mktemp -d "$(dirname "$OUT_JSON")/eslint.XXXXXX")"
418
+ ESLINT_JSON_FILE="$ESLINT_ARTIFACT_DIR/eslint-report.json"
419
+ ESLINT_PROVENANCE_FILE="$ESLINT_JSON_FILE.provenance.json"
418
420
  printf '' > "$TSC_TAIL_FILE"
419
421
  printf '' > "$TSC_CHANGED_FILE"
420
422
  printf '0' > "$TSC_COUNT_FILE"
@@ -422,6 +424,37 @@ printf '' > "$ESLINT_TAIL_FILE"
422
424
  printf 'skipped' > "$ESLINT_SCOPE_FILE"
423
425
  printf '' > "$ESLINT_JSON_FILE"
424
426
 
427
+ write_eslint_provenance() {
428
+ node - "$ESLINT_JSON_FILE" "$ESLINT_PROVENANCE_FILE" "$MERGE_BASE_VAL" "$@" <<'NODE' || rm -f "$ESLINT_PROVENANCE_FILE"
429
+ const { createHash } = require('node:crypto')
430
+ const fs = require('node:fs')
431
+ const [reportPath, receiptPath, baseSha, headBefore, headAfter, cleanBefore, cleanAfter, rawExit] = process.argv.slice(2)
432
+ const fullSha = value => /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(value || '')
433
+ let bytes = null, validReport = false
434
+ try {
435
+ bytes = fs.readFileSync(reportPath)
436
+ const report = JSON.parse(bytes.toString('utf8'))
437
+ validReport = bytes.length > 0 && Array.isArray(report) && report.length > 0
438
+ && report.every(file => file && typeof file.filePath === 'string' && file.filePath.trim() && Array.isArray(file.messages))
439
+ } catch {}
440
+ const exitCode = rawExit === '' ? null : Number(rawExit)
441
+ const reasons = []
442
+ if (!fullSha(baseSha)) reasons.push('invalid base SHA')
443
+ if (!fullSha(headBefore) || headBefore !== headAfter) reasons.push('HEAD missing or changed during lint')
444
+ if (cleanBefore !== 'true' || cleanAfter !== 'true') reasons.push('tracked worktree was dirty')
445
+ if (exitCode !== 0 && exitCode !== 1) reasons.push('ESLint did not complete with a result')
446
+ if (!validReport) reasons.push('ESLint report missing or malformed')
447
+ fs.writeFileSync(receiptPath, JSON.stringify({
448
+ schema: 1, status: reasons.length ? 'unavailable' : 'available',
449
+ ...(reasons.length ? { reason: reasons.join('; ') } : {}),
450
+ baseSha: baseSha === 'null' ? null : baseSha,
451
+ headShaBefore: headBefore || null, headShaAfter: headAfter || null,
452
+ trackedCleanBefore: cleanBefore === 'true', trackedCleanAfter: cleanAfter === 'true',
453
+ exitCode, reportSha256: bytes ? createHash('sha256').update(bytes).digest('hex') : null,
454
+ }, null, 2) + '\n')
455
+ NODE
456
+ }
457
+
425
458
  # Render a stylish-style tail from the JSON report. An empty or unparseable
426
459
  # report (eslint crashed, bad config) keeps whatever eslint wrote on stderr
427
460
  # instead of rendering "0 errors" from nothing.
@@ -473,7 +506,7 @@ run_tsc() {
473
506
  }
474
507
 
475
508
  run_eslint() {
476
- local has_config
509
+ local has_config head_before='' head_after='' clean_before=false clean_after=false lint_exit='' tracked_state
477
510
  has_config=false
478
511
  if [[ "$NM_LINKED" == "true" ]]; then
479
512
  (cd "$WORKTREE_PATH" && compgen -G '.eslintrc*' >/dev/null 2>&1) && has_config=true
@@ -486,8 +519,17 @@ run_eslint() {
486
519
  # One run, JSON formatter: the machine-readable report feeds
487
520
  # lint-findings.mjs and the human tail is rendered from the
488
521
  # same report, so the two can never disagree.
489
- (cd "$WORKTREE_PATH" && npx eslint --max-warnings=0 -f json -o "$ESLINT_JSON_FILE" "${ESLINT_TARGETS[@]}" 2> "$ESLINT_TAIL_FILE") || true
490
- render_eslint_tail
522
+ head_before="$(git -C "$WORKTREE_PATH" rev-parse HEAD 2>/dev/null || true)"
523
+ if tracked_state="$(git -C "$WORKTREE_PATH" status --porcelain=v1 --untracked-files=no 2>/dev/null)"; then
524
+ [[ -z "$tracked_state" ]] && clean_before=true
525
+ fi
526
+ lint_exit=0
527
+ (cd "$WORKTREE_PATH" && npx eslint --max-warnings=0 -f json -o "$ESLINT_JSON_FILE" "${ESLINT_TARGETS[@]}" 2> "$ESLINT_TAIL_FILE") || lint_exit=$?
528
+ head_after="$(git -C "$WORKTREE_PATH" rev-parse HEAD 2>/dev/null || true)"
529
+ if tracked_state="$(git -C "$WORKTREE_PATH" status --porcelain=v1 --untracked-files=no 2>/dev/null)"; then
530
+ [[ -z "$tracked_state" ]] && clean_after=true
531
+ fi
532
+ render_eslint_tail || true
491
533
  printf 'changed-files' > "$ESLINT_SCOPE_FILE"
492
534
  else
493
535
  printf 'no-changed-lintable-files\n' > "$ESLINT_TAIL_FILE"
@@ -508,6 +550,7 @@ run_eslint() {
508
550
  printf 'skipped\n' > "$ESLINT_TAIL_FILE"
509
551
  printf 'skipped' > "$ESLINT_SCOPE_FILE"
510
552
  fi
553
+ write_eslint_provenance "$head_before" "$head_after" "$clean_before" "$clean_after" "$lint_exit"
511
554
  }
512
555
 
513
556
  run_tsc &
@@ -582,6 +625,7 @@ python3 - \
582
625
  "$MERGE_BASE_VAL" \
583
626
  "$DEPS_INSTALL_FILE" \
584
627
  "$ESLINT_JSON_FILE" \
628
+ "$ESLINT_PROVENANCE_FILE" \
585
629
  "$TEST_RUNNER_JSON" \
586
630
  "$OUT_JSON" \
587
631
  "$BASE_RULES_FILE" \
@@ -592,8 +636,8 @@ import json, sys, os
592
636
  tsc_file, eslint_file, rules_file, delta_file, notes,
593
637
  changed_files_file, base_ref_val, eslint_scope_file,
594
638
  tsc_changed_file, tsc_count_file, merge_base_val,
595
- deps_install_file, eslint_json_file, test_runner_json, out_json,
596
- base_rules_file, base_rules_state) = sys.argv[1:22]
639
+ deps_install_file, eslint_json_file, eslint_provenance_file, test_runner_json, out_json,
640
+ base_rules_file, base_rules_state) = sys.argv[1:23]
597
641
 
598
642
  def parse_runner(raw):
599
643
  try:
@@ -665,6 +709,7 @@ data = {
665
709
  # no structured report (skipped, no config, crashed, or the repo-wide
666
710
  # fallback), so a consumer can tell "no lint report" from "no problems".
667
711
  "eslintJson": eslint_json_file if file_has_content(eslint_json_file) else None,
712
+ "eslintProvenance": eslint_provenance_file if file_has_content(eslint_provenance_file) else None,
668
713
  }
669
714
 
670
715
  for p in (tsc_file, eslint_file, rules_file, base_rules_file, changed_files_file,
@@ -0,0 +1,33 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ const FULL_SHA = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i
4
+ const HASH = /^[0-9a-f]{64}$/i
5
+ const unavailable = reason => ({ status: 'unavailable', reason })
6
+
7
+ export function validateLintReceipt(receipt, expected, reportBytes) {
8
+ if (!receipt || typeof receipt !== 'object' || Array.isArray(receipt)) return unavailable('receipt missing or malformed')
9
+ if (!expected || typeof expected !== 'object' || Array.isArray(expected)) return unavailable('expected provenance missing')
10
+ if (!(reportBytes instanceof Uint8Array)) return unavailable('raw lint report bytes unavailable')
11
+ if (typeof expected.baseSha !== 'string' || !FULL_SHA.test(expected.baseSha)
12
+ || typeof expected.headSha !== 'string' || !FULL_SHA.test(expected.headSha)) return unavailable('expected base/head must be full SHAs')
13
+ if (expected.trackedClean !== true) return unavailable('current tracked worktree is not clean')
14
+ if (receipt.schema !== 1) return unavailable('unsupported receipt schema')
15
+ if (receipt.status !== 'available') return unavailable(typeof receipt.reason === 'string' ? receipt.reason : 'receipt status is unavailable')
16
+ if (typeof receipt.baseSha !== 'string' || !FULL_SHA.test(receipt.baseSha)
17
+ || receipt.baseSha.toLowerCase() !== expected.baseSha.toLowerCase()) return unavailable('base SHA mismatch')
18
+ if (typeof receipt.headShaBefore !== 'string' || !FULL_SHA.test(receipt.headShaBefore)
19
+ || typeof receipt.headShaAfter !== 'string' || !FULL_SHA.test(receipt.headShaAfter)
20
+ || receipt.headShaBefore.toLowerCase() !== receipt.headShaAfter.toLowerCase()
21
+ || receipt.headShaBefore.toLowerCase() !== expected.headSha.toLowerCase()) return unavailable('head SHA mismatch or drift')
22
+ if (receipt.trackedCleanBefore !== true || receipt.trackedCleanAfter !== true) return unavailable('captured tracked worktree was not clean')
23
+ if (receipt.exitCode !== 0 && receipt.exitCode !== 1) return unavailable('invalid ESLint exit code')
24
+ if (typeof receipt.reportSha256 !== 'string' || !HASH.test(receipt.reportSha256)) return unavailable('report SHA-256 missing or malformed')
25
+ const reportSha256 = createHash('sha256').update(reportBytes).digest('hex')
26
+ if (reportSha256 !== receipt.reportSha256.toLowerCase()) return unavailable('lint report hash mismatch')
27
+ return {
28
+ status: 'available',
29
+ baseSha: receipt.baseSha, headSha: receipt.headShaBefore,
30
+ exitCode: receipt.exitCode, reportSha256,
31
+ trackedCleanBefore: receipt.trackedCleanBefore, trackedCleanAfter: receipt.trackedCleanAfter,
32
+ }
33
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "olko-obsidian",
3
3
  "description": "Keep an Obsidian vault in sync with work: PR sync, task rollover, morning routine.",
4
- "version": "1.76.0",
4
+ "version": "1.78.0",
5
5
  "author": {
6
6
  "name": "Oleg Koval"
7
7
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "olko-product",
3
3
  "description": "Take a product idea to a shippable build: MVP passes, full-stack scaffolds, UX/UI quality loops, launch plans.",
4
- "version": "1.76.0",
4
+ "version": "1.78.0",
5
5
  "author": {
6
6
  "name": "Oleg Koval"
7
7
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "olko-reflection",
3
3
  "description": "Look back and improve: self-critique, retrospectives, performance review, rapid learning.",
4
- "version": "1.76.0",
4
+ "version": "1.78.0",
5
5
  "author": {
6
6
  "name": "Oleg Koval"
7
7
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "olko-release",
3
3
  "description": "Ship a release: semantic-release setup, changelogs, store listing copy, release-day routine.",
4
- "version": "1.76.0",
4
+ "version": "1.78.0",
5
5
  "author": {
6
6
  "name": "Oleg Koval"
7
7
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "olko-skill-meta",
3
3
  "description": "Author and maintain agent skills and the AI toolchain itself.",
4
- "version": "1.76.0",
4
+ "version": "1.78.0",
5
5
  "author": {
6
6
  "name": "Oleg Koval"
7
7
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "olko-web-ops",
3
3
  "description": "Operate a website: WAF rules, search console audits, analytics bootstrap, docs indexes.",
4
- "version": "1.76.0",
4
+ "version": "1.78.0",
5
5
  "author": {
6
6
  "name": "Oleg Koval"
7
7
  },