@olegkoval/agent-skills 1.58.1 → 1.59.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/adapters/claude/olko-github-pr/skills/lekker-review/SKILL.md +105 -9
  2. package/adapters/claude/olko-github-pr/skills/lekker-review/references/agents/prover.md +18 -6
  3. package/adapters/claude/olko-github-pr/skills/lekker-review/references/fix-mode.md +2 -1
  4. package/adapters/claude/olko-github-pr/skills/lekker-review/references/github-post.md +6 -1
  5. package/adapters/claude/olko-github-pr/skills/lekker-review/references/traces.md +39 -0
  6. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/deps.mjs +305 -0
  7. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/revmux-engine.sh +7 -2
  8. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/run-log.mjs +344 -0
  9. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/selftest.mjs +107 -0
  10. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/setup-worktree.sh +16 -1
  11. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/test-runner.mjs +75 -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 +105 -9
  19. package/plugins/olko-github-pr/skills/lekker-review/references/agents/prover.md +18 -6
  20. package/plugins/olko-github-pr/skills/lekker-review/references/fix-mode.md +2 -1
  21. package/plugins/olko-github-pr/skills/lekker-review/references/github-post.md +6 -1
  22. package/plugins/olko-github-pr/skills/lekker-review/references/traces.md +39 -0
  23. package/plugins/olko-github-pr/skills/lekker-review/scripts/deps.mjs +305 -0
  24. package/plugins/olko-github-pr/skills/lekker-review/scripts/revmux-engine.sh +7 -2
  25. package/plugins/olko-github-pr/skills/lekker-review/scripts/run-log.mjs +344 -0
  26. package/plugins/olko-github-pr/skills/lekker-review/scripts/selftest.mjs +107 -0
  27. package/plugins/olko-github-pr/skills/lekker-review/scripts/setup-worktree.sh +16 -1
  28. package/plugins/olko-github-pr/skills/lekker-review/scripts/test-runner.mjs +75 -0
  29. package/plugins/olko-github-pr/skills/lekker-review/workflow.js +14 -2
  30. package/plugins/olko-obsidian/.claude-plugin/plugin.json +1 -1
  31. package/plugins/olko-product/.claude-plugin/plugin.json +1 -1
  32. package/plugins/olko-reflection/.claude-plugin/plugin.json +1 -1
  33. package/plugins/olko-release/.claude-plugin/plugin.json +1 -1
  34. package/plugins/olko-skill-meta/.claude-plugin/plugin.json +1 -1
  35. package/plugins/olko-web-ops/.claude-plugin/plugin.json +1 -1
@@ -141,6 +141,12 @@ returns the number checked as `hardRuleCount`.
141
141
  Resolve every `references/...` and script path relative to `SKILL_ROOT`; never
142
142
  assume a `~/.claude/skills/` installation path.
143
143
 
144
+ Run one simple command per shell call. Do not prefix commands with
145
+ `cd <dir> &&`; use absolute paths, `git -C <dir>`, and `grep -rn <pattern> <dir>`
146
+ instead. Permission checkers cannot vet a compound or `cd`-prefixed command,
147
+ so in an unattended or auto-approved run each one becomes a denial and a
148
+ wasted turn.
149
+
144
150
  ## Host selection
145
151
 
146
152
  Set `HOST=codex` when native Codex collaboration tools such as `spawn_agent`
@@ -159,6 +165,39 @@ single-agent review is not equivalent to lekker-review.
159
165
 
160
166
  ## Step 0 - Parse input
161
167
 
168
+ ### Dependencies (first thing, every run)
169
+
170
+ ```bash
171
+ node ${SKILL_ROOT}/scripts/deps.mjs check # add --unattended when no human is present
172
+ ```
173
+
174
+ Milliseconds: it only checks presence. The upstream version check needs the
175
+ network, so it runs at most once per `cadenceDays` (default 7) and the ledger
176
+ `~/.local/state/lekker-review/deps.json` records when it last ran. Act on `state`:
177
+
178
+ - `first-run`: ask ONCE (AskUserQuestion in Claude Code): a multiSelect of the
179
+ `offer[]` tools (label = tool, description = `usedBy`), plus "keep them
180
+ updated automatically?" yes/no. Then
181
+ `deps.mjs init --install <picked> --skip <the rest> --auto-update on|off`.
182
+ A tool not picked is recorded as skipped and never offered again; the user can
183
+ still run `deps.mjs install <tool>` later. Unattended: do not ask, do not
184
+ install; carry on and say `deps not initialised` in the header.
185
+ - `missing-required`: stop and print the `missing` list. The review needs them.
186
+ - `undecided` non-empty (a tool added to the registry since init): ask about
187
+ those tools only, then `deps.mjs install <tool>` or `deps.mjs skip <tool>`.
188
+ - `outdated` non-empty and `updated` null: one line, e.g.
189
+ `deps outdated: revmux 0.2.6 -> 0.2.7 (deps.mjs update)`. With auto-update on,
190
+ `check` already upgraded them when the check was due; report `updated`.
191
+ - `revmuxPromptsInSync: false`: run
192
+ `bash ${SKILL_ROOT}/scripts/install-revmux-prompts.sh`, which resyncs the
193
+ lekker profiles into `~/.config/revmux` (`deps.mjs update revmux` does the
194
+ same after upgrading).
195
+ - An install that returns `manual` (no Homebrew on PATH) prints the command to
196
+ run by hand; pass it on, do not improvise another installer.
197
+
198
+ `ENGINE=revmux` additionally needs revmux `present`; when it is missing or
199
+ skipped, fall back to the host's native engine and say why in the header.
200
+
162
201
  ### Pick the mode first
163
202
 
164
203
  `MODE=branch` when ANY of these hold:
@@ -313,6 +352,25 @@ treat as a full review and leave PREV_SHA unset. Also grep the same file for
313
352
  - Step 3.5 reuses that exact URL or local path so the artifact stays a living
314
353
  page for this PR.
315
354
 
355
+ **Open the run log** once mode, depth and engine are settled (see
356
+ `references/traces.md` for where every trace of a run lives):
357
+
358
+ Resolve the run-log script path in every shell call. The examples below use
359
+ Claude Code's `CLAUDE_PLUGIN_ROOT`. In Codex, replace the quoted script path in
360
+ each invocation with the absolute path to `scripts/run-log.mjs` beside the
361
+ selected `SKILL.md`. Do not rely on a shell variable set by an earlier call.
362
+
363
+ ```bash
364
+ node "${CLAUDE_PLUGIN_ROOT}/skills/lekker-review/scripts/run-log.mjs" start --repo <REPO_SLUG> --target <TARGET_SLUG> --mode <MODE> \
365
+ --depth <depth> --engine <ENGINE> [--fix]
366
+ ```
367
+
368
+ Keep the returned `runId` as `RUN_ID`. Then run
369
+ `node "${CLAUDE_PLUGIN_ROOT}/skills/lekker-review/scripts/run-log.mjs" mark <RUN_ID> <phase>`
370
+ at each step boundary: `context` (Step 1), `review` (Step 2), `synthesis`
371
+ (Step 3), `fix` (Step 4), `post` (Step 5). A marker is one line of JSON; never
372
+ skip the review because the log failed, just say `run log unavailable`.
373
+
316
374
  ---
317
375
 
318
376
  ## Depth gate
@@ -389,7 +447,14 @@ On completion, read `worktree.json`. Keys emitted:
389
447
  `worktreePath`, `repoRoot`, `headSha`, `headShaShort`, `tscTail`,
390
448
  `tscChangedTail`, `tscErrorCount`, `eslintTail`, `eslintScope`, `eslintJson`,
391
449
  `changedFiles`, `baseRef`, `mergeBase`, `projectRules`, `deltaFile`, `notes`,
392
- `depsInstall`.
450
+ `depsInstall`, `testRunner`.
451
+
452
+ `testRunner` is `{runner, dirs, reason}` from `scripts/test-runner.mjs`:
453
+ `runner` is `vitest|jest|mocha|node-test` only when its binary is installed
454
+ in the worktree, `dirs` lists `{dir, runner}` pairs for each usable package
455
+ (including binaries installed in a parent dir within the worktree), and `reason`
456
+ says why `runner` is null. Pass it to the workflow unchanged; Prove skips
457
+ without one instead of paying provers to find the missing `node_modules`.
393
458
 
394
459
  `notes` containing `changed-files-mismatch:<got>-vs-<expected>` means the
395
460
  merge-base still disagrees with the PR. Use the `files` list from `gh pr view`
@@ -602,7 +667,10 @@ First copy the script into the scratchpad. The Workflow tool rejects a
602
667
  `scriptPath` outside the working directory or added dirs ("scriptPath must be a
603
668
  script path this tool returned, or a file you can already read"), and the skill
604
669
  dir is usually neither: `cp ${SKILL_ROOT}/workflow.js <scratchpad>/workflow.js`.
605
- If it still rejects the path, pass the script inline via `script` instead.
670
+ If `workflow.js` is already in the working directory (an unattended runner may
671
+ stage it there), use that copy and skip the `cp`: some permission checks deny
672
+ reading files out of the skill dir. If the Workflow tool still rejects the
673
+ path, pass the script inline via `script` instead.
606
674
 
607
675
  Invoke the Workflow tool:
608
676
 
@@ -618,7 +686,8 @@ args: {
618
686
  contextFile: "<scratchpad>/context.json",
619
687
  worktreePath: <null for scan, else from worktree.json>,
620
688
  promptDir: "${SKILL_ROOT}/references/agents",
621
- prevSha: <null unless re-review>
689
+ prevSha: <null unless re-review>,
690
+ testRunner: <worktree.json testRunner, null for scan>
622
691
  }
623
692
  ```
624
693
 
@@ -663,14 +732,16 @@ The workflow runs three phases:
663
732
  thirty-finding PR launches thirty concurrent agents.
664
733
  - **Critic (deep only):** completeness critic gets the full deduped finding
665
734
  list + DIFF_FILE; its findings go through verifier agents before promotion.
666
- - **Prove (medium/deep, worktree required):** each non-hard-rule Critical gets
667
- one prover agent (`references/agents/prover.md`, sonnet, max 5, run
735
+ - **Prove (medium/deep, worktree and an installed test runner required):**
736
+ each Critical without a `rule` tag gets one prover agent (`references/agents/prover.md`, sonnet, max 5, run
668
737
  `maxConcurrent` at a time) that writes a test asserting the CORRECT behaviour,
669
738
  runs it in the worktree, and captures it failing because of the bug. The proof rides on the finding as
670
739
  `proof: {attempted, proven, outcome, reason, testCode?, testCommand?, redOutput?}`.
671
740
  A proof that comes back GREEN (code behaved correctly) is counter-evidence -
672
741
  the workflow automatically downgrades the finding from Critical to Important.
673
- Hard-rule findings are never proved (policy violations have no failing test).
742
+ Rule-tagged findings are never proved (policy violations have no failing
743
+ test). With `testRunner.runner` null, no prover runs and each candidate
744
+ carries `proof: {attempted: false, reason: "no runnable test runner: ..."}`.
674
745
 
675
746
  Findings have schema:
676
747
  `{file, line, severity, title, description, badCode, fix, rule?, precedent?, agreedBy?, verificationStatus?, verifierReasoning?, proof?}`
@@ -732,7 +803,8 @@ round; Prove runs through the current host's native engine. Steps:
732
803
  adapter's `agents` array has `degraded: true`. State the fallback in the
733
804
  review header (`**Note:** revmux unavailable - fell back to workflow
734
805
  engine` or `...fell back to codex engine`) and re-run the native path from
735
- the top of Step 2.
806
+ the top of Step 2. Quote the last lines of `<round dir>/revmux.stderr.log`
807
+ in that note: it is the only record of why the agents failed.
736
808
 
737
809
  **Trust boundary:** always pass `--config-dir ~/.config/revmux` explicitly.
738
810
  Without it revmux also reads the reviewed repo's checked-in `.revmux/`, which
@@ -972,6 +1044,24 @@ Write it against the sha that was actually reviewed. When fix mode later adds
972
1044
  commits, write a marker for the NEW head sha too - a gate keys on the tip
973
1045
  that is about to be pushed, and a marker for a superseded sha would be a lie.
974
1046
 
1047
+ **Close the run log** right after the receipt, then print the review:
1048
+
1049
+ ```bash
1050
+ node "${CLAUDE_PLUGIN_ROOT}/skills/lekker-review/scripts/run-log.mjs" attach <RUN_ID> <scratchpad>/findings.json <scratchpad>/worktree.json \
1051
+ <scratchpad>/preflight.json <scratchpad>/context.json
1052
+ node "${CLAUDE_PLUGIN_ROOT}/skills/lekker-review/scripts/run-log.mjs" finish <RUN_ID> --status ok --verdict lgtm|lgtm-with-changes|needs-work \
1053
+ --review-file "$REVIEW_FILE" [--wf <wf_id of the lekker-review-core run>]
1054
+ ```
1055
+
1056
+ `--wf` is the `runId` the Claude Code Workflow tool returned (omit it on other
1057
+ hosts). `finish` copies duration, tokens, agent count, verifier drops and
1058
+ prove results out of the harness's own record, which Claude Code deletes with
1059
+ old sessions. When fix mode, the artifact or a post follows, call `finish`
1060
+ again at the very end using the resolved script path, with every `--wf` id of
1061
+ the run (review and fix) and
1062
+ `--artifact-url`: the newest row wins. A run that dies part-way still gets
1063
+ `node "${CLAUDE_PLUGIN_ROOT}/skills/lekker-review/scripts/run-log.mjs" finish <RUN_ID> --status failed --note <why>`.
1064
+
975
1065
  Then print the full review as the response.
976
1066
 
977
1067
  **Closer goes last.** The run's FINAL message (this one, or the later summary
@@ -1038,8 +1128,9 @@ Read `references/fix-mode.md` and follow it. Shape of the run:
1038
1128
  its severity. Never auto-fix any other Observation or Idiomatic finding, or a
1039
1129
  title/process rule.
1040
1130
  3. Invoke the host's fix workflow. In Claude, copy `fix-workflow.js` to the
1041
- scratchpad first (same reason as the review workflow; inline `script` if the
1042
- path is still rejected), then run:
1131
+ scratchpad first (same reason as the review workflow; skip the copy when it
1132
+ is already in the working directory; inline `script` if the path is still
1133
+ rejected), then run:
1043
1134
  ```
1044
1135
  scriptPath: <scratchpad>/fix-workflow.js
1045
1136
  args: { repoSlug, prNumber, targetLabel, worktreePath, diffFile, contextFile,
@@ -1233,6 +1324,11 @@ If `repoRoot` starts with `/tmp/lekker-clone-`, also remove that clone dir.
1233
1324
  Remove the delta file if `deltaFile` was set in worktree.json. Verify with
1234
1325
  `git -C <repoRoot> worktree list` that no `lekker-review` entries remain.
1235
1326
 
1327
+ **Measuring changes.** `node "${CLAUDE_PLUGIN_ROOT}/skills/lekker-review/scripts/run-log.mjs" report --days 30`
1328
+ prints duration, tokens, findings, verifier drop rate and prove yield per
1329
+ engine and depth. Run it before and after a change to `workflow.js` or the
1330
+ prompts and quote both. `backfill` imports past runs (idempotent).
1331
+
1236
1332
  ---
1237
1333
 
1238
1334
  ## Failure rules
@@ -44,9 +44,10 @@ hold:
44
44
  network) and the repo has no test infra to fake it cheaply.
45
45
  - The buggy logic is not importable/reachable from a test without large
46
46
  scaffolding (deep framework wiring, a webhook server bootstrap).
47
- - No test runner exists: check `package.json` for `vitest` or `jest` (in
48
- `devDependencies` AND a matching script) and confirm `node_modules` is
49
- present. Missing either -> not attemptable.
47
+ - The code under test lives outside every package dir the prompt's
48
+ `TEST_RUNNER` line lists. The workflow already checked those dirs for an
49
+ installed runner binary, so do not re-check; a finding in any other package
50
+ has nothing to run its test.
50
51
 
51
52
  Deciding "not testable" quickly is a GOOD outcome, not a failure of yours --
52
53
  say why in one sentence and stop. Do not burn effort scaffolding around a fake
@@ -56,7 +57,8 @@ IO layer just to force a test into existence.
56
57
 
57
58
  ## Step 2 -- Write the test
58
59
 
59
- One file at the worktree ROOT named `lekker-proof-<file-slug>-<line>.test.ts`,
60
+ One file at the root of the listed package dir that contains the finding
61
+ (`.` means the worktree root) named `lekker-proof-<file-slug>-<line>.test.ts`,
60
62
  where `<file-slug>` is the finding's file path with every `/` and `.` replaced
61
63
  by `-` (e.g. finding at `src/sync/orders.ts:142` ->
62
64
  `lekker-proof-src-sync-orders-ts-142.test.ts`). The slug matters: another
@@ -77,10 +79,20 @@ repo's real test directories and trivially findable for cleanup.
77
79
 
78
80
  ## Step 3 -- Run it
79
81
 
80
- Exactly one run command, scoped to your file only:
82
+ Exactly one run command from that package dir, scoped to your file only, for
83
+ the runner paired with that package dir in the `TEST_RUNNER` mapping
84
+ (use the most specific package dir containing the finding):
81
85
 
82
86
  - vitest: `npx vitest run lekker-proof-<file-slug>-<line>.test.ts --no-coverage --reporter=verbose`
83
87
  - jest: `npx jest lekker-proof-<file-slug>-<line>.test.ts --ci`
88
+ - mocha: `npx mocha lekker-proof-<file-slug>-<line>.test.ts`
89
+ - node-test: `node --test lekker-proof-<file-slug>-<line>.test.ts` (needs a
90
+ runtime that strips types; if it rejects TypeScript, that is an environment
91
+ failure, below)
92
+
93
+ When the package dir is not `.`, prefix the command with
94
+ `cd <package dir> && ` and keep that prefix in `testCommand`: fix mode
95
+ re-runs the proof from the worktree root.
84
96
 
85
97
  Set the Bash tool's `timeout` parameter to 120000 for this call.
86
98
 
@@ -114,7 +126,7 @@ Delete your test file and verify the worktree is exactly as clean as you
114
126
  found it:
115
127
 
116
128
  ```bash
117
- rm lekker-proof-<file-slug>-<line>.test.ts
129
+ rm <package dir>/lekker-proof-<file-slug>-<line>.test.ts
118
130
  git -C <WORKTREE_PATH> status --porcelain
119
131
  ```
120
132
 
@@ -189,7 +189,8 @@ For each proven finding in a committable group:
189
189
 
190
190
  1. **Re-materialize the test**: write `proof.testCode` back to its original
191
191
  filename (the same `lekker-proof-<file-slug>-<line>.test.ts` name it was captured
192
- under) at the worktree root.
192
+ under) in the package dir `proof.testCommand` starts with `cd` into, or at
193
+ the worktree root when it has no `cd` prefix.
193
194
  2. **Run** exactly `proof.testCommand` (Bash `timeout` 120000).
194
195
  3. **Judge**:
195
196
  - **PASSES now** -> the fix demonstrably resolves the finding. Record
@@ -133,9 +133,14 @@ comments.
133
133
  ```bash
134
134
  gh api "repos/<REPO_SLUG>/pulls/<PR_NUMBER>/reviews" \
135
135
  --method POST \
136
- --input /tmp/lekker-post-$$.json
136
+ --input /tmp/lekker-post-$$.json # pending-ok
137
137
  ```
138
138
 
139
+ The payload has no `event` field on purpose: the review lands as a PENDING
140
+ draft for the user to submit. Keep the `# pending-ok` comment. Guard hooks
141
+ that block event-less review POSTs (they would otherwise create an invisible
142
+ draft by accident) let a command through when it carries that marker.
143
+
139
144
  ---
140
145
 
141
146
  ## Step 4 -- Handle 422 errors
@@ -0,0 +1,39 @@
1
+ # Where a run leaves traces
2
+
3
+ One table, so nobody has to rediscover this. The run log is the index: it
4
+ points at, and copies the numbers out of, everything else.
5
+
6
+ | Trace | Path | Written by | Kept | What it answers |
7
+ |---|---|---|---|---|
8
+ | Run ledger | `~/.local/state/lekker-review/runs.jsonl` | `scripts/run-log.mjs finish`, `backfill` | forever (one line per run) | how long, how many tokens, findings, drops, proofs, verdict, per engine and depth |
9
+ | Run archive | `~/.local/state/lekker-review/runs/<runId>/` | `run-log.mjs start/mark/attach/finish` | 90 days (`prune`) | `run.json` with timeline, plus the run's `findings.json`, `worktree.json`, `preflight.json`, `context.json` |
10
+ | Dependency ledger | `~/.local/state/lekker-review/deps.json` | `scripts/deps.mjs` | forever | which optional tools were installed or skipped, versions, last upstream check |
11
+ | Review report | `~/code-reviews/<date>-<target>-<repo>.md` | Step 3 | forever | the review as printed, including the `Review Cost` block |
12
+ | revmux archive | `~/code-reviews/revmux-tasks/<task>/<NN-run>/` | revmux, plus `revmux.stderr.log` from `scripts/revmux-engine.sh` | forever, never edit | `events.jsonl` (agent start, degraded, retried), `agents/*.jsonl` (per-agent cost and usage), `revmux.stderr.log` (why a round degraded; rounds before 2026-10-20 have none) |
13
+ | Harness workflow record | `~/.claude/projects/*/<session>/workflows/wf_*.json` | Claude Code Workflow tool | until Claude Code cleans the session | the source `finish` copies from: `durationMs`, `totalTokens`, `agentCount`, `result` |
14
+ | Cache | `~/.cache/lekker-review/` | Steps 1, 3, 5 | disposable | `reviewed/<sha>` branch-mode markers, `bot-precision.jsonl`, `repo-map.tsv` |
15
+ | Pattern memory | ICM (`icm recall`) | Step 5 housekeeping | ICM decay | recurring review and false-positive patterns per repo |
16
+
17
+ `LEKKER_STATE_DIR` moves the state root (tests use it). `LEKKER_CLAUDE_PROJECTS`
18
+ and `LEKKER_REVMUX_TASKS_DIR` point `run-log.mjs` at other harness and revmux
19
+ roots.
20
+
21
+ ## Reading it
22
+
23
+ ```bash
24
+ node ${SKILL_ROOT}/scripts/run-log.mjs report --days 30 # table
25
+ node ${SKILL_ROOT}/scripts/run-log.mjs report --days 90 --json # for scripts
26
+ jq -c 'select(.verdict=="needs-work")' ~/.local/state/lekker-review/runs.jsonl # ad hoc
27
+ ```
28
+
29
+ `report` keeps the newest row per `runId`, so a run finished twice (review,
30
+ then fix) counts once. Rows with `source: harness-backfill` or
31
+ `revmux-backfill` were imported from history and have no timeline or verdict.
32
+
33
+ ## Known gaps
34
+
35
+ - Backfilled harness rows cannot tell which review fell back from revmux to the
36
+ workflow engine; new runs record the engine at `start`.
37
+ - revmux tokens include cache reads, workflow `totalTokens` does not break them
38
+ out. Compare cost in dollars or wall time, not raw tokens, across engines.
39
+ - Verdict and severity counts exist only for runs logged with `finish`.
@@ -0,0 +1,305 @@
1
+ #!/usr/bin/env node
2
+ // First-run setup and dependency upkeep for lekker-review.
3
+ //
4
+ // Required tools are only checked, never installed: a review cannot run
5
+ // without them, and installing git or node behind someone's back is not this
6
+ // skill's call. Optional tools are offered once, on the first run, and the
7
+ // answer is remembered. A tool the user skipped is never offered again until
8
+ // `reset`.
9
+ //
10
+ // The upstream version check needs the network, so it runs on a cadence
11
+ // (default 7 days) and not on every review. The ledger records when it last
12
+ // ran and what it found.
13
+ //
14
+ // Usage:
15
+ // node deps.mjs check [--force] [--unattended] fast presence check; full version check when due
16
+ // node deps.mjs init [--install a,b] [--skip c] [--auto-update on|off] [--cadence-days N]
17
+ // node deps.mjs install <tool>
18
+ // node deps.mjs update [tool] upgrade outdated opted-in tools, resync revmux prompts
19
+ // node deps.mjs skip <tool>
20
+ // node deps.mjs reset forget every choice; the next check is a first run
21
+ //
22
+ // Ledger: ${LEKKER_STATE_DIR:-~/.local/state/lekker-review}/deps.json
23
+ // Every command prints one JSON object on stdout. Exit 0 unless the command
24
+ // itself failed; a missing required tool is reported as `state`, not an exit.
25
+
26
+ import { execFileSync } from 'node:child_process';
27
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from 'node:fs';
28
+ import { homedir } from 'node:os';
29
+ import { dirname, join } from 'node:path';
30
+ import { fileURLToPath } from 'node:url';
31
+
32
+ const SKILL_DIR = join(dirname(fileURLToPath(import.meta.url)), '..');
33
+ const STATE_DIR = process.env.LEKKER_STATE_DIR || join(homedir(), '.local', 'state', 'lekker-review');
34
+ const LEDGER = join(STATE_DIR, 'deps.json');
35
+ const DAY_MS = 86400000;
36
+ // A failed upstream lookup is retried, not recorded as "up to date". This keeps
37
+ // a flaky network from re-running the lookup on every single review.
38
+ const RETRY_AFTER_MS = 6 * 3600000;
39
+
40
+ const REQUIRED = ['git', 'gh', 'node', 'npx', 'jq', 'python3'];
41
+
42
+ // `usedBy` is shown in the first-run question, so the user knows what each
43
+ // tool buys before agreeing to install it.
44
+ const OPTIONAL = {
45
+ revmux: {
46
+ usedBy: '--engine revmux (alternative review engine)',
47
+ versionCmd: ['revmux', ['--version']],
48
+ repo: 'umputun/revmux',
49
+ brew: ['install', '--cask', 'umputun/apps/revmux'],
50
+ brewUpgrade: ['upgrade', '--cask', 'umputun/apps/revmux'],
51
+ postInstall: ['bash', [join(SKILL_DIR, 'scripts', 'install-revmux-prompts.sh')]],
52
+ },
53
+ icm: {
54
+ usedBy: 'pattern memory: Track B recall and Step 5 housekeeping',
55
+ versionCmd: ['icm', ['--version']],
56
+ repo: 'rtk-ai/icm',
57
+ brew: ['install', 'rtk-ai/tap/icm'],
58
+ brewUpgrade: ['upgrade', 'rtk-ai/tap/icm'],
59
+ },
60
+ };
61
+
62
+ const argv = process.argv.slice(2);
63
+ const cmd = argv[0] || 'check';
64
+
65
+ function flag(name, fallback = null) {
66
+ const i = argv.indexOf('--' + name);
67
+ return i === -1 ? fallback : argv[i + 1];
68
+ }
69
+ const has = (name) => argv.includes('--' + name);
70
+
71
+ function run(bin, args, timeout = 15000) {
72
+ try {
73
+ return { ok: true, out: execFileSync(bin, args, { encoding: 'utf8', timeout, stdio: ['ignore', 'pipe', 'pipe'] }).trim() };
74
+ } catch (err) {
75
+ return { ok: false, out: String(err.stderr || err.message).trim().split('\n').slice(-3).join(' ') };
76
+ }
77
+ }
78
+
79
+ const onPath = (bin) => run('/bin/sh', ['-c', `command -v ${bin}`]).ok;
80
+ const semver = (text) => (String(text).match(/(\d+)\.(\d+)\.(\d+)/) || [])[0] || null;
81
+
82
+ function newer(latest, installed) {
83
+ if (!latest || !installed) return false;
84
+ const a = latest.split('.').map(Number);
85
+ const b = installed.split('.').map(Number);
86
+ for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i];
87
+ return false;
88
+ }
89
+
90
+ // brew is looked up explicitly: the agent's shell PATH often lacks it, and MDM
91
+ // has deleted the /opt/homebrew/bin/brew symlink on this fleet before.
92
+ function findBrew() {
93
+ for (const p of ['/opt/homebrew/bin/brew', '/usr/local/bin/brew', '/home/linuxbrew/.linuxbrew/bin/brew']) {
94
+ if (existsSync(p)) return p;
95
+ }
96
+ return onPath('brew') ? 'brew' : null;
97
+ }
98
+
99
+ function loadLedger() {
100
+ if (!existsSync(LEDGER)) return null;
101
+ try {
102
+ return JSON.parse(readFileSync(LEDGER, 'utf8'));
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+
108
+ function saveLedger(ledger) {
109
+ mkdirSync(STATE_DIR, { recursive: true });
110
+ const tmp = LEDGER + '.tmp';
111
+ writeFileSync(tmp, JSON.stringify(ledger, null, 2) + '\n');
112
+ renameSync(tmp, LEDGER);
113
+ }
114
+
115
+ function blankLedger() {
116
+ return { schema: 1, initialisedAt: null, cadenceDays: 7, autoUpdate: false, checkedAt: null, lastAttemptAt: null, tools: {} };
117
+ }
118
+
119
+ function installedVersion(name) {
120
+ const [bin, args] = OPTIONAL[name].versionCmd;
121
+ if (!onPath(bin)) return null;
122
+ const r = run(bin, args);
123
+ return r.ok ? semver(r.out) : null;
124
+ }
125
+
126
+ function latestVersion(name) {
127
+ const r = run('gh', ['release', 'view', '-R', OPTIONAL[name].repo, '--json', 'tagName', '-q', '.tagName']);
128
+ return r.ok ? semver(r.out) : null;
129
+ }
130
+
131
+ function requiredMissing() {
132
+ return REQUIRED.filter((bin) => !onPath(bin));
133
+ }
134
+
135
+ function presence(ledger) {
136
+ const out = {};
137
+ for (const [name, entry] of Object.entries(ledger.tools)) {
138
+ if (entry.choice !== 'installed' || !Object.hasOwn(OPTIONAL, name)) continue;
139
+ out[name] = onPath(OPTIONAL[name].versionCmd[0]) ? 'present' : 'missing';
140
+ }
141
+ return out;
142
+ }
143
+
144
+ // The network half of `check`. Updates the ledger in place.
145
+ function fullCheck(ledger, now) {
146
+ ledger.lastAttemptAt = new Date(now).toISOString();
147
+ let allResolved = true;
148
+ for (const [name, entry] of Object.entries(ledger.tools)) {
149
+ if (entry.choice !== 'installed' || !Object.hasOwn(OPTIONAL, name)) continue;
150
+ entry.version = installedVersion(name);
151
+ const latest = latestVersion(name);
152
+ if (latest) entry.latest = latest;
153
+ else allResolved = false;
154
+ entry.outdated = newer(entry.latest, entry.version);
155
+ }
156
+ if (allResolved) ledger.checkedAt = ledger.lastAttemptAt;
157
+ if (existsSync(join(SKILL_DIR, 'scripts', 'install-revmux-prompts.sh')) && ledger.tools.revmux?.choice === 'installed') {
158
+ const r = run('bash', [join(SKILL_DIR, 'scripts', 'install-revmux-prompts.sh'), '--check']);
159
+ ledger.tools.revmux.promptsInSync = r.ok;
160
+ }
161
+ return allResolved;
162
+ }
163
+
164
+ function isDue(ledger, now) {
165
+ const age = (iso) => (iso ? now - Date.parse(iso) : Infinity);
166
+ return age(ledger.checkedAt) > ledger.cadenceDays * DAY_MS && age(ledger.lastAttemptAt) > RETRY_AFTER_MS;
167
+ }
168
+
169
+ function brewRun(args) {
170
+ const brew = findBrew();
171
+ if (!brew) return { status: 'manual', reason: 'brew not found', command: `brew ${args.join(' ')}` };
172
+ const r = run(brew, args, 300000);
173
+ return r.ok ? { status: 'ok' } : { status: 'failed', command: `brew ${args.join(' ')}`, tail: r.out };
174
+ }
175
+
176
+ function postInstall(name) {
177
+ const p = OPTIONAL[name].postInstall;
178
+ if (!p) return null;
179
+ const r = run(p[0], p[1], 60000);
180
+ return r.ok ? 'ok' : `failed: ${r.out}`;
181
+ }
182
+
183
+ function install(ledger, name) {
184
+ const res = brewRun(OPTIONAL[name].brew);
185
+ const version = installedVersion(name);
186
+ // brew can fail with "already installed": the binary on PATH is the truth.
187
+ if (version) {
188
+ ledger.tools[name] = { ...ledger.tools[name], choice: 'installed', version, decidedAt: new Date().toISOString() };
189
+ return { tool: name, status: 'ok', version, postInstall: postInstall(name) };
190
+ }
191
+ ledger.tools[name] = { ...ledger.tools[name], choice: 'installed', version: null, decidedAt: new Date().toISOString() };
192
+ return { tool: name, ...res };
193
+ }
194
+
195
+ function update(ledger, names) {
196
+ const results = [];
197
+ for (const name of names) {
198
+ const entry = ledger.tools[name];
199
+ if (!entry || entry.choice !== 'installed') continue;
200
+ const before = installedVersion(name);
201
+ const res = before ? brewRun(OPTIONAL[name].brewUpgrade) : brewRun(OPTIONAL[name].brew);
202
+ const after = installedVersion(name);
203
+ entry.version = after;
204
+ entry.outdated = newer(entry.latest, after);
205
+ // Resync even when brew could not run: the prompts follow the skill, not the binary.
206
+ const post = after ? postInstall(name) : null;
207
+ if (name === 'revmux' && post) entry.promptsInSync = post === 'ok';
208
+ results.push({ tool: name, before, after, ...res, postInstall: post });
209
+ }
210
+ return results;
211
+ }
212
+
213
+ function unknownTool(name) {
214
+ if (OPTIONAL[name]) return false;
215
+ console.log(JSON.stringify({ error: `unknown tool "${name}"`, known: Object.keys(OPTIONAL) }));
216
+ process.exitCode = 2;
217
+ return true;
218
+ }
219
+
220
+ const now = Date.now();
221
+ let ledger = loadLedger();
222
+
223
+ if (cmd === 'check') {
224
+ const missing = requiredMissing();
225
+ if (missing.length) {
226
+ console.log(JSON.stringify({ state: 'missing-required', missing }));
227
+ } else if (!ledger?.initialisedAt) {
228
+ const offer = Object.entries(OPTIONAL).map(([name, t]) => ({ tool: name, usedBy: t.usedBy, present: onPath(t.versionCmd[0]) }));
229
+ console.log(JSON.stringify({ state: 'first-run', offer }));
230
+ } else {
231
+ const due = has('force') || isDue(ledger, now);
232
+ if (due) fullCheck(ledger, now);
233
+ const outdated = Object.entries(ledger.tools).filter(([, e]) => e.choice === 'installed' && e.outdated).map(([n, e]) => ({ tool: n, version: e.version, latest: e.latest }));
234
+ let updated = null;
235
+ // Unattended runs only report: upgrading a binary is a machine change.
236
+ if (due && outdated.length && ledger.autoUpdate && !has('unattended')) {
237
+ updated = update(ledger, outdated.map((o) => o.tool));
238
+ }
239
+ if (due) saveLedger(ledger);
240
+ console.log(JSON.stringify({
241
+ state: due ? 'checked' : 'fresh',
242
+ checkedAt: ledger.checkedAt,
243
+ nextCheckAfter: ledger.checkedAt ? new Date(Date.parse(ledger.checkedAt) + ledger.cadenceDays * DAY_MS).toISOString() : null,
244
+ presence: presence(ledger),
245
+ outdated,
246
+ updated,
247
+ revmuxPromptsInSync: ledger.tools.revmux?.promptsInSync ?? null,
248
+ skipped: Object.entries(ledger.tools).filter(([, e]) => e.choice === 'skipped').map(([n]) => n),
249
+ // Tools added to the registry after the first run: offer these once.
250
+ undecided: Object.keys(OPTIONAL).filter((n) => !ledger.tools[n]).map((n) => ({ tool: n, usedBy: OPTIONAL[n].usedBy })),
251
+ }));
252
+ }
253
+ } else if (cmd === 'init') {
254
+ ledger = ledger || blankLedger();
255
+ const list = (s) => (s ? s.split(',').map((x) => x.trim()).filter(Boolean) : []);
256
+ const auto = flag('auto-update');
257
+ if (auto) ledger.autoUpdate = auto === 'on';
258
+ const cadence = Number(flag('cadence-days'));
259
+ if (cadence > 0) ledger.cadenceDays = cadence;
260
+ const results = [];
261
+ for (const name of list(flag('skip'))) {
262
+ if (unknownTool(name)) break;
263
+ ledger.tools[name] = { choice: 'skipped', decidedAt: new Date().toISOString() };
264
+ results.push({ tool: name, status: 'skipped' });
265
+ }
266
+ for (const name of list(flag('install'))) {
267
+ if (unknownTool(name)) break;
268
+ results.push(install(ledger, name));
269
+ }
270
+ // Answering the offer covers every tool in it: one not picked counts as
271
+ // skipped, so the question is never asked twice.
272
+ for (const name of Object.keys(OPTIONAL)) {
273
+ if (!ledger.tools[name]) ledger.tools[name] = { choice: 'skipped', decidedAt: new Date().toISOString() };
274
+ }
275
+ ledger.initialisedAt = ledger.initialisedAt || new Date(now).toISOString();
276
+ fullCheck(ledger, now);
277
+ saveLedger(ledger);
278
+ console.log(JSON.stringify({ state: 'initialised', ledger: LEDGER, autoUpdate: ledger.autoUpdate, cadenceDays: ledger.cadenceDays, results }));
279
+ } else if (cmd === 'install' || cmd === 'skip') {
280
+ const name = argv[1];
281
+ if (!unknownTool(name)) {
282
+ ledger = ledger || blankLedger();
283
+ const result = cmd === 'install'
284
+ ? install(ledger, name)
285
+ : ((ledger.tools[name] = { choice: 'skipped', decidedAt: new Date().toISOString() }), { tool: name, status: 'skipped' });
286
+ saveLedger(ledger);
287
+ console.log(JSON.stringify(result));
288
+ }
289
+ } else if (cmd === 'update') {
290
+ if (!ledger) {
291
+ console.log(JSON.stringify({ state: 'first-run', hint: 'run init first' }));
292
+ } else {
293
+ fullCheck(ledger, now);
294
+ const names = argv[1] ? [argv[1]] : Object.entries(ledger.tools).filter(([, e]) => e.choice === 'installed' && e.outdated).map(([n]) => n);
295
+ const results = update(ledger, names);
296
+ saveLedger(ledger);
297
+ console.log(JSON.stringify({ state: 'updated', results }));
298
+ }
299
+ } else if (cmd === 'reset') {
300
+ saveLedger(blankLedger());
301
+ console.log(JSON.stringify({ state: 'reset', ledger: LEDGER }));
302
+ } else {
303
+ console.log(JSON.stringify({ error: `unknown command "${cmd}"` }));
304
+ process.exitCode = 2;
305
+ }
@@ -134,18 +134,23 @@ cp "$WORKTREE_JSON" "$CONTEXT_DIR/worktree.json"
134
134
  cp "$PROFILE_FILE" "$PROFILE_INPUT_FILE"
135
135
 
136
136
  REVMUX_OUT="$OUT"
137
+ # revmux keeps no stderr of its own. Seven rounds between 2026-10-08 and
138
+ # 2026-10-14 had every agent exit -1 within seconds with nothing in the
139
+ # archive to say why, so keep it beside the round (input/scope.md -> round dir).
140
+ STDERR_LOG="$(dirname "$(dirname "$SCOPE_FILE")")/revmux.stderr.log"
137
141
  set +e
138
142
  revmux --task "$TASK" --run "$RUN" --profile "$PROFILE_NAME" \
139
143
  --workdir "$WORKDIR" --tasks-dir "$TASKS_DIR" --config-dir "$CONFIG_DIR" \
140
144
  --tools=Read,Grep,Glob,WebFetch,WebSearch \
141
- --no-tui > "$REVMUX_OUT"
145
+ --no-tui > "$REVMUX_OUT" 2> "$STDERR_LOG"
142
146
  CODE=$?
143
147
  set -e
148
+ cat "$STDERR_LOG" >&2
144
149
 
145
150
  case "$CODE" in
146
151
  0) printf 'revmux exit 0 (no findings)\n' >&2 ;;
147
152
  1) printf 'revmux exit 1 (findings reported)\n' >&2 ;;
148
- *) printf 'revmux exit %s (tool error)\n' "$CODE" >&2 ;;
153
+ *) printf 'revmux exit %s (tool error), stderr kept in %s\n' "$CODE" "$STDERR_LOG" >&2 ;;
149
154
  esac
150
155
 
151
156
  exit "$CODE"