@olegkoval/agent-skills 1.58.0 → 1.59.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 (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
@@ -140,6 +140,12 @@ returns the number checked as `hardRuleCount`.
140
140
  Resolve every `references/...` and script path relative to `SKILL_ROOT`; never
141
141
  assume a `~/.claude/skills/` installation path.
142
142
 
143
+ Run one simple command per shell call. Do not prefix commands with
144
+ `cd <dir> &&`; use absolute paths, `git -C <dir>`, and `grep -rn <pattern> <dir>`
145
+ instead. Permission checkers cannot vet a compound or `cd`-prefixed command,
146
+ so in an unattended or auto-approved run each one becomes a denial and a
147
+ wasted turn.
148
+
143
149
  ## Host selection
144
150
 
145
151
  Set `HOST=codex` when native Codex collaboration tools such as `spawn_agent`
@@ -158,6 +164,39 @@ single-agent review is not equivalent to lekker-review.
158
164
 
159
165
  ## Step 0 - Parse input
160
166
 
167
+ ### Dependencies (first thing, every run)
168
+
169
+ ```bash
170
+ node ${SKILL_ROOT}/scripts/deps.mjs check # add --unattended when no human is present
171
+ ```
172
+
173
+ Milliseconds: it only checks presence. The upstream version check needs the
174
+ network, so it runs at most once per `cadenceDays` (default 7) and the ledger
175
+ `~/.local/state/lekker-review/deps.json` records when it last ran. Act on `state`:
176
+
177
+ - `first-run`: ask ONCE (AskUserQuestion in Claude Code): a multiSelect of the
178
+ `offer[]` tools (label = tool, description = `usedBy`), plus "keep them
179
+ updated automatically?" yes/no. Then
180
+ `deps.mjs init --install <picked> --skip <the rest> --auto-update on|off`.
181
+ A tool not picked is recorded as skipped and never offered again; the user can
182
+ still run `deps.mjs install <tool>` later. Unattended: do not ask, do not
183
+ install; carry on and say `deps not initialised` in the header.
184
+ - `missing-required`: stop and print the `missing` list. The review needs them.
185
+ - `undecided` non-empty (a tool added to the registry since init): ask about
186
+ those tools only, then `deps.mjs install <tool>` or `deps.mjs skip <tool>`.
187
+ - `outdated` non-empty and `updated` null: one line, e.g.
188
+ `deps outdated: revmux 0.2.6 -> 0.2.7 (deps.mjs update)`. With auto-update on,
189
+ `check` already upgraded them when the check was due; report `updated`.
190
+ - `revmuxPromptsInSync: false`: run
191
+ `bash ${SKILL_ROOT}/scripts/install-revmux-prompts.sh`, which resyncs the
192
+ lekker profiles into `~/.config/revmux` (`deps.mjs update revmux` does the
193
+ same after upgrading).
194
+ - An install that returns `manual` (no Homebrew on PATH) prints the command to
195
+ run by hand; pass it on, do not improvise another installer.
196
+
197
+ `ENGINE=revmux` additionally needs revmux `present`; when it is missing or
198
+ skipped, fall back to the host's native engine and say why in the header.
199
+
161
200
  ### Pick the mode first
162
201
 
163
202
  `MODE=branch` when ANY of these hold:
@@ -312,6 +351,25 @@ treat as a full review and leave PREV_SHA unset. Also grep the same file for
312
351
  - Step 3.5 reuses that exact URL or local path so the artifact stays a living
313
352
  page for this PR.
314
353
 
354
+ **Open the run log** once mode, depth and engine are settled (see
355
+ `references/traces.md` for where every trace of a run lives):
356
+
357
+ Resolve the run-log script path in every shell call. The examples below use
358
+ Claude Code's `CLAUDE_PLUGIN_ROOT`. In Codex, replace the quoted script path in
359
+ each invocation with the absolute path to `scripts/run-log.mjs` beside the
360
+ selected `SKILL.md`. Do not rely on a shell variable set by an earlier call.
361
+
362
+ ```bash
363
+ node "${CLAUDE_PLUGIN_ROOT}/skills/lekker-review/scripts/run-log.mjs" start --repo <REPO_SLUG> --target <TARGET_SLUG> --mode <MODE> \
364
+ --depth <depth> --engine <ENGINE> [--fix]
365
+ ```
366
+
367
+ Keep the returned `runId` as `RUN_ID`. Then run
368
+ `node "${CLAUDE_PLUGIN_ROOT}/skills/lekker-review/scripts/run-log.mjs" mark <RUN_ID> <phase>`
369
+ at each step boundary: `context` (Step 1), `review` (Step 2), `synthesis`
370
+ (Step 3), `fix` (Step 4), `post` (Step 5). A marker is one line of JSON; never
371
+ skip the review because the log failed, just say `run log unavailable`.
372
+
315
373
  ---
316
374
 
317
375
  ## Depth gate
@@ -388,7 +446,14 @@ On completion, read `worktree.json`. Keys emitted:
388
446
  `worktreePath`, `repoRoot`, `headSha`, `headShaShort`, `tscTail`,
389
447
  `tscChangedTail`, `tscErrorCount`, `eslintTail`, `eslintScope`, `eslintJson`,
390
448
  `changedFiles`, `baseRef`, `mergeBase`, `projectRules`, `deltaFile`, `notes`,
391
- `depsInstall`.
449
+ `depsInstall`, `testRunner`.
450
+
451
+ `testRunner` is `{runner, dirs, reason}` from `scripts/test-runner.mjs`:
452
+ `runner` is `vitest|jest|mocha|node-test` only when its binary is installed
453
+ in the worktree, `dirs` lists `{dir, runner}` pairs for each usable package
454
+ (including binaries installed in a parent dir within the worktree), and `reason`
455
+ says why `runner` is null. Pass it to the workflow unchanged; Prove skips
456
+ without one instead of paying provers to find the missing `node_modules`.
392
457
 
393
458
  `notes` containing `changed-files-mismatch:<got>-vs-<expected>` means the
394
459
  merge-base still disagrees with the PR. Use the `files` list from `gh pr view`
@@ -601,7 +666,10 @@ First copy the script into the scratchpad. The Workflow tool rejects a
601
666
  `scriptPath` outside the working directory or added dirs ("scriptPath must be a
602
667
  script path this tool returned, or a file you can already read"), and the skill
603
668
  dir is usually neither: `cp ${SKILL_ROOT}/workflow.js <scratchpad>/workflow.js`.
604
- If it still rejects the path, pass the script inline via `script` instead.
669
+ If `workflow.js` is already in the working directory (an unattended runner may
670
+ stage it there), use that copy and skip the `cp`: some permission checks deny
671
+ reading files out of the skill dir. If the Workflow tool still rejects the
672
+ path, pass the script inline via `script` instead.
605
673
 
606
674
  Invoke the Workflow tool:
607
675
 
@@ -617,7 +685,8 @@ args: {
617
685
  contextFile: "<scratchpad>/context.json",
618
686
  worktreePath: <null for scan, else from worktree.json>,
619
687
  promptDir: "${SKILL_ROOT}/references/agents",
620
- prevSha: <null unless re-review>
688
+ prevSha: <null unless re-review>,
689
+ testRunner: <worktree.json testRunner, null for scan>
621
690
  }
622
691
  ```
623
692
 
@@ -662,14 +731,16 @@ The workflow runs three phases:
662
731
  thirty-finding PR launches thirty concurrent agents.
663
732
  - **Critic (deep only):** completeness critic gets the full deduped finding
664
733
  list + DIFF_FILE; its findings go through verifier agents before promotion.
665
- - **Prove (medium/deep, worktree required):** each non-hard-rule Critical gets
666
- one prover agent (`references/agents/prover.md`, sonnet, max 5, run
734
+ - **Prove (medium/deep, worktree and an installed test runner required):**
735
+ each Critical without a `rule` tag gets one prover agent (`references/agents/prover.md`, sonnet, max 5, run
667
736
  `maxConcurrent` at a time) that writes a test asserting the CORRECT behaviour,
668
737
  runs it in the worktree, and captures it failing because of the bug. The proof rides on the finding as
669
738
  `proof: {attempted, proven, outcome, reason, testCode?, testCommand?, redOutput?}`.
670
739
  A proof that comes back GREEN (code behaved correctly) is counter-evidence -
671
740
  the workflow automatically downgrades the finding from Critical to Important.
672
- Hard-rule findings are never proved (policy violations have no failing test).
741
+ Rule-tagged findings are never proved (policy violations have no failing
742
+ test). With `testRunner.runner` null, no prover runs and each candidate
743
+ carries `proof: {attempted: false, reason: "no runnable test runner: ..."}`.
673
744
 
674
745
  Findings have schema:
675
746
  `{file, line, severity, title, description, badCode, fix, rule?, precedent?, agreedBy?, verificationStatus?, verifierReasoning?, proof?}`
@@ -731,7 +802,8 @@ round; Prove runs through the current host's native engine. Steps:
731
802
  adapter's `agents` array has `degraded: true`. State the fallback in the
732
803
  review header (`**Note:** revmux unavailable - fell back to workflow
733
804
  engine` or `...fell back to codex engine`) and re-run the native path from
734
- the top of Step 2.
805
+ the top of Step 2. Quote the last lines of `<round dir>/revmux.stderr.log`
806
+ in that note: it is the only record of why the agents failed.
735
807
 
736
808
  **Trust boundary:** always pass `--config-dir ~/.config/revmux` explicitly.
737
809
  Without it revmux also reads the reviewed repo's checked-in `.revmux/`, which
@@ -971,6 +1043,24 @@ Write it against the sha that was actually reviewed. When fix mode later adds
971
1043
  commits, write a marker for the NEW head sha too - a gate keys on the tip
972
1044
  that is about to be pushed, and a marker for a superseded sha would be a lie.
973
1045
 
1046
+ **Close the run log** right after the receipt, then print the review:
1047
+
1048
+ ```bash
1049
+ node "${CLAUDE_PLUGIN_ROOT}/skills/lekker-review/scripts/run-log.mjs" attach <RUN_ID> <scratchpad>/findings.json <scratchpad>/worktree.json \
1050
+ <scratchpad>/preflight.json <scratchpad>/context.json
1051
+ node "${CLAUDE_PLUGIN_ROOT}/skills/lekker-review/scripts/run-log.mjs" finish <RUN_ID> --status ok --verdict lgtm|lgtm-with-changes|needs-work \
1052
+ --review-file "$REVIEW_FILE" [--wf <wf_id of the lekker-review-core run>]
1053
+ ```
1054
+
1055
+ `--wf` is the `runId` the Claude Code Workflow tool returned (omit it on other
1056
+ hosts). `finish` copies duration, tokens, agent count, verifier drops and
1057
+ prove results out of the harness's own record, which Claude Code deletes with
1058
+ old sessions. When fix mode, the artifact or a post follows, call `finish`
1059
+ again at the very end using the resolved script path, with every `--wf` id of
1060
+ the run (review and fix) and
1061
+ `--artifact-url`: the newest row wins. A run that dies part-way still gets
1062
+ `node "${CLAUDE_PLUGIN_ROOT}/skills/lekker-review/scripts/run-log.mjs" finish <RUN_ID> --status failed --note <why>`.
1063
+
974
1064
  Then print the full review as the response.
975
1065
 
976
1066
  **Closer goes last.** The run's FINAL message (this one, or the later summary
@@ -1037,8 +1127,9 @@ Read `references/fix-mode.md` and follow it. Shape of the run:
1037
1127
  its severity. Never auto-fix any other Observation or Idiomatic finding, or a
1038
1128
  title/process rule.
1039
1129
  3. Invoke the host's fix workflow. In Claude, copy `fix-workflow.js` to the
1040
- scratchpad first (same reason as the review workflow; inline `script` if the
1041
- path is still rejected), then run:
1130
+ scratchpad first (same reason as the review workflow; skip the copy when it
1131
+ is already in the working directory; inline `script` if the path is still
1132
+ rejected), then run:
1042
1133
  ```
1043
1134
  scriptPath: <scratchpad>/fix-workflow.js
1044
1135
  args: { repoSlug, prNumber, targetLabel, worktreePath, diffFile, contextFile,
@@ -1232,6 +1323,11 @@ If `repoRoot` starts with `/tmp/lekker-clone-`, also remove that clone dir.
1232
1323
  Remove the delta file if `deltaFile` was set in worktree.json. Verify with
1233
1324
  `git -C <repoRoot> worktree list` that no `lekker-review` entries remain.
1234
1325
 
1326
+ **Measuring changes.** `node "${CLAUDE_PLUGIN_ROOT}/skills/lekker-review/scripts/run-log.mjs" report --days 30`
1327
+ prints duration, tokens, findings, verifier drop rate and prove yield per
1328
+ engine and depth. Run it before and after a change to `workflow.js` or the
1329
+ prompts and quote both. `backfill` imports past runs (idempotent).
1330
+
1235
1331
  ---
1236
1332
 
1237
1333
  ## 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"