@olegkoval/agent-skills 1.70.0 → 1.71.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 (37) hide show
  1. package/adapters/claude/olko-github-pr/skills/lekker-review/SKILL.md +11 -2
  2. package/adapters/claude/olko-github-pr/skills/lekker-review/references/agents/conventions.md +3 -0
  3. package/adapters/claude/olko-github-pr/skills/lekker-review/references/agents/implementation.md +3 -0
  4. package/adapters/claude/olko-github-pr/skills/lekker-review/references/agents/quality.md +3 -0
  5. package/adapters/claude/olko-github-pr/skills/lekker-review/references/agents/simplification.md +3 -0
  6. package/adapters/claude/olko-github-pr/skills/lekker-review/references/agents/test-quality.md +3 -0
  7. package/adapters/claude/olko-github-pr/skills/lekker-review/references/agents/verifier.md +28 -3
  8. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/fixtures/project-rules/CLAUDE.md +4 -0
  9. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/fixtures/project-rules/planted.diff +9 -0
  10. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/project-rule-findings.mjs +122 -0
  11. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/selftest.mjs +160 -0
  12. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/setup-worktree.sh +63 -5
  13. package/package.json +1 -1
  14. package/plugins/olko-apple-kit/.claude-plugin/plugin.json +1 -1
  15. package/plugins/olko-creative/.claude-plugin/plugin.json +1 -1
  16. package/plugins/olko-garmin-kit/.claude-plugin/plugin.json +1 -1
  17. package/plugins/olko-git-tools/.claude-plugin/plugin.json +1 -1
  18. package/plugins/olko-github-pr/.claude-plugin/plugin.json +1 -1
  19. package/plugins/olko-github-pr/skills/lekker-review/SKILL.md +11 -2
  20. package/plugins/olko-github-pr/skills/lekker-review/references/agents/conventions.md +3 -0
  21. package/plugins/olko-github-pr/skills/lekker-review/references/agents/implementation.md +3 -0
  22. package/plugins/olko-github-pr/skills/lekker-review/references/agents/quality.md +3 -0
  23. package/plugins/olko-github-pr/skills/lekker-review/references/agents/simplification.md +3 -0
  24. package/plugins/olko-github-pr/skills/lekker-review/references/agents/test-quality.md +3 -0
  25. package/plugins/olko-github-pr/skills/lekker-review/references/agents/verifier.md +28 -3
  26. package/plugins/olko-github-pr/skills/lekker-review/scripts/fixtures/project-rules/CLAUDE.md +4 -0
  27. package/plugins/olko-github-pr/skills/lekker-review/scripts/fixtures/project-rules/planted.diff +9 -0
  28. package/plugins/olko-github-pr/skills/lekker-review/scripts/project-rule-findings.mjs +122 -0
  29. package/plugins/olko-github-pr/skills/lekker-review/scripts/selftest.mjs +160 -0
  30. package/plugins/olko-github-pr/skills/lekker-review/scripts/setup-worktree.sh +63 -5
  31. package/plugins/olko-github-pr/skills/lekker-review/workflow.js +62 -2
  32. package/plugins/olko-obsidian/.claude-plugin/plugin.json +1 -1
  33. package/plugins/olko-product/.claude-plugin/plugin.json +1 -1
  34. package/plugins/olko-reflection/.claude-plugin/plugin.json +1 -1
  35. package/plugins/olko-release/.claude-plugin/plugin.json +1 -1
  36. package/plugins/olko-skill-meta/.claude-plugin/plugin.json +1 -1
  37. package/plugins/olko-web-ops/.claude-plugin/plugin.json +1 -1
@@ -470,12 +470,13 @@ Fire both tracks in the same turn.
470
470
  At scan depth, collect review guidance without creating a worktree:
471
471
 
472
472
  ```bash
473
- LEKKER_RULES_ONLY=1 ${SKILL_ROOT}/scripts/setup-worktree.sh \
473
+ LEKKER_RULES_ONLY=1 LEKKER_BASE_REF=origin/<BASE_BRANCH> \
474
+ ${SKILL_ROOT}/scripts/setup-worktree.sh \
474
475
  <REPO_SLUG> <PR_BRANCH> <scratchpad>/rules.json
475
476
  ```
476
477
 
477
478
  Read `projectRules` from `rules.json` into `context.json`. This archive-only
478
- mode reads the PR tree and does not run static checks.
479
+ mode fetches the PR base for `baseProjectRules` and does not run static checks.
479
480
 
480
481
  Run via Bash with `run_in_background`:
481
482
 
@@ -687,6 +688,7 @@ Write `<scratchpad>/context.json` with keys:
687
688
  {
688
689
  "acList": "<numbered ACs from your issue tracker, or empty>",
689
690
  "projectRules": "<worktree.json or rules.json projectRules + any recalled review patterns appended under '## Recalled patterns'>",
691
+ "baseProjectRules": "<worktree.json or rules.json baseProjectRules, copied unchanged (no recalled patterns); null when absent>",
690
692
  "changedPaths": "<PR files[].path list, or an exact matching worktree.json changedFiles list>",
691
693
  "sentrySignals": "<monitoring issue summaries, or null>",
692
694
  "ciStatus": "<passing | failing: <names> | pending | N/A>",
@@ -915,6 +917,13 @@ value first). `REVIEW.md` ignore rules never suppress findings in changed paths.
915
917
  applies exclusions to agent and lint findings using `changedPaths`; if paths are
916
918
  unavailable, keep findings. It never switches off a hard rule from `CLAUDE.md` or the house rules.
917
919
 
920
+ **Project rules are hard rules.** A prohibition or obligation in `projectRules`
921
+ (NEVER, MUST, ALWAYS, DO NOT or plain-English equivalent) that a changed line
922
+ violates is raised as Critical with `rule: "PROJECT:<slug>"` and a verbatim
923
+ `ruleQuote`. The verifier runs `scripts/project-rule-findings.mjs`, which drops
924
+ a finding whose quote is not found in `projectRules`; otherwise it takes the
925
+ hard-rule path (anchor and applicability, no runtime challenges).
926
+
918
927
  **Approval standard.** The verdict answers "does this definitely improve the
919
928
  codebase's health", not "is this how I would have written it". Perfect code does
920
929
  not exist. `✅ LGTM - ship it` is the right call for a change that improves
@@ -127,6 +127,9 @@ Use it as a prior, not as a checklist:
127
127
  EXISTING_REVIEWS: read key "existingReviews" from CONTEXT_FILE (awareness only, skip findings already raised)
128
128
 
129
129
  PROJECT_RULES to verify: read key "projectRules" from CONTEXT_FILE.
130
+ A changed line that violates a prohibition or obligation in PROJECT_RULES is a Critical
131
+ finding tagged `rule: "PROJECT:<slug>"` with `ruleQuote` set to the exact rule sentence
132
+ (see PROJECT RULES AS HARD RULES in the task message).
130
133
 
131
134
  Diff: read the full unified PR diff from the file DIFF_FILE (absolute path given in your task message). Do NOT run gh pr diff.
132
135
  Worktree: WORKTREE_PATH is given in your task message (null in scan mode, diff only).
@@ -105,6 +105,9 @@ SENTRY_SIGNALS: read key "sentrySignals" from CONTEXT_FILE.
105
105
  EXISTING_REVIEWS: read key "existingReviews" from CONTEXT_FILE (awareness only, skip findings already raised)
106
106
 
107
107
  PROJECT_RULES to verify: read key "projectRules" from CONTEXT_FILE.
108
+ A changed line that violates a prohibition or obligation in PROJECT_RULES is a Critical
109
+ finding tagged `rule: "PROJECT:<slug>"` with `ruleQuote` set to the exact rule sentence
110
+ (see PROJECT RULES AS HARD RULES in the task message).
108
111
 
109
112
  Diff: read the full unified PR diff from the file DIFF_FILE (absolute path given in your task message). Do NOT run gh pr diff.
110
113
  Worktree: WORKTREE_PATH is given in your task message (null in scan mode, diff only).
@@ -72,6 +72,9 @@ Note: awareness only. Do not anchor on prior reviews. Skip findings already
72
72
  raised at the same file:line by another reviewer.
73
73
 
74
74
  PROJECT_RULES to verify: read key "projectRules" from CONTEXT_FILE.
75
+ A changed line that violates a prohibition or obligation in PROJECT_RULES is a Critical
76
+ finding tagged `rule: "PROJECT:<slug>"` with `ruleQuote` set to the exact rule sentence
77
+ (see PROJECT RULES AS HARD RULES in the task message).
75
78
 
76
79
  Diff: read the full unified PR diff from the file DIFF_FILE (absolute path given in your task message). Do NOT run gh pr diff.
77
80
  Worktree: WORKTREE_PATH is given in your task message (null in scan mode, diff only).
@@ -56,6 +56,9 @@ actionable: name the move or drop the finding.
56
56
  EXISTING_REVIEWS: read key "existingReviews" from CONTEXT_FILE (awareness only, skip findings already raised)
57
57
 
58
58
  PROJECT_RULES to verify: read key "projectRules" from CONTEXT_FILE.
59
+ A changed line that violates a prohibition or obligation in PROJECT_RULES is a Critical
60
+ finding tagged `rule: "PROJECT:<slug>"` with `ruleQuote` set to the exact rule sentence
61
+ (see PROJECT RULES AS HARD RULES in the task message).
59
62
 
60
63
  Diff: read the full unified PR diff from the file DIFF_FILE (absolute path given in your task message). Do NOT run gh pr diff.
61
64
  Worktree: WORKTREE_PATH is given in your task message (null in scan mode, diff only).
@@ -169,6 +169,9 @@ Return the review summary through these structured fields in addition to
169
169
  EXISTING_REVIEWS: read key "existingReviews" from CONTEXT_FILE (awareness only, skip findings already raised)
170
170
 
171
171
  PROJECT_RULES to verify: read key "projectRules" from CONTEXT_FILE.
172
+ A changed line that violates a prohibition or obligation in PROJECT_RULES is a Critical
173
+ finding tagged `rule: "PROJECT:<slug>"` with `ruleQuote` set to the exact rule sentence
174
+ (see PROJECT RULES AS HARD RULES in the task message).
172
175
  Diff: read DIFF_FILE. List every test file/spec
173
176
  Worktree: WORKTREE_PATH is given in your task message (null in scan mode, diff only).
174
177
 
@@ -28,14 +28,28 @@ unless Step 0 validates it through the house hard-rule path.
28
28
 
29
29
  ## Step 0 -- Hard-rule validation (check this FIRST)
30
30
 
31
- If the FINDING JSON has any non-empty `rule` field, including built-in tags
32
- such as `TS-1`, `TS-2`, `GQL-1`, or `PR-1`, do NOT run the five adversarial
31
+ If the FINDING JSON has any non-empty `rule` field, do NOT run the five adversarial
33
32
  challenges below. They ask
34
33
  runtime-failure questions that a standards violation can never answer, and
35
34
  answering them honestly would drop a finding that house-rules policy declares
36
35
  Critical on standards grounds rather than on runtime behaviour.
37
36
 
38
- Instead run exactly two checks:
37
+ For `rule: "weakens-project-rule"`, use this dedicated path:
38
+
39
+ 1. **Removed-line anchor**: confirm the quoted rule appears on a `-` line in
40
+ `DIFF_FILE` for the finding's file. Ignore `---` file headers. This anchor is
41
+ valid for both a deletion and a replacement; do not require a matching `+`
42
+ line or downgrade the finding because it has no production-code `+` line.
43
+ 2. **Base-rule validation**: write the FINDING JSON to a unique temp file and run
44
+ `node <this prompt's directory>/../../scripts/project-rule-findings.mjs <finding.json> <CONTEXT_FILE>`.
45
+ The validator requires the exact quote to exist in `baseProjectRules`; a
46
+ missing base copy or rejected quote fails this check. Do not validate against
47
+ `projectRules`, which may already reflect the PR's weakened rules.
48
+
49
+ Both checks pass -> `confirmed` at the finding's Important severity. Either
50
+ check fails -> `dropped`. Do not downgrade a confirmed weakening.
51
+
52
+ For all other non-empty `rule` tags, run exactly two checks:
39
53
 
40
54
  1. **Anchor**: the flagged code genuinely appears on a `+` line of
41
55
  `DIFF_FILE` (for PR-1, the PR title genuinely lacks a ticket present in
@@ -55,6 +69,17 @@ the rule and quoting the violating snippet. Either check fails -> `dropped`,
55
69
  with `reasoning` naming which check failed. There is no `downgraded` outcome
56
70
  for a hard rule: it either violates the rule or it does not.
57
71
 
72
+ For a `rule` starting with `PROJECT:`, the rule text lives in `baseProjectRules` of
73
+ `CONTEXT_FILE` when that key is a string (the base branch's copy, which the PR cannot
74
+ edit), otherwise in `projectRules`, not in `HOUSE_RULES_FILE`. Write the FINDING JSON to a unique temp file
75
+ (create it with `mktemp`, never a fixed name, because verifiers run in parallel) and run `node <this prompt's directory>/../../scripts/project-rule-findings.mjs <finding.json> <CONTEXT_FILE>`.
76
+ Exit 1 means the quoted rule is not in `projectRules` (or the tag is malformed):
77
+ `dropped`, with that output as `reasoning`. Any other non-zero exit means the validator
78
+ could not run: read that same key yourself and confirm the exact quoted sentence is in it.
79
+ If it is not, `dropped`. If it is, keep the finding at its severity, say in `reasoning` that
80
+ the validator was unavailable, set `validatorUnavailable: true`, and judge applicability as below. On exit 0, judge applicability
81
+ against the quoted rule as the second check.
82
+
58
83
  Then **stop** -- do not continue to Step 1 or the five challenges.
59
84
 
60
85
  ---
@@ -0,0 +1,4 @@
1
+ # Fixture rules
2
+
3
+ - NEVER call console.log in src/.
4
+ - NEVER mutate function arguments.
@@ -0,0 +1,9 @@
1
+ diff --git a/src/total.js b/src/total.js
2
+ --- a/src/total.js
3
+ +++ b/src/total.js
4
+ @@ -1,3 +1,6 @@
5
+ +export function addItem(list, item) {
6
+ + console.log('adding', item)
7
+ + list.push(item)
8
+ + return list
9
+ +}
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+ // Validates findings tagged `rule: "PROJECT:<slug>"`.
3
+ //
4
+ // A project's own prohibitions and obligations (CLAUDE.md, AGENTS.md, REVIEW.md)
5
+ // are collected into `projectRules`. A lens that raises one as a hard rule must
6
+ // quote the rule it enforces. A quote that is not in `projectRules` means the
7
+ // model invented the rule, so the finding is rejected instead of being allowed
8
+ // to ride the hard-rule path past the runtime challenges.
9
+ //
10
+ // node project-rule-findings.mjs <finding.json> <context.json>
11
+ //
12
+ // Exit 0 when valid or when the finding is not a PROJECT rule, 1 when rejected,
13
+ // 2 when the validator itself could not run (usage error, unreadable or malformed input).
14
+ import { readFileSync, realpathSync } from 'node:fs'
15
+ import { fileURLToPath } from 'node:url'
16
+
17
+ const TAG_PATTERN = /^PROJECT:[a-z0-9]+(-[a-z0-9]+)*$/
18
+ const MIN_QUOTE_LENGTH = 12
19
+
20
+ export function isProjectRuleTag(tag) {
21
+ return typeof tag === 'string' && TAG_PATTERN.test(tag)
22
+ }
23
+
24
+ // Case, runs of whitespace and markdown emphasis or list markers vary between a
25
+ // rules file and a model's copy of it; the words must not.
26
+ export function normalizeQuote(text) {
27
+ return String(text ?? '')
28
+ .replace(/(^|\n)\s{0,3}#{1,6}\s+/g, '$1')
29
+ .replace(/(^|\n)\s{0,3}>\s?/g, '$1')
30
+ .replace(/`([^`\n]+)`/g, '$1')
31
+ .replace(/(^|[\s([{])(\*{1,2}|_{1,2})(\S(?:.*?\S)?)\2(?=$|[\s.,;:!?)}\]])/g, '$1$3')
32
+ .replace(/^\s*[-+]\s+/gm, ' ')
33
+ .replace(/\s+/g, ' ')
34
+ .trim()
35
+ .toLowerCase()
36
+ }
37
+
38
+ const RECALLED_PATTERNS_HEADING = /^\s{0,3}#{1,6}\s+recalled patterns\s*$/gim
39
+
40
+ // Recalled patterns are memory, not the project's rules, so nothing below the last such heading is a valid quote source.
41
+ // SKILL.md appends that heading at the end; an earlier one inside a rules file must not drop the rules after it.
42
+ export function stripRecalledPatterns(text) {
43
+ const source = String(text ?? '')
44
+ let cut = -1
45
+ for (const match of source.matchAll(RECALLED_PATTERNS_HEADING)) cut = match.index
46
+ return cut === -1 ? source : source.slice(0, cut)
47
+ }
48
+
49
+ export function quoteAppearsIn(quote, projectRules) {
50
+ const needle = normalizeQuote(quote)
51
+ if (!needle) return false
52
+ const rules = stripRecalledPatterns(projectRules)
53
+ if (needle.length >= MIN_QUOTE_LENGTH && normalizeQuote(rules).includes(needle)) return true
54
+ return rules.split(/\r?\n/).some(line => normalizeQuote(line) === needle)
55
+ }
56
+
57
+ export function validateProjectRuleFinding(finding, projectRules) {
58
+ const tag = finding?.rule
59
+ if (typeof tag !== 'string' || !tag.startsWith('PROJECT:')) {
60
+ return { ok: true, reason: 'not a PROJECT rule' }
61
+ }
62
+ if (!isProjectRuleTag(tag)) {
63
+ return { ok: false, reason: `malformed tag "${tag}": expected PROJECT:<kebab-case-slug>` }
64
+ }
65
+ if (typeof finding.ruleQuote !== 'string' || finding.ruleQuote.trim() === '') {
66
+ return { ok: false, reason: 'missing ruleQuote: quote the project rule verbatim' }
67
+ }
68
+ if (!quoteAppearsIn(finding.ruleQuote, projectRules)) {
69
+ return { ok: false, reason: 'ruleQuote not found in projectRules' }
70
+ }
71
+ return { ok: true, reason: 'quote found in projectRules' }
72
+ }
73
+
74
+ export function validateWeakenedProjectRuleFinding(finding, baseProjectRules) {
75
+ if (finding?.rule !== 'weakens-project-rule') {
76
+ return { ok: true, reason: 'not a weakened project rule' }
77
+ }
78
+ if (typeof baseProjectRules !== 'string') {
79
+ return { ok: false, reason: 'baseProjectRules unavailable' }
80
+ }
81
+ if (typeof finding.ruleQuote !== 'string' || finding.ruleQuote.trim() === '') {
82
+ return { ok: false, reason: 'missing ruleQuote: quote the removed project rule' }
83
+ }
84
+ if (!quoteAppearsIn(finding.ruleQuote, baseProjectRules)) {
85
+ return { ok: false, reason: 'ruleQuote not found in baseProjectRules' }
86
+ }
87
+ return { ok: true, reason: 'quote found in baseProjectRules' }
88
+ }
89
+
90
+ function main(argv) {
91
+ const [findingPath, contextPath] = argv
92
+ if (!findingPath || !contextPath) {
93
+ console.error('usage: project-rule-findings.mjs <finding.json> <context.json>')
94
+ return 2
95
+ }
96
+ try {
97
+ const finding = JSON.parse(readFileSync(findingPath, 'utf8'))
98
+ const context = JSON.parse(readFileSync(contextPath, 'utf8'))
99
+ if (finding?.rule === 'weakens-project-rule') {
100
+ const result = validateWeakenedProjectRuleFinding(finding, context.baseProjectRules)
101
+ console.log(JSON.stringify(result))
102
+ return result.ok ? 0 : 1
103
+ }
104
+ // Base-branch rules are authoritative; PR-tree rules only when no base was resolved.
105
+ const authoritative = typeof context.baseProjectRules === 'string' ? context.baseProjectRules : context.projectRules
106
+ const result = validateProjectRuleFinding(finding, authoritative)
107
+ console.log(JSON.stringify(result))
108
+ return result.ok ? 0 : 1
109
+ } catch (error) {
110
+ // Exit 1 is reserved for "rejected"; a tooling failure must not read as one.
111
+ console.error(`project-rule-findings: unavailable: ${error.message}`)
112
+ return 2
113
+ }
114
+ }
115
+
116
+ function isMain() {
117
+ try { return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)) } catch { return false }
118
+ }
119
+
120
+ if (isMain()) {
121
+ process.exit(main(process.argv.slice(2)))
122
+ }
@@ -696,5 +696,165 @@ check('ledger: proven prover adds one', countProveAttempt(2, { attempted: true,
696
696
  const bareWrites = src.split('\n').filter(l => /proveAttemptCount\s*(\+\+|\+=|=[^=])/.test(l) && !/countProveAttempt\(|let proveAttemptCount/.test(l))
697
697
  check('the attempt count is written only through countProveAttempt', bareWrites, [])
698
698
 
699
+ console.log('\nproject rules: PROJECT:<slug> findings must quote a rule that exists')
700
+ const { spawnSync: spawnPr } = await import('node:child_process')
701
+ const { mkdtempSync: mkPr, cpSync: cpPr, readFileSync: rdPr, rmSync: rmPr, symlinkSync: syPr, writeFileSync: wfPr } = await import('node:fs')
702
+ const { tmpdir: tmpPr } = await import('node:os')
703
+ const { validateProjectRuleFinding, validateWeakenedProjectRuleFinding, isProjectRuleTag } =
704
+ await import('./project-rule-findings.mjs')
705
+
706
+ const fixtureDir = join(SKILL, 'scripts', 'fixtures', 'project-rules')
707
+ const prRepo = mkPr(join(tmpPr(), 'lekker-pr-'))
708
+ cpPr(join(fixtureDir, 'CLAUDE.md'), join(prRepo, 'CLAUDE.md'))
709
+ const prGit = (...a) => spawnPr('git', ['-C', prRepo, '-c', 'user.name=t', '-c', 'user.email=t@t', ...a], { encoding: 'utf8' })
710
+ prGit('init', '-q', '-b', 'main'); prGit('add', '.'); prGit('commit', '-qm', 'rules')
711
+ const prOut = join(prRepo, 'rules.json')
712
+ const collected = spawnPr('bash', [join(SKILL, 'scripts', 'setup-worktree.sh'), 'o/r', 'main', prOut], {
713
+ encoding: 'utf8', env: { ...process.env, LEKKER_RULES_ONLY: '1', LEKKER_LOCAL_REPO: prRepo },
714
+ })
715
+ const planted = collected.status === 0 ? JSON.parse(rdPr(prOut, 'utf8')).projectRules : ''
716
+ check('rules collection exits 0 on the planted fixture', collected.status, 0)
717
+ check('projectRules carries both planted rules',
718
+ [planted.includes('NEVER call console.log in src/.'), planted.includes('NEVER mutate function arguments.')], [true, true])
719
+ rmPr(prRepo, { recursive: true, force: true })
720
+
721
+ const prFinding = (rule, ruleQuote) => ({ rule, ruleQuote, file: 'src/total.js', line: 2, title: 't' })
722
+ check('accepts PROJECT:no-console-log with a matching quote',
723
+ validateProjectRuleFinding(prFinding('PROJECT:no-console-log', 'NEVER call console.log in src/.'), planted).ok, true)
724
+ check('accepts a quote that differs only in case and spacing',
725
+ validateProjectRuleFinding(prFinding('PROJECT:no-mutation', 'never mutate function arguments'), planted).ok, true)
726
+ check('accepts exact short project rules',
727
+ validateProjectRuleFinding(prFinding('PROJECT:no-eval', 'NEVER eval.'), '- NEVER eval.').ok, true)
728
+ check('rejects altered identifiers in project rule quotes',
729
+ validateProjectRuleFinding(prFinding('PROJECT:no-unsafe', 'NEVER call unsafe().'), 'NEVER call `_unsafe()`.').ok, false)
730
+ check('rejects altered operators in project rule quotes',
731
+ validateProjectRuleFinding(prFinding('PROJECT:nonnegative', 'MUST validate n = 0.'), 'MUST validate n >= 0.').ok, false)
732
+ check('rejects a quote that is absent from projectRules',
733
+ validateProjectRuleFinding(prFinding('PROJECT:no-eval', 'NEVER call eval anywhere in the codebase'), planted).ok, false)
734
+ check('rejects a PROJECT tag with no quote',
735
+ validateProjectRuleFinding({ rule: 'PROJECT:no-console-log' }, planted).ok, false)
736
+ const weakenedFinding = prFinding('weakens-project-rule', 'NEVER call console.log in src/.')
737
+ check('accepts a weakened rule quoted from baseProjectRules',
738
+ validateWeakenedProjectRuleFinding(weakenedFinding, planted).ok, true)
739
+ check('rejects a weakened rule quote absent from baseProjectRules',
740
+ validateWeakenedProjectRuleFinding(weakenedFinding, 'NEVER call eval.').ok, false)
741
+ check('rejects a weakened rule when baseProjectRules is unavailable',
742
+ validateWeakenedProjectRuleFinding(weakenedFinding, undefined).ok, false)
743
+ check('rejects a weakened rule with no quote',
744
+ validateWeakenedProjectRuleFinding({ rule: 'weakens-project-rule' }, planted).ok, false)
745
+ const verifierInstructions = rdPr(join(SKILL, 'references', 'agents', 'verifier.md'), 'utf8')
746
+ check('verifier has a dedicated weakened-project-rule path',
747
+ verifierInstructions.includes('For `rule: "weakens-project-rule"`, use this dedicated path:'), true)
748
+ check('verifier accepts deleted and replacement-line anchors',
749
+ verifierInstructions.includes('valid for both a deletion and a replacement'), true)
750
+ check('verifier confirms valid weakened rules at Important severity',
751
+ verifierInstructions.includes("`confirmed` at the finding's Important severity"), true)
752
+ check('rejects a malformed slug', validateProjectRuleFinding(prFinding('PROJECT:No Console', 'NEVER call console.log in src/.'), planted).ok, false)
753
+ check('a built-in tag is left to the house-rule path', validateProjectRuleFinding({ rule: 'TS-1' }, planted).ok, true)
754
+ const withRecalled = 'NEVER eval anywhere in src.\n## Recalled patterns\nNEVER call console.log in tests.'
755
+ check('accepts a quote above the recalled patterns heading',
756
+ validateProjectRuleFinding(prFinding('PROJECT:no-eval', 'NEVER eval anywhere in src.'), withRecalled).ok, true)
757
+ check('rejects a quote that appears only under recalled patterns',
758
+ validateProjectRuleFinding(prFinding('PROJECT:no-console-log', 'NEVER call console.log in tests.'), withRecalled).ok, false)
759
+ const twoRecalled = 'NEVER eval anywhere in src.\n## Recalled patterns\nNEVER use var in src.\n## Recalled patterns\nNEVER call console.log in tests.'
760
+ check('accepts a quote between two recalled patterns headings',
761
+ validateProjectRuleFinding(prFinding('PROJECT:no-var', 'NEVER use var in src.'), twoRecalled).ok, true)
762
+ check('rejects a quote after the last recalled patterns heading when the heading appears twice',
763
+ validateProjectRuleFinding(prFinding('PROJECT:no-console-log', 'NEVER call console.log in tests.'), twoRecalled).ok, false)
764
+ console.log('\nworkflow: a house-rule finding and a PROJECT finding on one line are never one issue')
765
+ const houseF = { file: 'src/total.js', line: 2, title: 'Missing null check on total', rule: 'TS-1' }
766
+ const projectF = { file: 'src/total.js', line: 2, title: 'Missing null check on total', rule: 'PROJECT:no-null-total', ruleQuote: 'NEVER skip the null check on total.' }
767
+ check('a house-rule finding and a PROJECT finding with similar titles are not sameIssue', sameIssue(houseF, projectF), false)
768
+ check('isProjectRuleTag accepts a kebab slug and rejects an empty one',
769
+ [isProjectRuleTag('PROJECT:a-b'), isProjectRuleTag('PROJECT:')], [true, false])
770
+ check('a PROJECT tag is a hard rule', isHardRule({ rule: 'PROJECT:no-console-log' }), true)
771
+ const quoteA = { file: 'src/total.js', line: 2, title: 'Missing null check on total', rule: 'PROJECT:no-null-total', ruleQuote: 'NEVER skip the null check on total.' }
772
+ const quoteB = { ...quoteA, ruleQuote: 'ALWAYS validate total before use.' }
773
+ check('two different quotes of one PROJECT rule are not sameIssue', sameIssue(quoteA, quoteB), false)
774
+ check('the same quote of one PROJECT rule is still sameIssue', sameIssue(quoteA, { ...quoteA }), true)
775
+
776
+ // Prompt guard: every lens that reads projectRules is told to raise the tag.
777
+ const lensFiles = ['quality', 'implementation', 'simplification', 'conventions', 'test-quality']
778
+ check('every lens file that reads projectRules carries the PROJECT instruction',
779
+ lensFiles.filter(n => {
780
+ const t = rdPr(join(SKILL, 'references', 'agents', `${n}.md`), 'utf8')
781
+ return t.includes('"projectRules"') && !t.includes('PROJECT:<slug>')
782
+ }), [])
783
+ // Behavioural: lift the real reviewPrompt and build prompts with it. Removing the
784
+ // append in reviewPrompt makes the listed-lens check fail.
785
+ function liftBlock(name) {
786
+ const m = new RegExp(`^const ${name} = [\\s\\S]*?\\n\\n`, 'm').exec(src)
787
+ if (!m) throw new Error(`selftest: const ${name} not found in workflow.js`)
788
+ return m[0]
789
+ }
790
+ const buildPrompt = new Function(
791
+ 'dim', 'SUPPRESSED', 'prevSha',
792
+ [liftBlock('PROJECT_RULE_DIMENSIONS'), liftBlock('PROJECT_RULE_INSTRUCTION'), liftConst('BUG_CLASS_INSTRUCTION'),
793
+ `const targetMetadata = '{}', promptDir = 'p', repoSlug = 'o/r', prNumber = 1, prUrl = 'u',
794
+ diffFile = 'd', contextFile = 'c', wtDisplay = 'w'`,
795
+ lift('reviewPrompt'), 'return reviewPrompt(dim)'].join('\n'),
796
+ )
797
+ const promptFor = key => buildPrompt({ key, file: `${key}.md` }, [], null)
798
+ check('a listed lens prompt carries the PROJECT instruction',
799
+ lensFiles.map(n => promptFor(n).includes('"PROJECT:<short-kebab-slug>"')), lensFiles.map(() => true))
800
+ check('an unlisted lens prompt does not', promptFor('deps').includes('PROJECT:<short-kebab-slug>'), false)
801
+
802
+ console.log('\nproject rules: dedup keeps the tag and its quote together')
803
+ const { dedup: dedupPr } = new Function(preamble + '\nreturn { dedup }')()
804
+ const plainF = { file: 'src/a.js', line: 2, severity: 'important', title: 'console.log left in src', bugClass: 'console-log', description: 'x', badCode: 'console.log(1)', fix: 'f', otherInstances: [] }
805
+ const taggedF = { ...plainF, severity: 'critical', rule: 'PROJECT:no-console-log', ruleQuote: 'NEVER call console.log in src/.' }
806
+ const taggedWithoutQuote = { ...taggedF }
807
+ delete taggedWithoutQuote.ruleQuote
808
+ for (const [label, pair] of [['plain first', [plainF, taggedF]], ['tagged first', [taggedF, plainF]]]) {
809
+ const [m] = dedupPr(pair)
810
+ check(`merge keeps rule and ruleQuote (${label})`, [m.rule, m.ruleQuote], ['PROJECT:no-console-log', 'NEVER call console.log in src/.'])
811
+ }
812
+ const [mergedQuote] = dedupPr([taggedWithoutQuote, taggedF])
813
+ check('merge fills missing quote for the same project rule',
814
+ [mergedQuote.rule, mergedQuote.ruleQuote], ['PROJECT:no-console-log', 'NEVER call console.log in src/.'])
815
+
816
+ const strayQuote = { ...plainF, ruleQuote: 'stale quote that belongs to no rule' }
817
+ const [mergedStray] = dedupPr([strayQuote, taggedF])
818
+ check('a stray quote on an untagged side does not survive the merge',
819
+ [mergedStray.rule, mergedStray.ruleQuote], ['PROJECT:no-console-log', 'NEVER call console.log in src/.'])
820
+ const [mergedLate] = dedupPr([{ ...plainF, rule: 'PROJECT:no-console-log' }, taggedF])
821
+ check('a quote from a later finding fills a quote-less same rule', mergedLate.ruleQuote, 'NEVER call console.log in src/.')
822
+ const otherRule = { ...taggedF, rule: 'PROJECT:no-mutation', ruleQuote: 'NEVER mutate function arguments.' }
823
+ check('two different PROJECT rules stay separate through dedup', dedupPr([taggedF, otherRule]).length, 2)
824
+ const [mergedNoQuote] = dedupPr([strayQuote, { ...taggedF, ruleQuote: undefined }])
825
+ check('a tagged side without a quote leaves no stray quote behind', mergedNoQuote.ruleQuote, undefined)
826
+
827
+ console.log('\nproject rules: validator crash is exit 2, rejection is exit 1')
828
+ const crashDir = mkPr(join(tmpPr(), 'lekker-crash-'))
829
+ const okCtx = join(crashDir, 'ctx.json'), badJson = join(crashDir, 'bad.json'), fnd = join(crashDir, 'f.json')
830
+ wfPr(okCtx, JSON.stringify({ projectRules: 'NEVER call console.log in src/.' }))
831
+ wfPr(badJson, '{not json')
832
+ wfPr(fnd, JSON.stringify({ rule: 'PROJECT:no-console-log', ruleQuote: 'NEVER call console.log in src/.' }))
833
+ const runV = (f, c) => spawnPr('node', [join(SKILL, 'scripts', 'project-rule-findings.mjs'), f, c], { encoding: 'utf8' }).status
834
+ check('exit 0 on a valid finding', runV(fnd, okCtx), 0)
835
+ // A PR that deletes the rule must not make its own violation pass: base rules win.
836
+ const spoofCtx = join(crashDir, 'spoof.json')
837
+ wfPr(spoofCtx, JSON.stringify({ projectRules: 'nothing here', baseProjectRules: 'NEVER call console.log in src/.' }))
838
+ check('quote found in base rules is accepted even when the PR tree dropped it', runV(fnd, spoofCtx), 0)
839
+ const addedCtx = join(crashDir, 'added.json')
840
+ wfPr(addedCtx, JSON.stringify({ projectRules: 'NEVER call console.log in src/.', baseProjectRules: '' }))
841
+ check('a rule only the PR tree contains is rejected when base rules exist', runV(fnd, addedCtx), 1)
842
+ const weakenedFile = join(crashDir, 'weakened.json')
843
+ wfPr(weakenedFile, JSON.stringify(weakenedFinding))
844
+ check('validator accepts a weakened-rule quote found in baseProjectRules',
845
+ runV(weakenedFile, spoofCtx), 0)
846
+ check('validator rejects a weakened-rule quote found only in projectRules',
847
+ runV(weakenedFile, addedCtx), 1)
848
+ wfPr(join(crashDir, 'rej.json'), JSON.stringify({ rule: 'PROJECT:no-eval', ruleQuote: 'NEVER call eval anywhere in code' }))
849
+ check('exit 1 on a rejected finding', runV(join(crashDir, 'rej.json'), okCtx), 1)
850
+ const symlinkValidator = join(crashDir, 'project-rule-findings.mjs')
851
+ syPr(join(SKILL, 'scripts', 'project-rule-findings.mjs'), symlinkValidator)
852
+ check('exit 1 on a rejected finding through a symlink',
853
+ spawnPr('node', [symlinkValidator, join(crashDir, 'rej.json'), okCtx], { encoding: 'utf8' }).status, 1)
854
+ check('exit 2 on a missing context file', runV(fnd, join(crashDir, 'nope.json')), 2)
855
+ check('exit 2 on a malformed context file', runV(fnd, badJson), 2)
856
+ check('exit 2 on a malformed finding file', runV(badJson, okCtx), 2)
857
+ rmPr(crashDir, { recursive: true, force: true })
858
+
699
859
  console.log(failed === 0 ? '\nall checks passed\n' : `\n${failed} check(s) FAILED\n`)
700
860
  process.exit(failed === 0 ? 0 : 1)
@@ -134,6 +134,20 @@ collect_project_rules() {
134
134
  -not -path '*/node_modules/*' 2>/dev/null | sort)
135
135
  }
136
136
 
137
+ # Hard rules must come from a tree the PR author does not control: a PR that
138
+ # deletes a rule and then breaks it would otherwise leave nothing to enforce.
139
+ collect_project_rules_at_ref() {
140
+ local repo="$1" ref="$2" dir status=0
141
+ dir="$(mktemp -d "/tmp/lekker-base-rules-XXXXXX")"
142
+ if git -C "$repo" archive "$ref" 2>/dev/null | tar -x -C "$dir" 2>/dev/null; then
143
+ collect_project_rules "$dir"
144
+ else
145
+ status=1
146
+ fi
147
+ rm -rf "$dir"
148
+ return "$status"
149
+ }
150
+
137
151
  # Scan depth needs PR-authored review guidance but skips expensive worktree
138
152
  # setup. Read the PR tree from an archive so no Git worktree or checkout is made.
139
153
  if [[ "${LEKKER_RULES_ONLY:-}" == "1" ]]; then
@@ -149,14 +163,40 @@ if [[ "${LEKKER_RULES_ONLY:-}" == "1" ]]; then
149
163
  exit 1
150
164
  fi
151
165
  fi
166
+ if [[ -n "${LEKKER_BASE_REF:-}" ]]; then
167
+ if [[ -z "$LOCAL_REPO" && "$LEKKER_BASE_REF" == origin/* ]]; then
168
+ BASE_BRANCH="${LEKKER_BASE_REF#origin/}"
169
+ if ! git -C "$REPO_ROOT" fetch origin \
170
+ "+refs/heads/${BASE_BRANCH}:refs/remotes/origin/${BASE_BRANCH}" 2>/dev/null; then
171
+ printf 'ERROR: could not fetch base ref %s for rules-only scan\n' "$LEKKER_BASE_REF" >&2
172
+ exit 1
173
+ fi
174
+ fi
175
+ if ! git -C "$REPO_ROOT" rev-parse --verify -q "${LEKKER_BASE_REF}^{commit}" >/dev/null; then
176
+ printf 'ERROR: base ref %s does not resolve for rules-only scan\n' "$LEKKER_BASE_REF" >&2
177
+ exit 1
178
+ fi
179
+ fi
152
180
  RULES_SOURCE="/tmp/lekker-rules-source-$$"
153
181
  mkdir -p "$RULES_SOURCE"
154
182
  git -C "$REPO_ROOT" archive "$RULES_REF" | tar -x -C "$RULES_SOURCE"
155
183
  PROJECT_RULES="$(collect_project_rules "$RULES_SOURCE")"
156
- python3 - "$OUT_JSON" "$PROJECT_RULES" <<'PYEOF'
184
+ BASE_PROJECT_RULES=""
185
+ HAVE_BASE_RULES=0
186
+ if [[ -n "${LEKKER_BASE_REF:-}" ]]; then
187
+ if BASE_PROJECT_RULES="$(collect_project_rules_at_ref "$REPO_ROOT" "$LEKKER_BASE_REF")"; then
188
+ HAVE_BASE_RULES=1
189
+ else
190
+ BASE_PROJECT_RULES=""
191
+ NOTES="${NOTES}base-rules-unavailable;"
192
+ fi
193
+ fi
194
+ python3 - "$OUT_JSON" "$PROJECT_RULES" "$BASE_PROJECT_RULES" "$HAVE_BASE_RULES" <<'PYEOF'
157
195
  import json, sys
196
+ out = {"projectRules": sys.argv[2]}
197
+ out["baseProjectRules"] = sys.argv[3] if sys.argv[4] == "1" else None
158
198
  with open(sys.argv[1], "w") as f:
159
- json.dump({"projectRules": sys.argv[2]}, f, indent=2)
199
+ json.dump(out, f, indent=2)
160
200
  PYEOF
161
201
  rm -rf "$RULES_SOURCE"
162
202
  printf 'OK %s\n' "$OUT_JSON"
@@ -475,6 +515,20 @@ PROJECT_RULES_FILE="/tmp/lekker-rules-$$.txt"
475
515
  printf '' > "$PROJECT_RULES_FILE"
476
516
  collect_project_rules "$WORKTREE_PATH" > "$PROJECT_RULES_FILE"
477
517
 
518
+ BASE_RULES_FILE="/tmp/lekker-base-rules-$$.txt"
519
+ BASE_RULES_STATE="absent"
520
+ printf '' > "$BASE_RULES_FILE"
521
+ if [[ -n "$MERGE_BASE_NOTE" && "$MERGE_BASE_NOTE" != "unknown" ]] \
522
+ && git -C "$REPO_ROOT" rev-parse --verify -q "${MERGE_BASE_NOTE}^{commit}" >/dev/null; then
523
+ if collect_project_rules_at_ref "$REPO_ROOT" "$MERGE_BASE_NOTE" > "$BASE_RULES_FILE"; then
524
+ BASE_RULES_STATE="present"
525
+ else
526
+ NOTES="${NOTES}base-rules-unavailable;"
527
+ fi
528
+ else
529
+ NOTES="${NOTES}base-rules-unavailable;"
530
+ fi
531
+
478
532
  # ---------------------------------------------------------------------------
479
533
  # 6. HEAD SHA
480
534
  # ---------------------------------------------------------------------------
@@ -518,14 +572,17 @@ python3 - \
518
572
  "$DEPS_INSTALL_FILE" \
519
573
  "$ESLINT_JSON_FILE" \
520
574
  "$TEST_RUNNER_JSON" \
521
- "$OUT_JSON" <<'PYEOF'
575
+ "$OUT_JSON" \
576
+ "$BASE_RULES_FILE" \
577
+ "$BASE_RULES_STATE" <<'PYEOF'
522
578
  import json, sys, os
523
579
 
524
580
  (worktree_path, repo_root, head_sha, head_sha_short,
525
581
  tsc_file, eslint_file, rules_file, delta_file, notes,
526
582
  changed_files_file, base_ref_val, eslint_scope_file,
527
583
  tsc_changed_file, tsc_count_file, merge_base_val,
528
- deps_install_file, eslint_json_file, test_runner_json, out_json) = sys.argv[1:20]
584
+ deps_install_file, eslint_json_file, test_runner_json, out_json,
585
+ base_rules_file, base_rules_state) = sys.argv[1:22]
529
586
 
530
587
  def parse_runner(raw):
531
588
  try:
@@ -581,6 +638,7 @@ data = {
581
638
  "tscTail": read_file(tsc_file),
582
639
  "eslintTail": read_file(eslint_file),
583
640
  "projectRules": read_file(rules_file),
641
+ "baseProjectRules": read_file(base_rules_file) if base_rules_state == "present" else None,
584
642
  "deltaFile": None if delta_file == "null" else delta_file,
585
643
  "notes": notes,
586
644
  "changedFiles": changed_files,
@@ -598,7 +656,7 @@ data = {
598
656
  "eslintJson": eslint_json_file if file_has_content(eslint_json_file) else None,
599
657
  }
600
658
 
601
- for p in (tsc_file, eslint_file, rules_file, changed_files_file,
659
+ for p in (tsc_file, eslint_file, rules_file, base_rules_file, changed_files_file,
602
660
  eslint_scope_file, tsc_changed_file, tsc_count_file,
603
661
  deps_install_file):
604
662
  try:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olegkoval/agent-skills",
3
- "version": "1.70.0",
3
+ "version": "1.71.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.70.0",
4
+ "version": "1.71.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.70.0",
4
+ "version": "1.71.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.70.0",
4
+ "version": "1.71.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.70.0",
4
+ "version": "1.71.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.70.0",
4
+ "version": "1.71.0",
5
5
  "author": {
6
6
  "name": "Oleg Koval"
7
7
  },