@olegkoval/agent-skills 1.76.0 → 1.77.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 (28) 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/git-evidence.mjs +39 -0
  4. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/revmux-adapter.mjs +1 -26
  5. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/revmux-normalization.mjs +27 -0
  6. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/run-log.mjs +5 -4
  7. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/setup-worktree.sh +51 -6
  8. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/validate-lint-receipt.mjs +33 -0
  9. package/package.json +1 -1
  10. package/plugins/olko-apple-kit/.claude-plugin/plugin.json +1 -1
  11. package/plugins/olko-creative/.claude-plugin/plugin.json +1 -1
  12. package/plugins/olko-garmin-kit/.claude-plugin/plugin.json +1 -1
  13. package/plugins/olko-git-tools/.claude-plugin/plugin.json +1 -1
  14. package/plugins/olko-github-pr/.claude-plugin/plugin.json +1 -1
  15. package/plugins/olko-github-pr/skills/lekker-review/SKILL.md +3 -1
  16. package/plugins/olko-github-pr/skills/lekker-review/scripts/codex-usage.mjs +69 -0
  17. package/plugins/olko-github-pr/skills/lekker-review/scripts/git-evidence.mjs +39 -0
  18. package/plugins/olko-github-pr/skills/lekker-review/scripts/revmux-adapter.mjs +1 -26
  19. package/plugins/olko-github-pr/skills/lekker-review/scripts/revmux-normalization.mjs +27 -0
  20. package/plugins/olko-github-pr/skills/lekker-review/scripts/run-log.mjs +5 -4
  21. package/plugins/olko-github-pr/skills/lekker-review/scripts/setup-worktree.sh +51 -6
  22. package/plugins/olko-github-pr/skills/lekker-review/scripts/validate-lint-receipt.mjs +33 -0
  23. package/plugins/olko-obsidian/.claude-plugin/plugin.json +1 -1
  24. package/plugins/olko-product/.claude-plugin/plugin.json +1 -1
  25. package/plugins/olko-reflection/.claude-plugin/plugin.json +1 -1
  26. package/plugins/olko-release/.claude-plugin/plugin.json +1 -1
  27. package/plugins/olko-skill-meta/.claude-plugin/plugin.json +1 -1
  28. 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));
@@ -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
+ }
@@ -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.77.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.77.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.77.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.77.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.77.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.77.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));
@@ -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
+ }
@@ -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.77.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.77.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.77.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.77.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.77.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.77.0",
5
5
  "author": {
6
6
  "name": "Oleg Koval"
7
7
  },