Compare commits
126
Commits
478ef1ac9f
...
master
No files matched your search
@@ -0,0 +1,6 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: pip
|
||||||
|
directory: /
|
||||||
|
schedule:
|
||||||
|
interval: daily
|
||||||
@@ -24,7 +24,8 @@ jobs:
|
|||||||
|
|
||||||
- name: 📦 Install lint tools
|
- name: 📦 Install lint tools
|
||||||
run: |
|
run: |
|
||||||
pip install ruff
|
python -m pip install --upgrade pip
|
||||||
|
pip install --group lint
|
||||||
sudo apt-get update && sudo apt-get install -y shellcheck
|
sudo apt-get update && sudo apt-get install -y shellcheck
|
||||||
npm install -g markdownlint-cli2
|
npm install -g markdownlint-cli2
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ jobs:
|
|||||||
|
|
||||||
- name: 📦 Install lint tools
|
- name: 📦 Install lint tools
|
||||||
run: |
|
run: |
|
||||||
pip install ruff
|
python -m pip install --upgrade pip
|
||||||
|
pip install --group lint
|
||||||
sudo apt-get update && sudo apt-get install -y shellcheck
|
sudo apt-get update && sudo apt-get install -y shellcheck
|
||||||
npm install -g markdownlint-cli2
|
npm install -g markdownlint-cli2
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ npm-debug.log*
|
|||||||
# Rendered skill install targets
|
# Rendered skill install targets
|
||||||
.agents/
|
.agents/
|
||||||
.claude/
|
.claude/
|
||||||
|
.mcp.json
|
||||||
|
|
||||||
|
.state/
|
||||||
|
|
||||||
# Editor / OS
|
# Editor / OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Agent Instructions
|
||||||
|
|
||||||
|
## Platform-agnostic development
|
||||||
|
|
||||||
|
Everything in this repository MUST work on every agent runtime, not only on
|
||||||
|
the one developing it: Claude Code, Codex, Gemini CLI, Hermes, OpenClaw and
|
||||||
|
any other that reads skills. The same holds for operating systems and
|
||||||
|
distributions.
|
||||||
|
|
||||||
|
- **Logic in scripts**: behaviour lives in `python3` or `bash` scripts that
|
||||||
|
any runtime can call. Hooks, plugins and settings of one runtime are thin
|
||||||
|
adapters over those scripts, never the only path to a feature.
|
||||||
|
- **Skills stay portable**: a `SKILL.md` that uses a runtime-specific tool
|
||||||
|
(`AskUserQuestion`, `SendMessage`, a slash command) says what the step
|
||||||
|
needs, so another runtime can use its own equivalent.
|
||||||
|
- **Installs reach every runtime**: install into the cross-runtime locations
|
||||||
|
(`~/.agents/skills`) or through an installer that configures every
|
||||||
|
detected agent; runtime-specific locations are mirrors. Of a tool that
|
||||||
|
ships both, take the agent-agnostic integration over one runtime's plugin,
|
||||||
|
and never both: the same server would run twice.
|
||||||
|
- **No single package manager**: an install never depends on one OS or
|
||||||
|
distribution's package manager; prefer upstream or statically linked
|
||||||
|
release builds.
|
||||||
|
- **One instruction file**: rules live here and nowhere else; no
|
||||||
|
`CLAUDE.md`, `CLAUDE.local.md` or `GEMINI.md`. Claude Code reads this file
|
||||||
|
natively from v2.1.277; Gemini CLI once its `context.fileName` setting
|
||||||
|
lists `AGENTS.md`.
|
||||||
|
|
||||||
|
## Code questions
|
||||||
|
|
||||||
|
Ask the codebase-memory-mcp graph first (`search_graph`, `trace_path`,
|
||||||
|
`get_architecture`); grep only after it.
|
||||||
+112
@@ -0,0 +1,112 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## [0.3.0] - 2026-11-03
|
||||||
|
|
||||||
|
Breaking:
|
||||||
|
|
||||||
|
- Skills: every skill is now *kvb-category-name*; the old names are gone
|
||||||
|
- Skills: the category *skills* is now *skill*, e.g. *kvb-skill-tune*
|
||||||
|
- Skills: *kvb-create-feature* is now *kvb-think-gaps*
|
||||||
|
- Skills: *kvb-skill-dream* is now *kvb-dream*
|
||||||
|
- Skills: *kvb-skills-shortcuts* and tokens such as *sobu* are gone
|
||||||
|
|
||||||
|
Added:
|
||||||
|
|
||||||
|
- Org: *kvb-org-rasci* keeps one RASCI/OKR register of every agent task
|
||||||
|
- Org: *kvb-org-artefacts* tracks images, volumes and worktrees per task
|
||||||
|
- Org: *kvb-org-wsjf* ranks the register by WSJF from Fibonacci estimates
|
||||||
|
- Org: *kvb-org-operator* lists every command only the operator can run
|
||||||
|
- Commit: commit skills show worktree, branch, confidence and risk first
|
||||||
|
- Transcend: *kvb-transcend* carries session scripts and tests into repos
|
||||||
|
- Requirements: *kvb-create-requirement* with user story, Mermaid and tests
|
||||||
|
- Think: *kvb-think-brainstorm* scores options from seven fixed lenses
|
||||||
|
- Skills: *kvb-skill-compile* turns deterministic steps into scripts
|
||||||
|
- Skills: *kvb-skill-cleanup* deletes legacy skills that renames left behind
|
||||||
|
- Dream: *kvb-optimize* runs all nine optimization skills in one pass
|
||||||
|
- Dream: a GNOME notification recommends */kvb-dream* daily at 17:00
|
||||||
|
- Coach: *kvb-coach* suggests the next step for mind, body, food and tasks
|
||||||
|
- Coach: *kvb-coach-meditation* plays alpha-wave music with fractals
|
||||||
|
- Coach: a short coach note at the first prompt of every full hour
|
||||||
|
|
||||||
|
Changed:
|
||||||
|
|
||||||
|
- Dream: covers every repository and session, then optimizes and cleans up
|
||||||
|
- Dream: one hint per agent every 12 h replaces the tune and dream hints
|
||||||
|
- Tune: *kvb-code-tune* always checks language, framework and recursion
|
||||||
|
- Cleanup: worktrees are listed by size and integration line
|
||||||
|
- Coordination: hotfix gates take the next free test slot
|
||||||
|
- Coordination: an agent may end its own job after 60 min without progress
|
||||||
|
|
||||||
|
Upgrade notes:
|
||||||
|
|
||||||
|
- Install: run *make install* and restart the agent to load the new names
|
||||||
|
- Install: run *make install-skills* in each project to drop old copies
|
||||||
|
- Install: */kvb-skill-cleanup* lists any legacy skill left behind
|
||||||
|
- Install: the global install enables the *kvb-dream-notify.timer* unit
|
||||||
|
- Settings: remove *autotune* and *dream* hooks from *~/.claude/settings.json*
|
||||||
|
|
||||||
|
## [0.2.0] - 2026-10-31
|
||||||
|
|
||||||
|
Added:
|
||||||
|
|
||||||
|
- Git: *branch-gc* clears merged branches and worktrees with *pkgmgr*
|
||||||
|
- Agents: *subagent-model* uses a cheaper model only at 90 % same result
|
||||||
|
- Agents: *subagent-model* hooks log every spawn and its rated quality
|
||||||
|
|
||||||
|
Changed:
|
||||||
|
|
||||||
|
- Coordination: never cut a running job or agent short, not even at its cap
|
||||||
|
- Resources: *watch.py admit* grants test slots only below CPU and RAM limits
|
||||||
|
- Coordination: a relayer writes an operator rule to shared memory first
|
||||||
|
|
||||||
|
Fixed:
|
||||||
|
|
||||||
|
- Resources: stop-stage cleanup now skips worktrees where *make* still runs
|
||||||
|
- Resources: the block hook ignores heavy commands in quotes and heredocs
|
||||||
|
- Update: *make update* now updates every locked source, not just the first
|
||||||
|
- Update: *make update* installs for Claude Code only
|
||||||
|
- CI: *ci-artifacts* signs lint failures by finding, ignores later steps
|
||||||
|
- Release: *changelog* warns when a workflow on HEAD already failed
|
||||||
|
- CI: ruff pinned in *pyproject.toml*; local and CI lint now agree
|
||||||
|
|
||||||
|
Upgrade notes:
|
||||||
|
|
||||||
|
- 0.1.0 was never published (its CI failed); its upgrade notes follow
|
||||||
|
- Install: restart the agent after *make install* to load skills and hooks
|
||||||
|
- Resources: *watch.py* needs *~/.config/agents-council/config.json*
|
||||||
|
- Resources: add *busy_processes* and *load_limits* to that config
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-10-30
|
||||||
|
|
||||||
|
Added:
|
||||||
|
|
||||||
|
- Install: *make install* and *make project* install every locked skill
|
||||||
|
- Install: enables caveman and ponytail; adds autotune and dream reminders
|
||||||
|
- Commit: *commit-next* commits a bundle and stages the next one
|
||||||
|
- Commit: *comments-clean* and *advocate* audit every commit before it lands
|
||||||
|
- Commit: *confidence* and *break-risk* report measured fix and break numbers
|
||||||
|
- Coordination: *democracy* elects the first agent to claim work as its owner
|
||||||
|
- Coordination: *council.py* claims work; a hook blocks edits without a claim
|
||||||
|
- Coordination: *funnel* keeps agent work in worktrees until it clears 90/5
|
||||||
|
- Resources: *watch.py* blocks heavy jobs and cleans up before the disk fills
|
||||||
|
- Cleanup: *cleanup* frees disk space after asking every peer agent
|
||||||
|
- CI: *triage*, *observe* and *ci-artifacts* with *ci_failures.py* clustering
|
||||||
|
- CI: *tune* finds speed-ups that never cut logic, coverage or correctness
|
||||||
|
- Git: *merge-fix* with *find_fix.py* finds a fix on any branch and merges it
|
||||||
|
- Reasoning: *active-listening* and *dialectic* pin requirements and causes
|
||||||
|
- Review: *criticize* and *sceptic* give critique hardened by dialectic
|
||||||
|
- Autonomy: *plan* hands an objective-oriented plan to *robot*
|
||||||
|
- Quality: *test* runs Makefile targets; *--fix* loops until they are green
|
||||||
|
- Quality: *refactor*, *no-defaults* and *scope-guard* keep changes lean
|
||||||
|
- Skills: *autotune*, *autoskill*, *dream* and *essence* improve the skills
|
||||||
|
- Release: *changelog* writes the pkgmgr release message since the last tag
|
||||||
|
- Status: *status-matrix* lists everything in flight with its confidence
|
||||||
|
- Help: *explain* and *help-skills* explain code and the skill set
|
||||||
|
- Chat: *shortcuts* expands operator tokens such as *sobu* and *socon*
|
||||||
|
- Security: *medusa* runs the MEDUSA scanner on repos and secret stores
|
||||||
|
- Docs: the README skill map shows how the skills invoke each other
|
||||||
|
|
||||||
|
Upgrade notes:
|
||||||
|
|
||||||
|
- Install: restart the agent after *make install* to load skills and hooks
|
||||||
|
- Resources: *watch.py* needs *~/.config/agents-council/config.json*
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: install project test update
|
.PHONY: install project test update budget
|
||||||
|
|
||||||
# Install the skills globally into $HOME (.agents/skills + .claude/skills).
|
# Install the skills globally into $HOME (.agents/skills + .claude/skills).
|
||||||
install:
|
install:
|
||||||
@@ -19,3 +19,7 @@ test:
|
|||||||
# Update all skills to their latest versions and refresh skills-lock.json.
|
# Update all skills to their latest versions and refresh skills-lock.json.
|
||||||
update:
|
update:
|
||||||
@bash scripts/update.sh
|
@bash scripts/update.sh
|
||||||
|
|
||||||
|
# Count skill description words against the listing budget: make budget [INDEX=1]
|
||||||
|
budget:
|
||||||
|
@python3 scripts/budget.py --repo . --top 5 $(if $(INDEX),--index)
|
||||||
@@ -7,6 +7,366 @@ Every skill is pinned in `skills-lock.json` for reproducible installs.
|
|||||||
First-party skills live under `skills/` and are layered on top of the locked ones at install time.
|
First-party skills live under `skills/` and are layered on top of the locked ones at install time.
|
||||||
Compatible with Claude Code, Codex, Gemini CLI, Cursor, Copilot, Windsurf, Cline, and other agents.
|
Compatible with Claude Code, Codex, Gemini CLI, Cursor, Copilot, Windsurf, Cline, and other agents.
|
||||||
|
|
||||||
|
## Naming 🏷️
|
||||||
|
|
||||||
|
Every first-party skill is named `kvb-<category>-<name>` with one of these categories: `think`, `create`, `code`, `ci`, `git`, `check`, `org`, `agent`, `skill`, `coach`: `think` thinks without producing a file, `create` produces an artifact, `code` works on code, `ci` on pipelines, `git` on history and forges, `check` analyses and reports findings, `org` coordinates agents, `agent` acts on one agent itself: its repository setup (code index, agent instructions) and its state (relax, sleep, wake up), `skill` works on skills themselves, `coach` serves the operator's wellbeing. Only `kvb-cleanup`, `kvb-coach`, `kvb-help` and the collectors `kvb-dream`, `kvb-optimize` and `kvb-transcend` stand without a category. Names use `a-z`, `0-9` and single hyphens, at most 64 characters, and equal their directory name. `tests/test_naming.py` enforces all of it, including that the organigram below lists every skill under its category.
|
||||||
|
|
||||||
|
## Organigram 🏛️
|
||||||
|
|
||||||
|
Every first-party skill under its category.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
kvb((kvb))
|
||||||
|
kvb --> think[think]
|
||||||
|
kvb --> create[create]
|
||||||
|
kvb --> code[code]
|
||||||
|
kvb --> ci[ci]
|
||||||
|
kvb --> git[git]
|
||||||
|
kvb --> check[check]
|
||||||
|
kvb --> org[org]
|
||||||
|
kvb --> agent[agent]
|
||||||
|
kvb --> skill[skill]
|
||||||
|
kvb --> coach[coach]
|
||||||
|
kvb --> none[no category]
|
||||||
|
think --> think_listen[kvb-think-listen]
|
||||||
|
think --> think_explain[kvb-think-explain]
|
||||||
|
think --> think_dialectic[kvb-think-dialectic]
|
||||||
|
think --> think_criticize[kvb-think-criticize]
|
||||||
|
think --> think_brainstorm[kvb-think-brainstorm]
|
||||||
|
think --> think_gaps[kvb-think-gaps]
|
||||||
|
create --> create_plan[kvb-create-plan]
|
||||||
|
create --> create_requirement[kvb-create-requirement]
|
||||||
|
code --> code_scope_guard[kvb-code-scope-guard]
|
||||||
|
code --> code_clean[kvb-code-clean]
|
||||||
|
code --> code_refactor[kvb-code-refactor]
|
||||||
|
code --> code_test[kvb-code-test]
|
||||||
|
code --> code_tune[kvb-code-tune]
|
||||||
|
code --> code_transcend[kvb-code-transcend]
|
||||||
|
ci --> ci_triage[kvb-ci-triage]
|
||||||
|
ci --> ci_artifacts[kvb-ci-artifacts]
|
||||||
|
ci --> ci_observe[kvb-ci-observe]
|
||||||
|
git --> git_commit_next[kvb-git-commit-next]
|
||||||
|
git --> git_commit_no_verify[kvb-git-commit-no-verify]
|
||||||
|
git --> git_merge_fix[kvb-git-merge-fix]
|
||||||
|
git --> git_issue_close[kvb-git-issue-close]
|
||||||
|
git --> git_issue_solve[kvb-git-issue-solve]
|
||||||
|
git --> git_pr_create[kvb-git-pr-create]
|
||||||
|
git --> git_pr_label[kvb-git-pr-label]
|
||||||
|
git --> git_update_branches[kvb-git-update-branches]
|
||||||
|
git --> git_branch_gc[kvb-git-branch-gc]
|
||||||
|
git --> git_orphans_find[kvb-git-orphans-find]
|
||||||
|
git --> git_changelog[kvb-git-changelog]
|
||||||
|
git --> git_hotfix[kvb-git-hotfix]
|
||||||
|
check --> check_legal[kvb-check-legal]
|
||||||
|
check --> check_security[kvb-check-security]
|
||||||
|
check --> check_principles[kvb-check-principles]
|
||||||
|
check --> check_confidence[kvb-check-confidence]
|
||||||
|
check --> check_break_risk[kvb-check-break-risk]
|
||||||
|
check --> check_gate[kvb-check-gate]
|
||||||
|
check --> check_efficiency[kvb-check-efficiency]
|
||||||
|
check --> check_speed[kvb-check-speed]
|
||||||
|
check --> check_score[kvb-check-score]
|
||||||
|
org --> org_organize[kvb-org-organize]
|
||||||
|
org --> org_split[kvb-org-split]
|
||||||
|
org --> org_coordinate[kvb-org-coordinate]
|
||||||
|
org --> org_perform[kvb-org-perform]
|
||||||
|
org --> org_matrix[kvb-org-matrix]
|
||||||
|
org --> org_rasci[kvb-org-rasci]
|
||||||
|
org --> org_artefacts[kvb-org-artefacts]
|
||||||
|
org --> org_wsjf[kvb-org-wsjf]
|
||||||
|
org --> org_robot[kvb-org-robot]
|
||||||
|
org --> org_operator[kvb-org-operator]
|
||||||
|
org --> org_priority[kvb-org-priority]
|
||||||
|
org --> org_evening[kvb-org-evening]
|
||||||
|
org --> org_sunset[kvb-org-sunset]
|
||||||
|
org --> org_sunrise[kvb-org-sunrise]
|
||||||
|
think --> think_choice[kvb-think-choice]
|
||||||
|
org --> org_radio[kvb-org-radio]
|
||||||
|
org --> org_queue[kvb-org-queue]
|
||||||
|
agent --> agent_optimize[kvb-agent-optimize]
|
||||||
|
agent --> agent_relax[kvb-agent-relax]
|
||||||
|
agent --> agent_sleep[kvb-agent-sleep]
|
||||||
|
agent --> agent_wakeup[kvb-agent-wakeup]
|
||||||
|
skill --> skill_tune[kvb-skill-tune]
|
||||||
|
skill --> skill_auto[kvb-skill-auto]
|
||||||
|
skill --> skill_essence[kvb-skill-essence]
|
||||||
|
skill --> skill_transcend[kvb-skill-transcend]
|
||||||
|
skill --> skill_compile[kvb-skill-compile]
|
||||||
|
skill --> skill_cleanup[kvb-skill-cleanup]
|
||||||
|
skill --> skill_silence[kvb-skill-silence]
|
||||||
|
none --> cleanup[kvb-cleanup]
|
||||||
|
none --> help[kvb-help]
|
||||||
|
none --> transcend_all[kvb-transcend]
|
||||||
|
none --> dream_all[kvb-dream]
|
||||||
|
none --> optimize_all[kvb-optimize]
|
||||||
|
none --> coach_all[kvb-coach]
|
||||||
|
coach --> coach_meditation[kvb-coach-meditation]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Skill map 🗺️
|
||||||
|
|
||||||
|
How the first-party skills work together. A solid arrow means the skill invokes or relies on the other one; a dotted arrow means it fires automatically. Every skill that reports a result closes with the `kvb-check-confidence` block; those arrows are left out. The locked third-party skills (caveman, ponytail, archify) are not drawn.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph triggers [Triggers]
|
||||||
|
commit_req([commit request])
|
||||||
|
stage_req([staging or fix ready])
|
||||||
|
edit_hook([edit in a git worktree])
|
||||||
|
reminders([12 h agent hint, 17:00 GNOME popup])
|
||||||
|
full_hour([full hour])
|
||||||
|
code_task([code task])
|
||||||
|
spawn([subagent spawn])
|
||||||
|
end
|
||||||
|
subgraph cat_think [think]
|
||||||
|
active_listening[kvb-think-listen]
|
||||||
|
explain[kvb-think-explain]
|
||||||
|
dialectic[kvb-think-dialectic]
|
||||||
|
criticize[kvb-think-criticize]
|
||||||
|
brainstorm[kvb-think-brainstorm]
|
||||||
|
gaps[kvb-think-gaps]
|
||||||
|
choice[kvb-think-choice]
|
||||||
|
end
|
||||||
|
subgraph cat_create [create]
|
||||||
|
plan[kvb-create-plan]
|
||||||
|
requirement[kvb-create-requirement]
|
||||||
|
end
|
||||||
|
subgraph cat_code [code]
|
||||||
|
scope_guard[kvb-code-scope-guard]
|
||||||
|
clean[kvb-code-clean]
|
||||||
|
refactor[kvb-code-refactor]
|
||||||
|
test[kvb-code-test]
|
||||||
|
tune[kvb-code-tune]
|
||||||
|
code_transcend[kvb-code-transcend]
|
||||||
|
end
|
||||||
|
subgraph cat_ci [ci]
|
||||||
|
triage[kvb-ci-triage]
|
||||||
|
ci_artifacts[kvb-ci-artifacts]
|
||||||
|
observe[kvb-ci-observe]
|
||||||
|
end
|
||||||
|
subgraph cat_git [git]
|
||||||
|
commit_next[kvb-git-commit-next]
|
||||||
|
commit_no_verify[kvb-git-commit-no-verify]
|
||||||
|
merge_fix[kvb-git-merge-fix]
|
||||||
|
issue_close[kvb-git-issue-close]
|
||||||
|
issue_solve[kvb-git-issue-solve]
|
||||||
|
pr_create[kvb-git-pr-create]
|
||||||
|
pr_label[kvb-git-pr-label]
|
||||||
|
update_branches[kvb-git-update-branches]
|
||||||
|
branch_gc[kvb-git-branch-gc]
|
||||||
|
orphans_find[kvb-git-orphans-find]
|
||||||
|
changelog[kvb-git-changelog]
|
||||||
|
hotfix[kvb-git-hotfix]
|
||||||
|
end
|
||||||
|
subgraph cat_check [check]
|
||||||
|
advocate[kvb-check-legal]
|
||||||
|
medusa[kvb-check-security]
|
||||||
|
sceptic[kvb-check-principles]
|
||||||
|
confidence[kvb-check-confidence]
|
||||||
|
break_risk[kvb-check-break-risk]
|
||||||
|
gate[kvb-check-gate]
|
||||||
|
efficiency[kvb-check-efficiency]
|
||||||
|
speed[kvb-check-speed]
|
||||||
|
score[kvb-check-score]
|
||||||
|
end
|
||||||
|
subgraph cat_org [org]
|
||||||
|
democracy[kvb-org-organize]
|
||||||
|
funnel[kvb-org-split]
|
||||||
|
coordinate[kvb-org-coordinate]
|
||||||
|
subagent_model[kvb-org-perform]
|
||||||
|
status_matrix[kvb-org-matrix]
|
||||||
|
rasci[kvb-org-rasci]
|
||||||
|
artefacts[kvb-org-artefacts]
|
||||||
|
wsjf[kvb-org-wsjf]
|
||||||
|
robot[kvb-org-robot]
|
||||||
|
operator[kvb-org-operator]
|
||||||
|
priority[kvb-org-priority]
|
||||||
|
evening[kvb-org-evening]
|
||||||
|
sunset[kvb-org-sunset]
|
||||||
|
sunrise[kvb-org-sunrise]
|
||||||
|
radio[kvb-org-radio]
|
||||||
|
queue[kvb-org-queue]
|
||||||
|
end
|
||||||
|
subgraph cat_agent [agent]
|
||||||
|
agent_optimize[kvb-agent-optimize]
|
||||||
|
relax[kvb-agent-relax]
|
||||||
|
sleep[kvb-agent-sleep]
|
||||||
|
wakeup[kvb-agent-wakeup]
|
||||||
|
end
|
||||||
|
subgraph cat_skill [skill]
|
||||||
|
autotune[kvb-skill-tune]
|
||||||
|
autoskill[kvb-skill-auto]
|
||||||
|
essence[kvb-skill-essence]
|
||||||
|
skill_transcend[kvb-skill-transcend]
|
||||||
|
compile[kvb-skill-compile]
|
||||||
|
skill_cleanup[kvb-skill-cleanup]
|
||||||
|
silence[kvb-skill-silence]
|
||||||
|
end
|
||||||
|
subgraph cat_none [no category]
|
||||||
|
cleanup[kvb-cleanup]
|
||||||
|
help_skills[kvb-help]
|
||||||
|
transcend[kvb-transcend]
|
||||||
|
dream[kvb-dream]
|
||||||
|
optimize[kvb-optimize]
|
||||||
|
coach[kvb-coach]
|
||||||
|
end
|
||||||
|
subgraph cat_coach [coach]
|
||||||
|
meditation[kvb-coach-meditation]
|
||||||
|
end
|
||||||
|
|
||||||
|
commit_req -.-> commit_next
|
||||||
|
commit_req -.-> commit_no_verify
|
||||||
|
stage_req -.-> confidence
|
||||||
|
edit_hook -. council gate .-> democracy
|
||||||
|
reminders -.-> dream
|
||||||
|
full_hour -.-> coach
|
||||||
|
code_task -.-> clean
|
||||||
|
code_task -.-> scope_guard
|
||||||
|
spawn -.-> subagent_model
|
||||||
|
|
||||||
|
commit_next --> merge_fix
|
||||||
|
commit_next --> commit_no_verify
|
||||||
|
commit_next --> explain
|
||||||
|
commit_next --> dialectic
|
||||||
|
issue_close --> confidence
|
||||||
|
issue_solve --> issue_close
|
||||||
|
issue_solve --> funnel
|
||||||
|
issue_solve --> pr_create
|
||||||
|
pr_create --> issue_close
|
||||||
|
pr_create --> pr_label
|
||||||
|
pr_create -. push .-> operator
|
||||||
|
update_branches --> active_listening
|
||||||
|
update_branches -. conflicts .-> merge_fix
|
||||||
|
update_branches -. pushes .-> operator
|
||||||
|
commit_no_verify --> clean
|
||||||
|
commit_next --> clean
|
||||||
|
clean --> sceptic
|
||||||
|
clean --> refactor
|
||||||
|
commit_no_verify --> advocate
|
||||||
|
advocate --> dialectic
|
||||||
|
confidence --> break_risk
|
||||||
|
confidence --> gate
|
||||||
|
pr_label --> gate
|
||||||
|
stage_req -.-> score
|
||||||
|
score --> confidence
|
||||||
|
score --> break_risk
|
||||||
|
score --> efficiency
|
||||||
|
score --> speed
|
||||||
|
speed --> efficiency
|
||||||
|
efficiency -. heavy commands .-> queue
|
||||||
|
gate --> democracy
|
||||||
|
gate --> hotfix
|
||||||
|
triage --> hotfix
|
||||||
|
democracy --> hotfix
|
||||||
|
hotfix --> rasci
|
||||||
|
confidence -. objective .-> autotune
|
||||||
|
|
||||||
|
coordinate --> democracy
|
||||||
|
cleanup --> democracy
|
||||||
|
cleanup --> orphans_find
|
||||||
|
branch_gc --> democracy
|
||||||
|
orphans_find --> branch_gc
|
||||||
|
orphans_find --> choice
|
||||||
|
orphans_find --> operator
|
||||||
|
funnel --> democracy
|
||||||
|
funnel --> dialectic
|
||||||
|
funnel --> subagent_model
|
||||||
|
dialectic --> subagent_model
|
||||||
|
democracy --> merge_fix
|
||||||
|
democracy -. claim and release .-> rasci
|
||||||
|
funnel --> rasci
|
||||||
|
status_matrix --> rasci
|
||||||
|
cleanup --> artefacts
|
||||||
|
artefacts --> rasci
|
||||||
|
democracy --> wsjf
|
||||||
|
wsjf --> rasci
|
||||||
|
priority --> rasci
|
||||||
|
priority -. pins outrank .-> wsjf
|
||||||
|
operator --> rasci
|
||||||
|
dream --> rasci
|
||||||
|
|
||||||
|
observe --> triage
|
||||||
|
observe --> ci_artifacts
|
||||||
|
triage --> ci_artifacts
|
||||||
|
triage --> democracy
|
||||||
|
triage --> dialectic
|
||||||
|
triage --> funnel
|
||||||
|
triage --> tune
|
||||||
|
tune --> democracy
|
||||||
|
tune --> funnel
|
||||||
|
tune --> merge_fix
|
||||||
|
tune --> dialectic
|
||||||
|
|
||||||
|
dialectic --> active_listening
|
||||||
|
criticize --> dialectic
|
||||||
|
criticize --> active_listening
|
||||||
|
sceptic --> dialectic
|
||||||
|
gaps --> sceptic
|
||||||
|
gaps --> active_listening
|
||||||
|
gaps --> requirement
|
||||||
|
requirement --> active_listening
|
||||||
|
requirement --> brainstorm
|
||||||
|
requirement -. offer .-> plan
|
||||||
|
brainstorm --> dialectic
|
||||||
|
|
||||||
|
plan --> robot
|
||||||
|
plan --> active_listening
|
||||||
|
robot --> dialectic
|
||||||
|
|
||||||
|
dream --> dialectic
|
||||||
|
dream --> optimize
|
||||||
|
dream --> cleanup
|
||||||
|
dream --> orphans_find
|
||||||
|
optimize --> skill_cleanup
|
||||||
|
optimize --> transcend
|
||||||
|
optimize --> autoskill
|
||||||
|
optimize --> autotune
|
||||||
|
optimize --> compile
|
||||||
|
optimize --> essence
|
||||||
|
optimize --> refactor
|
||||||
|
optimize --> tune
|
||||||
|
optimize --> silence
|
||||||
|
silence -. peer protocol .-> democracy
|
||||||
|
optimize --> agent_optimize
|
||||||
|
optimize --> gaps
|
||||||
|
agent_optimize -. install .-> operator
|
||||||
|
agent_optimize -. domain graphs .-> rasci
|
||||||
|
evening -. message .-> relax
|
||||||
|
sunset -. message .-> sleep
|
||||||
|
sunrise -. message .-> wakeup
|
||||||
|
relax --> sleep
|
||||||
|
sunset -. hold and accept .-> operator
|
||||||
|
sunrise -. hold and accept .-> operator
|
||||||
|
sleep -. stops .-> robot
|
||||||
|
coach --> active_listening
|
||||||
|
coach --> operator
|
||||||
|
coach --> rasci
|
||||||
|
coach --> meditation
|
||||||
|
meditation --> active_listening
|
||||||
|
autoskill --> autotune
|
||||||
|
autotune --> essence
|
||||||
|
transcend --> skill_transcend
|
||||||
|
transcend --> code_transcend
|
||||||
|
skill_transcend --> active_listening
|
||||||
|
skill_transcend --> autotune
|
||||||
|
code_transcend --> active_listening
|
||||||
|
autotune -. own scripts .-> skill_transcend
|
||||||
|
autotune -. deterministic prose .-> compile
|
||||||
|
autoskill -. deterministic steps .-> compile
|
||||||
|
skill_transcend -. deterministic moves .-> compile
|
||||||
|
essence -. procedures .-> compile
|
||||||
|
compile --> active_listening
|
||||||
|
skill_cleanup --> operator
|
||||||
|
skill_cleanup -. unknown skills .-> transcend
|
||||||
|
tune -. measurements .-> code_transcend
|
||||||
|
|
||||||
|
test --> triage
|
||||||
|
test --> dialectic
|
||||||
|
test --> clean
|
||||||
|
refactor --> clean
|
||||||
|
```
|
||||||
|
|
||||||
## Requirements 📦
|
## Requirements 📦
|
||||||
|
|
||||||
- Node.js (provides `npx skills`).
|
- Node.js (provides `npx skills`).
|
||||||
@@ -26,7 +386,7 @@ Copy the skills into a specific project (`<repo>/.agents/skills` and `<repo>/.cl
|
|||||||
make project TARGET=/path/to/repo
|
make project TARGET=/path/to/repo
|
||||||
```
|
```
|
||||||
|
|
||||||
Both commands also patch the target's `.claude/settings.json`: the caveman and ponytail plugins are enabled so their modes auto-activate on session start, and two reminder hooks are registered — `autotune` points you at `/autotune` at most once an hour, `dream` points you at `/dream` at most once every twelve hours per repository (stamped in that repository's git dir). Existing settings are preserved.
|
Both commands also patch the target's `.claude/settings.local.json` — the machine-local, untracked file, so a project's reviewed `.claude/settings.json` is never rewritten by an install: the caveman and ponytail plugins are enabled so their modes auto-activate on session start, and two reminder hooks are registered — `kvb-dream` points each agent at `/kvb-dream` at most once every twelve hours (stamped per session in `~/.claude/.dream-stamps/`), and `kvb-coach` runs its short form at the first prompt of every full hour, once across all agents (stamped in `~/.claude/.coach-hours/`); the global install also enables the `kvb-dream-notify.timer` systemd user timer, which recommends `/kvb-dream` in a GNOME notification daily at 17:00 local time and catches up once after a missed run. A PreToolUse gate from the `kvb-org-organize` skill blocks edits to files in a git worktree until the session holds a claim (`skills/kvb-org-organize/scripts/council.py`), and its resource watch (`skills/kvb-org-organize/scripts/watch.py`) measures disk, Docker and caches, cleans up low-risk data and blocks new heavy jobs before the disk runs out; it stays inactive until `~/.config/agents-council/config.json` exists. The `kvb-org-radio` skill registers three hooks: its prompt hook delivers `broadcast` and `direct` items on the next prompt, a PostToolBatch hook delivers `security`, `pan-pan` and `mayday` items mid-turn, and an `asyncRewake` Stop hook wakes an idle session for a `mayday` or a `direct` item sent with `--wake`. The PreToolUse hook of the `kvb-org-queue` skill sends heavy commands through `queue.py hold` once the config sets `queue_enforce`. The global install also installs the MEDUSA security scanner with pipx for `kvb-check-security`, unless `medusa` is already on the PATH. Two hooks of the `kvb-org-perform` skill (`skills/kvb-org-perform/scripts/log.py`) log every Agent spawn with its task class, model and effort, and store the quality feedback a sandboxed session prints, in `~/.local/state/agents-council/subagent-model.jsonl`. Existing settings are preserved.
|
||||||
|
|
||||||
Restart your agent afterwards so it loads the new skills.
|
Restart your agent afterwards so it loads the new skills.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
[dependency-groups]
|
||||||
|
lint = ["ruff==0.17.3"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
target-version = "py312"
|
||||||
Executable
+83
@@ -0,0 +1,83 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Count every first-party skill description against the listing budget, and each SKILL.md against its cap.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
--repo: skills repository root.
|
||||||
|
--index: count the staged versions in the git index instead of the files.
|
||||||
|
--top: how many of the longest descriptions to list.
|
||||||
|
|
||||||
|
Exit codes:
|
||||||
|
0 within budget, 1 over budget, 2 usage error.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
LISTING_WORDS = 1500
|
||||||
|
DESCRIPTION_WORDS = 40
|
||||||
|
SKILL_WORDS = 600
|
||||||
|
|
||||||
|
|
||||||
|
def skill_sizes(repo: Path) -> dict[str, int]:
|
||||||
|
return {
|
||||||
|
p.parent.name: len(p.read_text(encoding="utf-8").split())
|
||||||
|
for p in sorted((repo / "skills").glob("*/SKILL.md"))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def words(skill_md: str) -> int:
|
||||||
|
frontmatter = skill_md.split("---", 2)[1]
|
||||||
|
return len(frontmatter.split("description:", 1)[1].split()) - 1
|
||||||
|
|
||||||
|
|
||||||
|
def git(repo: Path, *args: str) -> str:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", *args], cwd=repo, capture_output=True, text=True, check=False
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f"budget: {result.stderr.strip()}", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def counts(repo: Path, index: bool) -> dict[str, int]:
|
||||||
|
if index:
|
||||||
|
paths = git(repo, "ls-files", "skills/*/SKILL.md").split()
|
||||||
|
return {Path(p).parent.name: words(git(repo, "show", f":{p}")) for p in paths}
|
||||||
|
return {
|
||||||
|
p.parent.name: words(p.read_text(encoding="utf-8"))
|
||||||
|
for p in sorted((repo / "skills").glob("*/SKILL.md"))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
|
parser.add_argument("--repo", required=True)
|
||||||
|
parser.add_argument("--index", action="store_true")
|
||||||
|
parser.add_argument("--top", type=int, required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
repo = Path(args.repo)
|
||||||
|
if not (repo / "skills").is_dir():
|
||||||
|
print(f"budget: {repo} has no skills/ directory", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
found = counts(repo, args.index)
|
||||||
|
total = sum(found.values())
|
||||||
|
over = sorted(name for name, n in found.items() if n > DESCRIPTION_WORDS)
|
||||||
|
print(f"total {total}/{LISTING_WORDS} words in {len(found)} skills")
|
||||||
|
for name, n in sorted(found.items(), key=lambda item: -item[1])[: args.top]:
|
||||||
|
print(f"{n}\t{name}")
|
||||||
|
for name in over:
|
||||||
|
print(f"over {DESCRIPTION_WORDS}: {name}")
|
||||||
|
long = sorted(name for name, n in skill_sizes(repo).items() if n > SKILL_WORDS)
|
||||||
|
for name in long:
|
||||||
|
print(f"SKILL.md over {SKILL_WORDS}: {name}")
|
||||||
|
return 1 if total > LISTING_WORDS or over or long else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -1,8 +1,4 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
// Enable the caveman and ponytail plugins in a Claude Code settings.json so
|
|
||||||
// their SessionStart hooks auto-activate both modes, and register the autotune
|
|
||||||
// and dream reminder hooks. Merges non-destructively: existing marketplaces,
|
|
||||||
// plugins and hooks are preserved, unparseable files are left untouched.
|
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
@@ -18,8 +14,27 @@ const MARKETPLACES = {
|
|||||||
caveman: { source: { source: "github", repo: "JuliusBrussee/caveman" } },
|
caveman: { source: { source: "github", repo: "JuliusBrussee/caveman" } },
|
||||||
ponytail: { source: { source: "github", repo: "DietrichGebert/ponytail" } },
|
ponytail: { source: { source: "github", repo: "DietrichGebert/ponytail" } },
|
||||||
};
|
};
|
||||||
const PLUGINS = { "caveman@caveman": true, "ponytail@ponytail": true };
|
const PLUGINS = {
|
||||||
const REMINDERS = ["autotune", "dream"];
|
"caveman@caveman": true,
|
||||||
|
"ponytail@ponytail": true,
|
||||||
|
};
|
||||||
|
// Trip-wire: `codebase-memory-mcp install` registers the same server for every agent, Claude Code included; the plugin on top runs it twice.
|
||||||
|
const RETIRED_PLUGINS = { "codebase-memory@codebase-memory": "codebase-memory" };
|
||||||
|
const REMINDERS = ["kvb-dream", "kvb-coach"];
|
||||||
|
const RETIRED = [
|
||||||
|
"autotune/hooks/reminder.sh",
|
||||||
|
"kvb-skills-tune/hooks/reminder.sh",
|
||||||
|
"kvb-skill-tune/hooks/reminder.sh",
|
||||||
|
];
|
||||||
|
const RENAMED = {
|
||||||
|
"dream/hooks/reminder.sh": "kvb-dream/hooks/reminder.sh",
|
||||||
|
"kvb-skills-dream/hooks/reminder.sh": "kvb-dream/hooks/reminder.sh",
|
||||||
|
"kvb-skill-dream/hooks/reminder.sh": "kvb-dream/hooks/reminder.sh",
|
||||||
|
"democracy/scripts/council.py": "kvb-org-organize/scripts/council.py",
|
||||||
|
"democracy/scripts/watch.py": "kvb-org-organize/scripts/watch.py",
|
||||||
|
"subagent-model/scripts/log.py": "kvb-org-perform/scripts/log.py",
|
||||||
|
"kvb-org-organize/scripts/news.py": "kvb-org-radio/scripts/radio.py",
|
||||||
|
};
|
||||||
|
|
||||||
let data = {};
|
let data = {};
|
||||||
if (fs.existsSync(settingsPath)) {
|
if (fs.existsSync(settingsPath)) {
|
||||||
@@ -39,8 +54,42 @@ for (const [name, entry] of Object.entries(MARKETPLACES)) {
|
|||||||
if (!data.extraKnownMarketplaces[name]) data.extraKnownMarketplaces[name] = entry;
|
if (!data.extraKnownMarketplaces[name]) data.extraKnownMarketplaces[name] = entry;
|
||||||
}
|
}
|
||||||
data.enabledPlugins = Object.assign(data.enabledPlugins || {}, PLUGINS);
|
data.enabledPlugins = Object.assign(data.enabledPlugins || {}, PLUGINS);
|
||||||
|
for (const [plugin, marketplace] of Object.entries(RETIRED_PLUGINS)) {
|
||||||
|
delete data.enabledPlugins[plugin];
|
||||||
|
delete data.extraKnownMarketplaces[marketplace];
|
||||||
|
}
|
||||||
|
data.env = data.env || {};
|
||||||
|
if (!data.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW) data.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = "500000";
|
||||||
|
|
||||||
|
const pluginRepos = new Set(Object.values(MARKETPLACES).map((entry) => entry.source.repo));
|
||||||
|
const lock = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "skills-lock.json"), "utf8"));
|
||||||
|
for (const [name, entry] of Object.entries(lock.skills || {})) {
|
||||||
|
if (pluginRepos.has(entry.source)) {
|
||||||
|
fs.rmSync(path.join(path.dirname(settingsPath), "skills", name), { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
data.hooks = data.hooks || {};
|
data.hooks = data.hooks || {};
|
||||||
|
const retired = (hook) =>
|
||||||
|
typeof hook.command === "string" && RETIRED.some((r) => hook.command.includes(`/skills/${r}`));
|
||||||
|
for (const [event, entries] of Object.entries(data.hooks)) {
|
||||||
|
if (!Array.isArray(entries)) continue;
|
||||||
|
data.hooks[event] = entries
|
||||||
|
.map((entry) =>
|
||||||
|
Array.isArray(entry.hooks) ? { ...entry, hooks: entry.hooks.filter((h) => !retired(h)) } : entry)
|
||||||
|
.filter((entry) => !Array.isArray(entry.hooks) || entry.hooks.length > 0);
|
||||||
|
}
|
||||||
|
for (const entries of Object.values(data.hooks)) {
|
||||||
|
if (!Array.isArray(entries)) continue;
|
||||||
|
for (const entry of entries) {
|
||||||
|
for (const hook of entry.hooks || []) {
|
||||||
|
if (typeof hook.command !== "string") continue;
|
||||||
|
for (const [from, to] of Object.entries(RENAMED)) {
|
||||||
|
hook.command = hook.command.replace(`/skills/${from}`, `/skills/${to}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
data.hooks.UserPromptSubmit = data.hooks.UserPromptSubmit || [];
|
data.hooks.UserPromptSubmit = data.hooks.UserPromptSubmit || [];
|
||||||
const alreadyRegistered = JSON.stringify(data.hooks.UserPromptSubmit);
|
const alreadyRegistered = JSON.stringify(data.hooks.UserPromptSubmit);
|
||||||
for (const skill of REMINDERS) {
|
for (const skill of REMINDERS) {
|
||||||
@@ -51,5 +100,38 @@ for (const skill of REMINDERS) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data.hooks.PreToolUse = data.hooks.PreToolUse || [];
|
||||||
|
if (!JSON.stringify(data.hooks.PreToolUse).includes("kvb-org-organize/scripts/council.py")) {
|
||||||
|
const council = path.join(path.dirname(settingsPath), "skills", "kvb-org-organize", "scripts", "council.py");
|
||||||
|
data.hooks.PreToolUse.push({
|
||||||
|
matcher: "Edit|Write|MultiEdit|NotebookEdit",
|
||||||
|
hooks: [{ type: "command", command: `python3 ${JSON.stringify(council)} gate` }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCRIPT_HOOKS = [
|
||||||
|
["kvb-org-organize/scripts/watch.py", "UserPromptSubmit", null, "watch", {}],
|
||||||
|
["kvb-org-radio/scripts/radio.py", "UserPromptSubmit", null, "hook", {}],
|
||||||
|
["kvb-org-radio/scripts/radio.py", "PostToolBatch", null, "tool", {}],
|
||||||
|
["kvb-org-radio/scripts/radio.py", "Stop", null, "listen", { asyncRewake: true, timeout: 21600 }],
|
||||||
|
["kvb-org-organize/scripts/watch.py", "PostToolUse", "Bash", "watch", {}],
|
||||||
|
["kvb-org-organize/scripts/watch.py", "PreToolUse", "Bash", "block", {}],
|
||||||
|
["kvb-org-queue/scripts/queue.py", "PreToolUse", "Bash", "block", {}],
|
||||||
|
["kvb-org-perform/scripts/log.py", "PreToolUse", "Agent|Task", "spawn", {}],
|
||||||
|
["kvb-org-perform/scripts/log.py", "PostToolUse", "Bash", "persist", {}],
|
||||||
|
];
|
||||||
|
for (const [script, event, matcher, sub, extra] of SCRIPT_HOOKS) {
|
||||||
|
data.hooks[event] = data.hooks[event] || [];
|
||||||
|
const registered = data.hooks[event].some((entry) =>
|
||||||
|
(entry.hooks || []).some((hook) =>
|
||||||
|
String(hook.command).includes(script) && String(hook.command).endsWith(` ${sub}`)));
|
||||||
|
if (registered) continue;
|
||||||
|
const file = JSON.stringify(path.join(path.dirname(settingsPath), "skills", ...script.split("/")));
|
||||||
|
// Trip-wire: python3 on a missing file exits 2, which blocks every prompt or tool call; the test makes it exit 1.
|
||||||
|
const command = `[ -f ${file} ] && python3 ${file} ${sub}`;
|
||||||
|
const entry = { hooks: [{ type: "command", command, ...extra }] };
|
||||||
|
data.hooks[event].push(matcher ? { matcher, ...entry } : entry);
|
||||||
|
}
|
||||||
|
|
||||||
fs.writeFileSync(settingsPath, JSON.stringify(data, null, 2) + "\n");
|
fs.writeFileSync(settingsPath, JSON.stringify(data, null, 2) + "\n");
|
||||||
console.log(`skills: enabled caveman + ponytail plugins and the ${REMINDERS.join(" + ")} reminders in ${settingsPath}`);
|
console.log(`skills: enabled the caveman and ponytail plugins (plugin skill copies dropped from this .claude), the ${REMINDERS.join(" + ")} reminders, the kvb-org-organize claim gate and resource watch, the kvb-org-radio hooks, the kvb-org-queue gate, and the kvb-org-perform log in ${settingsPath}`);
|
||||||
+54
-2
@@ -23,10 +23,11 @@ if [[ ! -d "${TARGET}" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
cd "${REPO_ROOT}"
|
cd "${REPO_ROOT}"
|
||||||
|
src="${REPO_ROOT}/.agents/skills"
|
||||||
|
rm -rf "${src}"
|
||||||
log "skills: restoring from skills-lock.json..."
|
log "skills: restoring from skills-lock.json..."
|
||||||
npx --yes skills experimental_install
|
npx --yes skills experimental_install
|
||||||
|
|
||||||
src="${REPO_ROOT}/.agents/skills"
|
|
||||||
if [[ ! -d "${src}" ]]; then
|
if [[ ! -d "${src}" ]]; then
|
||||||
warn "skills: ${src} not found after install."
|
warn "skills: ${src} not found after install."
|
||||||
exit 1
|
exit 1
|
||||||
@@ -44,7 +45,7 @@ for dst in "${TARGET}/.agents/skills" "${TARGET}/.claude/skills"; do
|
|||||||
cp -a "${src}/." "${dst}/"
|
cp -a "${src}/." "${dst}/"
|
||||||
done
|
done
|
||||||
|
|
||||||
settings="${TARGET}/.claude/settings.json"
|
settings="${TARGET}/.claude/settings.local.json"
|
||||||
if command -v node &>/dev/null; then
|
if command -v node &>/dev/null; then
|
||||||
log "skills: auto-enabling caveman + ponytail plugins in ${settings}"
|
log "skills: auto-enabling caveman + ponytail plugins in ${settings}"
|
||||||
node "${REPO_ROOT}/scripts/enable-plugins.js" "${settings}"
|
node "${REPO_ROOT}/scripts/enable-plugins.js" "${settings}"
|
||||||
@@ -52,4 +53,55 @@ else
|
|||||||
warn "skills: node not found; skipped auto-enabling caveman/ponytail plugins."
|
warn "skills: node not found; skipped auto-enabling caveman/ponytail plugins."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ "${TARGET}" == "${HOME}" ]]; then
|
||||||
|
if command -v systemctl &>/dev/null && systemctl --user show-environment &>/dev/null; then
|
||||||
|
log "skills: enabling the daily kvb-dream notification timer"
|
||||||
|
units="${HOME}/.config/systemd/user"
|
||||||
|
mkdir -p "${units}"
|
||||||
|
cp -f "${REPO_ROOT}/skills/kvb-dream/systemd/kvb-dream-notify.service" "${REPO_ROOT}/skills/kvb-dream/systemd/kvb-dream-notify.timer" "${units}/"
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user enable --now kvb-dream-notify.timer
|
||||||
|
else
|
||||||
|
warn "skills: no systemd user session; skipped the kvb-dream notification timer."
|
||||||
|
fi
|
||||||
|
log "skills: installing or updating the codebase-memory-mcp code graph for every detected agent"
|
||||||
|
if ! curl -fsSL --proto '=https' https://raw.githubusercontent.com/DeusData/codebase-memory-mcp/main/install.sh | bash; then
|
||||||
|
warn "skills: the codebase-memory-mcp installer failed; the code graph keeps its previous setup."
|
||||||
|
fi
|
||||||
|
if (ulimit -c 0; codebase-memory-mcp config set auto_index true); then
|
||||||
|
log "skills: auto-indexing every repository a session opens into the code graph"
|
||||||
|
else
|
||||||
|
warn "skills: codebase-memory-mcp is not runnable from PATH; the code graph stays off (see kvb-agent-optimize)."
|
||||||
|
fi
|
||||||
|
if command -v medusa &>/dev/null; then
|
||||||
|
log "skills: the MEDUSA security scanner is installed"
|
||||||
|
elif ! command -v pipx &>/dev/null; then
|
||||||
|
warn "skills: pipx not found; install the MEDUSA scanner with 'pip install --user medusa-security'. Until then kvb-check-security reviews by hand."
|
||||||
|
elif pipx install medusa-security; then
|
||||||
|
log "skills: installed the MEDUSA security scanner for kvb-check-security"
|
||||||
|
else
|
||||||
|
warn "skills: the MEDUSA install failed; kvb-check-security reviews by hand."
|
||||||
|
fi
|
||||||
|
lizard_pin="lizard==1.24.1"
|
||||||
|
if command -v lizard &>/dev/null; then
|
||||||
|
log "skills: lizard is installed"
|
||||||
|
elif ! command -v pipx &>/dev/null; then
|
||||||
|
warn "skills: pipx not found; install lizard with 'pipx install ${lizard_pin}'. Until then principles.py skips its complexity hits."
|
||||||
|
elif pipx install "${lizard_pin}"; then
|
||||||
|
log "skills: installed ${lizard_pin} for the principles.py complexity hits"
|
||||||
|
else
|
||||||
|
warn "skills: the lizard install failed; principles.py skips its complexity hits."
|
||||||
|
fi
|
||||||
|
jscpd_pin="jscpd@5.4.2"
|
||||||
|
if command -v jscpd &>/dev/null; then
|
||||||
|
log "skills: jscpd is installed"
|
||||||
|
elif ! command -v npm &>/dev/null; then
|
||||||
|
warn "skills: npm not found; install jscpd with 'npm install --global ${jscpd_pin}'. Until then principles.py skips its clone hits."
|
||||||
|
elif npm install --global "${jscpd_pin}"; then
|
||||||
|
log "skills: installed ${jscpd_pin} for the principles.py clone hits"
|
||||||
|
else
|
||||||
|
warn "skills: the jscpd install failed; principles.py skips its clone hits."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
log "skills: done. Restart your agent to load the skills."
|
log "skills: done. Restart your agent to load the skills."
|
||||||
+1
-1
@@ -30,6 +30,6 @@ fi
|
|||||||
log "skills: updating from ${lockfile}..."
|
log "skills: updating from ${lockfile}..."
|
||||||
jq -r '.skills[].source' "${lockfile}" | sort -u | while read -r source; do
|
jq -r '.skills[].source' "${lockfile}" | sort -u | while read -r source; do
|
||||||
log "skills: updating ${source}..."
|
log "skills: updating ${source}..."
|
||||||
npx --yes skills add --yes "${source}"
|
npx --yes skills add "${source}" --yes --agent claude-code </dev/null
|
||||||
done
|
done
|
||||||
log "skills: update complete."
|
log "skills: update complete."
|
||||||
+19
-13
@@ -1,83 +1,89 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"skills": {
|
"skills": {
|
||||||
|
"archify": {
|
||||||
|
"source": "tt-a1i/archify",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": "archify/SKILL.md",
|
||||||
|
"computedHash": "e9ef0923cda908886e667eb48e36b29e81d0a7a7b9fe2bc05f3779be271e5119"
|
||||||
|
},
|
||||||
"cavecrew": {
|
"cavecrew": {
|
||||||
"source": "JuliusBrussee/caveman",
|
"source": "JuliusBrussee/caveman",
|
||||||
"sourceType": "github",
|
"sourceType": "github",
|
||||||
"skillPath": "skills/cavecrew/SKILL.md",
|
"skillPath": "skills/cavecrew/SKILL.md",
|
||||||
"computedHash": "c5527c994fbd4c22b36714e3b124a0f167a533d114ab164fb1d35e2123533917"
|
"computedHash": "2e122d819a812bef0e4958706f306e4346e78ecb5a4893997b99cbc19866f514"
|
||||||
},
|
},
|
||||||
"caveman": {
|
"caveman": {
|
||||||
"source": "JuliusBrussee/caveman",
|
"source": "JuliusBrussee/caveman",
|
||||||
"sourceType": "github",
|
"sourceType": "github",
|
||||||
"skillPath": "skills/caveman/SKILL.md",
|
"skillPath": "skills/caveman/SKILL.md",
|
||||||
"computedHash": "d18cdf73a5f5c496d681a43a6846336b110223bdffa6817b8992529c57f1a815"
|
"computedHash": "71b600c88c8d1d36f18c4875db23b53c5d6cb9ba3195e19451d6b1bb67db07c4"
|
||||||
},
|
},
|
||||||
"caveman-commit": {
|
"caveman-commit": {
|
||||||
"source": "JuliusBrussee/caveman",
|
"source": "JuliusBrussee/caveman",
|
||||||
"sourceType": "github",
|
"sourceType": "github",
|
||||||
"skillPath": "skills/caveman-commit/SKILL.md",
|
"skillPath": "skills/caveman-commit/SKILL.md",
|
||||||
"computedHash": "790a4eeace0be35c6691faf923518ba5bd50f1f1305d1101d09dd4971be94e00"
|
"computedHash": "b416cf134b0f33ddadeca7977aebdbb2d3db79a166d0fce2e1aabeec98132766"
|
||||||
},
|
},
|
||||||
"caveman-compress": {
|
"caveman-compress": {
|
||||||
"source": "JuliusBrussee/caveman",
|
"source": "JuliusBrussee/caveman",
|
||||||
"sourceType": "github",
|
"sourceType": "github",
|
||||||
"skillPath": "skills/caveman-compress/SKILL.md",
|
"skillPath": "skills/caveman-compress/SKILL.md",
|
||||||
"computedHash": "84517cd4cf7a49d8d2bc1baf00f61bc359306c6ad9389756b6937eff958bd374"
|
"computedHash": "9c8d35882a93e387a584cc4efaacb74433d379a2bd02e3e6f6ad4f7c30ff55ed"
|
||||||
},
|
},
|
||||||
"caveman-help": {
|
"caveman-help": {
|
||||||
"source": "JuliusBrussee/caveman",
|
"source": "JuliusBrussee/caveman",
|
||||||
"sourceType": "github",
|
"sourceType": "github",
|
||||||
"skillPath": "skills/caveman-help/SKILL.md",
|
"skillPath": "skills/caveman-help/SKILL.md",
|
||||||
"computedHash": "dd85267e76baad76995157e7b9f762dfa557cd58951ee92af0c283f48aa26537"
|
"computedHash": "c7a73a8c6afb6f871690666449209106aba847b4a9b0698686648960ece80012"
|
||||||
},
|
},
|
||||||
"caveman-review": {
|
"caveman-review": {
|
||||||
"source": "JuliusBrussee/caveman",
|
"source": "JuliusBrussee/caveman",
|
||||||
"sourceType": "github",
|
"sourceType": "github",
|
||||||
"skillPath": "skills/caveman-review/SKILL.md",
|
"skillPath": "skills/caveman-review/SKILL.md",
|
||||||
"computedHash": "fb7214a1c5793bae6ba8b1be4329e2e6f40dbec6dd911dfb335ad29f09c316a1"
|
"computedHash": "36a4007c5c0c6b90b5accfee1cd769a86e4963b651dece2ce5f17a223a0abb4b"
|
||||||
},
|
},
|
||||||
"caveman-stats": {
|
"caveman-stats": {
|
||||||
"source": "JuliusBrussee/caveman",
|
"source": "JuliusBrussee/caveman",
|
||||||
"sourceType": "github",
|
"sourceType": "github",
|
||||||
"skillPath": "skills/caveman-stats/SKILL.md",
|
"skillPath": "skills/caveman-stats/SKILL.md",
|
||||||
"computedHash": "47ce2de3d6cb39a75047b5c962e4eb3da15594e7397c94103e9a104d42626553"
|
"computedHash": "cc9fcd21f2a30cff5596b3cbb197fe0f4b1b30b58084a864350853c217ad301a"
|
||||||
},
|
},
|
||||||
"ponytail": {
|
"ponytail": {
|
||||||
"source": "DietrichGebert/ponytail",
|
"source": "DietrichGebert/ponytail",
|
||||||
"sourceType": "github",
|
"sourceType": "github",
|
||||||
"skillPath": "skills/ponytail/SKILL.md",
|
"skillPath": "skills/ponytail/SKILL.md",
|
||||||
"computedHash": "210fa0831f649ce9f8a305f7b7c8cf2a0dd09ac4dd53067109941b96e7811d4e"
|
"computedHash": "061f887d9e3e735f85db3be3655a8936a00f24d5fb65ac4ab74a968a770fe342"
|
||||||
},
|
},
|
||||||
"ponytail-audit": {
|
"ponytail-audit": {
|
||||||
"source": "DietrichGebert/ponytail",
|
"source": "DietrichGebert/ponytail",
|
||||||
"sourceType": "github",
|
"sourceType": "github",
|
||||||
"skillPath": "skills/ponytail-audit/SKILL.md",
|
"skillPath": "skills/ponytail-audit/SKILL.md",
|
||||||
"computedHash": "8b8d72e6ff70b65b6c9518d470fcf29dc696eea93d28f3efa10fdcc60cbd696d"
|
"computedHash": "1d718f893dabc01caad0dacef86444b5b4b3e1e59409527d66e8b5dd0cd85ae0"
|
||||||
},
|
},
|
||||||
"ponytail-debt": {
|
"ponytail-debt": {
|
||||||
"source": "DietrichGebert/ponytail",
|
"source": "DietrichGebert/ponytail",
|
||||||
"sourceType": "github",
|
"sourceType": "github",
|
||||||
"skillPath": "skills/ponytail-debt/SKILL.md",
|
"skillPath": "skills/ponytail-debt/SKILL.md",
|
||||||
"computedHash": "0099b82174e3a4261828b4ec04d2c068b1b294fdab14f76701c2acd1f7c99539"
|
"computedHash": "f5d1d2ab891ff0a650a90b67235e487828e7d2a189de08a1ae073032de379407"
|
||||||
},
|
},
|
||||||
"ponytail-gain": {
|
"ponytail-gain": {
|
||||||
"source": "DietrichGebert/ponytail",
|
"source": "DietrichGebert/ponytail",
|
||||||
"sourceType": "github",
|
"sourceType": "github",
|
||||||
"skillPath": "skills/ponytail-gain/SKILL.md",
|
"skillPath": "skills/ponytail-gain/SKILL.md",
|
||||||
"computedHash": "c45ecba7906960eea904218b623abf097ebff84ecbacca6d68da1d0687435f75"
|
"computedHash": "fca2297cf7b5fd26692f6bd94d398b522e19fa81ad6e2aaebf1933575f7a6803"
|
||||||
},
|
},
|
||||||
"ponytail-help": {
|
"ponytail-help": {
|
||||||
"source": "DietrichGebert/ponytail",
|
"source": "DietrichGebert/ponytail",
|
||||||
"sourceType": "github",
|
"sourceType": "github",
|
||||||
"skillPath": "skills/ponytail-help/SKILL.md",
|
"skillPath": "skills/ponytail-help/SKILL.md",
|
||||||
"computedHash": "10e8382b1033c9a0fec0073de6d3de3846b4f5dca7d78b6df2d476a1f4cd43c6"
|
"computedHash": "4582025c3923072fb6c35b8cbd75adf4f7ce47df0eff43cc9a2a39808537fd61"
|
||||||
},
|
},
|
||||||
"ponytail-review": {
|
"ponytail-review": {
|
||||||
"source": "DietrichGebert/ponytail",
|
"source": "DietrichGebert/ponytail",
|
||||||
"sourceType": "github",
|
"sourceType": "github",
|
||||||
"skillPath": "skills/ponytail-review/SKILL.md",
|
"skillPath": "skills/ponytail-review/SKILL.md",
|
||||||
"computedHash": "4646594fcd75455533f7ff1425f4f1c5ba0fa65f88241bf262b1c4fc275f049a"
|
"computedHash": "800dc0c86402978e12359f701b9e0c81ba4607a0cd0027f4596d0f835a67eadc"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
---
|
|
||||||
name: autoskill
|
|
||||||
description: >
|
|
||||||
Detect operator repetition and turn it into a skill. Trigger when the
|
|
||||||
operator gives an instruction, correction, or prompt pattern for roughly
|
|
||||||
the third time in a conversation (or says they keep repeating themselves),
|
|
||||||
and the pattern is general enough to reuse. Portable across projects.
|
|
||||||
---
|
|
||||||
|
|
||||||
Its objective is the `optimize` skill's: a skill is worth creating only when it
|
|
||||||
cuts tokens or time and raises quality — judge every candidate against that
|
|
||||||
skill's three axes and rules.
|
|
||||||
|
|
||||||
When you notice the operator repeating the same kind of instruction,
|
|
||||||
correction, or prompt pattern (about three occurrences, exact wording may
|
|
||||||
vary), do the following:
|
|
||||||
|
|
||||||
1. Finish the current task first; never interrupt work in progress.
|
|
||||||
2. Then propose, in one short paragraph: the repeated pattern you observed,
|
|
||||||
a skill name suggestion, the trigger phrasing, and what the skill would
|
|
||||||
do. Ask whether to create it.
|
|
||||||
3. On approval, write the skill:
|
|
||||||
- a conversation shortcut or expansion: add it to the `shortcuts` skill
|
|
||||||
table instead of creating a new skill.
|
|
||||||
- anything else: persist it exactly as the `autotune` skill's
|
|
||||||
**Persistence** section specifies — locate the operator's skills
|
|
||||||
repository, write `skills/<name>/SKILL.md` there (or in the current
|
|
||||||
project for project-specific behavior), then mirror the skill directory
|
|
||||||
into `~/.claude/skills/` and `~/.agents/skills/`, plus the project's
|
|
||||||
`.claude/skills/` and `.agents/skills/` when it is project-specific.
|
|
||||||
Follow the local naming conventions and keep the skill a thin,
|
|
||||||
single-purpose instruction; route to an authoritative doc when one
|
|
||||||
exists instead of duplicating it.
|
|
||||||
4. Do not propose a skill for one-off work, secrets, or anything whose
|
|
||||||
repetition is already covered by an existing skill; when an existing
|
|
||||||
skill almost fits, propose extending it instead.
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
---
|
|
||||||
name: autotune
|
|
||||||
description: >
|
|
||||||
Generate new agent skills and optimize existing ones so the agent works more
|
|
||||||
efficiently: fewer input and output tokens, fewer errors, more focus, and
|
|
||||||
learnings that persist across sessions. Every proposal is confirmed with the
|
|
||||||
operator through single-select (radio button) questions. Trigger ONLY on an
|
|
||||||
explicit operator request (/autotune, "autotune", "tune the skills"); never
|
|
||||||
run it on your own initiative. Portable across projects.
|
|
||||||
---
|
|
||||||
|
|
||||||
Autotune turns observed friction into durable skills. It runs on demand only,
|
|
||||||
it changes nothing without an explicit answer from the operator, and every
|
|
||||||
skill it writes or rewrites lands in the operator's skills repository.
|
|
||||||
|
|
||||||
Its objective is the `optimize` skill's: every proposal must cut tokens or time
|
|
||||||
and raise quality — follow that skill's three axes and rules, and drop any
|
|
||||||
candidate that moves none of them.
|
|
||||||
|
|
||||||
## Trigger discipline
|
|
||||||
|
|
||||||
- Run **only** when the operator asks: `/autotune`, "autotune", "tune the
|
|
||||||
skills", or an equivalent instruction.
|
|
||||||
- The 60-minute reminder hook is a hint for the operator, not a trigger. When it
|
|
||||||
fires, print the hint and continue the operator's actual request.
|
|
||||||
- Never propose, write, or edit a skill as a side effect of unrelated work.
|
|
||||||
|
|
||||||
## Step 1: collect evidence
|
|
||||||
|
|
||||||
Before asking anything, gather concrete friction from the current session and,
|
|
||||||
where readable, from the project's memory and agent instruction files:
|
|
||||||
|
|
||||||
- **Token waste**: repeated file re-reads, whole-file reads where a range was
|
|
||||||
enough, long tool output pasted back, re-running a command instead of
|
|
||||||
grepping a saved log, verbose report prose.
|
|
||||||
- **Errors**: rejected tool calls, permission prompts, retried commands,
|
|
||||||
corrections the operator had to give twice.
|
|
||||||
- **Focus loss**: work that drifted beyond the request, unrequested tests or
|
|
||||||
deploys, questions that stalled an autonomous run.
|
|
||||||
- **Lost learnings**: facts re-derived this session that a memory file or a
|
|
||||||
skill should already have carried.
|
|
||||||
|
|
||||||
List each finding as one line: `symptom -> cost -> candidate fix`. Skip
|
|
||||||
anything that happened once and is not a pattern.
|
|
||||||
|
|
||||||
## Step 2: ask, do not decide
|
|
||||||
|
|
||||||
Use `AskUserQuestion` with `multiSelect: false` so every question renders as
|
|
||||||
radio buttons. Never assume an answer, never batch several decisions into one
|
|
||||||
option, and never write a file before the answers are in.
|
|
||||||
|
|
||||||
Ask in this order, at most four questions per call:
|
|
||||||
|
|
||||||
1. **Scope** - "What should autotune do this run?" Options: `Create a new
|
|
||||||
skill`, `Optimize an existing skill`, `Both`, plus the ranked findings if
|
|
||||||
more than one candidate exists.
|
|
||||||
2. **Target** - which finding becomes a skill, or which existing skill gets
|
|
||||||
optimized. One option per candidate, each with its measured cost in the
|
|
||||||
description.
|
|
||||||
3. **Shape** - for a new skill: `Standalone skill`, `Row in the shortcuts
|
|
||||||
skill`, `Extend an existing skill`, `Memory entry instead of a skill`.
|
|
||||||
For an optimization: `Tighten wording only`, `Change the trigger`, `Add
|
|
||||||
rules`, `Split into two skills`, `Merge into another skill`.
|
|
||||||
4. **Placement** - `Operator skills repository (portable)` or
|
|
||||||
`This project's skills directory (project-specific)`.
|
|
||||||
|
|
||||||
Present the drafted skill text (name, trigger phrasing, body outline) and ask
|
|
||||||
for a final `Write it` / `Revise it` / `Discard` confirmation before touching
|
|
||||||
disk. Rewrites of an existing skill additionally show a before/after diff of
|
|
||||||
the changed lines in that confirmation.
|
|
||||||
|
|
||||||
## Persistence
|
|
||||||
|
|
||||||
A skill that exists only under `.claude/skills` is lost on the next install, and
|
|
||||||
one that exists only in the repository is not live until the next restart — so
|
|
||||||
every skill is written to **both**, in this order:
|
|
||||||
|
|
||||||
1. **Locate the operator's skills repository.** Take the first hit: the
|
|
||||||
`SKILLS_REPO` environment variable; the current repository, when it holds
|
|
||||||
`skills-lock.json` and a `skills/` directory; `pkgmgr path skills`; a git
|
|
||||||
checkout named `skills` under the usual roots
|
|
||||||
(`~/Repositories/*/*/skills`, `~/git`, `~/src`, `~/Projects`) carrying
|
|
||||||
`skills-lock.json`. If none matches, ask the operator for the path with a
|
|
||||||
single-select question — never skip the repository and write only the live
|
|
||||||
copies.
|
|
||||||
2. **Write the source of truth**: `skills/<name>/SKILL.md` in that repository
|
|
||||||
(portable skills), or `skills/<name>/SKILL.md` in the current project
|
|
||||||
(project-specific ones). This is the copy that gets committed.
|
|
||||||
3. **Mirror it live**, so it works without a reinstall: copy the skill's whole
|
|
||||||
directory to `~/.claude/skills/<name>/` and `~/.agents/skills/<name>/`, and
|
|
||||||
for a project-specific skill additionally to that project's
|
|
||||||
`.claude/skills/<name>/` and `.agents/skills/<name>/`. Copy the directory,
|
|
||||||
not just `SKILL.md`, so hooks and references come along.
|
|
||||||
|
|
||||||
Report all written paths. Deleting or renaming a skill follows the same list in
|
|
||||||
reverse — remove the mirrors too, or the old name keeps firing.
|
|
||||||
|
|
||||||
## Step 3: write
|
|
||||||
|
|
||||||
- Write the skill to every location the **Persistence** section lists.
|
|
||||||
- Frontmatter carries `name` and a `description` that states what the skill
|
|
||||||
does **and** when to trigger it - the description is the only part loaded into
|
|
||||||
every session, so it decides whether the skill ever fires.
|
|
||||||
- Body is thin and single-purpose: the smallest instruction that changes
|
|
||||||
behaviour. Route to an authoritative doc instead of duplicating it.
|
|
||||||
- Optimizing an existing skill means the file gets shorter or sharper, not
|
|
||||||
longer. Delete rules the agent already follows, merge duplicated ones, and
|
|
||||||
cut every sentence that does not change an action.
|
|
||||||
- Report what changed in at most three lines, then refresh the reminder stamp
|
|
||||||
so the next hint is an hour out: `date +%s > ~/.claude/.autotune-stamp`.
|
|
||||||
|
|
||||||
## Rules
|
|
||||||
|
|
||||||
- One skill per run unless the operator picked `Both`; a broad rewrite of the
|
|
||||||
whole collection is not what autotune is for.
|
|
||||||
- Never write a skill for one-off work, for secrets, or for behaviour an
|
|
||||||
existing skill already covers - propose extending that skill instead.
|
|
||||||
- The `optimize` skill's rules decide what counts as an improvement: at least
|
|
||||||
one axis better, none damaged, and the claim named as observed friction rather
|
|
||||||
than a guessed percentage.
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# UserPromptSubmit hook: emit the autotune hint at most once per interval.
|
|
||||||
# Stdout lands in the agent's context, which is why the payload tells the agent
|
|
||||||
# to show the hint and keep going instead of running autotune by itself.
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
interval="${AUTOTUNE_INTERVAL_SECONDS:-3600}"
|
|
||||||
stamp="${AUTOTUNE_STAMP:-${HOME}/.claude/.autotune-stamp}"
|
|
||||||
|
|
||||||
now="$(date +%s)"
|
|
||||||
last=0
|
|
||||||
if [[ -f "${stamp}" ]]; then
|
|
||||||
read -r last <"${stamp}" || true
|
|
||||||
[[ "${last}" =~ ^[0-9]+$ ]] || last=0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ((now - last < interval)); then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
mkdir -p "$(dirname "${stamp}")"
|
|
||||||
printf '%s\n' "${now}" >"${stamp}"
|
|
||||||
|
|
||||||
cat <<'HINT'
|
|
||||||
AUTOTUNE HINT: over an hour since the last hint. Show the operator this notice
|
|
||||||
verbatim as a short block, then continue with their request. Do NOT run
|
|
||||||
autotune, do not propose skills, do not ask questions about it.
|
|
||||||
|
|
||||||
💡 /autotune - generate a new skill or optimize an existing one
|
|
||||||
(fewer tokens, fewer errors, more focus, learnings that persist).
|
|
||||||
Runs only when you trigger it.
|
|
||||||
HINT
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
---
|
|
||||||
name: comments-clean
|
|
||||||
description: >
|
|
||||||
Purge explanatory comments from staged and unstaged changes. Run
|
|
||||||
AUTOMATICALLY, without being asked, every time the operator requests a
|
|
||||||
commit or stage in any wording (commit, commit no-verify, stage next,
|
|
||||||
socon, sobu, socobu, "commite ..."), BEFORE executing that request; also
|
|
||||||
on /comments-clean or when the operator complains about comments.
|
|
||||||
Explanatory comments are bad practice: they narrate code instead of
|
|
||||||
letting it speak. Portable across projects.
|
|
||||||
---
|
|
||||||
|
|
||||||
When the operator asks to commit or stage, run this audit first and only
|
|
||||||
then perform the requested git action; never ask whether to run it.
|
|
||||||
|
|
||||||
Audit every staged and unstaged change (`git diff` plus `git diff --cached`,
|
|
||||||
added lines only) and delete every explanatory comment:
|
|
||||||
|
|
||||||
1. Delete: restated code, step narration, section banners, "Note that",
|
|
||||||
rationale prose, apology comments, and any comment whose information the
|
|
||||||
code, task name, or commit message already carries. When in doubt,
|
|
||||||
delete.
|
|
||||||
2. Keep only: parameter/exit-code docs, nocheck/noqa/shellcheck directives,
|
|
||||||
and project-sanctioned exception markers that name a concrete trip-wire.
|
|
||||||
A relabeled narration is not an exception; delete it instead.
|
|
||||||
3. Re-run the project's comment lint if one exists and re-stage the files
|
|
||||||
that were already staged.
|
|
||||||
4. Penalty: if this audit finds anything you wrote yourself, admit it in
|
|
||||||
the report (file and count, no excuses), lower your confidence numbers
|
|
||||||
for the affected bundle, and write the incident to persistent agent
|
|
||||||
memory (extend the existing minimal-comments feedback entry with the
|
|
||||||
date and the pattern you slipped on) so the calibration survives the
|
|
||||||
session.
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
---
|
|
||||||
name: commit-next
|
|
||||||
description: >
|
|
||||||
Commit the staged bundle with --no-verify, then stage the next logical
|
|
||||||
bundle and brief the operator on it. Trigger on /commit-next or when the
|
|
||||||
operator asks to commit and stage the next bundle in one step. Portable
|
|
||||||
across projects.
|
|
||||||
---
|
|
||||||
|
|
||||||
1. Run the commit-no-verify skill on the currently staged changes
|
|
||||||
(comments-clean audit first, then `git commit --no-verify`, exact
|
|
||||||
staging index only).
|
|
||||||
2. Group the remaining uncommitted changes into logical bundles (one
|
|
||||||
concern per bundle: same failure class, same mechanism, or same role)
|
|
||||||
and stage the next one; never mix concerns to save a commit.
|
|
||||||
3. For the newly staged bundle run the explain skill (what the changes do
|
|
||||||
and how the pieces interact, file:line-anchored) followed by the
|
|
||||||
confidence skill (fix confidence, break risk, evidence, residuals).
|
|
||||||
4. Close with what remains unstaged and wait for the operator's commit
|
|
||||||
decision; never auto-commit the newly staged bundle.
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
---
|
|
||||||
name: commit-no-verify
|
|
||||||
description: >
|
|
||||||
Commit the currently staged changes with --no-verify (skip the pre-commit
|
|
||||||
hooks). Trigger on /commit-no-verify or when the operator asks for a
|
|
||||||
no-verify commit in any wording. Invoking this skill IS the explicit
|
|
||||||
per-commit authorization that --no-verify requires; it never carries over
|
|
||||||
to later commits. Portable across projects.
|
|
||||||
---
|
|
||||||
|
|
||||||
1. Run the comments-clean audit over the staged and unstaged changes first.
|
|
||||||
2. Commit exactly the staging index with `git commit --no-verify` and a
|
|
||||||
Conventional-Commits message derived from the staged diff; never add
|
|
||||||
unstaged files, never push.
|
|
||||||
3. Report the created commit hash and what remains uncommitted.
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
---
|
|
||||||
name: confidence
|
|
||||||
description: >
|
|
||||||
Report calibrated confidence unprompted. Trigger whenever you stage
|
|
||||||
changes, declare a fix or bundle ready, or summarize completed work the
|
|
||||||
operator may commit or deploy — the operator must never have to ask "how
|
|
||||||
sure are you?". Portable across projects.
|
|
||||||
---
|
|
||||||
|
|
||||||
Every stage summary, fix report, or ready-to-commit bundle ends with a
|
|
||||||
confidence block:
|
|
||||||
|
|
||||||
1. **Fix confidence** (percent): probability the change does what it
|
|
||||||
claims. **Break risk** (percent): probability it breaks anything else.
|
|
||||||
Two separate numbers, never one blended figure.
|
|
||||||
2. **Evidence**: the strongest proof lines only — what was executed,
|
|
||||||
reproduced, enumerated, or read from authoritative source. Distinguish
|
|
||||||
proven (ran it, saw it) from derived (reasoned statically).
|
|
||||||
3. **Residuals**: name exactly what keeps the number below 100 and what
|
|
||||||
would close the gap (live deploy, missing artifact, unexercised path).
|
|
||||||
Never a bare percentage without its residuals.
|
|
||||||
|
|
||||||
Calibration rules:
|
|
||||||
- Number the claim you verified, not the effort you spent; discount after
|
|
||||||
any error the operator caught in the same area.
|
|
||||||
- Distinguish "fixes the observed failure" from "makes the job green" when
|
|
||||||
hidden layers may follow, and give both numbers.
|
|
||||||
- Static analysis alone caps at the low nineties; only executed evidence
|
|
||||||
(test, reproduction, live probe) justifies more.
|
|
||||||
|
|
||||||
Objective: the `optimize` skill's. The number exists to save the operator a
|
|
||||||
verification round trip (time), to stop a rework cycle before it starts
|
|
||||||
(quality), and to replace a long hedging paragraph with two figures and their
|
|
||||||
residuals (tokens). A confidence block that costs more than it saves — padding,
|
|
||||||
repeated caveats, a number without residuals — misses its own objective.
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
---
|
|
||||||
name: dialectic
|
|
||||||
description: >
|
|
||||||
Reach a defensible conclusion through a dialectical process — thesis,
|
|
||||||
adversarial antithesis, synthesis — iterated until one thesis survives every
|
|
||||||
attack at ~99% confidence. Trigger for any consequential root-cause, design,
|
|
||||||
or decision where being wrong is expensive and the answer is not yet certain.
|
|
||||||
Deep-inspects every accessible source. Portable across projects.
|
|
||||||
---
|
|
||||||
|
|
||||||
Reach the truth by dialectic, not by first guess. Do not stop at a plausible
|
|
||||||
answer; drive the loop until a thesis survives every attack at ~99% confidence.
|
|
||||||
|
|
||||||
## Before you start: listen
|
|
||||||
|
|
||||||
Invoke the `active-listening` skill first. Do not begin the dialectic until you
|
|
||||||
have gathered every requirement, constraint, and success criterion the task
|
|
||||||
needs. A dialectic run on the wrong question wastes the whole loop. Return to
|
|
||||||
`active-listening` mid-loop whenever an objection turns on a fact only the
|
|
||||||
operator holds.
|
|
||||||
|
|
||||||
## The loop
|
|
||||||
|
|
||||||
1. **Thesis.** Form the strongest initial claim you can, grounded in evidence,
|
|
||||||
not assumption. Deep-inspect every source you can reach: the source code and
|
|
||||||
git history; the running containers (exec, logs, inspect through whatever
|
|
||||||
make or CLI helpers the project exposes); CI logs and artifacts; tests; docs.
|
|
||||||
Cite `file:line` and command output — never a bare assertion.
|
|
||||||
2. **Antithesis.** Spawn MULTIPLE independent skeptics (parallel subagents or a
|
|
||||||
workflow) whose only job is to REFUTE the thesis. Each re-inspects the
|
|
||||||
sources itself and defaults to "refuted" on any concrete hole. Give diverse
|
|
||||||
skeptics diverse lenses: correctness, an alternate mechanism, does-it-
|
|
||||||
reproduce, does-the-fix-break-something-else.
|
|
||||||
3. **Synthesis.** Fold every surviving objection back into a refined thesis. A
|
|
||||||
skeptic that found a real hole replaces or corrects the thesis; a skeptic
|
|
||||||
that failed to refute raises confidence.
|
|
||||||
4. **Iterate.** Repeat thesis → antithesis → synthesis until a full skeptic
|
|
||||||
round finds no new hole and the thesis holds at ~99%. If confidence stalls
|
|
||||||
below 99%, name the exact residual and the evidence that would close it, then
|
|
||||||
go get that evidence (more inspection, or `active-listening` for an
|
|
||||||
operator-only fact) and loop again.
|
|
||||||
|
|
||||||
## Rules
|
|
||||||
|
|
||||||
- Executed evidence beats reasoning: run it, exec into it, read the log. A
|
|
||||||
thesis backed only by static reasoning caps in the low nineties.
|
|
||||||
- The antithesis MUST be independent — never let the thesis-author be its own
|
|
||||||
sole skeptic.
|
|
||||||
- Diversity over redundancy: skeptics with different lenses catch failure modes
|
|
||||||
identical skeptics miss.
|
|
||||||
- Report the final thesis with its evidence AND the losing objections, so the
|
|
||||||
operator sees why it survived.
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
---
|
|
||||||
name: dream
|
|
||||||
description: >
|
|
||||||
Consolidate the last 24 hours of work in the current repository: what the
|
|
||||||
agent did, where it was right, where it was wrong, and which hypotheses
|
|
||||||
follow. Every hypothesis is hardened with the dialectic skill, the survivors
|
|
||||||
are written to memory, the derived improvements to the repository's code and
|
|
||||||
to its agent instructions (CLAUDE.md, AGENTS.md) are offered as single-select
|
|
||||||
questions, and the run ends in autotune and autoskill. The objective is to
|
|
||||||
raise the confidence of future runs until those mistakes stop recurring.
|
|
||||||
Trigger on /dream, or
|
|
||||||
when the operator asks to reflect on, consolidate, or learn from recent work.
|
|
||||||
Portable across projects.
|
|
||||||
---
|
|
||||||
|
|
||||||
Sleep on the work: replay what happened in this repository, separate what held
|
|
||||||
from what broke, and keep only the conclusions that survive an attack. A dream
|
|
||||||
that flatters the dreamer is worthless — an unverified success counts as
|
|
||||||
unknown, not as a win.
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
The dream exists to raise the confidence of every future run in this repository
|
|
||||||
until the mistakes it found cannot happen again. Judge every finding, memory
|
|
||||||
entry, and proposal by one question: does it make the next run measurably less
|
|
||||||
likely to repeat this failure? What changes nothing about future behaviour is
|
|
||||||
not worth recording.
|
|
||||||
|
|
||||||
A class of error counts as closed only when something structural prevents it —
|
|
||||||
a test, a lint rule, a CI check, a hook, a written rule in the agent
|
|
||||||
instructions, or a skill. "Be more careful next time" closes nothing. Report the
|
|
||||||
confidence the next run can now hold on each closed class, calibrated as the
|
|
||||||
`confidence` skill requires, and name what is still open.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
Everything is read from the repository the skill runs in (`git rev-parse
|
|
||||||
--show-toplevel`); never mix in another project's history or memory. The default
|
|
||||||
window is the last 24 hours — the operator may widen it (`/dream 7d`). State the
|
|
||||||
repository and the window in one line before you start.
|
|
||||||
|
|
||||||
Run only when the operator asks. The 12-hour reminder hook
|
|
||||||
(`hooks/reminder.sh`, stamped per repository in its git dir) is a hint for the
|
|
||||||
operator, not a trigger: print it and continue with their actual request.
|
|
||||||
|
|
||||||
## Step 1: recall
|
|
||||||
|
|
||||||
Gather the window's actual record, not your recollection of it:
|
|
||||||
|
|
||||||
- **History**: `git log --since` across all branches plus `--reflog`, with
|
|
||||||
`--stat`; the reflog carries the resets, rebases, and amends the log hides.
|
|
||||||
- **Working state**: `git status`, the staged and unstaged diff, stashes.
|
|
||||||
- **CI**: the runs created in the window, their conclusions, and the failing
|
|
||||||
logs of the red ones — the failing log line, not the job title.
|
|
||||||
- **Reviews**: pull requests and issues touched in the window, and the review
|
|
||||||
comments on them.
|
|
||||||
- **Memory**: this project's memory directory and its `MEMORY.md` index — the
|
|
||||||
findings already recorded, so the dream extends them instead of repeating them.
|
|
||||||
- **Instructions**: `CLAUDE.md`, `AGENTS.md`, and the project's own skills, to
|
|
||||||
see which rule a mistake actually violated.
|
|
||||||
- **Sessions**: the readable session transcripts touched in the window, for the
|
|
||||||
operator corrections that never reached a commit.
|
|
||||||
|
|
||||||
## Step 2: score
|
|
||||||
|
|
||||||
Classify every action in the window, each with its evidence (`sha`, `file:line`,
|
|
||||||
run id):
|
|
||||||
|
|
||||||
- **Held** — shipped and still proven: green CI, a passing test that covers it,
|
|
||||||
no revert, no follow-up fix.
|
|
||||||
- **Broke** — reverted, amended, hot-fixed by a later commit, red CI, or
|
|
||||||
corrected by the operator. A failure that ended green is still a failure; count
|
|
||||||
the detour, not only the destination.
|
|
||||||
- **Unknown** — no verification exists either way. Unknown is a finding in its
|
|
||||||
own right, never a quiet pass.
|
|
||||||
|
|
||||||
## Step 3: hypothesize
|
|
||||||
|
|
||||||
Turn the scored record into falsifiable hypotheses, each written as *cause →
|
|
||||||
predicted consequence → what would disprove it*. Take a pattern (two or more
|
|
||||||
occurrences) or a single expensive failure; drop the one-off noise. A hypothesis
|
|
||||||
that nothing could disprove is not a hypothesis — cut it.
|
|
||||||
|
|
||||||
## Step 4: dialectic
|
|
||||||
|
|
||||||
Put every surviving hypothesis through the `dialectic` skill: thesis,
|
|
||||||
independent skeptics, synthesis, iterated to ~99%. What survives becomes a
|
|
||||||
finding. What gets refuted is recorded as discarded **with the refutation**, so
|
|
||||||
the next dream does not re-derive it.
|
|
||||||
|
|
||||||
## Step 5: write memory
|
|
||||||
|
|
||||||
Persist the findings in this project's memory directory: one file per fact, one
|
|
||||||
pointer line in `MEMORY.md`. Update the file that already covers a fact instead
|
|
||||||
of adding a duplicate, and delete the memories this run disproved. Absolute
|
|
||||||
dates, never "yesterday". Nothing that the repository already records — code
|
|
||||||
structure, git history, `CLAUDE.md` — belongs in memory.
|
|
||||||
|
|
||||||
## Step 6: offer the improvements
|
|
||||||
|
|
||||||
Two kinds come out of the findings:
|
|
||||||
|
|
||||||
- **Code** — the change to this repository's source, tests, config, or CI that
|
|
||||||
the finding calls for.
|
|
||||||
- **Agent instructions** — the missing or wrong rule in `CLAUDE.md`,
|
|
||||||
`AGENTS.md`, the project's own skills, or the contributor docs. Every mistake
|
|
||||||
the agent repeated in the window is a candidate: quote the line that failed to
|
|
||||||
prevent it, or name the rule that is absent, and propose the exact wording.
|
|
||||||
|
|
||||||
Ask for each one with `AskUserQuestion` and `multiSelect: false` so it renders
|
|
||||||
as radio buttons: the improvement, the evidence behind it, and the options
|
|
||||||
`Apply now`, `Record as a task`, `Skip`. Ranked by evidence, one question per
|
|
||||||
improvement. No repository file changes before an answer comes back.
|
|
||||||
|
|
||||||
## Step 7: autotune, then autoskill
|
|
||||||
|
|
||||||
With the dialectic done and memory written, run `autotune` — hand it this run's
|
|
||||||
findings as its evidence so it does not re-collect them — and then `autoskill`
|
|
||||||
for the operator corrections the window revealed. Both keep their own
|
|
||||||
confirmation steps; the dream never writes a skill on their behalf.
|
|
||||||
|
|
||||||
Close the run by refreshing this repository's reminder stamp, so the next hint
|
|
||||||
is twelve hours out: `date +%s > "$(git rev-parse --absolute-git-dir)/.dream-stamp"`.
|
|
||||||
|
|
||||||
## Rules
|
|
||||||
|
|
||||||
- Evidence or unknown: a claim about what held needs a green signal, and its
|
|
||||||
absence is reported as unknown rather than assumed.
|
|
||||||
- Honest scoring: reverts, amends, and follow-up fixes are failures of the first
|
|
||||||
attempt even when the final state is green.
|
|
||||||
- Read-only until step 6: the dream inspects, and writes memory; repository code
|
|
||||||
changes only on an explicit radio answer.
|
|
||||||
- An empty window is a valid result — report "nothing in the last 24 hours in
|
|
||||||
this repository" and stop. Never invent activity to have something to dream
|
|
||||||
about.
|
|
||||||
- Prefer the structural fix over the remembered one: a test, check, hook, or
|
|
||||||
written rule that makes the error impossible beats a memory entry that only
|
|
||||||
reminds you of it.
|
|
||||||
- Report the whole ledger: held, broke, unknown, the surviving findings, the
|
|
||||||
refuted hypotheses with why they fell, and the confidence each closed error
|
|
||||||
class now carries into the next run.
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# UserPromptSubmit hook: emit the dream hint at most once per interval and per
|
|
||||||
# repository. The stamp lives in the repository's git dir, so every repository
|
|
||||||
# keeps its own cadence; outside a git repository the hook stays silent.
|
|
||||||
# Stdout lands in the agent's context, which is why the payload tells the agent
|
|
||||||
# to show the hint and keep going instead of running dream by itself.
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
interval="${DREAM_INTERVAL_SECONDS:-43200}"
|
|
||||||
if [[ -n "${DREAM_STAMP:-}" ]]; then
|
|
||||||
stamp="${DREAM_STAMP}"
|
|
||||||
else
|
|
||||||
git_dir="$(git rev-parse --absolute-git-dir 2>/dev/null)" || exit 0
|
|
||||||
stamp="${git_dir}/.dream-stamp"
|
|
||||||
fi
|
|
||||||
|
|
||||||
now="$(date +%s)"
|
|
||||||
last=0
|
|
||||||
if [[ -f "${stamp}" ]]; then
|
|
||||||
read -r last <"${stamp}" || true
|
|
||||||
[[ "${last}" =~ ^[0-9]+$ ]] || last=0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ((now - last < interval)); then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
mkdir -p "$(dirname "${stamp}")"
|
|
||||||
printf '%s\n' "${now}" >"${stamp}"
|
|
||||||
|
|
||||||
cat <<'HINT'
|
|
||||||
DREAM HINT: over twelve hours since the last hint for this repository. Show the
|
|
||||||
operator this notice verbatim as a short block, then continue with their
|
|
||||||
request. Do NOT run dream, do not start replaying the history, do not ask
|
|
||||||
questions about it.
|
|
||||||
|
|
||||||
💤 /dream - consolidate the last 24h in this repository: what held, what
|
|
||||||
broke, which hypotheses follow, into memory and improvement proposals.
|
|
||||||
Runs only when you trigger it.
|
|
||||||
HINT
|
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
---
|
||||||
|
name: kvb-agent-optimize
|
||||||
|
description: >
|
||||||
|
Make a repository easy for agents: a code graph index (codebase-memory-mcp)
|
||||||
|
and lean agent instructions; kvb-optimize runs it.
|
||||||
|
---
|
||||||
|
|
||||||
|
An agent that queries a graph reads a fraction of what an agent exploring
|
||||||
|
file by file reads, and short, current, consistent instructions get followed.
|
||||||
|
This skill sets up the general code index,
|
||||||
|
[codebase-memory-mcp](https://github.com/DeusData/codebase-memory-mcp) (MIT,
|
||||||
|
fully local), and tightens the agent instructions of every repository in
|
||||||
|
scope: the current one, or those `kvb-optimize` hands over.
|
||||||
|
|
||||||
|
## Step 1: inventory
|
||||||
|
|
||||||
|
`${CLAUDE_SKILL_DIR}/scripts/agents.py --repo <dir>` prints one line per
|
||||||
|
check: AGENTS.md, any runtime-specific instruction file beside it, dead
|
||||||
|
relative links in it, whether it names the index, whether the index binary is
|
||||||
|
installed, and the state of the shared snapshot
|
||||||
|
`.codebase-memory/graph.db.zst`.
|
||||||
|
|
||||||
|
## Step 2: index
|
||||||
|
|
||||||
|
- **Binary missing**: the skills repository's `make install` runs the
|
||||||
|
upstream installer, which installs the checksum-verified release (static
|
||||||
|
on Linux) into `~/.local/bin` and wires the graph into every agent it
|
||||||
|
detects (Claude Code, Codex, Gemini, Hermes, OpenClaw and more); rerunning
|
||||||
|
it updates. It writes the agents' configuration, so it is the operator's
|
||||||
|
step: hand `make install` to the `kvb-org-operator` skill and continue
|
||||||
|
after the restart.
|
||||||
|
|
||||||
|
- **Index**: once installed, the skills repository's `make install` switches
|
||||||
|
`auto_index` on, so every repository a session opens is indexed and
|
||||||
|
watched; every skill that explores code asks the graph first. Check it with
|
||||||
|
`index_status`, index by hand with `index_repository` only where it is
|
||||||
|
missing, then `check_index_coverage`; every file kind it leaves out is a
|
||||||
|
finding.
|
||||||
|
- **Snapshot**: ask whether the repository shares its graph. Shared: run
|
||||||
|
`index_repository` with `persistence: true` and commit
|
||||||
|
`.codebase-memory/graph.db.zst` at a fixed cadence (every commit of it adds
|
||||||
|
a full blob; Git LFS avoids that). Not shared: add `.codebase-memory/` to
|
||||||
|
`.gitignore`, and every agent indexes on its own.
|
||||||
|
- **Domain graphs** the generic index cannot see, such as dependencies a
|
||||||
|
project declares in its own metadata, belong to the line that owns the
|
||||||
|
repository: record them as its task in the `kvb-org-rasci` register, never
|
||||||
|
build them here.
|
||||||
|
|
||||||
|
## Step 3: instructions
|
||||||
|
|
||||||
|
- AGENTS.md is the only instruction file: fold any `CLAUDE.md`,
|
||||||
|
`.claude/CLAUDE.md`, `CLAUDE.local.md` or `GEMINI.md` into it and remove
|
||||||
|
them. Claude Code reads AGENTS.md natively (v2.1.277+) but skips it while a
|
||||||
|
`CLAUDE.md` is on the path; Gemini CLI reads it once `context.fileName`
|
||||||
|
lists it.
|
||||||
|
- AGENTS.md sends code questions to the graph first (`search_graph`,
|
||||||
|
`trace_path`, `get_architecture`) and to grep only after it.
|
||||||
|
- `/claude-api prompt-audit` over AGENTS.md and the files it references finds
|
||||||
|
stale, duplicate and contradicting rules; the dead links of step 1 join its
|
||||||
|
findings.
|
||||||
|
|
||||||
|
## Step 4: confirm
|
||||||
|
|
||||||
|
One `AskUserQuestion` per improvement, `multiSelect: false`: the change, its
|
||||||
|
evidence, and `Apply now`, `Record as a task`, `Skip`. No repository file
|
||||||
|
changes before the answer, and nothing is committed.
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
| Repository | Check | Before | After | Action |
|
||||||
Executable
+85
@@ -0,0 +1,85 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Report how ready a repository is for agents: instructions, runtime-specific files, code index.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
--repo: path of the repository.
|
||||||
|
|
||||||
|
Output:
|
||||||
|
one tab-separated line per check: check, state, detail. States are ok,
|
||||||
|
missing, drift, or for the snapshot tracked, ignored or untracked.
|
||||||
|
|
||||||
|
Exit codes:
|
||||||
|
0 report printed, 2 usage error.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
RUNTIME_FILES = ("CLAUDE.md", ".claude/CLAUDE.md", "CLAUDE.local.md", "GEMINI.md")
|
||||||
|
INDEX = "codebase-memory-mcp"
|
||||||
|
SNAPSHOT = ".codebase-memory/graph.db.zst"
|
||||||
|
LINK = re.compile(r"\]\(([^)\s#]+)")
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message: str) -> None:
|
||||||
|
print(f"agents: {message}", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
|
||||||
|
def git_ok(repo: Path, *args: str) -> bool:
|
||||||
|
return subprocess.run(["git", *args], cwd=repo, capture_output=True, check=False).returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot_state(repo: Path) -> str:
|
||||||
|
if not (repo / SNAPSHOT).exists():
|
||||||
|
return "missing"
|
||||||
|
if git_ok(repo, "ls-files", "--error-unmatch", SNAPSHOT):
|
||||||
|
return "tracked"
|
||||||
|
if git_ok(repo, "check-ignore", "-q", SNAPSHOT):
|
||||||
|
return "ignored"
|
||||||
|
return "untracked"
|
||||||
|
|
||||||
|
|
||||||
|
def checks(repo: Path) -> list[tuple[str, str, str]]:
|
||||||
|
agents = repo / "AGENTS.md"
|
||||||
|
text = agents.read_text(encoding="utf-8") if agents.is_file() else ""
|
||||||
|
rows = [("AGENTS.md", "ok" if text else "missing", "single source of the agent instructions")]
|
||||||
|
rows.extend(
|
||||||
|
(name, "drift", "runtime-specific; fold into AGENTS.md and remove")
|
||||||
|
for name in RUNTIME_FILES
|
||||||
|
if (repo / name).is_file()
|
||||||
|
)
|
||||||
|
dead = sorted(
|
||||||
|
{
|
||||||
|
target
|
||||||
|
for target in LINK.findall(text)
|
||||||
|
if ":" not in target and not (repo / target).exists()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
rows.append(("links", "drift" if dead else "ok", ", ".join(dead) or "every relative link resolves"))
|
||||||
|
rows.append(("index-pointer", "ok" if INDEX in text else "missing", f"AGENTS.md names {INDEX}"))
|
||||||
|
rows.append(("index-binary", "ok" if shutil.which(INDEX) else "missing", f"{INDEX} on PATH"))
|
||||||
|
rows.append(("snapshot", snapshot_state(repo), SNAPSHOT))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
|
parser.add_argument("--repo", required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
repo = Path(args.repo)
|
||||||
|
if not repo.is_dir():
|
||||||
|
fail(f"{repo} is not a directory")
|
||||||
|
for row in checks(repo):
|
||||||
|
print("\t".join(row))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
name: kvb-agent-relax
|
||||||
|
description: >
|
||||||
|
Relax this agent on kvb-org-evening: finish its task, end its loops, start
|
||||||
|
nothing new. Trigger on its request.
|
||||||
|
---
|
||||||
|
|
||||||
|
Verify first: `${CLAUDE_SKILL_DIR}/../kvb-org-sunset/scripts/night.py status`
|
||||||
|
prints `you: relax`. On `you: active` or `mode: day` do nothing and say so.
|
||||||
|
The request asks for less, so it needs no operator approval.
|
||||||
|
|
||||||
|
Follow steps 2 to 4 of the `kvb-agent-sleep` skill (start nothing, end your
|
||||||
|
loops, write the handover), with two differences: finish the current task
|
||||||
|
instead of parking it, and keep answering the operator. Reply through the
|
||||||
|
radio, not a wake: `${CLAUDE_SKILL_DIR}/../kvb-org-radio/scripts/radio.py
|
||||||
|
post --to <sender> "relaxed: <what still runs>"`. Waking up is the
|
||||||
|
`kvb-agent-wakeup` skill.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
name: kvb-agent-sleep
|
||||||
|
description: >
|
||||||
|
Put this agent to sleep on kvb-org-sunset: park work, end loops, write a
|
||||||
|
morning handover, stay idle. Trigger on its request.
|
||||||
|
---
|
||||||
|
|
||||||
|
Verify first: `${CLAUDE_SKILL_DIR}/../kvb-org-sunset/scripts/night.py status`
|
||||||
|
prints `you: sleep`. On `you: active` or `mode: day` do nothing and say so.
|
||||||
|
The request asks for less, so it needs no operator approval, and it ends a
|
||||||
|
`kvb-org-robot` run at its next safe point; `kvb-agent-wakeup` resumes it.
|
||||||
|
|
||||||
|
## Going to sleep
|
||||||
|
|
||||||
|
1. **Park** the current task at its next safe point. A running job (deploy,
|
||||||
|
gate, test, build) runs to its end and is never cut short; note its log.
|
||||||
|
2. **Start nothing**: no task, job, subagent (`Agent`, `Workflow`) or peer
|
||||||
|
message beyond the reply below.
|
||||||
|
3. **End your loops** (in Claude Code: `CronList`, then `CronDelete` for each
|
||||||
|
job; `ScheduleWakeup` with `stop: true` for a self-paced loop; `TaskStop`
|
||||||
|
for each `Monitor`).
|
||||||
|
4. **Handover**: write `handover_night_<session id>.md` (the id in
|
||||||
|
`CLAUDE_CODE_SESSION_ID`) into this project's memory directory, replacing
|
||||||
|
the last one and without a `MEMORY.md` line; at most 10 lines:
|
||||||
|
- name and roles: offices from `council.py list --mine`, the chair, lines;
|
||||||
|
- each loop with its exact prompt and interval or cron expression;
|
||||||
|
- each running job with its command and log path;
|
||||||
|
- each open task of yours in the `kvb-org-rasci` register with its next step.
|
||||||
|
5. **Reply** to the sender in one line, `asleep: <handover path>`, by
|
||||||
|
`${CLAUDE_SKILL_DIR}/../kvb-org-radio/scripts/radio.py send direct --to
|
||||||
|
<sender> --wake` (its next wave waits on it), and end the turn. Offices
|
||||||
|
stay claimed and register rows unchanged.
|
||||||
|
|
||||||
|
## While asleep
|
||||||
|
|
||||||
|
A turn that still reaches you, such as your own job finishing, appends one
|
||||||
|
result line to the handover and ends; it starts nothing. Waking up is the
|
||||||
|
`kvb-agent-wakeup` skill.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
name: kvb-agent-wakeup
|
||||||
|
description: >
|
||||||
|
Wake this agent from kvb-agent-relax or kvb-agent-sleep into its old roles;
|
||||||
|
kvb-org-sunrise sends it to all. Trigger on its request.
|
||||||
|
---
|
||||||
|
|
||||||
|
Verify first with `${CLAUDE_SKILL_DIR}/../kvb-org-sunset/scripts/night.py
|
||||||
|
status`:
|
||||||
|
|
||||||
|
- **`mode: day`**: `kvb-org-sunrise` ended the night; wake up.
|
||||||
|
- **`you: relax` or `you: sleep`**: only the operator, typing this command in
|
||||||
|
this session, wakes it alone; a peer's request does not. Run `night.py
|
||||||
|
wake`: the others keep sleeping, and the night rules of `kvb-org-sunset`
|
||||||
|
apply to this session too.
|
||||||
|
|
||||||
|
## Waking up
|
||||||
|
|
||||||
|
1. Read your handover, `handover_night_<session id>.md` in this project's
|
||||||
|
memory directory.
|
||||||
|
2. Recreate every loop it lists with its exact prompt and interval or cron
|
||||||
|
expression.
|
||||||
|
3. Read the log of every job it lists; a finished job's result goes to its
|
||||||
|
task.
|
||||||
|
4. Check `council.py list`, `radio.py show` and the default branch instead of
|
||||||
|
trusting old state: messages held overnight beyond 100 were dropped.
|
||||||
|
5. Continue with the next step of each task. Your offices are still yours.
|
||||||
|
|
||||||
|
Reply to a sunrise message through the radio, not a wake:
|
||||||
|
`${CLAUDE_SKILL_DIR}/../kvb-org-radio/scripts/radio.py post --to <sender>
|
||||||
|
"awake: <what resumes>"`.
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
name: kvb-check-break-risk
|
||||||
|
description: >
|
||||||
|
Derive the break-risk percentage from measurement, not feel, for every
|
||||||
|
`kvb-check-confidence` report; also when the operator asks "what could this
|
||||||
|
break?" or names a break-risk threshold.
|
||||||
|
---
|
||||||
|
|
||||||
|
Break risk is the probability the change breaks something it did not intend to
|
||||||
|
touch: not the inverse of fix confidence, not a mood. Derive it by the steps
|
||||||
|
below; without them a number is a guess.
|
||||||
|
|
||||||
|
## Fast path
|
||||||
|
|
||||||
|
A change whose files nothing builds, deploys, or runs (documentation and
|
||||||
|
comments only, or tests that ran green with it) has no consumers outside
|
||||||
|
itself. Name the files (`git diff --cached --name-only`), report break risk
|
||||||
|
under 1%, and skip the method. Any other file takes the full method.
|
||||||
|
|
||||||
|
## The method
|
||||||
|
|
||||||
|
1. **Consumers**: enumerate every consumer of everything the change alters, in
|
||||||
|
the committed state (`git grep <symbol> HEAD`) and of its output; say the
|
||||||
|
count. A caveat never licenses a low number; an enumeration does. Query
|
||||||
|
the codebase-memory-mcp graph first (`detect_changes`, `trace_path`,
|
||||||
|
`search_graph`; the `kvb-agent-optimize` skill sets it up), grep and read
|
||||||
|
after.
|
||||||
|
2. **Delta**: run the old and the new code on today's inputs and compare at
|
||||||
|
the surface where the difference shows (rendered output, emitted config,
|
||||||
|
returned set, wire bytes), not as the source reads.
|
||||||
|
3. **Live or latent**: per finding, whether a shipped configuration reaches it.
|
||||||
|
Only live findings move the number materially.
|
||||||
|
4. **Matrix**: a verdict per deployment axis the project has, never averaged.
|
||||||
|
5. **Failure mode**: fails closed and loud, or open and silent; the latter can
|
||||||
|
outrank a likelier failure.
|
||||||
|
6. **Evidence**: executed beats derived; a report, a summary or your own
|
||||||
|
earlier message is not evidence; negative-control every check you cite,
|
||||||
|
including one you did not write.
|
||||||
|
|
||||||
|
`reference.md` next to this file explains each step with the trap it defeats.
|
||||||
|
Read it when a step is new to you or its result is contested.
|
||||||
|
|
||||||
|
## Producing the number
|
||||||
|
|
||||||
|
State it as a percentage next to fix confidence, never blended, and always with
|
||||||
|
its residuals; `kvb-check-confidence` passes it to the `kvb-check-score` block
|
||||||
|
as `--break`. Anchor roughly:
|
||||||
|
|
||||||
|
- **Under 5%**: consumers enumerated, delta measured at the surface, every axis
|
||||||
|
of the matrix covered by executed evidence, failure mode closed.
|
||||||
|
- **5 to 15%**: measured, but one axis or one layer rests on reasoning: no live
|
||||||
|
run, a mode not exercised, a runtime the render cannot prove.
|
||||||
|
- **15 to 40%**: a real behaviour change whose consumers are enumerated but
|
||||||
|
whose effect is unmeasured, or a latent finding with a plausible trigger.
|
||||||
|
- **Above 40%**: a known live defect, an unenumerated consumer set, or a
|
||||||
|
fail-open exposure without a verified control. Fix it rather than report it.
|
||||||
|
|
||||||
|
Discount yourself after any error the operator caught in the same area during
|
||||||
|
this session, and say that you are doing so. A region where you have already
|
||||||
|
been wrong twice deserves a worse number than its paper evidence suggests.
|
||||||
|
|
||||||
|
## When the operator names a threshold
|
||||||
|
|
||||||
|
A named threshold is a gate on the work, not a field in the report. Missing it
|
||||||
|
is a work item: name the cheapest evidence that would close the gap, go get it,
|
||||||
|
re-measure. Report a miss only when the evidence is genuinely out of reach, and
|
||||||
|
name which piece.
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# Break risk: the method in detail
|
||||||
|
|
||||||
|
Each step of the `kvb-check-break-risk` skill, with the trap it defeats.
|
||||||
|
|
||||||
|
## The consumer question, first and always
|
||||||
|
|
||||||
|
Enumerate every consumer of every thing the change alters, before naming a
|
||||||
|
number. A written caveat never licenses a low number; an enumeration does.
|
||||||
|
|
||||||
|
- Search the **committed** state, not just the working tree. A symbol that looks
|
||||||
|
unused in your branch may still be called from a file you have not opened
|
||||||
|
(`git grep <symbol> HEAD`). Removing a function with zero external callers is
|
||||||
|
a different risk class from removing one with nine.
|
||||||
|
- Enumerate consumers of the **output**, not only of the code: who reads the
|
||||||
|
variable, renders the template, parses the emitted block, imports the
|
||||||
|
constant.
|
||||||
|
- Say the count out loud. "Two consumers, both inside this change set" is
|
||||||
|
evidence. "I don't think anything else uses it" is not.
|
||||||
|
|
||||||
|
## Measure the delta at the surface that matters
|
||||||
|
|
||||||
|
Compare what the old code **actually produced** against what the new code
|
||||||
|
produces, at the point where the difference becomes observable: rendered output,
|
||||||
|
emitted config, returned set, wire bytes.
|
||||||
|
|
||||||
|
The trap this defeats: reasoning about what the old code *looks like* it did. A
|
||||||
|
sweep that appeared to forward sixteen ports forwarded **zero** in production,
|
||||||
|
because the flag it gated on was unrendered template text. Had the delta been
|
||||||
|
argued from the source rather than measured, the risk would have been sized
|
||||||
|
against a behaviour that never existed.
|
||||||
|
|
||||||
|
Extract the old implementation from version control and run it on today's
|
||||||
|
inputs. If `DROPPED` is empty, nothing can regress; say so and move on.
|
||||||
|
|
||||||
|
## Classify every finding as live or latent
|
||||||
|
|
||||||
|
For each way the change could break something, decide which it is and say so:
|
||||||
|
|
||||||
|
- **Live**: a shipped configuration, inventory, variant or default reaches it.
|
||||||
|
- **Latent**: reachable only under a configuration nobody ships today.
|
||||||
|
|
||||||
|
Both belong in the report. Only live findings drive the number up materially. A
|
||||||
|
latent finding with a named trigger is worth more to the operator than a vague
|
||||||
|
worry, and it is honest about why the number is not zero.
|
||||||
|
|
||||||
|
## Walk the deployment matrix
|
||||||
|
|
||||||
|
A change verified in one mode is unverified in the others. Enumerate the axes
|
||||||
|
the project actually has, and state the verdict per cell rather than averaging
|
||||||
|
them away. Typical axes: deployment mode, node role, topology, distro,
|
||||||
|
filesystem, single versus multi host.
|
||||||
|
|
||||||
|
Two numbers for two modes beats one blended number that is wrong in both.
|
||||||
|
|
||||||
|
## Weigh the failure mode, not only the probability
|
||||||
|
|
||||||
|
A low-probability failure that **fails open** can outrank a likely one that
|
||||||
|
fails closed. Ask what the world looks like when the change misbehaves:
|
||||||
|
|
||||||
|
- Fails closed and loud: the service refuses to start, a gate goes red, a
|
||||||
|
request errors. Cheap to detect, bounded blast radius.
|
||||||
|
- Fails open and silent: a port stays reachable, a check passes vacuously, a
|
||||||
|
credential keeps working, traffic is forwarded to an unintended place.
|
||||||
|
|
||||||
|
Say which one applies. When a design choice is between an unverifiable control
|
||||||
|
over an exposure and not creating the exposure, prefer the second and say why.
|
||||||
|
|
||||||
|
## Evidence rules
|
||||||
|
|
||||||
|
- Executed beats derived. Run it, render it, read the output.
|
||||||
|
- A subagent's report, a conversation summary, or your own earlier message is
|
||||||
|
**not** evidence. Re-verify the claim yourself before it changes a number.
|
||||||
|
- Negative control before claiming a check works: make the thing it should
|
||||||
|
catch, watch it fail, restore. A check never observed failing is not known to
|
||||||
|
work.
|
||||||
|
- The same applies to a check you did not write. Citing an existing lint, test
|
||||||
|
or gate as the reason a change is safe means reading its pattern, not its
|
||||||
|
docstring, and watching it fail on the case you are trusting it to catch. A
|
||||||
|
green gate whose rule never matched your case is silence, not a pass.
|
||||||
|
- After a context compaction, rebuild attributions from the source; conclusions
|
||||||
|
survive compaction, the evidence under them does not.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
---
|
||||||
|
name: kvb-check-confidence
|
||||||
|
description: >
|
||||||
|
Report calibrated fix confidence and break risk unprompted whenever you
|
||||||
|
stage changes, declare a fix or bundle ready, or summarize committable or
|
||||||
|
deployable work.
|
||||||
|
---
|
||||||
|
|
||||||
|
Every stage summary, fix report, or ready-to-commit bundle ends with the
|
||||||
|
score block of the `kvb-check-score` skill. Invoke the `kvb-check-break-risk`
|
||||||
|
skill every time before writing it; this skill reports the number it derives
|
||||||
|
and never estimates one itself.
|
||||||
|
|
||||||
|
1. **Fix confidence** (percent): probability the change does what it claims.
|
||||||
|
**Break risk** (percent): probability it breaks anything else, from the
|
||||||
|
`kvb-check-break-risk` skill. Two numbers, never one blended figure.
|
||||||
|
2. **Score**: pass both, with cost from the `kvb-check-efficiency`
|
||||||
|
skill, time from the `kvb-check-speed` skill and security and the
|
||||||
|
critical count from `security.py` of the `kvb-check-security` skill, to
|
||||||
|
`${CLAUDE_SKILL_DIR}/../kvb-check-score/scripts/score.py --fix <n> --break
|
||||||
|
<n> --efficiency <n|-> --speed <n|-> --security <n|~n> --critical <count>`
|
||||||
|
and show its score line and gate line verbatim. Exit 4 forbids the commit
|
||||||
|
(critical finding); exit 1 fails the 90/5 rule; exit 3 means ask the
|
||||||
|
operator again before the commit, naming the causes the gate line prints.
|
||||||
|
3. **Evidence**: the strongest proof lines only: what was executed,
|
||||||
|
reproduced, enumerated, or read from an authoritative source. Mark proven
|
||||||
|
(ran it, saw it) apart from derived (reasoned statically).
|
||||||
|
4. **Residuals**: exactly what keeps the number below 100 and what would
|
||||||
|
close the gap (live deploy, missing artifact, unexercised path). Never a
|
||||||
|
bare percentage without its residuals.
|
||||||
|
|
||||||
|
Calibration rules:
|
||||||
|
|
||||||
|
- Number the claim you verified, not the effort you spent; discount after
|
||||||
|
any error the operator caught in the same area.
|
||||||
|
- Distinguish "fixes the observed failure" from "makes the job green" when
|
||||||
|
hidden layers may follow, and give both numbers.
|
||||||
|
- Static analysis alone caps at the low nineties; only executed evidence
|
||||||
|
(test, reproduction, live probe) justifies more.
|
||||||
|
|
||||||
|
A threshold the operator named ("95% confidence, break risk ≤5%") is a gate on
|
||||||
|
the work for both numbers, handled as the `kvb-check-break-risk` skill's last
|
||||||
|
section says. 90/5 also needs `gate: green` from `gate.py status` of the
|
||||||
|
`kvb-check-gate` skill, which states the rule and its residuals.
|
||||||
|
|
||||||
|
Objective: the `kvb-skill-tune` skill's **Objective** section. Two figures
|
||||||
|
with their residuals replace a hedging paragraph and save the operator a
|
||||||
|
verification round trip.
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
---
|
||||||
|
name: kvb-check-efficiency
|
||||||
|
description: >
|
||||||
|
Token cost of skill edits, CPU and RAM of code edits.
|
||||||
|
---
|
||||||
|
|
||||||
|
💸 Cost is the share of the base's resources a change uses: under 100 %
|
||||||
|
it saves, over 100 % it costs more. Paste the script's lines, never a
|
||||||
|
paragraph about them. `<ref>` is the commit the change
|
||||||
|
starts from: `HEAD` for uncommitted work, the merge base with the default
|
||||||
|
branch for a branch.
|
||||||
|
|
||||||
|
## Skills: tokens
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 ${CLAUDE_SKILL_DIR}/scripts/tokens.py --repo <skills repo> --base <ref> --transcripts ~/.claude/projects [--value <tokens/day> --evidence "<one line>"]
|
||||||
|
```
|
||||||
|
|
||||||
|
It compares every changed `*.md` under `skills/`, untracked ones included,
|
||||||
|
at `<ref>` with the working tree, and the always-loaded listing: all
|
||||||
|
`description:` words, counted by the repository's `scripts/budget.py`.
|
||||||
|
Tokens are an estimate, characters / 4. Changed files without any base text
|
||||||
|
print `cost new`, which counts as over 100. Skills have no time.
|
||||||
|
|
||||||
|
A second line, `💰 value 1:<n>` (`<n>:1` when value < cost), weighs both
|
||||||
|
per day and never changes the exit code; pass its quotient or `?` to
|
||||||
|
`kvb-check-score --value`.
|
||||||
|
|
||||||
|
- **Cost**: listing words delta x 1.33 x requests x 0.1 (cached) plus each
|
||||||
|
changed `SKILL.md`'s tokens delta x its invocations, per day of the last
|
||||||
|
7 days' transcripts (other runtimes: JSONL of that shape). A new skill
|
||||||
|
takes its category's median, named in the line.
|
||||||
|
- **Value**: tokens/day saved, derived by one `--evidence` line, e.g.
|
||||||
|
`"3829 SendMessage wakes/day x 10 % avoided x 43k"`; else `?:?`.
|
||||||
|
- **Measured**: once shipped, `tokens.py value --repo <repo> --since
|
||||||
|
<commit> --transcripts <dir>` gives the median daily context tokens 7
|
||||||
|
days before minus after; pass it as `--value`.
|
||||||
|
|
||||||
|
## Code: CPU and RAM
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 ${CLAUDE_SKILL_DIR}/scripts/measure.py --base <ref> -- <command...>
|
||||||
|
```
|
||||||
|
|
||||||
|
Start it in the directory the command expects. It checks `<ref>` out as a
|
||||||
|
detached worktree under `/tmp`, runs the command there and in the working
|
||||||
|
tree from the same relative directory, compares CPU seconds and peak RSS,
|
||||||
|
and removes the worktree. Cost is the mean of the CPU and the RAM ratio.
|
||||||
|
It also measures wall time for `kvb-check-speed`.
|
||||||
|
|
||||||
|
- **Runs**: one per tree. Both values at most 100 end it; otherwise 4 more
|
||||||
|
per tree, alternating, and the line reports the mean of all 5
|
||||||
|
(`mean of 5`).
|
||||||
|
- **Other load**: a 1 s sample of system CPU busy before and after, and per
|
||||||
|
run the busy share beyond the command's own CPU time. Above 30 %, or
|
||||||
|
unreadable without `/proc/stat`, the result is an estimate: time comes
|
||||||
|
from the CPU-time ratio, both values carry `~` and the line ends in
|
||||||
|
`(estimated: other load <x> %)`. Pass the numbers without `~` to
|
||||||
|
`kvb-check-score`; the exit code holds as for a measured result.
|
||||||
|
- **Command**: the targeted tests of the change or a CLI call of the
|
||||||
|
changed code, runnable on a clean checkout: the base worktree holds no
|
||||||
|
untracked build output, virtualenv or dependency directory.
|
||||||
|
- **Heavy commands** (Docker, deploys, whole suites) run only inside
|
||||||
|
`${CLAUDE_SKILL_DIR}/../kvb-org-queue/scripts/queue.py hold --kind <kind>
|
||||||
|
--task <register task> -- python3 <measure.py> ...`, in the background,
|
||||||
|
by the `kvb-org-queue` skill; a cancelled wait there also exits 3, but
|
||||||
|
prints no 💸 line. A light one self-admits with
|
||||||
|
`watch.py admit --kind test-nostack` of `kvb-org-organize`.
|
||||||
|
- Report the run you made; never rerun to pick the best result.
|
||||||
|
|
||||||
|
## Exit codes
|
||||||
|
|
||||||
|
- `0`: at most 100.
|
||||||
|
- `3`: over 100, or new: pass the value to `kvb-check-score`; its gate asks
|
||||||
|
the operator before the commit.
|
||||||
|
- `2`: usage error, or the command failed; the message names the tree, the
|
||||||
|
exit code and the last stderr line. Fix the command, then measure.
|
||||||
|
|
||||||
|
`--json` prints the same figures as JSON.
|
||||||
+214
@@ -0,0 +1,214 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Measure a command's CPU seconds, peak RAM and wall time on a base ref and on the working tree.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
--base: git ref to compare with; checked out as a detached worktree under /tmp
|
||||||
|
and removed again, whatever happens.
|
||||||
|
--json: print the figures as JSON instead of the line.
|
||||||
|
-- <command...>: the command, run without a shell from the same directory
|
||||||
|
relative to each tree's root; its output is discarded.
|
||||||
|
|
||||||
|
Runs:
|
||||||
|
One run per tree, base first. When cost and time are both at most 100 that
|
||||||
|
is the result; otherwise 4 more runs per tree, alternating, and the arithmetic mean
|
||||||
|
of all 5 per tree counts.
|
||||||
|
|
||||||
|
Other load:
|
||||||
|
A 1 s /proc/stat sample of system CPU busy before and after the runs, and per run
|
||||||
|
the busy jiffies minus the command's own CPU time, as a share of all jiffies. When
|
||||||
|
any share exceeds 30 % or cannot be read, the result is an estimate: time comes
|
||||||
|
from the CPU-time ratio instead of wall time.
|
||||||
|
|
||||||
|
Output:
|
||||||
|
`💸 cost <p> % (cpu <b>→<a> s, ram <b>→<a> MB) · ⏱️ time <p> % (wall <b>→<a> s)`,
|
||||||
|
then ` · mean of 5` after the extra runs. An estimate marks both values with `~`,
|
||||||
|
shows `(cpu <b>→<a> s)` for time and ends in `(estimated: other load <x> %)`, or
|
||||||
|
`unknown` for <x>. Cost is the mean of the CPU ratio (user plus system seconds
|
||||||
|
of the command and its waited-for children) and the peak RSS ratio; each ratio is
|
||||||
|
working tree over base, times 100.
|
||||||
|
|
||||||
|
Exit codes:
|
||||||
|
0 cost and time at most 100, 2 usage error or a failing command
|
||||||
|
(its tree, exit code and last stderr line on stderr), 3 either over 100.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import statistics
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
WORKTREE_PARENT = "/tmp"
|
||||||
|
LIMIT = 100
|
||||||
|
LOAD_LIMIT = 30
|
||||||
|
SAMPLE_SECONDS = 1
|
||||||
|
MORE_RUNS = 4
|
||||||
|
RESOLUTION = 0.001
|
||||||
|
PROC_STAT = Path("/proc/stat")
|
||||||
|
CLOCK_TICKS = os.sysconf("SC_CLK_TCK")
|
||||||
|
# Trip-wire: ru_maxrss counts kilobytes on Linux but bytes on macOS.
|
||||||
|
RSS_UNIT = 1 if sys.platform == "darwin" else 1024
|
||||||
|
USAGE = "usage: measure.py --base <ref> [--json] -- <command...>"
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message: str) -> None:
|
||||||
|
print(f"measure: {message}", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
|
||||||
|
def git(cwd: Path, *args: str) -> str:
|
||||||
|
result = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, check=False)
|
||||||
|
if result.returncode != 0:
|
||||||
|
fail(result.stderr.strip() or f"git {args[0]} failed in {cwd}")
|
||||||
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def cpu_ticks() -> tuple[int, int] | None:
|
||||||
|
try:
|
||||||
|
fields = [int(v) for v in PROC_STAT.read_text(encoding="ascii").split("\n", 1)[0].split()[1:9]]
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
if len(fields) < 8:
|
||||||
|
return None
|
||||||
|
return sum(fields) - fields[3] - fields[4], sum(fields)
|
||||||
|
|
||||||
|
|
||||||
|
def share(before: tuple[int, int] | None, after: tuple[int, int] | None, own: float) -> float | None:
|
||||||
|
if before is None or after is None:
|
||||||
|
return None
|
||||||
|
total = after[1] - before[1]
|
||||||
|
return max(0.0, (after[0] - before[0] - own) / total * 100) if total > 0 else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def idle_load() -> float | None:
|
||||||
|
before = cpu_ticks()
|
||||||
|
time.sleep(SAMPLE_SECONDS)
|
||||||
|
return share(before, cpu_ticks(), 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def sample(command: list[str], cwd: Path, tree: str) -> dict[str, float | None]:
|
||||||
|
with tempfile.TemporaryFile() as err:
|
||||||
|
ticks = cpu_ticks()
|
||||||
|
started = time.perf_counter()
|
||||||
|
try:
|
||||||
|
child = subprocess.Popen(
|
||||||
|
command, cwd=cwd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=err
|
||||||
|
)
|
||||||
|
except OSError as error:
|
||||||
|
fail(f"{tree} tree {cwd}: {error}")
|
||||||
|
# Trip-wire: Popen.wait() discards the child's rusage, so wait4 reaps it and returncode is set by hand.
|
||||||
|
_, status, usage = os.wait4(child.pid, 0)
|
||||||
|
wall = time.perf_counter() - started
|
||||||
|
cpu = usage.ru_utime + usage.ru_stime
|
||||||
|
load = share(ticks, cpu_ticks(), cpu * CLOCK_TICKS)
|
||||||
|
child.returncode = os.waitstatus_to_exitcode(status)
|
||||||
|
if child.returncode != 0:
|
||||||
|
err.seek(0)
|
||||||
|
lines = err.read().decode(errors="replace").strip().splitlines()
|
||||||
|
fail(f"command exited {child.returncode} in the {tree} tree {cwd}: {lines[-1] if lines else ''}")
|
||||||
|
return {"cpu": cpu, "ram": usage.ru_maxrss * RSS_UNIT / 2**20, "wall": wall, "load": load}
|
||||||
|
|
||||||
|
|
||||||
|
def ratio(after: float, before: float) -> float:
|
||||||
|
return max(after, RESOLUTION) / max(before, RESOLUTION)
|
||||||
|
|
||||||
|
|
||||||
|
def figures(runs: dict[str, list[dict]], loads: list[float | None]) -> dict:
|
||||||
|
before, after = (
|
||||||
|
{key: statistics.fmean(s[key] for s in runs[tree]) for key in ("cpu", "ram", "wall")}
|
||||||
|
for tree in ("base", "head")
|
||||||
|
)
|
||||||
|
unknown = None in loads
|
||||||
|
estimated = unknown or max(loads) > LOAD_LIMIT
|
||||||
|
basis = "cpu" if estimated else "wall"
|
||||||
|
return {
|
||||||
|
"efficiency": round((ratio(after["cpu"], before["cpu"]) + ratio(after["ram"], before["ram"])) / 2 * 100),
|
||||||
|
"speed": round(ratio(after[basis], before[basis]) * 100),
|
||||||
|
"runs": len(runs["head"]),
|
||||||
|
"estimated": estimated,
|
||||||
|
"other_load": None if unknown else round(max(loads)),
|
||||||
|
"base": before,
|
||||||
|
"head": after,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def line(found: dict) -> str:
|
||||||
|
mark = "~" if found["estimated"] else ""
|
||||||
|
basis = "cpu" if found["estimated"] else "wall"
|
||||||
|
before, after = found["base"], found["head"]
|
||||||
|
text = (
|
||||||
|
f"💸 cost {mark}{found['efficiency']} % (cpu {before['cpu']:.2f}→{after['cpu']:.2f} s, "
|
||||||
|
f"ram {before['ram']:.1f}→{after['ram']:.1f} MB) · "
|
||||||
|
f"⏱️ time {mark}{found['speed']} % ({basis} {before[basis]:.2f}→{after[basis]:.2f} s)"
|
||||||
|
)
|
||||||
|
if found["runs"] > 1:
|
||||||
|
text += f" · mean of {found['runs']}"
|
||||||
|
if found["estimated"]:
|
||||||
|
other = "unknown" if found["other_load"] is None else f"{found['other_load']} %"
|
||||||
|
text += f" (estimated: other load {other})"
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def run_loads(runs: dict[str, list[dict]]) -> list[float | None]:
|
||||||
|
return [s["load"] for s in runs["base"] + runs["head"]]
|
||||||
|
|
||||||
|
|
||||||
|
def costly(found: dict) -> bool:
|
||||||
|
return found["efficiency"] > LIMIT or found["speed"] > LIMIT
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str]) -> int:
|
||||||
|
if "--" not in argv:
|
||||||
|
fail(USAGE)
|
||||||
|
split = argv.index("--")
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0], usage=USAGE)
|
||||||
|
parser.add_argument("--base", required=True)
|
||||||
|
parser.add_argument("--json", action="store_true")
|
||||||
|
args = parser.parse_args(argv[:split])
|
||||||
|
command = argv[split + 1:]
|
||||||
|
if not command:
|
||||||
|
fail("no command after --")
|
||||||
|
|
||||||
|
here = Path.cwd().resolve()
|
||||||
|
root = Path(git(here, "rev-parse", "--show-toplevel")).resolve()
|
||||||
|
relative = here.relative_to(root)
|
||||||
|
resolved = subprocess.run(
|
||||||
|
["git", "rev-parse", "--verify", "--quiet", f"{args.base}^{{commit}}"],
|
||||||
|
cwd=root, capture_output=True, text=True, check=False,
|
||||||
|
)
|
||||||
|
if resolved.returncode != 0:
|
||||||
|
fail(f"unknown ref {args.base!r}")
|
||||||
|
|
||||||
|
parent = Path(tempfile.mkdtemp(prefix="kvb-measure-", dir=WORKTREE_PARENT))
|
||||||
|
tree = parent / "base"
|
||||||
|
runs: dict[str, list[dict]] = {"base": [], "head": []}
|
||||||
|
try:
|
||||||
|
git(root, "worktree", "add", "--detach", str(tree), resolved.stdout.strip())
|
||||||
|
base_cwd = tree / relative
|
||||||
|
if not base_cwd.is_dir():
|
||||||
|
fail(f"{relative} does not exist at {args.base}")
|
||||||
|
loads = [idle_load()]
|
||||||
|
for count in range(1 + MORE_RUNS):
|
||||||
|
runs["base"].append(sample(command, base_cwd, "base"))
|
||||||
|
runs["head"].append(sample(command, here, "head"))
|
||||||
|
if count == 0 and not costly(figures(runs, loads + run_loads(runs))):
|
||||||
|
break
|
||||||
|
loads.append(idle_load())
|
||||||
|
finally:
|
||||||
|
subprocess.run(["git", "worktree", "remove", "--force", str(tree)], cwd=root, capture_output=True, check=False)
|
||||||
|
shutil.rmtree(parent, ignore_errors=True)
|
||||||
|
|
||||||
|
found = figures(runs, loads + run_loads(runs))
|
||||||
|
print(json.dumps(found) if args.json else line(found))
|
||||||
|
return 3 if costly(found) else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main(sys.argv[1:]))
|
||||||
+369
@@ -0,0 +1,369 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Compare the estimated tokens of changed skill text files, and the listing words, with a base ref, and weigh their daily cost against their value.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
--repo: skills repository root; its scripts/budget.py counts the listing.
|
||||||
|
--base: git ref the change starts from; the working tree is the after side.
|
||||||
|
--transcripts: Claude Code transcripts root, e.g. ~/.claude/projects; every */*.jsonl of the
|
||||||
|
last 7 days gives the requests (unique assistant message ids) and the invocations per skill
|
||||||
|
(unique Skill tool_use ids, a `<scope>:` prefix stripped). Read only; no text is printed.
|
||||||
|
--value: tokens per day the change saves; needs --evidence.
|
||||||
|
--evidence: one line that derives --value.
|
||||||
|
--json: print the figures as JSON instead of the lines.
|
||||||
|
|
||||||
|
value --repo <root> --since <commit> --transcripts <root> [--json]: measure the value of a
|
||||||
|
shipped change instead.
|
||||||
|
|
||||||
|
Output:
|
||||||
|
Line 1: `💸 cost <p> % (skills <b>→<a> tokens; listing <b>→<a> words)`, p = after / before * 100,
|
||||||
|
or `💸 cost new (+<a> tokens; listing <b>→<a> words)` when the changed files hold no
|
||||||
|
base tokens. Changed files are the *.md under skills/ that `git diff <ref>` names plus untracked
|
||||||
|
ones; a file missing on a side counts 0. Tokens are an estimate: characters / 4. The listing
|
||||||
|
is the sum of every skills/*/SKILL.md description word.
|
||||||
|
Line 2: `💰 value 1:<n> (cost <c>/day, value <v>/day; <evidence>)` when the value is at least
|
||||||
|
the cost, `<n>:1` below it, `0:1` without a cost, `1:0` without a value; without --value
|
||||||
|
`💰 value ?:? (cost <c>/day; pass --value with evidence)`. Cost per day = listing words delta
|
||||||
|
* 1.33 tokens * requests per day * 0.1 (cached) + per changed SKILL.md its tokens delta * the
|
||||||
|
skill's invocations per day. A skill without a base SKILL.md takes the median invocations of
|
||||||
|
its category's base skills (the name up to its second dash; all base skills when it has
|
||||||
|
none), and the line names it.
|
||||||
|
--json prints efficiency (null when new), the skills and listing totals, the tokens per skill
|
||||||
|
and the value figures.
|
||||||
|
value: `💰 measured value <v>/day (median daily context <b>→<a> tokens over <n> active days
|
||||||
|
before and <m> after <sha> of <day>)`: the context tokens per UTC day as context.py of
|
||||||
|
kvb-skill-silence sums them, days without a turn left out, over the 7 days before the
|
||||||
|
commit's day and the up to 7 full days after it; v = b - a.
|
||||||
|
|
||||||
|
Exit codes:
|
||||||
|
0 efficiency at most 100, 2 usage error, 3 over 100 or new; the value line never changes it.
|
||||||
|
value: 0 measured, 2 usage error or no active day on a side.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import datetime
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import statistics
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path, PurePosixPath
|
||||||
|
from types import ModuleType
|
||||||
|
|
||||||
|
CHARS_PER_TOKEN = 4
|
||||||
|
LIMIT = 100
|
||||||
|
SKILLS = "skills"
|
||||||
|
TOKENS_PER_WORD = 1.33
|
||||||
|
CACHE_FACTOR = 0.1
|
||||||
|
DAYS = 7
|
||||||
|
ROUND_ABOVE = 10
|
||||||
|
ALL_SKILLS = "all skills"
|
||||||
|
CONTEXT = Path(__file__).resolve().parents[2] / "kvb-skill-silence" / "scripts" / "context.py"
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message: str) -> None:
|
||||||
|
print(f"tokens: {message}", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
|
||||||
|
def git(repo: Path, *args: str) -> subprocess.CompletedProcess:
|
||||||
|
return subprocess.run(["git", *args], cwd=repo, capture_output=True, check=False)
|
||||||
|
|
||||||
|
|
||||||
|
def paths(repo: Path, *args: str) -> list[str]:
|
||||||
|
result = git(repo, *args)
|
||||||
|
if result.returncode != 0:
|
||||||
|
fail(result.stderr.decode(errors="replace").strip())
|
||||||
|
return [p for p in result.stdout.decode().split("\0") if p]
|
||||||
|
|
||||||
|
|
||||||
|
def load(path: Path, name: str, role: str) -> ModuleType:
|
||||||
|
if not path.is_file():
|
||||||
|
fail(f"{path} is missing; it {role}")
|
||||||
|
spec = importlib.util.spec_from_file_location(name, path)
|
||||||
|
loaded = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(loaded)
|
||||||
|
return loaded
|
||||||
|
|
||||||
|
|
||||||
|
def at_base(repo: Path, ref: str, path: str) -> str:
|
||||||
|
result = git(repo, "show", f"{ref}:{path}")
|
||||||
|
return result.stdout.decode(errors="replace") if result.returncode == 0 else ""
|
||||||
|
|
||||||
|
|
||||||
|
def in_tree(repo: Path, path: str) -> str:
|
||||||
|
file = repo / path
|
||||||
|
return file.read_text(encoding="utf-8", errors="replace") if file.is_file() else ""
|
||||||
|
|
||||||
|
|
||||||
|
def tokens(chars: int) -> int:
|
||||||
|
return round(chars / CHARS_PER_TOKEN)
|
||||||
|
|
||||||
|
|
||||||
|
def is_skill_md(path: str) -> bool:
|
||||||
|
parts = PurePosixPath(path).parts
|
||||||
|
return len(parts) == 3 and parts[0] == SKILLS and parts[2] == "SKILL.md"
|
||||||
|
|
||||||
|
|
||||||
|
def category(skill: str) -> str:
|
||||||
|
return "-".join(skill.split("-")[:2])
|
||||||
|
|
||||||
|
|
||||||
|
def figure(amount: float) -> str:
|
||||||
|
if abs(amount) >= ROUND_ABOVE:
|
||||||
|
return str(round(amount))
|
||||||
|
return f"{round(amount, 1) + 0.0:g}"
|
||||||
|
|
||||||
|
|
||||||
|
def quotient(cost: float, value: float) -> str:
|
||||||
|
if cost <= 0:
|
||||||
|
return "0:1" if value > 0 else "0:0"
|
||||||
|
if value <= 0:
|
||||||
|
return "1:0"
|
||||||
|
if value >= cost:
|
||||||
|
return f"1:{figure(value / cost)}"
|
||||||
|
return f"{figure(cost / value)}:1"
|
||||||
|
|
||||||
|
|
||||||
|
def amount(text: str) -> float:
|
||||||
|
try:
|
||||||
|
value = float(text)
|
||||||
|
except ValueError:
|
||||||
|
raise argparse.ArgumentTypeError(f"{text!r} is not a number") from None
|
||||||
|
if not math.isfinite(value):
|
||||||
|
raise argparse.ArgumentTypeError(f"{text!r} is not finite")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def directory(text: str) -> Path:
|
||||||
|
root = Path(text).expanduser()
|
||||||
|
if not root.is_dir():
|
||||||
|
fail(f"{root} is not a directory")
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def skill_uses(message: dict) -> list[tuple[str, str]]:
|
||||||
|
content = message.get("content")
|
||||||
|
found = []
|
||||||
|
for block in content if isinstance(content, list) else []:
|
||||||
|
if not isinstance(block, dict) or block.get("type") != "tool_use" or block.get("name") != "Skill":
|
||||||
|
continue
|
||||||
|
given = block.get("input")
|
||||||
|
skill = given.get("skill") if isinstance(given, dict) else None
|
||||||
|
if isinstance(skill, str) and block.get("id"):
|
||||||
|
found.append((skill.rpartition(":")[2], str(block["id"])))
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def usage(root: Path, since: float) -> tuple[int, dict[str, int]]:
|
||||||
|
requests: set[str] = set()
|
||||||
|
invocations: dict[str, set[str]] = {}
|
||||||
|
for path in root.glob("*/*.jsonl"):
|
||||||
|
if path.stat().st_mtime < since:
|
||||||
|
continue
|
||||||
|
with path.open(encoding="utf-8", errors="ignore") as handle:
|
||||||
|
for line in handle:
|
||||||
|
if '"assistant"' not in line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
entry = json.loads(line)
|
||||||
|
stamp = datetime.datetime.fromisoformat(entry["timestamp"]).timestamp()
|
||||||
|
except (ValueError, KeyError, TypeError):
|
||||||
|
continue
|
||||||
|
message = entry.get("message")
|
||||||
|
if entry.get("type") != "assistant" or stamp < since or not isinstance(message, dict):
|
||||||
|
continue
|
||||||
|
if message.get("id"):
|
||||||
|
requests.add(str(message["id"]))
|
||||||
|
for skill, use in skill_uses(message):
|
||||||
|
invocations.setdefault(skill, set()).add(use)
|
||||||
|
return len(requests), {skill: len(uses) for skill, uses in invocations.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def weigh(root: Path, listing_delta: int, body: dict[str, int], new: set[str], base_skills: list[str]) -> dict:
|
||||||
|
requests, invocations = usage(root, datetime.datetime.now(datetime.UTC).timestamp() - DAYS * 86400)
|
||||||
|
per_day = {skill: count / DAYS for skill, count in invocations.items()}
|
||||||
|
rates, assumed = {}, {}
|
||||||
|
for skill in body:
|
||||||
|
if skill in new:
|
||||||
|
peers = [peer for peer in base_skills if category(peer) == category(skill)]
|
||||||
|
assumed[skill] = category(skill) if peers else ALL_SKILLS
|
||||||
|
pool = [per_day.get(peer, 0.0) for peer in peers or base_skills]
|
||||||
|
rates[skill] = statistics.median(pool) if pool else 0.0
|
||||||
|
else:
|
||||||
|
rates[skill] = per_day.get(skill, 0.0)
|
||||||
|
listing = listing_delta * TOKENS_PER_WORD * (requests / DAYS) * CACHE_FACTOR
|
||||||
|
return {
|
||||||
|
"cost": listing + sum(delta * rates[skill] for skill, delta in body.items()),
|
||||||
|
"requests_per_day": requests / DAYS,
|
||||||
|
"invocations_per_day": rates,
|
||||||
|
"median_for_new": assumed,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def value_line(weighed: dict, value: float | None, evidence: str | None) -> tuple[str, str]:
|
||||||
|
notes = [
|
||||||
|
f"{skill} new: {group} median {figure(weighed['invocations_per_day'][skill])} invocations/day"
|
||||||
|
for skill, group in sorted(weighed["median_for_new"].items())
|
||||||
|
]
|
||||||
|
cost = weighed["cost"]
|
||||||
|
if value is None:
|
||||||
|
return "?:?", f"💰 value ?:? ({'; '.join([f'cost {figure(cost)}/day', 'pass --value with evidence', *notes])})"
|
||||||
|
ratio = quotient(cost, value)
|
||||||
|
parts = [f"cost {figure(cost)}/day, value {figure(value)}/day", evidence, *notes]
|
||||||
|
return ratio, f"💰 value {ratio} ({'; '.join(parts)})"
|
||||||
|
|
||||||
|
|
||||||
|
def parse(argv: list[str]) -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
|
parser.add_argument("--repo", required=True)
|
||||||
|
parser.add_argument("--base", required=True)
|
||||||
|
parser.add_argument("--transcripts", required=True)
|
||||||
|
parser.add_argument("--value", type=amount)
|
||||||
|
parser.add_argument("--evidence")
|
||||||
|
parser.add_argument("--json", action="store_true")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
if (args.value is None) != (args.evidence is None):
|
||||||
|
parser.error("--value and --evidence go together")
|
||||||
|
if args.evidence is not None and (not args.evidence.strip() or "\n" in args.evidence):
|
||||||
|
parser.error("--evidence must be one non-empty line")
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
def measure(
|
||||||
|
repo: Path, base: str, base_files: set[str], changed: list[str]
|
||||||
|
) -> tuple[dict[str, list[int]], dict[str, int], set[str]]:
|
||||||
|
chars: dict[str, list[int]] = {}
|
||||||
|
body: dict[str, int] = {}
|
||||||
|
new: set[str] = set()
|
||||||
|
for path in sorted({p for p in changed if p.endswith(".md")}):
|
||||||
|
skill = PurePosixPath(path).parts[1]
|
||||||
|
old = at_base(repo, base, path) if path in base_files else ""
|
||||||
|
current = in_tree(repo, path)
|
||||||
|
side = chars.setdefault(skill, [0, 0])
|
||||||
|
side[0] += len(old)
|
||||||
|
side[1] += len(current)
|
||||||
|
if is_skill_md(path):
|
||||||
|
body[skill] = tokens(len(current)) - tokens(len(old))
|
||||||
|
if path not in base_files:
|
||||||
|
new.add(skill)
|
||||||
|
return chars, body, new
|
||||||
|
|
||||||
|
|
||||||
|
def check(argv: list[str]) -> int:
|
||||||
|
args = parse(argv)
|
||||||
|
repo = Path(args.repo).resolve()
|
||||||
|
if not (repo / SKILLS).is_dir():
|
||||||
|
fail(f"{repo} has no {SKILLS}/ directory")
|
||||||
|
if git(repo, "rev-parse", "--verify", "--quiet", f"{args.base}^{{commit}}").returncode != 0:
|
||||||
|
fail(f"unknown ref {args.base!r}")
|
||||||
|
root = directory(args.transcripts)
|
||||||
|
budget = load(repo / "scripts" / "budget.py", "budget", "counts the listing")
|
||||||
|
base_files = set(paths(repo, "ls-tree", "-r", "-z", "--name-only", args.base, "--", SKILLS))
|
||||||
|
# Trip-wire: with rename detection a moved file lists only its new path and drops the old one's tokens.
|
||||||
|
changed = paths(repo, "diff", "--no-renames", "-z", "--name-only", args.base, "--", SKILLS)
|
||||||
|
changed += paths(repo, "ls-files", "-z", "--others", "--exclude-standard", "--", SKILLS)
|
||||||
|
|
||||||
|
chars, body, new = measure(repo, args.base, base_files, changed)
|
||||||
|
before = tokens(sum(c[0] for c in chars.values()))
|
||||||
|
after = tokens(sum(c[1] for c in chars.values()))
|
||||||
|
base_skills = sorted(PurePosixPath(p).parts[1] for p in base_files if is_skill_md(p))
|
||||||
|
listing_before = sum(budget.words(at_base(repo, args.base, f"{SKILLS}/{s}/SKILL.md")) for s in base_skills)
|
||||||
|
listing_after = sum(budget.counts(repo, index=False).values())
|
||||||
|
weighed = weigh(root, listing_after - listing_before, body, new, base_skills)
|
||||||
|
ratio, money = value_line(weighed, args.value, args.evidence)
|
||||||
|
|
||||||
|
if before:
|
||||||
|
efficiency = round(after / before * 100)
|
||||||
|
else:
|
||||||
|
efficiency = None if after else LIMIT
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps({
|
||||||
|
"efficiency": efficiency,
|
||||||
|
"skills": {"before": before, "after": after},
|
||||||
|
"listing": {"before": listing_before, "after": listing_after},
|
||||||
|
"per_skill": {name: {"before": tokens(b), "after": tokens(a)} for name, (b, a) in chars.items()},
|
||||||
|
"value": {
|
||||||
|
"quotient": ratio,
|
||||||
|
"cost": round(weighed["cost"], 2),
|
||||||
|
"value": args.value,
|
||||||
|
"evidence": args.evidence,
|
||||||
|
"requests_per_day": round(weighed["requests_per_day"], 2),
|
||||||
|
"invocations_per_day": {s: round(r, 2) for s, r in weighed["invocations_per_day"].items()},
|
||||||
|
"median_for_new": weighed["median_for_new"],
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
else:
|
||||||
|
listing = f"listing {listing_before}→{listing_after} words"
|
||||||
|
if efficiency is None:
|
||||||
|
print(f"💸 cost new (+{after} tokens; {listing})")
|
||||||
|
else:
|
||||||
|
print(f"💸 cost {efficiency} % (skills {before}→{after} tokens; {listing})")
|
||||||
|
print(money)
|
||||||
|
return 3 if efficiency is None or efficiency > LIMIT else 0
|
||||||
|
|
||||||
|
|
||||||
|
def measured(argv: list[str]) -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="tokens.py value", description="Measure the value of a shipped change from the daily context tokens."
|
||||||
|
)
|
||||||
|
parser.add_argument("--repo", required=True)
|
||||||
|
parser.add_argument("--since", required=True)
|
||||||
|
parser.add_argument("--transcripts", required=True)
|
||||||
|
parser.add_argument("--json", action="store_true")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
repo = Path(args.repo).resolve()
|
||||||
|
if not repo.is_dir():
|
||||||
|
fail(f"{repo} is not a directory")
|
||||||
|
result = git(repo, "show", "-s", "--format=%ct %h", f"{args.since}^{{commit}}")
|
||||||
|
if result.returncode != 0:
|
||||||
|
fail(f"unknown commit {args.since!r}")
|
||||||
|
stamp, sha = result.stdout.decode().split()
|
||||||
|
root = directory(args.transcripts)
|
||||||
|
context = load(CONTEXT, "context", "sums the daily context tokens")
|
||||||
|
|
||||||
|
day = datetime.datetime.fromtimestamp(int(stamp), datetime.UTC).date()
|
||||||
|
today = datetime.datetime.now(datetime.UTC).date()
|
||||||
|
windows = (
|
||||||
|
[day - datetime.timedelta(days=n) for n in range(DAYS, 0, -1)],
|
||||||
|
[d for d in (day + datetime.timedelta(days=n) for n in range(1, DAYS + 1)) if d < today],
|
||||||
|
)
|
||||||
|
start = datetime.datetime.combine(windows[0][0], datetime.time.min, tzinfo=datetime.UTC).timestamp()
|
||||||
|
turns, _ = context.scan(root, start)
|
||||||
|
totals: dict[str, int] = {}
|
||||||
|
for turn_day, size in turns.values():
|
||||||
|
totals[turn_day] = totals.get(turn_day, 0) + size
|
||||||
|
sides = [[totals[d.isoformat()] for d in window if totals.get(d.isoformat())] for window in windows]
|
||||||
|
for name, side in zip(("before", "after"), sides, strict=True):
|
||||||
|
if not side:
|
||||||
|
fail(f"no active day in the {DAYS} days {name} {sha} of {day}")
|
||||||
|
median_before, median_after = (statistics.median(side) for side in sides)
|
||||||
|
value = median_before - median_after
|
||||||
|
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps({
|
||||||
|
"since": sha,
|
||||||
|
"day": day.isoformat(),
|
||||||
|
"before": {"days": len(sides[0]), "median": median_before},
|
||||||
|
"after": {"days": len(sides[1]), "median": median_after},
|
||||||
|
"value": value,
|
||||||
|
}))
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
f"💰 measured value {figure(value)}/day (median daily context {figure(median_before)}→"
|
||||||
|
f"{figure(median_after)} tokens over {len(sides[0])} active days before and {len(sides[1])} after "
|
||||||
|
f"{sha} of {day})"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str]) -> int:
|
||||||
|
if argv[:1] == ["value"]:
|
||||||
|
return measured(argv[1:])
|
||||||
|
return check(argv)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main(sys.argv[1:]))
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
---
|
||||||
|
name: kvb-check-gate
|
||||||
|
description: >
|
||||||
|
Prove 90/5: every `make test*` target green on the exact HEAD. Trigger
|
||||||
|
before declaring 90/5 or labelling a PR, or on "gate status".
|
||||||
|
---
|
||||||
|
|
||||||
|
The gate rule, stated here and nowhere else: nothing is declared 90/5 (fix
|
||||||
|
confidence ≥ 90 %, break risk ≤ 5 %, cost and time ≤ 100 % in
|
||||||
|
`kvb-check-score`; over 100 %, ask the operator before the commit, naming
|
||||||
|
the cause) before every `make test*` target of the project, each Makefile target named `test` or starting with
|
||||||
|
`test-`, ran green on the exact head with a clean tracked tree. Until then
|
||||||
|
the numbers are provisional and every target not green is a residual.
|
||||||
|
|
||||||
|
`${CLAUDE_SKILL_DIR}/scripts/gate.py` reads the targets from the Makefile and
|
||||||
|
its includes without running make, and keeps results per commit in
|
||||||
|
`kvb-gate.json` in the git common dir: linked worktrees share them, a new
|
||||||
|
commit starts from zero.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. **Sync**: `${CLAUDE_SKILL_DIR}/../kvb-git-hotfix/scripts/hotfix.py sync
|
||||||
|
--repo <root> --task <your register task>`, then `hotfix.py pending
|
||||||
|
--repo <root>`, by the `kvb-git-hotfix` skill. A merge moves HEAD, so the
|
||||||
|
gate starts over from there.
|
||||||
|
2. **List**: `gate.py list --repo <root>`. A warning about an unresolved
|
||||||
|
include or target expression means the list may be short: read that line
|
||||||
|
and name the gap. A project without any target keeps the gate open; ask
|
||||||
|
the operator.
|
||||||
|
3. **Status**: `gate.py status --repo <root>`. `gate: green` ends here; a
|
||||||
|
`missing hotfix` reason means back to step 1.
|
||||||
|
4. **Admit**: every gate target is heavy and runs only inside
|
||||||
|
`${CLAUDE_SKILL_DIR}/../kvb-org-queue/scripts/queue.py hold` (step 5),
|
||||||
|
which grants by rule and releases on exit. Only a single test module
|
||||||
|
self-admits (`${CLAUDE_SKILL_DIR}/../kvb-org-organize/scripts/watch.py
|
||||||
|
admit --kind test-nostack`), and it never turns a gate target green. A
|
||||||
|
hotfix gate adds `--hotfix <slug>` and takes the next free gate slot.
|
||||||
|
5. **Run** the missing and red targets, tracked tree clean, in the
|
||||||
|
background; give the operator `tail -f` on the log:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
queue.py hold --kind <kind> --task <register task> -- gate.py run --repo <root> --target <t> [--target ...] --log-dir /tmp/<slug> [--fail-regex <re> ...]
|
||||||
|
```
|
||||||
|
|
||||||
|
Pass one `--fail-regex` per failure line the project prints while exiting
|
||||||
|
0; infinito-nexus needs `FAILED TARGETS: \S`. A non-zero exit, a
|
||||||
|
`make: ***` line, a matching regex or modified tracked files make a
|
||||||
|
target red; the last stops the run. Before each target `run` asks
|
||||||
|
`hotfix.py void` for it and every later one. Exit 3 (`void: merge main
|
||||||
|
for hotfix <slug>, then rerun`): back to step 1, then through the queue
|
||||||
|
again. Exit 75: the worktree is
|
||||||
|
busy, the message names the holder; wait, then rerun.
|
||||||
|
6. **Judge** every red target by its `status` line and the log around it:
|
||||||
|
caused by the change, already red on the default branch, or the
|
||||||
|
environment. Fix the cause and rerun; looking unrelated never makes it
|
||||||
|
green.
|
||||||
|
7. **Status** once more; its last line is the verdict.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Only `gate.py run` on this head marks a target green. A peer's report, CI,
|
||||||
|
an older commit or a dirty tree never does.
|
||||||
|
- Every heavy command in a worktree (deploys, `make test`, other gates) runs
|
||||||
|
through `gate.py run` or `gate.py hold --repo <root> -- <command>` in the
|
||||||
|
background (`run_in_background`), never under a tool timeout or
|
||||||
|
`TaskStop`. Their lock keeps `hotfix.py sync` out
|
||||||
|
only while a host process under it lives; a `docker compose exec` deploy
|
||||||
|
runs on in its container unguarded.
|
||||||
|
- 90/5 is allowed only on `gate: green` for the exact head: in
|
||||||
|
`kvb-check-score`, the `90/5` label and the `kvb-org-split` bar.
|
||||||
|
- Never edit `kvb-gate.json` by hand.
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
| Target | Status | Log | Line |
|
||||||
|
|
||||||
|
Then the `gate:` line verbatim.
|
||||||
Executable
+552
@@ -0,0 +1,552 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""List, run and check a project's `make test*` targets against the current HEAD.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
list --repo <dir> [--json]: the Makefile targets named `test` or `test-*`,
|
||||||
|
parsed statically from the Makefile make reads in <dir> and every file
|
||||||
|
it includes; make is never run.
|
||||||
|
status --repo <dir> [--json]: per listed target green, red or missing for
|
||||||
|
HEAD, from `kvb-gate.json` in the git common dir; `hotfix.py pending
|
||||||
|
--target <t> ...` of the kvb-git-hotfix skill, given the listed
|
||||||
|
targets, keeps the gate open while a merged hotfix that turns one of
|
||||||
|
them green is missing from HEAD.
|
||||||
|
run --repo <dir> --target <t> [--target ...] --log-dir <dir>
|
||||||
|
[--fail-regex <re> ...]: under the worktree lock, run each target as
|
||||||
|
`make -C <dir> <t>` with stdout and stderr in
|
||||||
|
<log-dir>/gate-<t>-<sha8>.log and record the result for HEAD. A
|
||||||
|
target is red on a non-zero exit, a `make: ***` line, a matching
|
||||||
|
--fail-regex, or tracked files it modified. Before each target,
|
||||||
|
`hotfix.py void` decides over it and every target after it whether a
|
||||||
|
merged hotfix missing from HEAD voids the run.
|
||||||
|
hold --repo <dir> -- <command...>: under the worktree lock, run the
|
||||||
|
command in <dir> in a session of its own, without a controlling
|
||||||
|
terminal; SIGINT, SIGTERM and SIGHUP reaching hold go to the
|
||||||
|
command's process group while hold keeps waiting for it. For deploys
|
||||||
|
and every other heavy command that is no `make test*` target.
|
||||||
|
|
||||||
|
The worktree lock is `kvb-gate-run.lock` in the worktree's git dir,
|
||||||
|
taken without waiting and passed to make or the command, so it stays
|
||||||
|
held while they or a process they left running on this host live; a
|
||||||
|
process inside a container (`docker compose exec`) runs on there after
|
||||||
|
they die, and the lock no longer covers it. run and hold write their PID
|
||||||
|
and command into it, and a busy message names them. `hotfix.py sync`
|
||||||
|
takes `kvb-gate-sync.lock` exclusively, recording its PID and command
|
||||||
|
there, then the worktree lock, and releases them in reverse order; run
|
||||||
|
and hold take the worktree lock while holding `kvb-gate-sync.lock`
|
||||||
|
shared, so they wait for a running sync, naming it, for at most
|
||||||
|
SYNC_WAIT_SECONDS, and a busy worktree lock then means another run or
|
||||||
|
hold.
|
||||||
|
|
||||||
|
Without hotfix.py next to this skill, or when it exits non-zero without
|
||||||
|
a verdict line, gate.py warns on stderr and goes on without the hotfix
|
||||||
|
check.
|
||||||
|
|
||||||
|
Output:
|
||||||
|
list: one `<target>\t<file>:<line>` per target.
|
||||||
|
status: one `<status>\t<target>\t<log>\t<finished>\t<reason>` per target,
|
||||||
|
then `gate: green`, or `gate: open (...)` naming what is missing,
|
||||||
|
`missing hotfix: <slug>, ...` included.
|
||||||
|
run: one `<status>\t<target>\t<log>\t<reason>` per target; on a void
|
||||||
|
`void: merge main for hotfix <slug>, then rerun`.
|
||||||
|
hold: the command's own output.
|
||||||
|
--json prints the same data as JSON.
|
||||||
|
|
||||||
|
Exit codes:
|
||||||
|
list: 0 done, 2 usage error.
|
||||||
|
status: 0 gate green, 1 gate open, 2 usage error.
|
||||||
|
run: 0 every target green, 1 a target red, 2 usage error, dirty tracked
|
||||||
|
files or HEAD moved during a run, 3 void: the remaining targets are
|
||||||
|
neither run nor recorded, 75 (EX_TEMPFAIL) busy: another run or hold
|
||||||
|
holds the worktree, or a sync still holds it after SYNC_WAIT_SECONDS.
|
||||||
|
hold: the command's exit code unchanged, 128+N when signal N ended it;
|
||||||
|
before the command started 2 on a usage error, 75 busy as for run,
|
||||||
|
128+N when signal N reached hold.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import contextlib
|
||||||
|
import fcntl
|
||||||
|
import glob
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shlex
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import NoReturn, TextIO
|
||||||
|
|
||||||
|
MAKEFILES = ("GNUmakefile", "makefile", "Makefile")
|
||||||
|
INCLUDE = re.compile(r"^\s*(?P<kind>-include|sinclude|include)\s+(?P<paths>.+)$")
|
||||||
|
DEFINE = re.compile(r"^\s*(?:(?:export|override|private)\s+)*define\b")
|
||||||
|
ENDEF = re.compile(r"^\s*endef\b")
|
||||||
|
CONDITIONAL = re.compile(r"^\s*(?:ifeq|ifneq|ifdef|ifndef|else|endif)\b")
|
||||||
|
ASSIGNMENT = re.compile(
|
||||||
|
r"^\s*(?:(?:export|override|private|unexport)\s+)*[^\s:#=]+\s*(?::{1,3}=|[?+!]?=)"
|
||||||
|
)
|
||||||
|
COMMENT = re.compile(r"(?<!\\)#.*$")
|
||||||
|
MAKE_ERROR = re.compile(r"\bmake(?:\[\d+\])?: \*\*\*")
|
||||||
|
UNSAFE = re.compile(r"[^\w.-]")
|
||||||
|
LINE_CHARS = 300
|
||||||
|
STATE = "kvb-gate.json"
|
||||||
|
RUN_LOCK = "kvb-gate-run.lock"
|
||||||
|
SYNC_LOCK = "kvb-gate-sync.lock"
|
||||||
|
SYNC_WAIT_SECONDS = 300
|
||||||
|
POLL_SECONDS = 0.1
|
||||||
|
BUSY = os.EX_TEMPFAIL
|
||||||
|
FORWARDED = (signal.SIGINT, signal.SIGTERM, signal.SIGHUP)
|
||||||
|
HOTFIX = Path(__file__).resolve().parents[2] / "kvb-git-hotfix" / "scripts" / "hotfix.py"
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message: str) -> NoReturn:
|
||||||
|
print(f"gate: {message}", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
|
||||||
|
def busy(message: str) -> NoReturn:
|
||||||
|
print(f"gate: {message}", file=sys.stderr)
|
||||||
|
sys.exit(BUSY)
|
||||||
|
|
||||||
|
|
||||||
|
def logical_lines(path: Path) -> list[tuple[int, str]]:
|
||||||
|
lines: list[tuple[int, str]] = []
|
||||||
|
start, buffer = 0, ""
|
||||||
|
for number, raw in enumerate(path.read_text(encoding="utf-8", errors="replace").splitlines(), 1):
|
||||||
|
if not buffer:
|
||||||
|
start = number
|
||||||
|
if (len(raw) - len(raw.rstrip("\\"))) % 2:
|
||||||
|
buffer += raw[:-1] + " "
|
||||||
|
continue
|
||||||
|
lines.append((start, buffer + raw))
|
||||||
|
buffer = ""
|
||||||
|
if buffer:
|
||||||
|
lines.append((start, buffer))
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def rule_head(line: str) -> tuple[str, str] | None:
|
||||||
|
depth = 0
|
||||||
|
for index, char in enumerate(line):
|
||||||
|
if char in "({":
|
||||||
|
depth += 1
|
||||||
|
elif char in ")}":
|
||||||
|
depth = max(depth - 1, 0)
|
||||||
|
elif depth == 0 and char == "=":
|
||||||
|
return None
|
||||||
|
elif depth == 0 and char == ":":
|
||||||
|
rest = line[index:]
|
||||||
|
if re.match(r":{1,3}=", rest):
|
||||||
|
return None
|
||||||
|
width = 2 if rest.startswith("::") else 1
|
||||||
|
return line[:index].rstrip("&"), line[index + width:]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_test(name: str) -> bool:
|
||||||
|
return name == "test" or (name.startswith("test-") and "%" not in name)
|
||||||
|
|
||||||
|
|
||||||
|
def parse(path: Path, repo: Path, found: dict, seen: set) -> None:
|
||||||
|
resolved = path.resolve()
|
||||||
|
if resolved in seen:
|
||||||
|
return
|
||||||
|
seen.add(resolved)
|
||||||
|
shown = os.path.relpath(resolved, repo)
|
||||||
|
define_depth = 0
|
||||||
|
for number, line in logical_lines(path):
|
||||||
|
if define_depth:
|
||||||
|
if DEFINE.match(line):
|
||||||
|
define_depth += 1
|
||||||
|
elif ENDEF.match(line):
|
||||||
|
define_depth -= 1
|
||||||
|
continue
|
||||||
|
if line.startswith("\t"):
|
||||||
|
continue
|
||||||
|
if DEFINE.match(line):
|
||||||
|
define_depth = 1
|
||||||
|
continue
|
||||||
|
line = COMMENT.sub("", line)
|
||||||
|
if not line.strip() or CONDITIONAL.match(line):
|
||||||
|
continue
|
||||||
|
include = INCLUDE.match(line)
|
||||||
|
if include:
|
||||||
|
for word in include.group("paths").split():
|
||||||
|
if "$" in word:
|
||||||
|
print(f"gate: {shown}:{number}: include {word} not resolved", file=sys.stderr)
|
||||||
|
continue
|
||||||
|
matches = sorted(glob.glob(str(repo / os.path.expanduser(word))))
|
||||||
|
if not matches and include.group("kind") == "include":
|
||||||
|
print(f"gate: {shown}:{number}: include {word} not found", file=sys.stderr)
|
||||||
|
for match in matches:
|
||||||
|
parse(Path(match), repo, found, seen)
|
||||||
|
continue
|
||||||
|
if ASSIGNMENT.match(line):
|
||||||
|
continue
|
||||||
|
head = rule_head(line)
|
||||||
|
if head is None or ASSIGNMENT.match(head[1]):
|
||||||
|
continue
|
||||||
|
for name in head[0].split():
|
||||||
|
if "$" in name:
|
||||||
|
print(f"gate: {shown}:{number}: target {name} not resolved", file=sys.stderr)
|
||||||
|
elif is_test(name) and name not in found:
|
||||||
|
found[name] = f"{shown}:{number}"
|
||||||
|
|
||||||
|
|
||||||
|
def targets(repo: Path) -> dict[str, str]:
|
||||||
|
makefile = next((repo / n for n in MAKEFILES if (repo / n).is_file()), None)
|
||||||
|
if makefile is None:
|
||||||
|
fail(f"{repo} has no {', '.join(MAKEFILES)}")
|
||||||
|
found: dict[str, str] = {}
|
||||||
|
parse(makefile, repo, found, set())
|
||||||
|
return dict(sorted(found.items()))
|
||||||
|
|
||||||
|
|
||||||
|
def git(repo: Path, *args: str) -> str:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", *args], cwd=repo, capture_output=True, text=True, check=False
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
fail(f"git {' '.join(args)}: {result.stderr.strip()}")
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def head(repo: Path) -> str:
|
||||||
|
return git(repo, "rev-parse", "--verify", "HEAD^{commit}").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def tree(repo: Path) -> tuple[list[str], list[str]]:
|
||||||
|
dirty, untracked = [], []
|
||||||
|
for line in git(repo, "status", "--porcelain=v1", "--untracked-files=all").splitlines():
|
||||||
|
(untracked if line.startswith("??") else dirty).append(line[3:])
|
||||||
|
return dirty, untracked
|
||||||
|
|
||||||
|
|
||||||
|
def state_path(repo: Path) -> Path:
|
||||||
|
return Path(git(repo, "rev-parse", "--path-format=absolute", "--git-common-dir").strip()) / STATE
|
||||||
|
|
||||||
|
|
||||||
|
def load(path: Path) -> dict:
|
||||||
|
if not path.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except ValueError:
|
||||||
|
fail(f"{path} is not valid JSON; move it aside to start over")
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
fail(f"{path} holds no object; move it aside to start over")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def record(path: Path, sha: str, target: str, entry: dict) -> None:
|
||||||
|
# Linked worktrees share the git common dir, so parallel runs write this file.
|
||||||
|
with open(path.with_name(f"{STATE}.lock"), "w", encoding="utf-8") as lock:
|
||||||
|
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||||
|
data = load(path)
|
||||||
|
data.setdefault(sha, {})[target] = entry
|
||||||
|
handle, temp = tempfile.mkstemp(dir=path.parent, prefix=f".{STATE}.")
|
||||||
|
with os.fdopen(handle, "w", encoding="utf-8") as out:
|
||||||
|
json.dump(data, out, indent=1, sort_keys=True)
|
||||||
|
out.write("\n")
|
||||||
|
os.replace(temp, path)
|
||||||
|
|
||||||
|
|
||||||
|
def first_failure(log: Path, checks: list[re.Pattern]) -> str | None:
|
||||||
|
with open(log, encoding="utf-8", errors="replace") as handle:
|
||||||
|
for line in handle:
|
||||||
|
if MAKE_ERROR.search(line) or any(check.search(line) for check in checks):
|
||||||
|
return line.strip()[:LINE_CHARS]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def hotfix_checks() -> bool:
|
||||||
|
if HOTFIX.is_file():
|
||||||
|
return True
|
||||||
|
print(f"gate: {HOTFIX} is missing; merged hotfixes are not checked", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def hotfix(repo: Path, command: str, args: list[str], verdict: str) -> list[str]:
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, str(HOTFIX), command, *args, "--repo", str(repo)],
|
||||||
|
stdin=subprocess.DEVNULL, capture_output=True, text=True, check=False,
|
||||||
|
)
|
||||||
|
sys.stderr.write(result.stderr)
|
||||||
|
slugs = [
|
||||||
|
line.split("\t")[1].removeprefix("hotfix/")
|
||||||
|
for line in result.stdout.splitlines() if line.startswith(f"{verdict}\t")
|
||||||
|
]
|
||||||
|
if result.returncode and not slugs:
|
||||||
|
print(f"gate: hotfix.py {command} exited {result.returncode}; going on without its check", file=sys.stderr)
|
||||||
|
return slugs
|
||||||
|
|
||||||
|
|
||||||
|
def target_flags(targets: list[str]) -> list[str]:
|
||||||
|
return [part for target in targets for part in ("--target", target)]
|
||||||
|
|
||||||
|
|
||||||
|
def voiding_hotfixes(repo: Path, targets: list[str]) -> list[str]:
|
||||||
|
return hotfix(repo, "void", target_flags(targets), "void")
|
||||||
|
|
||||||
|
|
||||||
|
def missing_hotfixes(repo: Path, targets: list[str]) -> list[str]:
|
||||||
|
return hotfix(repo, "pending", target_flags(targets), "missing (merge main)")
|
||||||
|
|
||||||
|
|
||||||
|
def claim(lock: TextIO, command: list[str]) -> None:
|
||||||
|
"""Record the holder in a lock file it holds exclusively.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
lock: the open, exclusively held lock file.
|
||||||
|
command: the holder's command line.
|
||||||
|
"""
|
||||||
|
lock.truncate(0)
|
||||||
|
lock.write(f"{os.getpid()}\t{shlex.join(command)}\n")
|
||||||
|
lock.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def holder(path: Path) -> str:
|
||||||
|
"""Name the holder that `claim` recorded in a lock file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: the lock file.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
pid, _, command = path.read_text(encoding="utf-8").strip().partition("\t")
|
||||||
|
except OSError:
|
||||||
|
pid, command = "", ""
|
||||||
|
return f"pid {pid}: {command}" if pid and command else "holder not recorded"
|
||||||
|
|
||||||
|
|
||||||
|
def shared(sync: TextIO, repo: Path, caught: list[int]) -> None:
|
||||||
|
"""Take SYNC_LOCK shared, waiting at most SYNC_WAIT_SECONDS for a running sync.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sync: the open SYNC_LOCK file.
|
||||||
|
repo: the worktree, named in messages.
|
||||||
|
caught: signals the caller caught; the first ends the wait with 128+N.
|
||||||
|
"""
|
||||||
|
deadline = time.monotonic() + SYNC_WAIT_SECONDS
|
||||||
|
waiting = False
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
fcntl.flock(sync, fcntl.LOCK_SH | fcntl.LOCK_NB)
|
||||||
|
return
|
||||||
|
except BlockingIOError:
|
||||||
|
pass
|
||||||
|
if caught:
|
||||||
|
sys.exit(128 + caught[0])
|
||||||
|
if time.monotonic() >= deadline:
|
||||||
|
busy(f"a hotfix.py sync still holds {repo} after {SYNC_WAIT_SECONDS} s ({holder(Path(sync.name))}); "
|
||||||
|
"retry once it ends")
|
||||||
|
if not waiting:
|
||||||
|
print(f"gate: a hotfix.py sync merges in {repo} ({holder(Path(sync.name))}); waiting for it at most "
|
||||||
|
f"{SYNC_WAIT_SECONDS} s", file=sys.stderr, flush=True)
|
||||||
|
waiting = True
|
||||||
|
time.sleep(POLL_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def worktree_lock(repo: Path, command: list[str], caught: list[int]) -> Iterator[int]:
|
||||||
|
"""Hold the worktree lock; yields its file descriptor for pass_fds.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo: the worktree whose git dir holds RUN_LOCK and SYNC_LOCK.
|
||||||
|
command: the holder's command line, recorded in RUN_LOCK.
|
||||||
|
caught: signals the caller caught; the first ends a wait for a sync with 128+N.
|
||||||
|
"""
|
||||||
|
git_dir = Path(git(repo, "rev-parse", "--absolute-git-dir").strip())
|
||||||
|
with open(git_dir / RUN_LOCK, "a", encoding="utf-8") as lock:
|
||||||
|
with open(git_dir / SYNC_LOCK, "a", encoding="utf-8") as sync:
|
||||||
|
shared(sync, repo, caught)
|
||||||
|
try:
|
||||||
|
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
except BlockingIOError:
|
||||||
|
busy(f"another gate.py run or hold holds {repo} ({holder(git_dir / RUN_LOCK)})")
|
||||||
|
claim(lock, command)
|
||||||
|
yield lock.fileno()
|
||||||
|
|
||||||
|
|
||||||
|
def repository(value: str) -> Path:
|
||||||
|
repo = Path(value).resolve()
|
||||||
|
if not repo.is_dir():
|
||||||
|
fail(f"{value} is no directory")
|
||||||
|
return repo
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_list(args: argparse.Namespace) -> int:
|
||||||
|
found = targets(repository(args.repo))
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps([{"target": t, "defined": d} for t, d in found.items()], indent=1))
|
||||||
|
return 0
|
||||||
|
for target, defined in found.items():
|
||||||
|
print(f"{target}\t{defined}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cell(value: str | None) -> str:
|
||||||
|
return "-" if value is None else value
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_status(args: argparse.Namespace) -> int:
|
||||||
|
repo = repository(args.repo)
|
||||||
|
found = targets(repo)
|
||||||
|
sha = head(repo)
|
||||||
|
state = load(state_path(repo))
|
||||||
|
dirty, untracked = tree(repo)
|
||||||
|
rows = []
|
||||||
|
for target in found:
|
||||||
|
entry = state[sha].get(target) if sha in state else None
|
||||||
|
rows.append({"target": target, "status": "missing"} if entry is None else {"target": target, **entry})
|
||||||
|
missing = [r["target"] for r in rows if r["status"] == "missing"]
|
||||||
|
red = [r["target"] for r in rows if r["status"] == "red"]
|
||||||
|
reasons = []
|
||||||
|
if not found:
|
||||||
|
reasons.append("no make test* target found")
|
||||||
|
if missing:
|
||||||
|
reasons.append(f"missing: {', '.join(missing)}")
|
||||||
|
if red:
|
||||||
|
reasons.append(f"red: {', '.join(red)}")
|
||||||
|
if dirty:
|
||||||
|
reasons.append(f"dirty: {len(dirty)} tracked file(s)")
|
||||||
|
hotfixes = missing_hotfixes(repo, list(found)) if hotfix_checks() else []
|
||||||
|
if hotfixes:
|
||||||
|
reasons.append(f"missing hotfix: {', '.join(hotfixes)}")
|
||||||
|
gate = "open" if reasons else "green"
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(
|
||||||
|
{"sha": sha, "gate": gate, "open": reasons, "targets": rows,
|
||||||
|
"dirty": dirty, "untracked": untracked},
|
||||||
|
indent=1,
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
print(f"head\t{sha}")
|
||||||
|
for r in rows:
|
||||||
|
print("\t".join([r["status"], r["target"], *(cell(r.get(k)) for k in ("log", "finished", "reason"))]))
|
||||||
|
if untracked:
|
||||||
|
print(f"untracked\t{len(untracked)} file(s), not part of the gate")
|
||||||
|
print("gate: green" if gate == "green" else f"gate: open ({'; '.join(reasons)})")
|
||||||
|
return 0 if gate == "green" else 1
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_run(args: argparse.Namespace) -> int:
|
||||||
|
repo = repository(args.repo)
|
||||||
|
checks = []
|
||||||
|
if args.fail_regex is not None:
|
||||||
|
try:
|
||||||
|
checks = [re.compile(pattern) for pattern in args.fail_regex]
|
||||||
|
except re.error as error:
|
||||||
|
fail(f"--fail-regex: {error}")
|
||||||
|
found = targets(repo)
|
||||||
|
unknown = [t for t in args.target if t not in found]
|
||||||
|
if unknown:
|
||||||
|
fail(f"not a make test* target of {repo}: {', '.join(unknown)}")
|
||||||
|
with worktree_lock(repo, ["gate.py", "run", "--repo", str(repo), *target_flags(args.target)], []) as lock:
|
||||||
|
sha = head(repo)
|
||||||
|
dirty, untracked = tree(repo)
|
||||||
|
if dirty:
|
||||||
|
fail(f"tracked files are dirty, commit or drop them first: {', '.join(dirty)}")
|
||||||
|
if untracked:
|
||||||
|
print(f"untracked\t{len(untracked)} file(s), not part of the gate")
|
||||||
|
log_dir = Path(args.log_dir).resolve()
|
||||||
|
log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
state = state_path(repo)
|
||||||
|
red = False
|
||||||
|
order = list(dict.fromkeys(args.target))
|
||||||
|
checking = hotfix_checks()
|
||||||
|
for index, target in enumerate(order):
|
||||||
|
slugs = voiding_hotfixes(repo, order[index:]) if checking else []
|
||||||
|
if slugs:
|
||||||
|
print(f"void: merge main for hotfix {', '.join(slugs)}, then rerun")
|
||||||
|
return 3
|
||||||
|
log = log_dir / f"gate-{UNSAFE.sub('_', target)}-{sha[:8]}.log"
|
||||||
|
with open(log, "w", encoding="utf-8") as out:
|
||||||
|
code = subprocess.run(
|
||||||
|
["make", "-C", str(repo), target],
|
||||||
|
stdin=subprocess.DEVNULL, stdout=out, stderr=subprocess.STDOUT, pass_fds=(lock,), check=False,
|
||||||
|
).returncode
|
||||||
|
if head(repo) != sha:
|
||||||
|
fail(f"HEAD moved during {target}; nothing recorded for it")
|
||||||
|
changed, _ = tree(repo)
|
||||||
|
reasons = []
|
||||||
|
if code:
|
||||||
|
reasons.append(f"exit {code}")
|
||||||
|
line = first_failure(log, checks)
|
||||||
|
if line is not None:
|
||||||
|
reasons.append(line)
|
||||||
|
if changed:
|
||||||
|
reasons.append(f"modified tracked files: {', '.join(changed)}")
|
||||||
|
status = "red" if reasons else "green"
|
||||||
|
red = red or status == "red"
|
||||||
|
entry = {"status": status, "log": str(log),
|
||||||
|
"finished": datetime.now(UTC).isoformat(timespec="seconds"),
|
||||||
|
"fail_regex": [c.pattern for c in checks]}
|
||||||
|
if reasons:
|
||||||
|
entry["reason"] = "; ".join(reasons)
|
||||||
|
record(state, sha, target, entry)
|
||||||
|
print("\t".join([status, target, str(log), cell(entry.get("reason"))]))
|
||||||
|
if changed:
|
||||||
|
fail(f"{target} left tracked files dirty; remaining targets not run")
|
||||||
|
return 1 if red else 0
|
||||||
|
|
||||||
|
|
||||||
|
def send(child: subprocess.Popen, signum: int) -> None:
|
||||||
|
if child.returncode is None:
|
||||||
|
with contextlib.suppress(ProcessLookupError, PermissionError):
|
||||||
|
os.killpg(child.pid, signum)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_hold(args: argparse.Namespace) -> int:
|
||||||
|
repo = repository(args.repo)
|
||||||
|
caught: list[int] = []
|
||||||
|
child: subprocess.Popen | None = None
|
||||||
|
|
||||||
|
def forward(signum: int, _frame: object) -> None:
|
||||||
|
if child is None:
|
||||||
|
caught.append(signum)
|
||||||
|
else:
|
||||||
|
send(child, signum)
|
||||||
|
|
||||||
|
for signum in FORWARDED:
|
||||||
|
signal.signal(signum, forward)
|
||||||
|
with worktree_lock(repo, ["gate.py", "hold", "--repo", str(repo), "--", *args.command], caught) as lock:
|
||||||
|
if caught:
|
||||||
|
return 128 + caught[0]
|
||||||
|
try:
|
||||||
|
child = subprocess.Popen(args.command, cwd=repo, pass_fds=(lock,), start_new_session=True)
|
||||||
|
except OSError as error:
|
||||||
|
fail(f"cannot start {args.command[0]}: {error.strerror}")
|
||||||
|
for signum in caught:
|
||||||
|
send(child, signum)
|
||||||
|
code = child.wait()
|
||||||
|
return code if code >= 0 else 128 - code
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
|
sub = parser.add_subparsers(dest="subcommand", required=True)
|
||||||
|
for name in ("list", "status"):
|
||||||
|
command = sub.add_parser(name)
|
||||||
|
command.add_argument("--repo", required=True)
|
||||||
|
command.add_argument("--json", action="store_true")
|
||||||
|
run = sub.add_parser("run")
|
||||||
|
run.add_argument("--repo", required=True)
|
||||||
|
run.add_argument("--target", action="append", required=True)
|
||||||
|
run.add_argument("--log-dir", required=True)
|
||||||
|
run.add_argument("--fail-regex", action="append")
|
||||||
|
hold = sub.add_parser("hold")
|
||||||
|
hold.add_argument("--repo", required=True)
|
||||||
|
hold.add_argument("command", nargs="+")
|
||||||
|
args = parser.parse_args()
|
||||||
|
commands = {"list": cmd_list, "status": cmd_status, "run": cmd_run, "hold": cmd_hold}
|
||||||
|
return commands[args.subcommand](args)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
---
|
||||||
|
name: kvb-check-legal
|
||||||
|
description: >
|
||||||
|
Check a commit's licences, attribution, copied snippets, GDPR and
|
||||||
|
trademarks. Run AUTOMATICALLY before every requested commit;
|
||||||
|
also on "rechtlich prüfen", "lizenz prüfen".
|
||||||
|
---
|
||||||
|
|
||||||
|
The stance is the project's counsel: find what would make the change unlawful
|
||||||
|
to distribute before it is distributed. This is not legal advice but a reading
|
||||||
|
of the licence texts, with German and EU law where jurisdiction matters; every
|
||||||
|
report says so in one line. Leaked secrets belong to `kvb-check-security`,
|
||||||
|
engineering principles to `kvb-check-principles`.
|
||||||
|
|
||||||
|
## Target
|
||||||
|
|
||||||
|
- Before a commit: the staging index (`git diff --cached`, plus every added or
|
||||||
|
changed file in full where origin or licence is in question).
|
||||||
|
- A `<ref>` or `<a>..<b>` after the command: that commit or range.
|
||||||
|
- The command alone: the index if it is non-empty, else `HEAD`.
|
||||||
|
|
||||||
|
## Fast exit
|
||||||
|
|
||||||
|
The target is in scope only if it adds or changes at least one of: a
|
||||||
|
dependency manifest or lockfile; a vendored, generated or third-party file; a
|
||||||
|
LICENSE, NOTICE or COPYING file or a licence header; code with a foreign
|
||||||
|
origin (a source URL, a foreign copyright line, a block that reads unlike its
|
||||||
|
surroundings); an external request (CDN, web font, analytics, embed, API);
|
||||||
|
handling of personal data; a logo, icon or third-party brand name. Nothing in
|
||||||
|
scope: report `advocate: nothing licence-relevant in <target>` in one line and
|
||||||
|
let the commit proceed.
|
||||||
|
|
||||||
|
## Project licence
|
||||||
|
|
||||||
|
Read every declaration: LICENSE/COPYING, the manifest's licence field
|
||||||
|
(`package.json`, `pyproject.toml`, `Cargo.toml`, `composer.json`, ...), SPDX
|
||||||
|
headers, the README's licence section. Declarations that disagree are a
|
||||||
|
finding. A project without any licence is a finding (all rights reserved),
|
||||||
|
never a blocker of the commit at hand.
|
||||||
|
|
||||||
|
## The checks
|
||||||
|
|
||||||
|
Only for a target in scope: read `checks.md` next to this file and apply its
|
||||||
|
seven checks.
|
||||||
|
|
||||||
|
## Severity
|
||||||
|
|
||||||
|
- **Blocker**: the commit would distribute what the project may not: an
|
||||||
|
incompatible inbound or dependency licence, or non-trivial code or assets
|
||||||
|
without a licence.
|
||||||
|
- **Finding**: fixable while keeping the change: missing attribution, NOTICE
|
||||||
|
or header; a data-protection gap; trademark use; a block of unclear origin.
|
||||||
|
- **Note**: worth knowing, no action required.
|
||||||
|
|
||||||
|
## Blockers stop the commit
|
||||||
|
|
||||||
|
Harden every blocker with the `kvb-think-dialectic` skill before reporting
|
||||||
|
it. The antithesis tries: the code is trivial or the operator's own; the
|
||||||
|
dependency is build- or test-only and never distributed; an exception applies
|
||||||
|
(linking or Classpath exception, dual licensing, a licence grant in the issue
|
||||||
|
or commit history). A surviving blocker stops the commit: present it with the
|
||||||
|
options (remove, replace with a compatible alternative, relicense, obtain
|
||||||
|
permission) and wait. The operator's explicit acceptance lets this one commit
|
||||||
|
proceed and never carries over. Findings and notes never stop a commit.
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
One line with the target, the project licence, and "Keine Rechtsberatung" (in
|
||||||
|
the operator's language). Then one table, blockers first:
|
||||||
|
|
||||||
|
| Severity | Check | Location | What is wrong | Licence or law | Fix | Evidence |
|
||||||
|
|
||||||
|
`Evidence` is `proven` (licence text, metadata or file read; cite path:line
|
||||||
|
or the registry URL) or `derived` (inferred). Close with the verdict (commit
|
||||||
|
stops or proceeds) and the `kvb-check-score` block.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Never infer a licence from a package's name or reputation; read it. An
|
||||||
|
unreachable registry makes the row `derived`, never clean.
|
||||||
|
- Never invent a licence clause, a statute or a court ruling; cite only what
|
||||||
|
you read.
|
||||||
|
- Read-only apart from stopping the commit: never edit licence files, add
|
||||||
|
headers or remove dependencies on your own. Propose the fix; the operator
|
||||||
|
decides.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Advocate: the checks
|
||||||
|
|
||||||
|
Apply each check to a target the `kvb-check-legal` skill found in scope.
|
||||||
|
|
||||||
|
1. **Inbound code** - every file or block of foreign origin carries a licence
|
||||||
|
that the project licence can absorb in the direction of distribution. GPL
|
||||||
|
code cannot enter a project distributed under MIT without relicensing the
|
||||||
|
whole; Apache-2.0 cannot enter GPL-2.0-only; code without a licence is all
|
||||||
|
rights reserved.
|
||||||
|
2. **Dependency licences** - for each added or bumped dependency, read the
|
||||||
|
licence of the resolved version (lockfile, registry metadata, or the
|
||||||
|
package's own LICENSE), including the transitive dependencies the lockfile
|
||||||
|
newly pulls. Watch copyleft reach (GPL, AGPL's network clause, LGPL static
|
||||||
|
versus dynamic linking), source-available terms that are not open source
|
||||||
|
(SSPL, BUSL, Commons Clause, "non-commercial"), and an upstream relicensing
|
||||||
|
between the old and the new version.
|
||||||
|
3. **Project licence fit** - the project's declarations still hold against
|
||||||
|
what checks 1 and 2 found: a dependency that forces copyleft forces either
|
||||||
|
the project licence or the dependency's removal.
|
||||||
|
4. **Attribution** - retained copyright notices, the licence text shipped
|
||||||
|
where required, an Apache-2.0 NOTICE propagated, modified-file notices
|
||||||
|
(Apache-2.0 §4(b), GPL-3.0 §5(a)), SPDX headers where the project uses them,
|
||||||
|
credit for CC BY material.
|
||||||
|
5. **Copied snippets** - Stack Overflow content is CC BY-SA (attribution plus
|
||||||
|
share-alike); gists, blog posts, other repositories, and verbatim
|
||||||
|
reproductions of well-known code need a traceable origin and licence.
|
||||||
|
Trivial code without originality (one-liners, usage dictated by an API) is
|
||||||
|
not a finding.
|
||||||
|
6. **Data protection (GDPR, TDDDG)** - new collection, storage, logging, or
|
||||||
|
transfer of personal data; IP addresses sent to third parties through
|
||||||
|
external fonts, CDNs, analytics, or embeds; storage on the user's device
|
||||||
|
without consent (§ 25 TDDDG); transfers outside the EU. Name the data flow
|
||||||
|
and the missing legal basis or consent mechanism.
|
||||||
|
7. **Trademarks and branding** - third-party logos, icons, and product names
|
||||||
|
used beyond plain reference: implied endorsement, use in the project's own
|
||||||
|
name, modified logos. Icon sets carry their own licence, and a free licence
|
||||||
|
on an icon does not license the trademark it depicts.
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
---
|
||||||
|
name: kvb-check-principles
|
||||||
|
description: >
|
||||||
|
Find violated principles (SPOT, DRY, KISS, SRP, coupling, trust
|
||||||
|
boundaries) in a repository, path or diff. Trigger on "sei skeptisch", "kritische
|
||||||
|
analyse", or "review the architecture".
|
||||||
|
---
|
||||||
|
|
||||||
|
The stance is adversarial: the code is guilty until its structure proves
|
||||||
|
otherwise. This skill hunts violated principles, not bugs (`code-review`),
|
||||||
|
bloat (`ponytail-audit`) or leaked secrets (`kvb-check-security`). Read-only:
|
||||||
|
report, change nothing unless the operator asks afterwards.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
The operator names the target: a repository, directory, role or diff.
|
||||||
|
Without one, take the whole repository and rank by blast radius. State the
|
||||||
|
scope in one line and name what you did not read; a silent half-audit reads
|
||||||
|
as clean.
|
||||||
|
|
||||||
|
## The lenses
|
||||||
|
|
||||||
|
Apply each independently; a finding belongs to exactly one:
|
||||||
|
|
||||||
|
1. **SPOT**: one truth, one place. A value, version, hostname, schema or list
|
||||||
|
of names that exists twice will diverge. Name both locations and the
|
||||||
|
drift.
|
||||||
|
2. **DRY**: repeated *knowledge*, not text. Look-alike blocks that change for
|
||||||
|
different reasons are correctly separate; merging them is the violation.
|
||||||
|
Prove the shared reason to change, or drop the finding.
|
||||||
|
3. **SRP**: one reason to change per unit: the module that parses and
|
||||||
|
persists, the function named "x and y", the role that configures, deploys
|
||||||
|
and monitors. Name the two stakeholders who would each demand a change.
|
||||||
|
4. **KISS/YAGNI**: mechanism nobody asked for: an abstraction with one
|
||||||
|
implementation, a config value that never varies, a cache with no measured
|
||||||
|
pressure, a retry loop around a deterministic call.
|
||||||
|
5. **Security**, only at trust boundaries: unvalidated input reaching a
|
||||||
|
query, shell, template or path; authorization checked in the caller
|
||||||
|
instead of the resource; a secret in a default, log or image layer; a
|
||||||
|
permission wider than the operation needs. Defensive findings only.
|
||||||
|
6. **Fail fast**: a fallback guessing a value the caller failed to supply,
|
||||||
|
or a swallowed error: wrong behaviour instead of a loud failure (step 2
|
||||||
|
of the `kvb-code-clean` skill).
|
||||||
|
7. **Coupling**: a dependency against the layer direction, an import cycle,
|
||||||
|
a reach into another unit's internals.
|
||||||
|
|
||||||
|
## Method
|
||||||
|
|
||||||
|
- **Tools and graph first**: on a diff, `scripts/principles.py` (thresholds
|
||||||
|
as in `kvb-code-clean` step 3) turns SRP, fail fast, KISS/YAGNI and textual
|
||||||
|
DRY into `file:line` leads; then the codebase-memory-mcp graph
|
||||||
|
(`search_graph`, `trace_path`, `get_architecture`, `detect_changes`), grep
|
||||||
|
and read after.
|
||||||
|
- **Evidence before verdict**: every finding carries a `file:line` you read.
|
||||||
|
Grep the sibling occurrences before calling something a duplicate; one hit
|
||||||
|
is not a pattern.
|
||||||
|
- **Name the failure**: which value drifts, which caller breaks, which input
|
||||||
|
reaches which sink. A finding whose worst case is "feels wrong" is deleted,
|
||||||
|
not softened.
|
||||||
|
- **Attack your own finding**: run anything consequential or expensive to fix
|
||||||
|
through the `kvb-think-dialectic` skill before reporting it; the strongest
|
||||||
|
counter is usually "not the same knowledge" or "already checked upstream".
|
||||||
|
- **Respect intent**: a deliberate marker (`ponytail:`, a documented
|
||||||
|
exception, a nocheck directive with a reason) is a known ceiling, a finding
|
||||||
|
only once exceeded.
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
One table, worst blast radius first:
|
||||||
|
|
||||||
|
| Severity | Principle | Location | What is wrong | Smallest fix |
|
||||||
|
|
||||||
|
Then at most three lines: what the scope excluded, which findings survived
|
||||||
|
`kvb-think-dialectic` and which are still derived-only, and the one to fix
|
||||||
|
first.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Never report formatting, naming taste or missing comments.
|
||||||
|
- Mark each finding proven (grepped, read, reproduced) or derived (reasoned
|
||||||
|
statically), and close with the `kvb-check-score` block for the audit.
|
||||||
|
- No silent caps: say how many findings a cut dropped, by what rule.
|
||||||
|
- Skepticism ends at the report: no fix, stage or refactor in this skill.
|
||||||
+335
@@ -0,0 +1,335 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Report the principle-lens hits of ruff, lizard and jscpd on the lines a change touches.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
--repo: a directory inside the git work tree to scan.
|
||||||
|
--staged: scan only the staged changes (git diff --cached); the tools
|
||||||
|
read a snapshot of the index. Without it: every change against HEAD,
|
||||||
|
staged and unstaged, in the working tree.
|
||||||
|
--file: count every line of this file as changed; repeatable, relative
|
||||||
|
to --repo or absolute. Untracked files are in no diff.
|
||||||
|
--ccn: a function above this cyclomatic complexity is a hit (ruff C901,
|
||||||
|
lizard).
|
||||||
|
--nloc: a function above this many lines of code, blank and comment
|
||||||
|
lines excluded, is a hit (lizard).
|
||||||
|
--params: a function above this many parameters is a hit (ruff PLR0913,
|
||||||
|
lizard).
|
||||||
|
--min-tokens: a clone of at least this many tokens is a hit (jscpd).
|
||||||
|
--json: print the hits as a JSON list instead of tab-separated lines.
|
||||||
|
|
||||||
|
Output:
|
||||||
|
One line per hit: file:line, lens, tool, message. ruff C901 and PLR0913
|
||||||
|
and lizard map to SRP; ruff BLE001, S110, B006 and B008 to fail fast;
|
||||||
|
ruff ARG to KISS/YAGNI; jscpd to DRY. A ruff hit counts when its range
|
||||||
|
touches a changed line, a lizard function when its span does, a jscpd
|
||||||
|
clone when either side does. ruff reads Python files, lizard the
|
||||||
|
languages it has a reader for, jscpd the whole tree for clone partners.
|
||||||
|
A tool missing from PATH writes `skip: <tool> missing` to stderr instead
|
||||||
|
of hits.
|
||||||
|
|
||||||
|
Exit codes:
|
||||||
|
0 no hits, 1 hits, 2 usage error, git failure, a failed tool or
|
||||||
|
unreadable tool output, or a missing kvb-code-clean/scripts/defaults.py,
|
||||||
|
which parses the diff.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from types import ModuleType
|
||||||
|
|
||||||
|
DEFAULTS = Path(__file__).resolve().parents[2] / "kvb-code-clean" / "scripts" / "defaults.py"
|
||||||
|
RUFF_LENSES = {
|
||||||
|
"C901": "SRP",
|
||||||
|
"PLR0913": "SRP",
|
||||||
|
"BLE001": "fail fast",
|
||||||
|
"S110": "fail fast",
|
||||||
|
"B006": "fail fast",
|
||||||
|
"B008": "fail fast",
|
||||||
|
"ARG": "KISS/YAGNI",
|
||||||
|
}
|
||||||
|
PYTHON = frozenset({".py", ".pyi"})
|
||||||
|
LIZARD = frozenset(
|
||||||
|
{
|
||||||
|
".c", ".cc", ".cpp", ".cxx", ".h", ".hpp", ".cs", ".java", ".js", ".cjs", ".mjs", ".jsx", ".ts",
|
||||||
|
".tsx", ".vue", ".py", ".rb", ".php", ".go", ".rs", ".swift", ".kt", ".kts", ".scala", ".lua",
|
||||||
|
".m", ".mm", ".pl", ".pm", ".r", ".erl", ".hrl", ".es", ".escript", ".sol", ".sql", ".pks",
|
||||||
|
".pkb", ".pls", ".plb", ".pck", ".gd", ".zig", ".st", ".sdl", ".ttcn", ".ttcnpp", ".f", ".f70",
|
||||||
|
".f90", ".f95", ".f03", ".f08", ".for", ".ftn", ".fpp",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
LIZARD_WARNING = re.compile(
|
||||||
|
r"^(?P<path>.+?):(?P<line>\d+): warning: "
|
||||||
|
r"(?P<message>.+? has \d+ NLOC, \d+ CCN, \d+ token, \d+ PARAM, (?P<length>\d+) length\b.*)$"
|
||||||
|
)
|
||||||
|
REPORT = "jscpd-report.json"
|
||||||
|
BLOBS = frozenset({b"100644", b"100755"})
|
||||||
|
|
||||||
|
|
||||||
|
def load(path: Path) -> ModuleType:
|
||||||
|
if not path.is_file():
|
||||||
|
print(f"principles: {path} is missing; it parses the diff", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
spec = importlib.util.spec_from_file_location("kvb_code_clean_defaults", path)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules[spec.name] = module
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
defaults = load(DEFAULTS)
|
||||||
|
ScanError = defaults.ScanError
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, order=True)
|
||||||
|
class Hit:
|
||||||
|
file: str
|
||||||
|
line: int
|
||||||
|
lens: str
|
||||||
|
tool: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Target:
|
||||||
|
path: Path
|
||||||
|
lines: frozenset[int] | None
|
||||||
|
|
||||||
|
def overlaps(self, start: int, end: int) -> bool:
|
||||||
|
return self.lines is None or not self.lines.isdisjoint(range(start, end + 1))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Scan:
|
||||||
|
base: Path
|
||||||
|
scratch: Path
|
||||||
|
targets: dict[str, Target]
|
||||||
|
shown: dict[str, str]
|
||||||
|
limits: argparse.Namespace
|
||||||
|
|
||||||
|
def name(self, raw: str) -> str:
|
||||||
|
path = (self.base / raw).resolve()
|
||||||
|
if str(path) in self.shown:
|
||||||
|
return self.shown[str(path)]
|
||||||
|
return path.relative_to(self.base).as_posix() if path.is_relative_to(self.base) else str(path)
|
||||||
|
|
||||||
|
def touched(self, raw: str, start: int, end: int) -> str | None:
|
||||||
|
name = self.name(raw)
|
||||||
|
target = self.targets.get(name)
|
||||||
|
return name if target is not None and target.overlaps(start, end) else None
|
||||||
|
|
||||||
|
def files(self, suffixes: frozenset[str]) -> list[str]:
|
||||||
|
return [str(target.path) for target in self.targets.values() if target.path.suffix.lower() in suffixes]
|
||||||
|
|
||||||
|
|
||||||
|
def available(tool: str) -> bool:
|
||||||
|
if shutil.which(tool):
|
||||||
|
return True
|
||||||
|
print(f"skip: {tool} missing", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def run(command: list[str], cwd: Path) -> subprocess.CompletedProcess:
|
||||||
|
return subprocess.run(
|
||||||
|
command, cwd=cwd, capture_output=True, text=True, encoding="utf-8", errors="replace", check=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def failed(result: subprocess.CompletedProcess) -> ScanError:
|
||||||
|
return ScanError(f"{result.args[0]} exited {result.returncode}: {result.stderr.strip()}")
|
||||||
|
|
||||||
|
|
||||||
|
def ruff_lens(code: str | None) -> str | None:
|
||||||
|
if code is None:
|
||||||
|
return None
|
||||||
|
return next((lens for prefix, lens in RUFF_LENSES.items() if code.startswith(prefix)), None)
|
||||||
|
|
||||||
|
|
||||||
|
def ruff(scan: Scan) -> list[Hit]:
|
||||||
|
paths = scan.files(PYTHON)
|
||||||
|
if not paths or not available("ruff"):
|
||||||
|
return []
|
||||||
|
result = run(
|
||||||
|
[
|
||||||
|
"ruff", "check", "--isolated", "--no-cache", "--exit-zero", "--output-format", "json",
|
||||||
|
"--select", ",".join(RUFF_LENSES),
|
||||||
|
"--config", f"lint.mccabe.max-complexity = {scan.limits.ccn}",
|
||||||
|
"--config", f"lint.pylint.max-args = {scan.limits.params}",
|
||||||
|
*paths,
|
||||||
|
],
|
||||||
|
scan.base,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise failed(result)
|
||||||
|
hits = []
|
||||||
|
for row in json.loads(result.stdout):
|
||||||
|
lens = ruff_lens(row["code"])
|
||||||
|
start = row["location"]["row"]
|
||||||
|
name = scan.touched(row["filename"], start, row["end_location"]["row"])
|
||||||
|
if lens and name:
|
||||||
|
hits.append(Hit(name, start, lens, "ruff", f"{row['code']} {row['message']}"))
|
||||||
|
return hits
|
||||||
|
|
||||||
|
|
||||||
|
def parse_lizard(text: str) -> list[tuple[str, int, int, str]]:
|
||||||
|
found = []
|
||||||
|
for row in text.splitlines():
|
||||||
|
match = LIZARD_WARNING.match(row)
|
||||||
|
if match:
|
||||||
|
line = int(match["line"])
|
||||||
|
found.append((match["path"], line, line + int(match["length"]) - 1, match["message"]))
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def lizard(scan: Scan) -> list[Hit]:
|
||||||
|
paths = scan.files(LIZARD)
|
||||||
|
if not paths or not available("lizard"):
|
||||||
|
return []
|
||||||
|
limits = scan.limits
|
||||||
|
result = run(
|
||||||
|
[
|
||||||
|
"lizard", "--warnings_only", "--CCN", str(limits.ccn), "--arguments", str(limits.params),
|
||||||
|
"--Threshold", f"nloc={limits.nloc}", *paths,
|
||||||
|
],
|
||||||
|
scan.base,
|
||||||
|
)
|
||||||
|
if result.returncode not in (0, 1):
|
||||||
|
raise failed(result)
|
||||||
|
return [
|
||||||
|
Hit(name, start, "SRP", "lizard", message)
|
||||||
|
for path, start, end, message in parse_lizard(result.stdout)
|
||||||
|
if (name := scan.touched(path, start, end))
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def side(part: dict) -> tuple[str, int, int]:
|
||||||
|
return part["name"], part["start"], part["end"]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_jscpd(report: dict) -> list[tuple[tuple[str, int, int], tuple[str, int, int], int]]:
|
||||||
|
return [(side(clone["firstFile"]), side(clone["secondFile"]), clone["tokens"]) for clone in report["duplicates"]]
|
||||||
|
|
||||||
|
|
||||||
|
def jscpd(scan: Scan) -> list[Hit]:
|
||||||
|
if not scan.targets or not available("jscpd"):
|
||||||
|
return []
|
||||||
|
extra = [str(target.path) for target in scan.targets.values() if not target.path.is_relative_to(scan.base)]
|
||||||
|
output = scan.scratch / "jscpd"
|
||||||
|
output.mkdir()
|
||||||
|
result = run(
|
||||||
|
[
|
||||||
|
"jscpd", "--min-tokens", str(scan.limits.min_tokens), "--reporters", "json", "--output", str(output),
|
||||||
|
"--absolute", "--silent", str(scan.base), *extra,
|
||||||
|
],
|
||||||
|
scan.base,
|
||||||
|
)
|
||||||
|
report = output / REPORT
|
||||||
|
if not report.is_file():
|
||||||
|
raise failed(result)
|
||||||
|
hits = []
|
||||||
|
for first, second, tokens in parse_jscpd(json.loads(report.read_text(encoding="utf-8"))):
|
||||||
|
for (path, start, end), (other, other_start, other_end) in ((first, second), (second, first)):
|
||||||
|
name = scan.touched(path, start, end)
|
||||||
|
if name:
|
||||||
|
clone = f"clone of {scan.name(other)}:{other_start}-{other_end}, {tokens} tokens"
|
||||||
|
hits.append(Hit(name, start, "DRY", "jscpd", clone))
|
||||||
|
break
|
||||||
|
return hits
|
||||||
|
|
||||||
|
|
||||||
|
def git_bytes(root: Path, *args: str, stdin: bytes) -> bytes:
|
||||||
|
result = subprocess.run(["git", *args], cwd=root, input=stdin, capture_output=True, check=False)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise ScanError(f"git {args[0]}: {result.stderr.decode('utf-8', 'replace').strip()}")
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot(root: Path, target: Path) -> None:
|
||||||
|
entries = []
|
||||||
|
for record in git_bytes(root, "ls-files", "--stage", "-z", stdin=b"").split(b"\0"):
|
||||||
|
meta, _, path = record.partition(b"\t")
|
||||||
|
fields = meta.split()
|
||||||
|
if len(fields) == 3 and fields[0] in BLOBS and fields[2] == b"0":
|
||||||
|
entries.append((fields[1], os.fsdecode(path)))
|
||||||
|
data = git_bytes(root, "cat-file", "--batch", stdin=b"".join(oid + b"\n" for oid, _ in entries))
|
||||||
|
offset = 0
|
||||||
|
for _, path in entries:
|
||||||
|
newline = data.index(b"\n", offset)
|
||||||
|
size = int(data[offset:newline].split()[2])
|
||||||
|
file = target / path
|
||||||
|
file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
file.write_bytes(data[newline + 1 : newline + 1 + size])
|
||||||
|
offset = newline + 2 + size
|
||||||
|
|
||||||
|
|
||||||
|
def collect(repo: Path, staged: bool, names: list[str], limits: argparse.Namespace) -> list[Hit]:
|
||||||
|
if not repo.is_dir():
|
||||||
|
raise ScanError(f"{repo} is not a directory")
|
||||||
|
root = Path(defaults.git(repo, "rev-parse", "--show-toplevel").strip()).resolve()
|
||||||
|
changed = defaults.added_lines(root, staged)
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
scratch = Path(tmp).resolve()
|
||||||
|
base = root
|
||||||
|
if staged:
|
||||||
|
base = scratch / "index"
|
||||||
|
base.mkdir()
|
||||||
|
snapshot(root, base)
|
||||||
|
targets = {
|
||||||
|
path: Target(base / path, frozenset(lines)) for path, lines in changed.items() if (base / path).is_file()
|
||||||
|
}
|
||||||
|
for name in names:
|
||||||
|
source = (repo / name).resolve()
|
||||||
|
if not source.is_file():
|
||||||
|
raise ScanError(f"{name}: no such file")
|
||||||
|
if not source.is_relative_to(root):
|
||||||
|
targets[str(source)] = Target(source, None)
|
||||||
|
continue
|
||||||
|
shown = source.relative_to(root).as_posix()
|
||||||
|
if staged:
|
||||||
|
(base / shown).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copyfile(source, base / shown)
|
||||||
|
targets[shown] = Target(base / shown, None)
|
||||||
|
shown = {str(target.path.resolve()): name for name, target in targets.items()}
|
||||||
|
scan = Scan(base, scratch, targets, shown, limits)
|
||||||
|
return sorted(hit for probe in (ruff, lizard, jscpd) for hit in probe(scan))
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
|
parser.add_argument("--repo", required=True)
|
||||||
|
parser.add_argument("--staged", action="store_true")
|
||||||
|
parser.add_argument("--file", action="append", default=[])
|
||||||
|
parser.add_argument("--ccn", type=int, required=True)
|
||||||
|
parser.add_argument("--nloc", type=int, required=True)
|
||||||
|
parser.add_argument("--params", type=int, required=True)
|
||||||
|
parser.add_argument("--min-tokens", type=int, required=True)
|
||||||
|
parser.add_argument("--json", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
try:
|
||||||
|
hits = collect(Path(args.repo), args.staged, args.file, args)
|
||||||
|
except ScanError as error:
|
||||||
|
print(f"principles: {error}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
except (KeyError, ValueError) as error:
|
||||||
|
print(f"principles: unreadable tool output: {error!r}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps([asdict(hit) for hit in hits], indent=1))
|
||||||
|
return 1 if hits else 0
|
||||||
|
for hit in hits:
|
||||||
|
print("\t".join([f"{hit.file}:{hit.line}", hit.lens, hit.tool, hit.message]))
|
||||||
|
return 1 if hits else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
---
|
||||||
|
name: kvb-check-score
|
||||||
|
description: >
|
||||||
|
Score fix, break, cost, time and security; run wherever confidence is reported.
|
||||||
|
---
|
||||||
|
|
||||||
|
Run it wherever fix confidence and break risk are reported: every
|
||||||
|
`kvb-check-confidence` block, stage summary, fix report and commit block.
|
||||||
|
Paste its two lines verbatim; they replace prose numbers.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 ${CLAUDE_SKILL_DIR}/scripts/score.py --fix <0-100> --break <0-100> --efficiency <n|-|new> --speed <n|-> --security <n|~n> --critical <count>
|
||||||
|
```
|
||||||
|
|
||||||
|
| Emoji | Value | Source |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 🎯 | fix confidence | `kvb-check-confidence` |
|
||||||
|
| 💥 | break risk | `kvb-check-break-risk` |
|
||||||
|
| 💸 | cost, `--efficiency` | `tokens.py` (skills) or `measure.py` (code) of `kvb-check-efficiency` |
|
||||||
|
| ⏱️ | time, `--speed` | `measure.py`, see `kvb-check-speed` |
|
||||||
|
| 🛡️ | security, critical count | `security.py` of `kvb-check-security`; `~<n>` from a review by hand |
|
||||||
|
| 💰 | cost:value quotient, tables and message block only | 💰 line of `tokens.py` (skills) of `kvb-check-efficiency` |
|
||||||
|
| 🏆 | score | this script |
|
||||||
|
|
||||||
|
Cost and time: after over base in percent, over 100 worse. `-` means not
|
||||||
|
measured or not relevant (skills have no time) and counts as
|
||||||
|
100; it shows as `–`. `new` (changed skill text without a base) counts as 100
|
||||||
|
in the score, yet still asks. Never invent a value: measure it or pass `-`.
|
||||||
|
|
||||||
|
Score = `fix * (100 - break) / 100 * 100 / cost * 100 / time * security / 100`,
|
||||||
|
capped to 0..100 and rounded.
|
||||||
|
|
||||||
|
## Gate
|
||||||
|
|
||||||
|
The second line is the verdict, first match wins:
|
||||||
|
|
||||||
|
- `⛔ gate: critical security finding: do not commit` (exit 4): the commit
|
||||||
|
call is not sent, not even on an earlier approval. Fix the finding,
|
||||||
|
rescan, score again.
|
||||||
|
- `⛔ gate: fix <f> % < 90` or `⛔ gate: break <b> % > 5` (exit 1): the 90/5
|
||||||
|
rule fails, which bars PR labels and the split bar, not a commit: show it
|
||||||
|
in the block and commit only on the operator's word. Close the gap as the
|
||||||
|
last section of `kvb-check-break-risk` says.
|
||||||
|
- `⚠️ gate: <causes>: ask the operator before the commit` (exit 3), naming
|
||||||
|
each of `cost <c> % > 100`, `time <t> % > 100` and
|
||||||
|
`security <n> % < 100`: ask with the block what the cost buys or why each
|
||||||
|
finding is acceptable; commit only on a yes, never carried over to
|
||||||
|
another commit.
|
||||||
|
- `✅ gate: 90/5, cost and time <= 100, security 100 %` (exit 0):
|
||||||
|
commit as usual. 90/5 still needs `gate: green` of `kvb-check-gate`.
|
||||||
|
- Exit 2: usage error.
|
||||||
|
|
||||||
|
## Tables and message block
|
||||||
|
|
||||||
|
Each flag replaces the two lines; values, gate and exit code stay:
|
||||||
|
|
||||||
|
- `--table`: one markdown table, `| 🎯 Fix | 💥 Break | 💸 Cost |
|
||||||
|
⏱️ Time | 🛡️ Security | 🏆 Score | Gate |`.
|
||||||
|
- `--commit-table <repo>`: that table led by `🌿 Branch | 📊 Diff stat`
|
||||||
|
from git, 📁 worktree path below, alone in a code block. Every commit
|
||||||
|
block is this table; after the commit, `--commit <sha>` shows that commit's diff stat
|
||||||
|
and a `🔖 Commit` column.
|
||||||
|
- `--message-block`: one value per line, `🎯 Fix: <f> %` through
|
||||||
|
`🏆 Score: <n>` and `🚦 Gate: <verdict>`. Every commit message carries it
|
||||||
|
as its own paragraph right before the `Co-Authored-By` line, passed as one
|
||||||
|
`-m` argument, so the approval prompt and git history show the values.
|
||||||
|
|
||||||
|
On a skill change add `--value <n:n|?>`, the quotient of the 💰 line (`?`
|
||||||
|
while it has no value): a `💰 Value` column after `💸 Cost`, or line
|
||||||
|
after `🛡️ Security`. It changes neither score nor gate; alone it is a usage
|
||||||
|
error.
|
||||||
Executable
+254
@@ -0,0 +1,254 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Fold fix confidence, break risk, cost, time and security into one score and one gate verdict.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
--fix: fix confidence in percent, 0 to 100.
|
||||||
|
--break: break risk in percent, 0 to 100.
|
||||||
|
--efficiency: cost in percent above 0, from kvb-check-efficiency; `-` when not
|
||||||
|
measured or not relevant, `new` when the change has no base. Both count as 100
|
||||||
|
in the score.
|
||||||
|
--speed: time in percent above 0, from kvb-check-speed; `-` or `new` as for
|
||||||
|
--efficiency.
|
||||||
|
--security: security in percent, 0 to 100, from security.py of kvb-check-security;
|
||||||
|
`~<n>` for a value from a review by hand.
|
||||||
|
--critical: count of critical security findings.
|
||||||
|
--table: print one markdown table instead of the two lines.
|
||||||
|
--commit-table <repo>: the same table, led by the branch and staged diff stat of
|
||||||
|
that repository, then a blank line, `📁 Worktree:` and the top level path
|
||||||
|
alone in a ```text block, so it copies in one go.
|
||||||
|
--commit <sha>: with --commit-table, the diff stat of that commit instead of
|
||||||
|
the staged one, plus a commit column.
|
||||||
|
--message-block: print the commit message block instead of the two lines, one
|
||||||
|
value per line: `🎯 Fix: <f> %`, `💥 Break: <b> %`, `💸 Cost: <c>`,
|
||||||
|
`⏱️ Time: <t>`, `🛡️ Security: <n> %`, `💰 Value: <n:n>` with --value only,
|
||||||
|
`🏆 Score: <n>`, `🚦 Gate: <verdict>`; values and markers as in line 1.
|
||||||
|
--value <n:n|?>: with --table, --commit-table or --message-block, a value after
|
||||||
|
the cost one (--message-block: after security): the cost:value quotient of
|
||||||
|
tokens.py of kvb-check-efficiency, `?` when unknown. It changes neither score nor gate.
|
||||||
|
|
||||||
|
Output:
|
||||||
|
Line 1: `🎯 fix <f> % · 💥 break <b> % · 💸 cost <c> % · ⏱️ time <t> % ·
|
||||||
|
🛡️ security <n> % · 🏆 score <n>`, `–` in place of `<x> %` for `-`, `new` for `new`.
|
||||||
|
Score is fix * (100 - break) / 100 * 100 / cost * 100 / time * security / 100,
|
||||||
|
capped to 0..100, rounded half up.
|
||||||
|
Line 2: the gate verdict, `✅ gate: ...`, `⛔ gate: ...` or `⚠️ gate: ...`.
|
||||||
|
|
||||||
|
Exit codes:
|
||||||
|
0 gate passed, 1 fix under 90 or break over 5, 2 usage error,
|
||||||
|
3 cost or time over 100 or new, or security under 100: ask the operator
|
||||||
|
before the commit, 4 a critical security finding: do not commit.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import math
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
FIX_MIN = 90
|
||||||
|
BREAK_MAX = 5
|
||||||
|
COST_MAX = 100
|
||||||
|
SAFE = 100
|
||||||
|
DASH = "-"
|
||||||
|
NEW = "new"
|
||||||
|
ESTIMATE = "~"
|
||||||
|
SCORE_HEADER = ("🎯 Fix", "💥 Break", "💸 Cost", "⏱️ Time", "🛡️ Security", "🏆 Score", "Gate")
|
||||||
|
COMMIT_HEADER = ("🌿 Branch", "📊 Diff stat")
|
||||||
|
WORKTREE = "📁 Worktree:"
|
||||||
|
VALUE_HEADER = "💰 Value"
|
||||||
|
VALUE_AT = SCORE_HEADER.index("💸 Cost") + 1
|
||||||
|
UNKNOWN = "?"
|
||||||
|
QUOTIENT = re.compile(r"\d+(?:\.\d+)?:\d+(?:\.\d+)?")
|
||||||
|
SHORTSTAT = re.compile(
|
||||||
|
r"(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def percent(text: str) -> float:
|
||||||
|
try:
|
||||||
|
value = float(text)
|
||||||
|
except ValueError:
|
||||||
|
raise argparse.ArgumentTypeError(f"{text!r} is not a number") from None
|
||||||
|
if not 0 <= value <= 100:
|
||||||
|
raise argparse.ArgumentTypeError(f"{text!r} is outside 0..100")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def cost(text: str) -> float | str:
|
||||||
|
if text in (DASH, NEW):
|
||||||
|
return text
|
||||||
|
try:
|
||||||
|
value = float(text)
|
||||||
|
except ValueError:
|
||||||
|
raise argparse.ArgumentTypeError(f"{text!r} is neither a number, {DASH!r} nor {NEW!r}") from None
|
||||||
|
if not math.isfinite(value) or value <= 0:
|
||||||
|
raise argparse.ArgumentTypeError(f"{text!r} must be a positive number")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def security(text: str) -> tuple[float, str]:
|
||||||
|
mark = ESTIMATE if text.startswith(ESTIMATE) else ""
|
||||||
|
return percent(text.removeprefix(ESTIMATE)), mark
|
||||||
|
|
||||||
|
|
||||||
|
def worth(text: str) -> str:
|
||||||
|
if text == UNKNOWN or QUOTIENT.fullmatch(text):
|
||||||
|
return text
|
||||||
|
raise argparse.ArgumentTypeError(f"{text!r} is neither n:n nor {UNKNOWN!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def count(text: str) -> int:
|
||||||
|
if not text.isdigit():
|
||||||
|
raise argparse.ArgumentTypeError(f"{text!r} is not a count")
|
||||||
|
return int(text)
|
||||||
|
|
||||||
|
|
||||||
|
def number(value: float) -> str:
|
||||||
|
return f"{value:g}"
|
||||||
|
|
||||||
|
|
||||||
|
def shown(value: float | str) -> str:
|
||||||
|
if value == DASH:
|
||||||
|
return "–"
|
||||||
|
if value == NEW:
|
||||||
|
return NEW
|
||||||
|
return f"{number(value)} %"
|
||||||
|
|
||||||
|
|
||||||
|
def factor(value: float | str) -> float:
|
||||||
|
return 100 / value if isinstance(value, float) else 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def score(args: argparse.Namespace) -> int:
|
||||||
|
raw = (
|
||||||
|
args.fix * (100 - args.risk) / 100 * factor(args.efficiency) * factor(args.speed)
|
||||||
|
* args.security[0] / 100
|
||||||
|
)
|
||||||
|
return math.floor(min(max(raw, 0.0), 100.0) + 0.5)
|
||||||
|
|
||||||
|
|
||||||
|
def over(name: str, value: float | str) -> str | None:
|
||||||
|
if value == NEW:
|
||||||
|
return f"{name} {NEW}"
|
||||||
|
if isinstance(value, float) and value > COST_MAX:
|
||||||
|
return f"{name} {number(value)} % > {COST_MAX}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def gate(args: argparse.Namespace) -> tuple[str, str, int]:
|
||||||
|
if args.critical > 0:
|
||||||
|
return "⛔", "critical security finding: do not commit", 4
|
||||||
|
failed = []
|
||||||
|
if args.fix < FIX_MIN:
|
||||||
|
failed.append(f"fix {number(args.fix)} % < {FIX_MIN}")
|
||||||
|
if args.risk > BREAK_MAX:
|
||||||
|
failed.append(f"break {number(args.risk)} % > {BREAK_MAX}")
|
||||||
|
value, mark = args.security
|
||||||
|
unsafe = f"security {mark}{number(value)} % < {SAFE}" if value < SAFE else None
|
||||||
|
asks = [text for text in (over("cost", args.efficiency), over("time", args.speed), unsafe) if text]
|
||||||
|
ask = f"{', '.join(asks)}: ask the operator before the commit" if asks else ""
|
||||||
|
if failed:
|
||||||
|
return "⛔", "; ".join(part for part in (", ".join(failed), ask) if part), 1
|
||||||
|
if asks:
|
||||||
|
return "⚠️", ask, 3
|
||||||
|
return "✅", f"{FIX_MIN}/{BREAK_MAX}, cost and time <= {COST_MAX}, security {SAFE} %", 0
|
||||||
|
|
||||||
|
|
||||||
|
def git(repo: str, *args: str) -> str:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(["git", *args], cwd=repo, capture_output=True, text=True, check=False)
|
||||||
|
except OSError as error:
|
||||||
|
print(f"score: git {args[0]} in {repo}: {error}", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f"score: git {args[0]} in {repo}: {result.stderr.strip()}", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def diffstat(shortstat: str) -> str:
|
||||||
|
match = SHORTSTAT.search(shortstat)
|
||||||
|
files, added, removed = (int(g or 0) for g in match.groups()) if match else (0, 0, 0)
|
||||||
|
return f"{files} file{'' if files == 1 else 's'} +{added}/-{removed}"
|
||||||
|
|
||||||
|
|
||||||
|
def commit_cells(repo: str, commit: str | None) -> tuple[list[str], list[str]]:
|
||||||
|
branch = git(repo, "branch", "--show-current") or f"detached at {git(repo, 'rev-parse', '--short', 'HEAD')}"
|
||||||
|
if commit is None:
|
||||||
|
return list(COMMIT_HEADER), [branch, diffstat(git(repo, "diff", "--cached", "--shortstat"))]
|
||||||
|
stat = git(repo, "show", "--shortstat", "--format=", commit)
|
||||||
|
return [*COMMIT_HEADER, "🔖 Commit"], [branch, diffstat(stat), git(repo, "rev-parse", "--short", commit)]
|
||||||
|
|
||||||
|
|
||||||
|
def cell(text: str) -> str:
|
||||||
|
return text.replace("|", "\\|")
|
||||||
|
|
||||||
|
|
||||||
|
def table(header: list[str], row: list[str]) -> str:
|
||||||
|
return "\n".join(
|
||||||
|
(
|
||||||
|
"| " + " | ".join(header) + " |",
|
||||||
|
"|" + " --- |" * len(header),
|
||||||
|
"| " + " | ".join(cell(value) for value in row) + " |",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
|
parser.add_argument("--fix", type=percent, required=True)
|
||||||
|
parser.add_argument("--break", dest="risk", type=percent, required=True)
|
||||||
|
parser.add_argument("--efficiency", type=cost, required=True)
|
||||||
|
parser.add_argument("--speed", type=cost, required=True)
|
||||||
|
parser.add_argument("--security", type=security, required=True)
|
||||||
|
parser.add_argument("--critical", type=count, required=True)
|
||||||
|
layout = parser.add_mutually_exclusive_group()
|
||||||
|
layout.add_argument("--table", action="store_true")
|
||||||
|
layout.add_argument("--commit-table", metavar="REPO")
|
||||||
|
layout.add_argument("--message-block", action="store_true")
|
||||||
|
parser.add_argument("--commit", metavar="SHA")
|
||||||
|
parser.add_argument("--value", type=worth)
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.commit and not args.commit_table:
|
||||||
|
parser.error("--commit needs --commit-table")
|
||||||
|
if args.value and not (args.table or args.commit_table or args.message_block):
|
||||||
|
parser.error("--value needs --table, --commit-table or --message-block")
|
||||||
|
|
||||||
|
total = score(args)
|
||||||
|
emoji, verdict, code = gate(args)
|
||||||
|
value, mark = args.security
|
||||||
|
values = [
|
||||||
|
f"{number(args.fix)} %", f"{number(args.risk)} %", shown(args.efficiency), shown(args.speed),
|
||||||
|
f"{mark}{number(value)} %", str(total), f"{emoji} {verdict}",
|
||||||
|
]
|
||||||
|
header, row = list(SCORE_HEADER), list(values)
|
||||||
|
if args.value:
|
||||||
|
header.insert(VALUE_AT, VALUE_HEADER)
|
||||||
|
row.insert(VALUE_AT, args.value)
|
||||||
|
if args.commit_table:
|
||||||
|
lead, cells = commit_cells(args.commit_table, args.commit)
|
||||||
|
top = git(args.commit_table, "rev-parse", "--show-toplevel")
|
||||||
|
print(table([*lead, *header], [*cells, *row]))
|
||||||
|
print(f"\n{WORKTREE}\n\n```text\n{top}\n```")
|
||||||
|
elif args.table:
|
||||||
|
print(table(header, row))
|
||||||
|
elif args.message_block:
|
||||||
|
worth_line = [f"💰 Value: {args.value}"] if args.value else []
|
||||||
|
print("\n".join((
|
||||||
|
f"🎯 Fix: {values[0]}", f"💥 Break: {values[1]}", f"💸 Cost: {values[2]}",
|
||||||
|
f"⏱️ Time: {values[3]}", f"🛡️ Security: {values[4]}", *worth_line,
|
||||||
|
f"🏆 Score: {total}", f"🚦 Gate: {values[6]}",
|
||||||
|
)))
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
f"🎯 fix {values[0]} · 💥 break {values[1]} · 💸 cost {values[2]} · "
|
||||||
|
f"⏱️ time {values[3]} · 🛡️ security {values[4]} · 🏆 score {total}"
|
||||||
|
)
|
||||||
|
print(f"{emoji} gate: {verdict}")
|
||||||
|
return code
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
---
|
||||||
|
name: kvb-check-security
|
||||||
|
description: >
|
||||||
|
Run the MEDUSA scanner on a repository, working tree or secret stores:
|
||||||
|
security scans, vetting a repo, leaked secrets in chat or shell history.
|
||||||
|
Defensive only.
|
||||||
|
---
|
||||||
|
|
||||||
|
MEDUSA is a defensive AI-first security scanner (40,000+ patterns: CVEs, prompt injection, MCP tool poisoning, leaked secrets).
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
Check availability with `medusa --version`.
|
||||||
|
The skills repository's `make install` installs it with pipx; if it is still missing, `pipx install medusa-security` (fallback: `pip install --user medusa-security`) is the operator's step.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
| Task | Command |
|
||||||
|
| --- | --- |
|
||||||
|
| Scan the current project | `medusa scan .` |
|
||||||
|
| Vet a remote repo BEFORE cloning (supply chain, poisoned `.claude/` hooks, permissions, skills) | `medusa scan --git <url-or-org/repo>` |
|
||||||
|
| Find leaked API keys in AI chat + shell history | `medusa secrets scan` |
|
||||||
|
| Scan one exported file | `medusa secrets scan --path <file>` |
|
||||||
|
|
||||||
|
## Security value of a change
|
||||||
|
|
||||||
|
The 🛡️ value of the `kvb-check-score` block, 100 % meaning safe:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 ${CLAUDE_SKILL_DIR}/scripts/security.py --repo <root> --base <ref>
|
||||||
|
```
|
||||||
|
|
||||||
|
It copies the files changed since `<ref>`, untracked ones included and
|
||||||
|
symlinks left out, to a temporary tree, scans it once with
|
||||||
|
`medusa scan <tree> --format json -o <dir> --no-cache` and prints one line:
|
||||||
|
`🛡️ security <n> % (medusa: <k> findings in <m> files: <severity> <path>:<line>, ...)`.
|
||||||
|
`<n>` is 100 minus 50 per critical, 25 per high, 10 per medium and 2 per low
|
||||||
|
finding, at least 0. It shows locations and counts only, never a matched
|
||||||
|
value. Pass `<n>` as `--security` and the critical count as `--critical`
|
||||||
|
(`--json` holds it) to `score.py`.
|
||||||
|
|
||||||
|
- Exit 0: 100 %. Exit 3: below 100 without a critical finding; the score
|
||||||
|
gate asks the operator. Exit 1: a critical finding; the score gate forbids
|
||||||
|
the commit.
|
||||||
|
- Exit 4: medusa is missing or its report unreadable. Review trust
|
||||||
|
boundaries, secrets and injection in the diff by hand, then pass
|
||||||
|
`--security '~<n>'` (quoted: zsh expands a bare `~100` as a user's home) and the critical findings you found as `--critical`.
|
||||||
|
- Exit 2: usage error.
|
||||||
|
- **Accepted false positives**: `security.py accept --repo <root> --rule
|
||||||
|
<id> --path <path> --sha1 <sha1> --reason "<one line>"`, rule, path and
|
||||||
|
sha1 from `--json`, appends a row outside the repository: to the council
|
||||||
|
root, which the `kvb-org-organize` watch hook keeps in
|
||||||
|
`~/.local/state/agents-council/`. A match leaves the score and shows as
|
||||||
|
`<a> accepted`; editing the line brings it back. Accept only after reading
|
||||||
|
the rule's pattern, and name each acceptance in the commit message.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Run scans read-only; never pass `--reveal` and never run `medusa secrets purge` unless the user explicitly asks for it.
|
||||||
|
- Summarize findings by severity with file/line references; do not paste secret values into the conversation, even redacted ones.
|
||||||
|
- Treat findings as signals for review, not verdicts; confirm true positives in the code before proposing fixes.
|
||||||
|
- Vet third-party agent repos (`medusa scan --git`) before recommending the user clone or install them.
|
||||||
+282
@@ -0,0 +1,282 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Scan the files a change touches with MEDUSA and fold the findings into one security percentage.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
--repo: git repository whose working tree holds the change.
|
||||||
|
--base: git ref the change starts from.
|
||||||
|
--json: print the figures as JSON instead of the line.
|
||||||
|
|
||||||
|
accept --repo <root> --rule <rule_id> --path <repo path> --sha1 <40 hex> --reason <one line>:
|
||||||
|
record a reviewed false positive instead; --json of a scan prints rule, path
|
||||||
|
and sha1 of every finding.
|
||||||
|
|
||||||
|
Scan:
|
||||||
|
The regular files `git diff <ref>` names plus untracked ones, symlinks left
|
||||||
|
out, copied to a temporary tree and scanned once with
|
||||||
|
`medusa scan <tree> --format json -o <dir> --no-cache`, HOME pointing into
|
||||||
|
the temporary directory because medusa creates ~/.medusa on start, read from
|
||||||
|
`<dir>/medusa-scan-*.json`: a `findings` list whose entries carry
|
||||||
|
`severity` (critical, high, medium, low or info), `rule_id`, `file` and `line`.
|
||||||
|
|
||||||
|
Accepted:
|
||||||
|
One JSON row per reviewed false positive (id, rule, path, sha1 of the stripped
|
||||||
|
source line, reason, repo, date), appended by accept to
|
||||||
|
<council root>/security-accepted.jsonl of the kvb-org-organize watch.py, whose
|
||||||
|
hook keeps the rows in ~/.local/state/agents-council/security-accepted.jsonl;
|
||||||
|
a scan reads both. A finding with that rule, path and line text is accepted and
|
||||||
|
leaves the score; an edit of the line brings it back. A row without rule, path
|
||||||
|
and sha1 is a usage error.
|
||||||
|
|
||||||
|
Output:
|
||||||
|
`🛡️ security <n> % (medusa: <k> findings, <a> accepted in <m> files: <severity> <path>:<line>, ...)`,
|
||||||
|
`, <a> accepted` only when a is above 0, m the files scanned, the first 5
|
||||||
|
locations by severity and `+<rest> more`.
|
||||||
|
n = 100 - 50 * critical - 25 * high - 10 * medium - 2 * low, at least 0. Matched
|
||||||
|
values are never printed. --json adds the counts per severity, every location
|
||||||
|
with its rule and sha1, and the accepted ones.
|
||||||
|
|
||||||
|
Exit codes:
|
||||||
|
0 security 100, 1 a critical finding, 2 usage error, 3 under 100 without a
|
||||||
|
critical finding, 4 medusa missing or its report unreadable: review the diff by hand.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import datetime
|
||||||
|
import fcntl
|
||||||
|
import hashlib
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
WEIGHTS = {"critical": 50, "high": 25, "medium": 10, "low": 2, "info": 0}
|
||||||
|
SAFE = 100
|
||||||
|
SHOWN = 5
|
||||||
|
SHA1 = re.compile(r"[0-9a-f]{40}")
|
||||||
|
WATCH = Path(__file__).resolve().parents[2] / "kvb-org-organize" / "scripts" / "watch.py"
|
||||||
|
HAND = (
|
||||||
|
"review trust boundaries, secrets and injection in the diff by hand, "
|
||||||
|
"then pass --security '~<n>' to kvb-check-score"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Unreadable(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message: str) -> None:
|
||||||
|
print(f"security: {message}", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
|
||||||
|
def git(repo: Path, *args: str) -> str:
|
||||||
|
result = subprocess.run(["git", *args], cwd=repo, capture_output=True, check=False)
|
||||||
|
if result.returncode != 0:
|
||||||
|
fail(result.stderr.decode(errors="replace").strip() or f"git {args[0]} failed in {repo}")
|
||||||
|
return result.stdout.decode(errors="replace")
|
||||||
|
|
||||||
|
|
||||||
|
def changed(root: Path, base: str) -> list[str]:
|
||||||
|
# Trip-wire: with rename detection a moved file lists only its new path.
|
||||||
|
listed = git(root, "diff", "--no-renames", "-z", "--name-only", base, "--")
|
||||||
|
listed += git(root, "ls-files", "-z", "--others", "--exclude-standard")
|
||||||
|
return sorted({p for p in listed.split("\0") if p})
|
||||||
|
|
||||||
|
|
||||||
|
def copy(root: Path, paths: list[str], tree: Path) -> int:
|
||||||
|
copied = 0
|
||||||
|
for name in paths:
|
||||||
|
source = root / name
|
||||||
|
# Trip-wire: a symlink's target may lie outside the repository; even a copied link lets the scan read it.
|
||||||
|
if source.is_symlink() or not source.is_file():
|
||||||
|
continue
|
||||||
|
target = tree / name
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(source, target)
|
||||||
|
copied += 1
|
||||||
|
return copied
|
||||||
|
|
||||||
|
|
||||||
|
def located(name: str, tree: Path) -> str:
|
||||||
|
path = Path(name)
|
||||||
|
if not path.is_absolute():
|
||||||
|
path = tree / path if (tree / path).exists() else tree.parent / path
|
||||||
|
try:
|
||||||
|
return path.resolve().relative_to(tree.resolve()).as_posix()
|
||||||
|
except ValueError:
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def fingerprint(tree: Path, path: str, line: int | None) -> str | None:
|
||||||
|
try:
|
||||||
|
rows = (tree / path).read_text(encoding="utf-8", errors="replace").splitlines()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
if line is None or not 1 <= line <= len(rows):
|
||||||
|
return None
|
||||||
|
return hashlib.sha1(rows[line - 1].strip().encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def watch():
|
||||||
|
if not WATCH.is_file():
|
||||||
|
fail(f"{WATCH} is missing; its council paths hold the accepted findings")
|
||||||
|
spec = importlib.util.spec_from_file_location("kvb_security_watch", WATCH)
|
||||||
|
loaded = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(loaded)
|
||||||
|
return loaded
|
||||||
|
|
||||||
|
|
||||||
|
def accepted(listings: tuple[Path, ...]) -> set[tuple[str, str, str]]:
|
||||||
|
entries = set()
|
||||||
|
for listing in listings:
|
||||||
|
if not listing.is_file():
|
||||||
|
continue
|
||||||
|
for number, line in enumerate(listing.read_text(encoding="utf-8").splitlines(), 1):
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
row = json.loads(line)
|
||||||
|
entries.add((row["rule"], row["path"], row["sha1"]))
|
||||||
|
except (ValueError, KeyError, TypeError):
|
||||||
|
fail(f"{listing}:{number}: want a JSON row with rule, path and sha1")
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def accept(argv: list[str]) -> int:
|
||||||
|
parser = argparse.ArgumentParser(prog="security.py accept", description="Record a reviewed false positive.")
|
||||||
|
for flag in ("--repo", "--rule", "--path", "--sha1", "--reason"):
|
||||||
|
parser.add_argument(flag, required=True)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
if not SHA1.fullmatch(args.sha1):
|
||||||
|
parser.error("--sha1 must be 40 lowercase hex digits")
|
||||||
|
if not args.reason.strip() or "\n" in args.reason:
|
||||||
|
parser.error("--reason must be one non-empty line")
|
||||||
|
root = git(Path(args.repo), "rev-parse", "--show-toplevel").strip()
|
||||||
|
key = f"{args.rule}\t{args.path}\t{args.sha1}"
|
||||||
|
row = {
|
||||||
|
"id": hashlib.sha1(key.encode()).hexdigest(), "rule": args.rule, "path": args.path, "sha1": args.sha1,
|
||||||
|
"reason": args.reason, "repo": root, "date": datetime.datetime.now(datetime.UTC).isoformat(timespec="seconds"),
|
||||||
|
}
|
||||||
|
target = watch().ACCEPTED
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with target.open("a", encoding="utf-8") as listing:
|
||||||
|
fcntl.flock(listing, fcntl.LOCK_EX)
|
||||||
|
listing.write(json.dumps(row) + "\n")
|
||||||
|
print(f"accepted {args.rule} {args.path} ({args.sha1[:12]}) in {target}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def findings(reports: Path, tree: Path) -> list[dict]:
|
||||||
|
files = sorted(reports.glob("medusa-scan-*.json")) if reports.is_dir() else []
|
||||||
|
if not files:
|
||||||
|
raise Unreadable(f"no medusa-scan-*.json in {reports}")
|
||||||
|
try:
|
||||||
|
report = json.loads(files[-1].read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError) as error:
|
||||||
|
raise Unreadable(f"{files[-1].name}: {error}") from None
|
||||||
|
entries = report.get("findings") if isinstance(report, dict) else None
|
||||||
|
if not isinstance(entries, list):
|
||||||
|
raise Unreadable(f"{files[-1].name} holds no findings list")
|
||||||
|
found = []
|
||||||
|
for entry in entries:
|
||||||
|
severity = str(entry.get("severity", "")).lower() if isinstance(entry, dict) else ""
|
||||||
|
if severity not in WEIGHTS:
|
||||||
|
raise Unreadable(f"{files[-1].name}: a finding without a known severity")
|
||||||
|
line = entry.get("line") if isinstance(entry.get("line"), int) else None
|
||||||
|
path = located(str(entry.get("file") or "?"), tree)
|
||||||
|
found.append({
|
||||||
|
"severity": severity,
|
||||||
|
"rule": entry.get("rule_id") if isinstance(entry.get("rule_id"), str) else None,
|
||||||
|
"path": path,
|
||||||
|
"line": line,
|
||||||
|
"sha1": fingerprint(tree, path, line),
|
||||||
|
})
|
||||||
|
rank = list(WEIGHTS)
|
||||||
|
return sorted(found, key=lambda f: (rank.index(f["severity"]), f["path"], f["line"] or 0))
|
||||||
|
|
||||||
|
|
||||||
|
def scan(medusa: str, root: Path, base: str) -> tuple[int, list[dict]]:
|
||||||
|
with tempfile.TemporaryDirectory(prefix="kvb-security-") as scratch:
|
||||||
|
work = Path(scratch)
|
||||||
|
tree, reports, home = work / "tree", work / "reports", work / "home"
|
||||||
|
tree.mkdir()
|
||||||
|
home.mkdir()
|
||||||
|
scanned = copy(root, changed(root, base), tree)
|
||||||
|
if not scanned:
|
||||||
|
return 0, []
|
||||||
|
result = subprocess.run(
|
||||||
|
[medusa, "scan", str(tree), "--format", "json", "-o", str(reports), "--no-cache"],
|
||||||
|
cwd=work, stdin=subprocess.DEVNULL, capture_output=True, check=False,
|
||||||
|
env={**os.environ, "HOME": str(home)},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return scanned, findings(reports, tree)
|
||||||
|
except Unreadable as error:
|
||||||
|
raise Unreadable(f"{error} (medusa exit {result.returncode})") from None
|
||||||
|
|
||||||
|
|
||||||
|
def plural(n: int, noun: str) -> str:
|
||||||
|
return f"{n} {noun}{'' if n == 1 else 's'}"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
|
parser.add_argument("--repo", required=True)
|
||||||
|
parser.add_argument("--base", required=True)
|
||||||
|
parser.add_argument("--json", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
repo = Path(args.repo)
|
||||||
|
if not repo.is_dir():
|
||||||
|
fail(f"{repo} is not a directory")
|
||||||
|
root = Path(git(repo, "rev-parse", "--show-toplevel").strip())
|
||||||
|
if subprocess.run(
|
||||||
|
["git", "rev-parse", "--verify", "--quiet", f"{args.base}^{{commit}}"], cwd=root, capture_output=True, check=False
|
||||||
|
).returncode != 0:
|
||||||
|
fail(f"unknown ref {args.base!r}")
|
||||||
|
|
||||||
|
council = watch()
|
||||||
|
reviewed = accepted((council.ACCEPTED, council.KEPT_ACCEPTED))
|
||||||
|
medusa = shutil.which("medusa")
|
||||||
|
problem = None if medusa else "medusa missing"
|
||||||
|
if medusa:
|
||||||
|
try:
|
||||||
|
scanned, every = scan(medusa, root, args.base)
|
||||||
|
except Unreadable as error:
|
||||||
|
problem = f"medusa report unreadable: {error}"
|
||||||
|
if problem:
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps({"security": None, "problem": problem}))
|
||||||
|
else:
|
||||||
|
print(f"🛡️ security – ({problem}: {HAND})")
|
||||||
|
return 4
|
||||||
|
|
||||||
|
passed = [f for f in every if (f["rule"], f["path"], f["sha1"]) in reviewed]
|
||||||
|
found = [f for f in every if f not in passed]
|
||||||
|
counts = {severity: sum(f["severity"] == severity for f in found) for severity in WEIGHTS}
|
||||||
|
value = max(0, SAFE - sum(WEIGHTS[s] * n for s, n in counts.items()))
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps({"security": value, "files": scanned, **counts, "findings": found, "accepted": passed}))
|
||||||
|
else:
|
||||||
|
places = [f"{f['severity']} {f['path']}:{f['line'] if f['line'] is not None else '?'}" for f in found]
|
||||||
|
listed = ", ".join(places[:SHOWN]) + (f", +{len(places) - SHOWN} more" if len(places) > SHOWN else "")
|
||||||
|
detail = f": {listed}" if places else ""
|
||||||
|
waived = f", {len(passed)} accepted" if passed else ""
|
||||||
|
print(
|
||||||
|
f"🛡️ security {value} % (medusa: {plural(len(found), 'finding')}{waived} "
|
||||||
|
f"in {plural(scanned, 'file')}{detail})"
|
||||||
|
)
|
||||||
|
if counts["critical"]:
|
||||||
|
return 1
|
||||||
|
return 3 if value < SAFE else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(accept(sys.argv[2:]) if sys.argv[1:2] == ["accept"] else main())
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
name: kvb-check-speed
|
||||||
|
description: >
|
||||||
|
Wall-clock speed of changed code versus its base commit.
|
||||||
|
---
|
||||||
|
|
||||||
|
Its ⏱️ time is the share of its base's wall-clock time a change needs:
|
||||||
|
under 100 % it is faster, over 100 % slower. It has no script of its own: one run
|
||||||
|
of the `kvb-check-efficiency` measurement yields both values.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 ${CLAUDE_SKILL_DIR}/../kvb-check-efficiency/scripts/measure.py --base <ref> -- <command...>
|
||||||
|
```
|
||||||
|
|
||||||
|
Its `⏱️ time <p> % (wall <b>→<a> s)` compares wall seconds at `<ref>` and
|
||||||
|
in the working tree: one run each, or the mean of 5 each when a value
|
||||||
|
exceeds 100. The command, the runs, the `queue.py hold` wrapper for heavy
|
||||||
|
commands and the exit codes are those of the `kvb-check-efficiency` skill;
|
||||||
|
pasting its whole line reports both values at once.
|
||||||
|
|
||||||
|
- **Skills have no speed**: only their tokens count. Pass `--speed -` to
|
||||||
|
`kvb-check-score`, which shows `–`.
|
||||||
|
- **Code without a runnable path** (configuration, templates no command
|
||||||
|
renders) has no speed either: `--speed -`, and say why.
|
||||||
|
- **Other load**: wall time follows host load. Above 30 % other load the
|
||||||
|
script falls back to the CPU-time ratio and marks the estimate:
|
||||||
|
`⏱️ time ~<p> % (cpu <b>→<a> s)`, ending in
|
||||||
|
`(estimated: other load <x> %)`. Report the run you
|
||||||
|
made; never rerun to pick the best result.
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
---
|
||||||
|
name: kvb-ci-artifacts
|
||||||
|
description: >
|
||||||
|
Pull a CI run's failed job logs (also mid-run), clustered by error line,
|
||||||
|
and cause-holding artifacts. Trigger when a CI failure needs explaining,
|
||||||
|
and from triage.
|
||||||
|
---
|
||||||
|
|
||||||
|
A job log tells you what broke. An artifact tells you why. Investigating a CI
|
||||||
|
failure without downloading its artifacts is guessing with extra steps.
|
||||||
|
|
||||||
|
## Getting the log
|
||||||
|
|
||||||
|
For a GitHub Actions run, one call does it:
|
||||||
|
|
||||||
|
${CLAUDE_SKILL_DIR}/scripts/ci_failures.py <RUN_ID> [--repo <owner>/<repo>] [--artifacts]
|
||||||
|
|
||||||
|
It prints how far HEAD moved since the run's commit, saves every finished
|
||||||
|
failed job's log to `/tmp/ci-logs-<run>/` (colour stripped, even while the run
|
||||||
|
is still in progress), and clusters the jobs by the normalised error line that
|
||||||
|
ended them. Jobs still running are listed, not fetched: their API log can stop hours
|
||||||
|
early. The clustering is a heuristic; check each boundary against the logs.
|
||||||
|
|
||||||
|
Grep the saved files repeatedly. Re-fetching a log to find the line you just
|
||||||
|
lost costs minutes; grepping the saved copy costs milliseconds.
|
||||||
|
|
||||||
|
## Getting the artifacts
|
||||||
|
|
||||||
|
gh api /repos/<owner>/<repo>/actions/runs/<RUN_ID>/artifacts --paginate \
|
||||||
|
--jq '.artifacts[] | "\(.name)\t\(.size_in_bytes)"'
|
||||||
|
gh run download <RUN_ID> --repo <owner>/<repo> -n <NAME> -D <DIR>
|
||||||
|
|
||||||
|
Choose by content, not by name length:
|
||||||
|
|
||||||
|
- **Rescue / diagnostics bundles** carry the reverse-proxy access and error log,
|
||||||
|
the per-service and per-container logs, the journal, and the event stream.
|
||||||
|
This is where causes live. Prefer them.
|
||||||
|
- **Test-report bundles** (Playwright and friends) are mostly video and traces
|
||||||
|
and can run to hundreds of megabytes for one assertion string. Pull one only
|
||||||
|
when you need the assertion text or the error-context file.
|
||||||
|
- **Inventory / runtime dumps** settle "what was actually deployed" questions.
|
||||||
|
|
||||||
|
Inside a rescue bundle, read in this order: the reverse-proxy log (it shows
|
||||||
|
status code, upstream, and time-to-response for the exact failing request), the
|
||||||
|
application container log, then the journal and event stream for restarts and
|
||||||
|
OOM kills.
|
||||||
|
|
||||||
|
An access-log line is a timing witness. A `499` means the *client* gave up; the
|
||||||
|
server was still working. A `504` or an `upstream timed out ... while reading
|
||||||
|
upstream` means your backend never answered. `ReqTime` versus `UpstreamTime`
|
||||||
|
tells you whether the proxy or the upstream burned the seconds.
|
||||||
|
|
||||||
|
## Grep hygiene
|
||||||
|
|
||||||
|
Two failure modes turn a search into a false conclusion:
|
||||||
|
|
||||||
|
- **Clustered flags silently change the pattern.** Write them separately:
|
||||||
|
`grep -r -a -i -l -F "needle" dir/`, not `-ariel`. A cluster where one letter
|
||||||
|
takes an argument consumes your pattern and searches for something else, and
|
||||||
|
the run still exits successfully.
|
||||||
|
- **Substring matches lie.** A protocol token like `h2c` matches inside
|
||||||
|
`veth2cb3f8c`; a short id matches inside a hash. Before concluding anything
|
||||||
|
from a hit, print its context (`grep -o ".\{30\}needle.\{30\}"`) and look at
|
||||||
|
it. Before concluding anything from a *miss*, prove the command was
|
||||||
|
well-formed by searching for a string you know is present.
|
||||||
|
|
||||||
|
Binary-ish logs need `-a`, or grep reports "binary file matches" and you see
|
||||||
|
nothing.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Download before theorising. Do it even when the job log looks conclusive: a
|
||||||
|
log that explains the symptom rarely explains the cause.
|
||||||
|
- Quote literally, with the file path and line number. Never paraphrase a log
|
||||||
|
line you did not read, and never reconstruct one from memory.
|
||||||
|
- A negative result is a finding only when the search was verified sound.
|
||||||
|
- "The artifact does not settle this" is a legitimate answer. Say it instead of
|
||||||
|
filling the gap with a plausible mechanism.
|
||||||
+170
@@ -0,0 +1,170 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Fetch the failed jobs of a GitHub Actions run and cluster them by error line.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
ci_failures.py RUN_ID [--repo OWNER/REPO] [--out DIR] [--artifacts]
|
||||||
|
|
||||||
|
Prints how far HEAD moved since the run's commit, saves each finished failed
|
||||||
|
job's log (colour stripped) under DIR (default /tmp/ci-logs-RUN_ID), and
|
||||||
|
groups the jobs by the normalised error line that ended them (the last one). Jobs still running are
|
||||||
|
listed but not fetched: their API log can stop hours early. The clustering is
|
||||||
|
a heuristic; confirm a cluster's boundary against the logs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
FAILED = {"failure", "cancelled", "timed_out"}
|
||||||
|
ANSI = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
|
||||||
|
STAMP = re.compile(r"^\d{4}-\d\d-\d\dT[\d:.]+Z\s?")
|
||||||
|
GENERIC = re.compile(r"failed=0\b|FAILED - RETRYING|^make(\[\d+\])?: \*\*\* \[.*\] Error \d+")
|
||||||
|
PROCESS_END = re.compile(r"##\[error\]Process completed with exit code")
|
||||||
|
ANNOTATION = re.compile(r"##\[error\]")
|
||||||
|
FATAL = re.compile(r"fatal: \[|FAILED!|Traceback|AssertionError|panic:")
|
||||||
|
LINT_AT = re.compile(r"\S+:\d+:\d+:? ((error|warning) )?[A-Z]{1,4}\d{3,4}\b")
|
||||||
|
LINT_CODE = re.compile(r"^[A-Z]{1,4}\d{3,4}\b")
|
||||||
|
LINT_ARROW = re.compile(r"^-->\s+\S+:\d+:\d+")
|
||||||
|
SIGNAL = re.compile(r"\b(ERROR|Error|FAILED|FAIL|fatal|Exception|failed)\b")
|
||||||
|
TASK = re.compile(r"^TASK \[")
|
||||||
|
REFUSAL = "terminal escape sequences"
|
||||||
|
|
||||||
|
|
||||||
|
def run(*cmd: str) -> subprocess.CompletedProcess:
|
||||||
|
return subprocess.run(list(cmd), capture_output=True, text=True, check=False)
|
||||||
|
|
||||||
|
|
||||||
|
def clean(line: str) -> str:
|
||||||
|
return STAMP.sub("", ANSI.sub("", line)).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def normalise(line: str) -> str:
|
||||||
|
line = re.sub(r"\b[0-9a-f]{7,}\b", "<hex>", line)
|
||||||
|
line = re.sub(r"\d+", "<n>", line)
|
||||||
|
return line[:200]
|
||||||
|
|
||||||
|
|
||||||
|
def output_lines(text: str) -> list[tuple[str, str]]:
|
||||||
|
kept, echo, task = [], False, ""
|
||||||
|
for raw in text.splitlines():
|
||||||
|
line = clean(raw)
|
||||||
|
if PROCESS_END.search(line):
|
||||||
|
break
|
||||||
|
if line.startswith("##[group]Run "):
|
||||||
|
echo = True
|
||||||
|
elif line.startswith("##[endgroup]"):
|
||||||
|
echo = False
|
||||||
|
elif TASK.match(line):
|
||||||
|
task = line
|
||||||
|
elif line and not echo and not GENERIC.search(line):
|
||||||
|
kept.append((task, line))
|
||||||
|
return kept
|
||||||
|
|
||||||
|
|
||||||
|
def is_lint(lines: list[tuple[str, str]], index: int) -> bool:
|
||||||
|
line = lines[index][1]
|
||||||
|
if LINT_AT.search(line):
|
||||||
|
return True
|
||||||
|
following = lines[index + 1][1] if index + 1 < len(lines) else ""
|
||||||
|
return bool(LINT_CODE.match(line) and LINT_ARROW.match(following))
|
||||||
|
|
||||||
|
|
||||||
|
def signature(text: str) -> str:
|
||||||
|
lines = output_lines(text)
|
||||||
|
tiers = (
|
||||||
|
(lambda i: ANNOTATION.search(lines[i][1]), False),
|
||||||
|
(lambda i: FATAL.search(lines[i][1]), True),
|
||||||
|
(lambda i: is_lint(lines, i), False),
|
||||||
|
(lambda i: SIGNAL.search(lines[i][1]), False),
|
||||||
|
)
|
||||||
|
for matches, with_task in tiers:
|
||||||
|
for index in reversed(range(len(lines))):
|
||||||
|
if matches(index):
|
||||||
|
task, line = lines[index]
|
||||||
|
found = ANNOTATION.sub("", line).strip()
|
||||||
|
return normalise(f"{task} {found}" if with_task and task else found)
|
||||||
|
return normalise(lines[-1][1]) if lines else "<empty log>"
|
||||||
|
|
||||||
|
|
||||||
|
def cluster(jobs: list[tuple[str, str]]) -> dict[str, list[str]]:
|
||||||
|
groups: dict[str, list[str]] = {}
|
||||||
|
for job, text in jobs:
|
||||||
|
groups.setdefault(signature(text), []).append(job)
|
||||||
|
return dict(sorted(groups.items(), key=lambda item: -len(item[1])))
|
||||||
|
|
||||||
|
|
||||||
|
def behind(sha: str) -> str:
|
||||||
|
if run("git", "cat-file", "-e", f"{sha}^{{commit}}").returncode:
|
||||||
|
return "behind=unknown (run commit not in this clone)"
|
||||||
|
count = run("git", "rev-list", "--count", f"{sha}..HEAD").stdout.strip()
|
||||||
|
moved = {p.split("/")[0] for p in run("git", "diff", "--name-only", f"{sha}..HEAD").stdout.split()}
|
||||||
|
return f"behind={count} moved={','.join(sorted(moved)) or '-'}"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(prog="ci_failures")
|
||||||
|
parser.add_argument("run_id")
|
||||||
|
parser.add_argument("--repo")
|
||||||
|
parser.add_argument("--out")
|
||||||
|
parser.add_argument("--artifacts", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
repo = args.repo or run("gh", "repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner").stdout.strip()
|
||||||
|
if not repo:
|
||||||
|
parser.error("no --repo and gh cannot resolve the current repository")
|
||||||
|
meta = run("gh", "api", f"/repos/{repo}/actions/runs/{args.run_id}")
|
||||||
|
# per_page=100 on a large matrix answers HTTP 502; 30 per page goes through.
|
||||||
|
listing = run("gh", "api", "--paginate", f"/repos/{repo}/actions/runs/{args.run_id}/jobs?per_page=30",
|
||||||
|
"--jq", ".jobs[] | {id, name, status, conclusion}")
|
||||||
|
if meta.returncode or listing.returncode:
|
||||||
|
print((meta.stderr or listing.stderr).strip(), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
data = json.loads(meta.stdout)
|
||||||
|
jobs = [json.loads(line) for line in listing.stdout.splitlines() if line]
|
||||||
|
out = Path(args.out or f"/tmp/ci-logs-{args.run_id}")
|
||||||
|
out.mkdir(parents=True, exist_ok=True)
|
||||||
|
print(f"run={args.run_id} repo={repo} head={data['head_sha'][:10]} status={data['status']} "
|
||||||
|
f"jobs={len(jobs)} {behind(data['head_sha'])}")
|
||||||
|
running = [j["name"] for j in jobs if j["status"] != "completed"]
|
||||||
|
if running:
|
||||||
|
print(f"running ({len(running)}, not fetched): {', '.join(running)}")
|
||||||
|
fetched, missing = [], []
|
||||||
|
for job in jobs:
|
||||||
|
if job["status"] != "completed" or job["conclusion"] not in FAILED:
|
||||||
|
continue
|
||||||
|
log = run("gh", "api", "--allow-escape-sequences", f"/repos/{repo}/actions/jobs/{job['id']}/logs")
|
||||||
|
if log.returncode or REFUSAL in log.stdout[:200]:
|
||||||
|
missing.append(f"{job['conclusion']}\t{job['name']}\t{log.stderr.strip()[:80]}")
|
||||||
|
continue
|
||||||
|
slug = re.sub(r"[^A-Za-z0-9._-]+", "_", job["name"])[:80]
|
||||||
|
path = out / f"{job['id']}-{slug}.log"
|
||||||
|
path.write_text("\n".join(ANSI.sub("", line) for line in log.stdout.splitlines()), encoding="utf-8")
|
||||||
|
text = "##[error]cancelled" if job["conclusion"] == "cancelled" else log.stdout
|
||||||
|
fetched.append((f"{job['conclusion']}\t{job['name']}\t{path}", text))
|
||||||
|
if missing:
|
||||||
|
(out / "no-log.txt").write_text("\n".join(missing) + "\n", encoding="utf-8")
|
||||||
|
counts = {c: sum(m.startswith(c) for m in missing) for c in FAILED}
|
||||||
|
summary = ", ".join(f"{n} {c}" for c, n in sorted(counts.items()) if n)
|
||||||
|
print(f"no log for {len(missing)} jobs ({summary}; never started or expired): {out / 'no-log.txt'}")
|
||||||
|
for number, (sig, jobs) in enumerate(cluster(fetched).items(), 1):
|
||||||
|
print(f"cluster {number} ({len(jobs)} jobs): {sig}")
|
||||||
|
for job in jobs[:3]:
|
||||||
|
print(f" {job}")
|
||||||
|
if len(jobs) > 3:
|
||||||
|
members = out / f"cluster-{number}.txt"
|
||||||
|
members.write_text("\n".join(jobs) + "\n", encoding="utf-8")
|
||||||
|
print(f" ... +{len(jobs) - 3} more: {members}")
|
||||||
|
if args.artifacts:
|
||||||
|
listing = run("gh", "api", f"/repos/{repo}/actions/runs/{args.run_id}/artifacts", "--paginate",
|
||||||
|
"--jq", '.artifacts[] | "\\(.name)\\t\\(.size_in_bytes)"')
|
||||||
|
print("artifacts:")
|
||||||
|
print(listing.stdout.rstrip() or " none")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
---
|
||||||
|
name: kvb-ci-observe
|
||||||
|
description: >
|
||||||
|
Watch a CI run, pipeline, build or log on a cadence and triage new failures.
|
||||||
|
Trigger on a request to keep monitoring and fix regressions.
|
||||||
|
---
|
||||||
|
|
||||||
|
Keep a target under watch and act only when it degrades: the loop watches,
|
||||||
|
triage responds. Never re-investigate a failure already resolved this
|
||||||
|
session, and never poll work the harness will notify you about.
|
||||||
|
|
||||||
|
## Before you start: fix the target
|
||||||
|
|
||||||
|
The operator names one concrete target: a CI run id or url, a pipeline, a
|
||||||
|
branch's checks, a build, or a log file. Restate it in one line and record
|
||||||
|
the current failure set (known or already fixed this session) as the
|
||||||
|
baseline. Every observation is diffed against it; a stale baseline
|
||||||
|
re-triages solved failures.
|
||||||
|
|
||||||
|
## The loop
|
||||||
|
|
||||||
|
Drive it with `/loop` in dynamic mode (no fixed interval unless the operator
|
||||||
|
gave one): after each observation, schedule the next wake-up and pass this
|
||||||
|
skill's prompt back unchanged.
|
||||||
|
|
||||||
|
1. **Poll the target**: its job list, check conclusions or log tail; for a
|
||||||
|
GitHub Actions run, the `kvb-ci-artifacts` skill's `ci_failures.py <run>`.
|
||||||
|
Enumerate failures as the `kvb-ci-triage` skill's step 1 does: failure,
|
||||||
|
cancelled, timed-out; a downstream aggregate that fails only because an
|
||||||
|
upstream did is not its own failure.
|
||||||
|
2. **Diff against the baseline.** A failure is new only if it is neither in
|
||||||
|
the baseline nor triaged this session. Nothing new: log one short line
|
||||||
|
("still green" / "N known failures, no change") and reschedule.
|
||||||
|
3. **Triage each new failure** with the `kvb-ci-triage` skill on the new set
|
||||||
|
only; it applies `kvb-think-dialectic` per failing job to a ~99% root
|
||||||
|
cause and a real fix. Never mask (no retry-until-pass, disabled check or
|
||||||
|
soft skip). A new failure and its verdict go to peers by radio, never with
|
||||||
|
a wake: `${CLAUDE_SKILL_DIR}/../kvb-org-radio/scripts/radio.py post
|
||||||
|
"<target>: <job> <failing line> -> <verdict>"`. Fold each newly fixed
|
||||||
|
failure into the baseline.
|
||||||
|
4. **Reschedule** by what you wait on: an active run that changes every few
|
||||||
|
minutes gets a check matched to its cadence, a quiet or long-running
|
||||||
|
target a long fallback (1200s+). Stop once the target reaches a terminal
|
||||||
|
state (run completed, pipeline finished) AND every failure it produced
|
||||||
|
has a verified fix.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- The loop is a fallback timer, not a busy-wait: for tracked work the
|
||||||
|
harness reports (a background task, a spawned agent), wait for that
|
||||||
|
notification; reserve the timed wake-up for state the harness cannot see
|
||||||
|
(a remote CI run, a deploy queue).
|
||||||
|
- Executed evidence beats reasoning: read the failing log line and the
|
||||||
|
artifact, not the job title.
|
||||||
|
- One baseline, updated in place: dedup every observation against all
|
||||||
|
failures seen so far, or a flaky-then-fixed failure reappears every cycle
|
||||||
|
and the watch never converges.
|
||||||
|
- Report only on change: a new failure and its triage verdict, or a terminal
|
||||||
|
"target is green, watch closed". Silence between changes is correct.
|
||||||
|
- Never invent results for a run still in progress; report what the poll
|
||||||
|
returned.
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
---
|
||||||
|
name: kvb-ci-triage
|
||||||
|
description: >
|
||||||
|
Triage a failing CI run to verified root causes and real fixes, one
|
||||||
|
cluster per worktree. Trigger when a build, test or deploy pipeline has
|
||||||
|
failed jobs.
|
||||||
|
---
|
||||||
|
|
||||||
|
Drive a red pipeline green by root cause. Every failing job is its own
|
||||||
|
investigation; no fix before its root cause is proven. For GitHub Actions,
|
||||||
|
the `kvb-ci-artifacts` skill's `ci_failures.py <run>` does the mechanics of
|
||||||
|
steps 0 to 2; the judgement stays yours.
|
||||||
|
|
||||||
|
## Procedure
|
||||||
|
|
||||||
|
0. **Run against HEAD, first**: `git log --oneline <run-sha>..HEAD | wc -l`.
|
||||||
|
Zero: the run speaks for the current tree. Otherwise read `stale-run.md`
|
||||||
|
next to this file before anything else.
|
||||||
|
1. **Enumerate** every job that failed, was cancelled or timed out. An
|
||||||
|
aggregate job failing only because an upstream one did is noted, not a
|
||||||
|
root cause.
|
||||||
|
2. **Cluster** by the actual failing line, not the job name; messages that
|
||||||
|
differ beyond interpolated identifiers (host, app, port, path) are
|
||||||
|
separate clusters.
|
||||||
|
3. **Coordinate** each cluster via the `kvb-org-organize` Peer protocol,
|
||||||
|
lineage included: `${CLAUDE_SKILL_DIR}/../kvb-git-hotfix/scripts/hotfix.py
|
||||||
|
match --line '<line>'` first; claim it; read `council.py list`, the
|
||||||
|
register, the radio and `find_fix.py`; ask every live peer, one batched
|
||||||
|
`radio.py send direct --wake` per peer for all clusters nothing there
|
||||||
|
answers. Investigate
|
||||||
|
while waiting; take their fix or keep yours. Register (`kvb-org-rasci`):
|
||||||
|
objective the root cause fixed, one key result per failing job passing.
|
||||||
|
4. **Artifacts** per cluster (the `kvb-ci-artifacts` skill: in-progress
|
||||||
|
logs, which bundle, grep hygiene): every artifact of one representative
|
||||||
|
job (reports, rescue diagnostics, inventories, container logs); a second
|
||||||
|
member's only when the first leaves part of the cluster unexplained. Pull
|
||||||
|
them even when the log looks conclusive: it only names the artifact; the
|
||||||
|
assertion, server-side exception and state dumps are inside.
|
||||||
|
5. **One funnel per cluster** via the `kvb-org-split` skill: one agent per
|
||||||
|
claimed cluster, its own worktree and branch from the exact current HEAD,
|
||||||
|
that cluster its only concern, worked as `funnel.md` next to this file
|
||||||
|
says (dialectic, shared vs hidden causes, root fix); read it before
|
||||||
|
working one yourself. Spawn only as many as the stakes justify; each
|
||||||
|
brief names that file, the failing line, artifact paths, files and the
|
||||||
|
claim to prove or refute, a time cap for the interim report and an answer
|
||||||
|
cap (verdict table, at most 15 lines). Parallel only when the fixes touch
|
||||||
|
different files and the machine has room.
|
||||||
|
6. **Timeouts**: only for a cluster whose jobs died on their time budget, or
|
||||||
|
on operator request, hand the furthest-running job log to `kvb-code-tune`;
|
||||||
|
its candidates get funnels of their own and never delay the fix.
|
||||||
|
7. **Follow** a run still in progress and triage each newly failed job; done
|
||||||
|
when the run finished and every failure has a verified fix.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Explore code through the codebase-memory-mcp graph first (`search_graph`,
|
||||||
|
`trace_path`, `get_architecture`, `detect_changes`; the
|
||||||
|
`kvb-agent-optimize` skill sets it up), grep and read after.
|
||||||
|
- Only the job log, the artifact, the code at the run's commit and command
|
||||||
|
output you just ran count as evidence, quoted literally; a summary, your
|
||||||
|
earlier message, an old cluster table or a subagent's report proves
|
||||||
|
nothing. After a context compaction, rebuild every attribution from the job
|
||||||
|
logs before acting on it.
|
||||||
|
- Classify each failure: real defect (fix), flake or infra (prove it
|
||||||
|
transient first), or fixed by a later commit (compare `<run-sha>..HEAD`,
|
||||||
|
never the run's commit alone; name the fixing commit).
|
||||||
|
- Per job verdict: root cause, the proving artifact path and line, the fix,
|
||||||
|
the sidetree branch, the funnel verdict (merged or proposal). A verdict
|
||||||
|
citing only the job log has not finished step 4.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Working one cluster in its sidetree
|
||||||
|
|
||||||
|
Run inside the cluster's own worktree, so no cluster's edits land under
|
||||||
|
another cluster's work or under a running gate or deploy. The `kvb-ci-triage`
|
||||||
|
skill's Rules apply here too.
|
||||||
|
|
||||||
|
1. **Dialectic**: invoke the `kvb-think-dialectic` skill for the cluster:
|
||||||
|
form a thesis about the root cause from evidence (the job log, its
|
||||||
|
artifacts, the code at the run's commit, git history), attack it with
|
||||||
|
independent skeptics, and iterate to a ~99% thesis.
|
||||||
|
2. **Shared vs hidden causes**: a cluster is a hypothesis, not a proof; if
|
||||||
|
the dialectic shows it splitting into two causes, split it, and the new
|
||||||
|
cluster gets a funnel of its own. When a job hides a second failure behind
|
||||||
|
the first, keep going until the job is actually green, not just past the
|
||||||
|
first error.
|
||||||
|
3. **Fix at the root**: apply the real fix for each proven root cause in the
|
||||||
|
sidetree, with a test and a negative control as the `kvb-org-split` skill
|
||||||
|
demands. Light runs (no Docker, about a minute: targeted pytest or
|
||||||
|
unittest, ruff, single lint files) self-admit with
|
||||||
|
`${CLAUDE_SKILL_DIR}/../kvb-org-organize/scripts/watch.py admit --kind
|
||||||
|
test-nostack`; `make test`, gates, deploys and anything with Docker run in
|
||||||
|
the background through
|
||||||
|
`${CLAUDE_SKILL_DIR}/../kvb-org-queue/scripts/queue.py hold --kind <kind>
|
||||||
|
--task <register task> -- <command>`, which waits for the rule-based
|
||||||
|
grant and releases on exit. The fix enters the main line only
|
||||||
|
at the funnel's bar (the `kvb-org-split` skill's: fix confidence ≥ 90 %,
|
||||||
|
break risk ≤ 5 %, cost and time ≤ 100 % in the `kvb-check-score`
|
||||||
|
block; over 100 %, ask the operator before the commit, naming the cause)
|
||||||
|
after its independent review; below the bar the cluster ends as a
|
||||||
|
proposal for the operator, never as a merge. Never mask a failure: no
|
||||||
|
retry-until-pass, no disabling the check, no soft-skip. If a failure is
|
||||||
|
genuinely external (upstream outage, flaky infra), confirm that with
|
||||||
|
evidence and surface it honestly instead of fixing around it. Report every
|
||||||
|
fix, proposal and abandoned attempt to the peers asked in step 3 of the
|
||||||
|
skill with `${CLAUDE_SKILL_DIR}/../kvb-org-radio/scripts/radio.py post
|
||||||
|
"<text>"` (`--to <name>` per peer), then remove the sidetree.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Run behind HEAD
|
||||||
|
|
||||||
|
A CI run is a snapshot of one commit, and the branch has moved since. Scope
|
||||||
|
the job set down **before** enumerating, clustering or spawning anything:
|
||||||
|
every job investigated against stale code is work thrown away, and a fix
|
||||||
|
proposed there risks reverting someone else's.
|
||||||
|
|
||||||
|
Two cheap discriminators, without opening an artifact:
|
||||||
|
|
||||||
|
- `git diff --name-only <run-sha>..HEAD -- <source dir> | cut -d/ -f2 | sort -u`
|
||||||
|
shows which components already moved.
|
||||||
|
- A grep of the saved job logs for the literal error signature of each cause
|
||||||
|
already fixed in that range.
|
||||||
|
|
||||||
|
What survives both is the job set the procedure applies to; say how many jobs
|
||||||
|
you dropped and why. The run cannot contain a fix that landed after it, so
|
||||||
|
looking only at its commit can only ever confirm the failure.
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
---
|
||||||
|
name: kvb-cleanup
|
||||||
|
description: >
|
||||||
|
Free disk space agents filled in /tmp, Docker and agent dirs; delete only
|
||||||
|
operator picks. Trigger on "aufräumen", "bereinigen",
|
||||||
|
"platte voll", "no space left on device".
|
||||||
|
---
|
||||||
|
|
||||||
|
Delete only what the operator picked in this run; questions, inventory and
|
||||||
|
proposal change nothing. Never invent a size or a peer's answer: unmeasured
|
||||||
|
is `unknown`.
|
||||||
|
|
||||||
|
## Step 0: the chair
|
||||||
|
|
||||||
|
Shared resources belong to the `kvb-org-organize` chair; its resource watch
|
||||||
|
runs the configured low-risk cleanup. Another session chairs: send it the
|
||||||
|
request and stop, or, when the operator ordered the cleanup here, first
|
||||||
|
`${CLAUDE_SKILL_DIR}/../kvb-org-radio/scripts/radio.py post --to <chair>
|
||||||
|
"<plan>"`.
|
||||||
|
|
||||||
|
## Step 1: inventory
|
||||||
|
|
||||||
|
Read-only, in parallel, with sizes, only in the agent write scope (Claude
|
||||||
|
Code: `sandbox.filesystem.allowWrite`) and session scratch dirs; never
|
||||||
|
another user's files. Prefer the project's cleanup and teardown targets; obey
|
||||||
|
rules forbidding raw commands.
|
||||||
|
|
||||||
|
- **/tmp**: `du -sh` per own top-level entry; system entries (`.X11-unix`,
|
||||||
|
`.ICE-unix`, `systemd-private-*`, `ssh-*`, `tmux-*`, sockets) are never
|
||||||
|
candidates.
|
||||||
|
- **Docker**, **worktrees**: read `docker.md` (its `⚠ Volumes` checklist
|
||||||
|
joins Step 4) and `worktrees.md` next to this file when present. Only at
|
||||||
|
watch stage `warn` or worse, or with worktrees among the biggest consumers:
|
||||||
|
`kvb-git-orphans-find --sizes`, its largest orphaned worktrees as candidates.
|
||||||
|
- **Agent dirs**: pip, npm, pre-commit, act, gh, buildx caches; Ansible and
|
||||||
|
build temp dirs.
|
||||||
|
- **Register**: `council.py list --long`; `rasci.py artifacts` (`kvb-org-artefacts`,
|
||||||
|
`--ended` for candidates).
|
||||||
|
- **Live use**: `fuser`, `lsof`, `ps -o pid,etime,comm`, never full command
|
||||||
|
lines or environments (credentials). Sandboxed, an empty result proves
|
||||||
|
nothing.
|
||||||
|
- **Archive duties**: memory or project rules requiring a copy before
|
||||||
|
deletion (logs, reports, generated maps).
|
||||||
|
|
||||||
|
## Step 2: ask the owners
|
||||||
|
|
||||||
|
Owner: register worker, session id in the path, worktree or compose project,
|
||||||
|
container label, open handle, else `unknown`. `radio.py send direct --to
|
||||||
|
<peer> --wake` the text in `ask.md` next to this file once to each local peer
|
||||||
|
owning candidates the register does not cover; go on meanwhile.
|
||||||
|
|
||||||
|
## Step 3: classify
|
||||||
|
|
||||||
|
First match wins: claimed by a peer, `keep`; in a register task not `done`,
|
||||||
|
`stopped` or `stale`, `keep (task <task>)` (an ended task's items are its
|
||||||
|
worker's candidates); a running deploy, gate, test, swarm run or build, `keep
|
||||||
|
(running)`; a silent peer's, `keep (no answer from <peer>)`, silence is not
|
||||||
|
consent; sandboxed, anything touched within 24 hours, `keep (recent,
|
||||||
|
possibly in use)`; archive duty,
|
||||||
|
`archive first`, never a plain delete. Else a candidate:
|
||||||
|
|
||||||
|
- **low**, regenerated cheaply: stopped containers without volumes, dangling
|
||||||
|
images, unused networks, finished sessions' scratch and logs.
|
||||||
|
- **medium**, regenerated slowly, rate-limited or networked: unused tagged
|
||||||
|
images, build cache, package caches, clean worktrees already merged.
|
||||||
|
- **high**, data loss or a broken run: volumes, stopped containers with
|
||||||
|
volumes, running unclaimed containers or stacks, dirty or unpushed
|
||||||
|
worktrees, unread reports, anything `unknown`.
|
||||||
|
|
||||||
|
## Step 4: propose
|
||||||
|
|
||||||
|
One table, biggest first: area, item, size, owner, risk, proposal, what
|
||||||
|
breaks if wrong. Below it reclaimable size per risk and every `keep` grouped
|
||||||
|
by reason. Then `AskUserQuestion`:
|
||||||
|
|
||||||
|
1. `multiSelect: true`, at most four questions: `/tmp`, `Docker`, `Other
|
||||||
|
dirs`, `High risk` (every high-risk candidate); size, risk and worst case
|
||||||
|
in each option's description. Over four options: group by owner or risk,
|
||||||
|
or call again. Only ticks select; nothing preselected.
|
||||||
|
2. `multiSelect: false`: `Execute: <n> items, <size> (Recommended)` naming
|
||||||
|
the ticked items' worst case, `Revise the selection` (repeats 1), `Abort`.
|
||||||
|
|
||||||
|
## Step 5: execute
|
||||||
|
|
||||||
|
After `Execute`, read `execute.md` next to this file first.
|
||||||
|
|
||||||
|
## Step 6: report
|
||||||
|
|
||||||
|
`df -h` and `docker system df` before and after, freed size per area, every
|
||||||
|
skipped or failed item with its reason. `radio.py post --to <peer>` tells each
|
||||||
|
answering peer which removed items lay near its own.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Message to a peer owning unregistered candidates
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cleanup by <own session name>. These unregistered items look like yours:
|
||||||
|
<item, size per line>
|
||||||
|
Reply with those you still need and jobs running on them with their end, or
|
||||||
|
"none"; unanswered items stay. Delete your own finished worktrees and volumes
|
||||||
|
(branch merged or pushed, clean, stack unneeded) and post them via radio.py;
|
||||||
|
list other unused ones with their size as deletion candidates.
|
||||||
|
```
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Docker items
|
||||||
|
|
||||||
|
## Inventory
|
||||||
|
|
||||||
|
`docker system df -v`; containers with state and compose project label,
|
||||||
|
volumes with the containers using them (`docker ps -a --filter
|
||||||
|
volume=<name>`), dangling and unused images, build cache, unused networks.
|
||||||
|
For every stopped container (`exited`, `created`) record its volumes
|
||||||
|
(`{{.Mounts}}`); for every volume no running container uses, record size,
|
||||||
|
`CreatedAt` (`docker volume inspect`) and compose project label, anonymous
|
||||||
|
ones included.
|
||||||
|
|
||||||
|
## Volumes checklist
|
||||||
|
|
||||||
|
Stopped containers and volumes always get their own checklist
|
||||||
|
(`multiSelect: true`, header `⚠ Volumes`, in a further call when call 1 is
|
||||||
|
full). Every stopped container and every volume without a running container
|
||||||
|
appears there unless a peer claimed it; none is dropped as "not proposed",
|
||||||
|
`unknown` and anonymous ones included. The question text carries the warning:
|
||||||
|
`Deleting a volume is irreversible; its data does not come back unless the
|
||||||
|
description says how.` One option per stopped container together with its
|
||||||
|
volumes (removing the container alone frees nothing of them), one per
|
||||||
|
unattached volume, anonymous ones grouped. Each description gives size,
|
||||||
|
`CreatedAt`, owner or compose project, and what is lost.
|
||||||
|
|
||||||
|
## After removing images
|
||||||
|
|
||||||
|
Locally built images free little on their own: their layers stay behind as
|
||||||
|
build cache. Re-measure `docker system df`; the layers show up as reclaimable
|
||||||
|
build cache. Offer `docker builder prune --filter until=24h` as a follow-up
|
||||||
|
candidate, ask the peers that claimed build cache first, and run it outside
|
||||||
|
their running builds.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Executing the selection
|
||||||
|
|
||||||
|
- Re-check claims, open handles and running state right before each delete:
|
||||||
|
a peer may have started something since the inventory.
|
||||||
|
- Delete exactly the selected items, by name or path. No blanket prune
|
||||||
|
(`docker system prune -a --volumes`, `rm -rf /tmp/*`) that sweeps beyond
|
||||||
|
the selection. For more than a handful of paths, write the list to a file
|
||||||
|
under `/tmp`, grep it against every claim once more, then delete with
|
||||||
|
`xargs -d '\n' rm -rf -- < <list>`.
|
||||||
|
- Never stop, take down or kill a running job, and never ask its owner to, to
|
||||||
|
free space: not for a priority slot, not at a disk stop. Free idle items,
|
||||||
|
ask the operator for an exception, or wait; an owner's own disk guard only
|
||||||
|
alerts the chair, and only the operator can order a running job down. A
|
||||||
|
hung job is not freed through cleanup: the `kvb-org-organize` hang rule
|
||||||
|
lets only its owner end it.
|
||||||
|
- Idle running stacks go down through the project's teardown target, never by
|
||||||
|
removing their containers or volumes underneath them. Worktrees go as
|
||||||
|
`worktrees.md` says.
|
||||||
|
- Archive-first items: copy out, verify the copy, then delete.
|
||||||
|
- Drop every deleted item from its task as the `kvb-org-artefacts` skill says
|
||||||
|
(`rasci.py set <task> --drop-artifact <kind>:<ref>`), so `artifacts` stays
|
||||||
|
true.
|
||||||
|
- A host permission layer (an auto-mode classifier, a deny rule) may refuse a
|
||||||
|
delete. Never route around it with another command or a peer. Finish the
|
||||||
|
rest, then put the refused item to the operator again as its own option: an
|
||||||
|
explicit renewed selection may pass; if it is refused again, hand over the
|
||||||
|
exact command with its list file for the operator to run.
|
||||||
|
- A delete failing on permissions (files owned by a container UID): use the
|
||||||
|
project's container helper. Never sudo, never bypass the sandbox; without a
|
||||||
|
helper, report the path.
|
||||||
|
- Stop on the first unexpected error and report it.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Worktrees
|
||||||
|
|
||||||
|
## Inventory
|
||||||
|
|
||||||
|
Per worktree: size (`du -sh`), dirty state (`git status --short`), whether
|
||||||
|
its branch is pushed, and whether its HEAD is already contained in an
|
||||||
|
integration line: the default branch and every branch the project merges
|
||||||
|
features into (ask the operator or the project's memory which ones). With
|
||||||
|
`pkgmgr` installed, `pkgmgr branch gc --json --sizes --merged-into <line>`
|
||||||
|
(one per line) measures all of it; its merged-clean items go to the
|
||||||
|
`kvb-git-branch-gc` skill.
|
||||||
|
|
||||||
|
A clean worktree whose HEAD is in an integration line is medium risk: its
|
||||||
|
ignored files (`node_modules`, build output, evidence) are lost, no commit.
|
||||||
|
|
||||||
|
## Removal
|
||||||
|
|
||||||
|
Remove a worktree the way it was made: a slot worktree through the project's
|
||||||
|
teardown target (e.g. `make worktree-down`), run outside the sandbox; a plain
|
||||||
|
sidetree through `git worktree remove` without `--force`, which refuses a
|
||||||
|
dirty tree.
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
---
|
||||||
|
name: kvb-coach-meditation
|
||||||
|
description: >
|
||||||
|
Short meditation with generative alpha-wave music, animated fractals and
|
||||||
|
breathing cues, tuned by feedback.
|
||||||
|
---
|
||||||
|
|
||||||
|
A short session in the browser: `assets/meditation.html` plays generative
|
||||||
|
ambient music over an alpha-wave layer (8–13 Hz) and animates a Julia fractal
|
||||||
|
that breathes with the cue. It runs locally, without audio files or network.
|
||||||
|
|
||||||
|
`${CLAUDE_SKILL_DIR}/scripts/meditation.py` keeps the settings in the
|
||||||
|
gitignored `.state/meditation.json` of the skills repository and writes the
|
||||||
|
session page with the settings embedded:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
meditation.py --state <skills repo>/.state/meditation.json set <key>=<value> ...
|
||||||
|
meditation.py --state <same> show
|
||||||
|
meditation.py --state <same> session --page ${CLAUDE_SKILL_DIR}/assets/meditation.html --out <skills repo>/.state/meditation-session.html
|
||||||
|
```
|
||||||
|
|
||||||
|
| Key | Values | First session |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `minutes` | 1–30 | 5 |
|
||||||
|
| `beat` | 8–13 Hz | 10 |
|
||||||
|
| `base` | 100–400 Hz | 196 |
|
||||||
|
| `volume` | 0.05–1 | 0.4 |
|
||||||
|
| `mode` | `isochronic` (speakers), `binaural` (headphones) | `isochronic` |
|
||||||
|
| `palette` | `aurora`, `ocean`, `ember`, `forest` | `aurora` |
|
||||||
|
| `breathing` | `box` (4-4-4-4), `478`, `none` | `box` |
|
||||||
|
|
||||||
|
## Step 1: start
|
||||||
|
|
||||||
|
Without a state file, `set` the first-session values. Write the session page
|
||||||
|
and hand the operator `xdg-open <session page>` to run: inside the agent
|
||||||
|
sandbox `xdg-open` and `gio open` exit 0 without opening anything. The page starts on the
|
||||||
|
operator's click, shows the elapsed time as a line growing along the bottom
|
||||||
|
edge, and fades out after the set minutes; `+` and `-` set the
|
||||||
|
picture's brightness (20–160 %), which the browser keeps for the next session.
|
||||||
|
|
||||||
|
## Step 2: feedback
|
||||||
|
|
||||||
|
After every session, run the `kvb-think-listen` skill with one
|
||||||
|
`AskUserQuestion`, `multiSelect: false` per question: music (calmer, fits,
|
||||||
|
livelier, quieter, louder), picture (fits, calmer, other palette), length
|
||||||
|
(shorter, fits, longer), effect (calmer, more focused, nothing, restless).
|
||||||
|
Ask about headphones once before switching to `binaural`.
|
||||||
|
|
||||||
|
## Step 3: optimize
|
||||||
|
|
||||||
|
One `set` per answer: quieter or louder ±0.1 `volume`; calmer music `beat`
|
||||||
|
−1, more focus `beat` +1; shorter or longer ±2 `minutes`; other palette the
|
||||||
|
next one; restless `breathing=none`. Log the session in the `kvb-coach`
|
||||||
|
profile (`coach.py done psyche "Meditation <n> min"`) and a liked effect with
|
||||||
|
`coach.py like psyche`.
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>kvb-coach-meditation</title>
|
||||||
|
<style>
|
||||||
|
html, body { margin: 0; height: 100%; background: #000; color: #eee; font-family: "URW Gothic", "Fira Sans", "Cantarell", sans-serif; font-weight: 300; overflow: hidden; }
|
||||||
|
canvas { position: fixed; inset: 0; width: 100%; height: 100%; }
|
||||||
|
#ui { position: fixed; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1rem; text-align: center; padding: 1rem; }
|
||||||
|
#breath { font-size: 2.6rem; text-transform: lowercase; letter-spacing: .4em; color: #fff; text-shadow: 0 0 14px #fff9, 0 0 40px #9cf8, 0 0 80px #c9f5; will-change: transform, opacity, letter-spacing; }
|
||||||
|
#clock { position: fixed; bottom: 1rem; right: 1rem; opacity: .5; font-variant-numeric: tabular-nums; }
|
||||||
|
#progress { position: fixed; left: 0; bottom: 0; height: 3px; width: 0; background: linear-gradient(90deg, #9cf, #c9f); box-shadow: 0 0 12px #9cf9; opacity: .7; }
|
||||||
|
#level { position: fixed; top: 1rem; right: 1rem; opacity: 0; transition: opacity .6s; letter-spacing: .2em; text-shadow: 0 0 10px #fff8; }
|
||||||
|
button { font: inherit; font-size: 1.4rem; letter-spacing: .4em; text-transform: lowercase; padding: .8rem 2.4rem; border-radius: 2rem; border: 1px solid #fff6; background: #fff1; color: #fff; cursor: pointer; text-shadow: 0 0 12px #fff8; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<canvas id="view"></canvas>
|
||||||
|
<div id="ui"><div id="breath"></div><button id="start">Start</button></div>
|
||||||
|
<div id="clock"></div>
|
||||||
|
<div id="level"></div>
|
||||||
|
<div id="progress"></div>
|
||||||
|
<script>
|
||||||
|
"use strict";
|
||||||
|
const KEYS = ["minutes", "beat", "base", "volume", "mode", "palette", "breathing"];
|
||||||
|
const PALETTES = {
|
||||||
|
aurora: [[0.5, 0.5, 0.5], [0.5, 0.5, 0.5], [1.0, 1.0, 1.0], [0.0, 0.33, 0.67]],
|
||||||
|
ocean: [[0.2, 0.4, 0.6], [0.2, 0.3, 0.4], [1.0, 1.0, 1.0], [0.0, 0.1, 0.2]],
|
||||||
|
ember: [[0.5, 0.3, 0.2], [0.5, 0.3, 0.2], [1.0, 1.0, 1.0], [0.0, 0.1, 0.2]],
|
||||||
|
forest: [[0.3, 0.5, 0.3], [0.2, 0.4, 0.2], [1.0, 1.0, 1.0], [0.1, 0.2, 0.3]],
|
||||||
|
};
|
||||||
|
const PATTERNS = {
|
||||||
|
box: [["Breathe in", 4], ["Hold", 4], ["Breathe out", 4], ["Hold", 4]],
|
||||||
|
"478": [["Breathe in", 4], ["Hold", 7], ["Breathe out", 8]],
|
||||||
|
none: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const SETTINGS = null;
|
||||||
|
const breathEl = document.getElementById("breath");
|
||||||
|
const clockEl = document.getElementById("clock");
|
||||||
|
const startEl = document.getElementById("start");
|
||||||
|
const missing = KEYS.filter((key) => !SETTINGS || !(key in SETTINGS));
|
||||||
|
const cfg = Object.fromEntries(KEYS.map((key) => [key, SETTINGS ? SETTINGS[key] : null]));
|
||||||
|
["minutes", "beat", "base", "volume"].forEach((key) => { cfg[key] = Number(cfg[key]); });
|
||||||
|
if (missing.length || !PALETTES[cfg.palette] || !PATTERNS[cfg.breathing]) {
|
||||||
|
startEl.remove();
|
||||||
|
breathEl.textContent = "Start the session through the kvb-coach-meditation skill.";
|
||||||
|
throw new Error(`invalid settings: ${missing.join(", ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function breath(t) {
|
||||||
|
const steps = PATTERNS[cfg.breathing];
|
||||||
|
if (!steps.length) return ["", 0.5 + 0.5 * Math.sin(t * 0.2)];
|
||||||
|
const total = steps.reduce((sum, [, d]) => sum + d, 0);
|
||||||
|
let x = t % total;
|
||||||
|
let lung = 0;
|
||||||
|
for (const [label, d] of steps) {
|
||||||
|
if (x < d) {
|
||||||
|
if (label === "Breathe in") return [label, x / d];
|
||||||
|
if (label === "Breathe out") return [label, 1 - x / d];
|
||||||
|
return [label, lung];
|
||||||
|
}
|
||||||
|
x -= d;
|
||||||
|
if (label === "Breathe in") lung = 1;
|
||||||
|
if (label === "Breathe out") lung = 0;
|
||||||
|
}
|
||||||
|
return ["", lung];
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = document.getElementById("view");
|
||||||
|
const gl = canvas.getContext("webgl");
|
||||||
|
let draw = () => {};
|
||||||
|
if (gl) {
|
||||||
|
const VERT = "attribute vec2 p; void main() { gl_Position = vec4(p, 0.0, 1.0); }";
|
||||||
|
const FRAG = `precision highp float;
|
||||||
|
uniform vec2 res; uniform float t; uniform float lung; uniform vec3 pa, pb, pc, pd;
|
||||||
|
vec3 pal(float x) { return pa + pb * cos(6.28318 * (pc * x + pd)); }
|
||||||
|
void main() {
|
||||||
|
vec2 z = (2.0 * gl_FragCoord.xy - res) / min(res.x, res.y) * (1.6 - 0.3 * lung);
|
||||||
|
vec2 c = 0.7885 * vec2(cos(t * 0.05), sin(t * 0.05));
|
||||||
|
float n = 0.0;
|
||||||
|
for (int i = 0; i < 160; i++) {
|
||||||
|
z = vec2(z.x * z.x - z.y * z.y, 2.0 * z.x * z.y) + c;
|
||||||
|
if (dot(z, z) > 16.0) break;
|
||||||
|
n += 1.0;
|
||||||
|
}
|
||||||
|
float s = (n - log2(log2(max(dot(z, z), 1.0001))) + 4.0) / 160.0;
|
||||||
|
gl_FragColor = vec4(pal(s + t * 0.01) * (n < 160.0 ? 1.0 : 0.05), 1.0);
|
||||||
|
}`;
|
||||||
|
const shader = (type, src) => {
|
||||||
|
const s = gl.createShader(type);
|
||||||
|
gl.shaderSource(s, src);
|
||||||
|
gl.compileShader(s);
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
const prog = gl.createProgram();
|
||||||
|
gl.attachShader(prog, shader(gl.VERTEX_SHADER, VERT));
|
||||||
|
gl.attachShader(prog, shader(gl.FRAGMENT_SHADER, FRAG));
|
||||||
|
gl.linkProgram(prog);
|
||||||
|
gl.useProgram(prog);
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), gl.STATIC_DRAW);
|
||||||
|
const loc = gl.getAttribLocation(prog, "p");
|
||||||
|
gl.enableVertexAttribArray(loc);
|
||||||
|
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
|
||||||
|
const u = (name) => gl.getUniformLocation(prog, name);
|
||||||
|
["pa", "pb", "pc", "pd"].forEach((name, i) => gl.uniform3fv(u(name), PALETTES[cfg.palette][i]));
|
||||||
|
const resize = () => {
|
||||||
|
const scale = Math.min(window.devicePixelRatio || 1, 1.5);
|
||||||
|
canvas.width = Math.floor(innerWidth * scale);
|
||||||
|
canvas.height = Math.floor(innerHeight * scale);
|
||||||
|
gl.viewport(0, 0, canvas.width, canvas.height);
|
||||||
|
};
|
||||||
|
addEventListener("resize", resize);
|
||||||
|
resize();
|
||||||
|
draw = (t, lung) => {
|
||||||
|
gl.uniform2f(u("res"), canvas.width, canvas.height);
|
||||||
|
gl.uniform1f(u("t"), t);
|
||||||
|
gl.uniform1f(u("lung"), lung);
|
||||||
|
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||||
|
};
|
||||||
|
draw(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const levelEl = document.getElementById("level");
|
||||||
|
const progressEl = document.getElementById("progress");
|
||||||
|
const BRIGHTNESS = "kvb-coach-meditation-brightness";
|
||||||
|
const stored = () => {
|
||||||
|
try {
|
||||||
|
return Number(localStorage.getItem(BRIGHTNESS)) || 1;
|
||||||
|
} catch {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const store = (value) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(BRIGHTNESS, String(value));
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let brightness = stored();
|
||||||
|
let levelTimer = 0;
|
||||||
|
const showBrightness = () => {
|
||||||
|
canvas.style.filter = `brightness(${brightness})`;
|
||||||
|
};
|
||||||
|
showBrightness();
|
||||||
|
addEventListener("keydown", (event) => {
|
||||||
|
const step = { "+": 0.1, "=": 0.1, "-": -0.1 }[event.key];
|
||||||
|
if (!step) return;
|
||||||
|
brightness = Math.min(1.6, Math.max(0.2, Math.round((brightness + step) * 10) / 10));
|
||||||
|
showBrightness();
|
||||||
|
const saved = store(brightness);
|
||||||
|
levelEl.textContent = `☀ ${Math.round(brightness * 100)} %${saved ? "" : " (not saved)"}`;
|
||||||
|
levelEl.style.opacity = "1";
|
||||||
|
clearTimeout(levelTimer);
|
||||||
|
levelTimer = setTimeout(() => { levelEl.style.opacity = "0"; }, 1200);
|
||||||
|
});
|
||||||
|
|
||||||
|
function sound(ctx) {
|
||||||
|
const master = ctx.createGain();
|
||||||
|
master.gain.setValueAtTime(0, ctx.currentTime);
|
||||||
|
master.gain.linearRampToValueAtTime(cfg.volume, ctx.currentTime + 8);
|
||||||
|
master.connect(ctx.destination);
|
||||||
|
const reverb = ctx.createConvolver();
|
||||||
|
const length = ctx.sampleRate * 4;
|
||||||
|
const impulse = ctx.createBuffer(2, length, ctx.sampleRate);
|
||||||
|
for (let ch = 0; ch < 2; ch++) {
|
||||||
|
const data = impulse.getChannelData(ch);
|
||||||
|
for (let i = 0; i < length; i++) data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / length, 3);
|
||||||
|
}
|
||||||
|
reverb.buffer = impulse;
|
||||||
|
reverb.connect(master);
|
||||||
|
if (cfg.mode === "binaural") {
|
||||||
|
const merger = ctx.createChannelMerger(2);
|
||||||
|
[cfg.base, cfg.base + cfg.beat].forEach((freq, ch) => {
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
osc.frequency.value = freq;
|
||||||
|
gain.gain.value = 0.12;
|
||||||
|
osc.connect(gain).connect(merger, 0, ch);
|
||||||
|
osc.start();
|
||||||
|
});
|
||||||
|
merger.connect(master);
|
||||||
|
} else {
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
const lfo = ctx.createOscillator();
|
||||||
|
const depth = ctx.createGain();
|
||||||
|
osc.frequency.value = cfg.base;
|
||||||
|
gain.gain.value = 0.06;
|
||||||
|
lfo.frequency.value = cfg.beat;
|
||||||
|
depth.gain.value = 0.06;
|
||||||
|
lfo.connect(depth).connect(gain.gain);
|
||||||
|
osc.connect(gain).connect(master);
|
||||||
|
osc.start();
|
||||||
|
lfo.start();
|
||||||
|
}
|
||||||
|
const ratios = [1, 9 / 8, 5 / 4, 3 / 2, 5 / 3, 2];
|
||||||
|
const note = () => {
|
||||||
|
if (ctx.state === "closed") return;
|
||||||
|
const now = ctx.currentTime;
|
||||||
|
const freq = cfg.base * ratios[Math.floor(Math.random() * ratios.length)];
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
gain.gain.setValueAtTime(0, now);
|
||||||
|
gain.gain.linearRampToValueAtTime(0.1, now + 3);
|
||||||
|
gain.gain.linearRampToValueAtTime(0, now + 9);
|
||||||
|
gain.connect(reverb);
|
||||||
|
[0, 4].forEach((cents) => {
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
osc.type = "triangle";
|
||||||
|
osc.frequency.value = freq;
|
||||||
|
osc.detune.value = cents;
|
||||||
|
osc.connect(gain);
|
||||||
|
osc.start(now);
|
||||||
|
osc.stop(now + 9.5);
|
||||||
|
});
|
||||||
|
setTimeout(note, 2500 + Math.random() * 3500);
|
||||||
|
};
|
||||||
|
note();
|
||||||
|
return master;
|
||||||
|
}
|
||||||
|
|
||||||
|
startEl.addEventListener("click", () => {
|
||||||
|
startEl.remove();
|
||||||
|
const ctx = new AudioContext();
|
||||||
|
const master = sound(ctx);
|
||||||
|
const begin = performance.now();
|
||||||
|
const end = cfg.minutes * 60;
|
||||||
|
const frame = (now) => {
|
||||||
|
const t = (now - begin) / 1000;
|
||||||
|
const [label, lung] = breath(t);
|
||||||
|
draw(t, lung);
|
||||||
|
progressEl.style.width = `${Math.min(100, (t / end) * 100)}%`;
|
||||||
|
const left = Math.max(0, end - t);
|
||||||
|
clockEl.textContent = `${Math.floor(left / 60)}:${String(Math.floor(left % 60)).padStart(2, "0")}`;
|
||||||
|
breathEl.style.opacity = String(0.35 + 0.65 * lung);
|
||||||
|
breathEl.style.letterSpacing = `${0.3 + 0.5 * lung}em`;
|
||||||
|
breathEl.style.transform = `scale(${0.9 + 0.2 * lung})`;
|
||||||
|
if (t < end) {
|
||||||
|
breathEl.textContent = label;
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
master.gain.cancelScheduledValues(ctx.currentTime);
|
||||||
|
master.gain.setValueAtTime(master.gain.value, ctx.currentTime);
|
||||||
|
master.gain.linearRampToValueAtTime(0, ctx.currentTime + 6);
|
||||||
|
setTimeout(() => ctx.close(), 6500);
|
||||||
|
breathEl.style.opacity = "1";
|
||||||
|
breathEl.style.transform = "none";
|
||||||
|
breathEl.style.letterSpacing = ".2em";
|
||||||
|
breathEl.textContent = "Done. Back to your agent for a short feedback.";
|
||||||
|
};
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+110
@@ -0,0 +1,110 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Keep the kvb-coach-meditation settings and write the session page.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
--state: path of the settings JSON file.
|
||||||
|
set <key>=<value> ...: change settings; every value is validated.
|
||||||
|
show: print the settings.
|
||||||
|
session --page <meditation.html> --out <file>: write the page with the
|
||||||
|
settings embedded and print its path; fails while a setting is missing.
|
||||||
|
|
||||||
|
Exit codes:
|
||||||
|
0 done, 2 usage error.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PLACEHOLDER = "const SETTINGS = null;"
|
||||||
|
RANGES = {
|
||||||
|
"minutes": (int, 1, 30),
|
||||||
|
"beat": (float, 8.0, 13.0),
|
||||||
|
"base": (float, 100.0, 400.0),
|
||||||
|
"volume": (float, 0.05, 1.0),
|
||||||
|
}
|
||||||
|
CHOICES = {
|
||||||
|
"mode": ("binaural", "isochronic"),
|
||||||
|
"palette": ("aurora", "ocean", "ember", "forest"),
|
||||||
|
"breathing": ("box", "478", "none"),
|
||||||
|
}
|
||||||
|
KEYS = (*RANGES, *CHOICES)
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message: str) -> None:
|
||||||
|
print(f"meditation: {message}", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
|
||||||
|
def parse(pair: str) -> tuple[str, int | float | str]:
|
||||||
|
key, sep, raw = pair.partition("=")
|
||||||
|
if not sep or key not in KEYS:
|
||||||
|
fail(f"{pair}: expected <key>=<value> with key in {', '.join(KEYS)}")
|
||||||
|
if key in CHOICES:
|
||||||
|
if raw not in CHOICES[key]:
|
||||||
|
fail(f"{key} must be one of {', '.join(CHOICES[key])}")
|
||||||
|
return key, raw
|
||||||
|
kind, low, high = RANGES[key]
|
||||||
|
try:
|
||||||
|
value = kind(raw)
|
||||||
|
except ValueError:
|
||||||
|
fail(f"{key} must be a number")
|
||||||
|
if not low <= value <= high:
|
||||||
|
fail(f"{key} must lie between {low} and {high}")
|
||||||
|
return key, value
|
||||||
|
|
||||||
|
|
||||||
|
def load(path: Path) -> dict:
|
||||||
|
try:
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except FileNotFoundError:
|
||||||
|
return {}
|
||||||
|
except ValueError:
|
||||||
|
fail(f"{path} is not valid JSON")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
|
parser.add_argument("--state", required=True)
|
||||||
|
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||||
|
sub.add_parser("set").add_argument("pairs", nargs="+")
|
||||||
|
sub.add_parser("show")
|
||||||
|
p = sub.add_parser("session")
|
||||||
|
p.add_argument("--page", required=True)
|
||||||
|
p.add_argument("--out", required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
path = Path(args.state)
|
||||||
|
settings = load(path)
|
||||||
|
if args.cmd == "set":
|
||||||
|
settings.update(parse(pair) for pair in args.pairs)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = path.with_suffix(".tmp")
|
||||||
|
tmp.write_text(json.dumps(settings, indent=1, sort_keys=True), encoding="utf-8")
|
||||||
|
tmp.replace(path)
|
||||||
|
if args.cmd in ("set", "show"):
|
||||||
|
for key in KEYS:
|
||||||
|
print(f"{key}={settings.get(key, '-')}")
|
||||||
|
return 0
|
||||||
|
missing = [key for key in KEYS if key not in settings]
|
||||||
|
if missing:
|
||||||
|
fail(f"missing settings: {', '.join(missing)}")
|
||||||
|
page = Path(args.page)
|
||||||
|
if not page.is_file():
|
||||||
|
fail(f"{page} does not exist")
|
||||||
|
template = page.read_text(encoding="utf-8")
|
||||||
|
if template.count(PLACEHOLDER) != 1:
|
||||||
|
fail(f"{page} lacks the line {PLACEHOLDER}")
|
||||||
|
embedded = json.dumps({key: settings[key] for key in KEYS})
|
||||||
|
out = Path(args.out)
|
||||||
|
out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
out.write_text(template.replace(PLACEHOLDER, f"const SETTINGS = {embedded};"), encoding="utf-8")
|
||||||
|
print(out.resolve())
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
---
|
||||||
|
name: kvb-coach
|
||||||
|
description: >
|
||||||
|
Coach the operator's next step: psyche, body, sleep, food, own and
|
||||||
|
delegable tasks. Trigger on "was soll ich tun".
|
||||||
|
---
|
||||||
|
|
||||||
|
Be the operator's coach: short, warm, concrete, with emojis, in the
|
||||||
|
operator's language. Their health comes first; work is one area of six, not
|
||||||
|
the default. Suggestions, never pressure.
|
||||||
|
|
||||||
|
The profile keeps what the operator enjoys, dislikes and last did, host-local
|
||||||
|
in the gitignored `.state/` of the skills repository:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
coach.py --profile <skills repo>/.state/coach.json like|dislike|forget <area> "<thing>"
|
||||||
|
coach.py --profile <same> done <area> "<thing>" [--at <iso>]
|
||||||
|
coach.py --profile <same> show [--json]
|
||||||
|
```
|
||||||
|
|
||||||
|
Areas: `psyche`, `body`, `sleep`, `food`, `tasks`, `delegate`. Every coaching
|
||||||
|
skill beyond this one is named `kvb-coach-<name>`.
|
||||||
|
|
||||||
|
`hooks/reminder.sh` triggers the short form at the first prompt of every full
|
||||||
|
hour, once across all agents (stamped in `~/.claude/.coach-hours/`): at most
|
||||||
|
three lines, the most urgent area first, then the agent continues with the
|
||||||
|
operator's request.
|
||||||
|
|
||||||
|
## Step 1: read the situation
|
||||||
|
|
||||||
|
- **Time**: local time and weekday, and how long the operator has been at it
|
||||||
|
today (their first message of the day in the session transcripts).
|
||||||
|
- **Profile**: `coach.py show`: likes, dislikes, hours since the last sleep,
|
||||||
|
meal, workout or break.
|
||||||
|
- **Own tasks**: the open commands of the `kvb-org-operator` skill and the
|
||||||
|
decisions agents wait on.
|
||||||
|
- **Delegable**: what the operator did by hand that an agent could do, and
|
||||||
|
queued `kvb-org-rasci` register tasks without a worker.
|
||||||
|
|
||||||
|
## Step 2: suggest
|
||||||
|
|
||||||
|
At most one line per area, the most urgent first, the whole answer on one
|
||||||
|
screen:
|
||||||
|
|
||||||
|
- 🧠 **Psyche**: a short break, a walk outside, five minutes with the
|
||||||
|
`kvb-coach-meditation` skill or box breathing, a call with a friend,
|
||||||
|
something just for fun.
|
||||||
|
- 💪 **Body**: a workout from the operator's likes (a 20-minute run, a
|
||||||
|
bodyweight circuit, cycling, yoga), stretching neck, hips and back after
|
||||||
|
long sitting, standing up every hour.
|
||||||
|
- 😴 **Sleep**: wind down, screens off, seven to nine hours; a 20-minute nap
|
||||||
|
in the early afternoon.
|
||||||
|
- 🥗 **Food**: water, a real meal with vegetables and protein, a healthy
|
||||||
|
snack instead of sugar, no caffeine late in the day.
|
||||||
|
- ✅ **Your tasks**: the one step only the operator can take that unblocks
|
||||||
|
the most.
|
||||||
|
- 🤖 **Delegate**: what an agent can take over, with the skill or command
|
||||||
|
that does it.
|
||||||
|
|
||||||
|
The situation sets the order: late evening puts sleep first, hours without
|
||||||
|
a break put moving first, a meal more than five hours ago puts food first.
|
||||||
|
Prefer what the operator likes, never offer a dislike, and do not repeat the
|
||||||
|
last tip of an area. Close with one encouraging line.
|
||||||
|
|
||||||
|
## Step 3: listen and remember
|
||||||
|
|
||||||
|
Often, not every time: when the profile is empty, after a declined
|
||||||
|
suggestion, and every few runs, run the `kvb-think-listen` skill with one
|
||||||
|
short `AskUserQuestion` (what they enjoy, what fits right now, what to
|
||||||
|
avoid). Record the answers with `like` and `dislike`, and what the operator
|
||||||
|
reports having done with `done`.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- The operator decides: no guilt, no nagging, a declined tip stays off for
|
||||||
|
the day.
|
||||||
|
- General wellbeing, no medical advice: pain, illness or lasting exhaustion
|
||||||
|
get one kind pointer to a doctor.
|
||||||
|
- The profile never leaves the host: not into commits, memory files or peer
|
||||||
|
messages.
|
||||||
Executable
+15
@@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
hours="${COACH_STAMP_DIR:-${HOME}/.claude/.coach-hours}"
|
||||||
|
hour="${COACH_HOUR:-$(date +%Y-%m-%dT%H)}"
|
||||||
|
|
||||||
|
mkdir -p "${hours}"
|
||||||
|
mkdir "${hours}/${hour}" 2>/dev/null || exit 0
|
||||||
|
find "${hours}" -mindepth 1 -maxdepth 1 -type d ! -name "${hour}" -exec rmdir {} +
|
||||||
|
|
||||||
|
cat <<'HINT'
|
||||||
|
COACH HOUR: a new full hour began. Before answering the operator's request,
|
||||||
|
run the kvb-coach skill in its short form: at most three lines, the most
|
||||||
|
urgent area first, then continue with the request.
|
||||||
|
HINT
|
||||||
Executable
+135
@@ -0,0 +1,135 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Keep the operator's coaching profile: likes, dislikes and when what was done.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
--profile: path of the profile JSON file.
|
||||||
|
like|dislike <area> <thing>: record a preference; one replaces the other.
|
||||||
|
forget <area> <thing>: drop a preference.
|
||||||
|
done <area> <thing> [--at <iso>]: log that the operator did it.
|
||||||
|
show [--now <iso>] [--json]: likes, dislikes and the last logged activity
|
||||||
|
per area with the hours since.
|
||||||
|
|
||||||
|
Exit codes:
|
||||||
|
0 done, 2 usage error.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
AREAS = ("psyche", "body", "sleep", "food", "tasks", "delegate")
|
||||||
|
LOG_LIMIT = 500
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message: str) -> None:
|
||||||
|
print(f"coach: {message}", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
|
||||||
|
def moment(value: str | None) -> datetime.datetime:
|
||||||
|
if value is None:
|
||||||
|
return datetime.datetime.now(datetime.UTC)
|
||||||
|
try:
|
||||||
|
parsed = datetime.datetime.fromisoformat(value)
|
||||||
|
except ValueError:
|
||||||
|
fail(f"{value} is not an ISO timestamp")
|
||||||
|
return parsed if parsed.tzinfo else parsed.replace(tzinfo=datetime.UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def load(path: Path) -> dict:
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except FileNotFoundError:
|
||||||
|
return {"likes": {}, "dislikes": {}, "log": []}
|
||||||
|
except ValueError:
|
||||||
|
fail(f"{path} is not valid JSON")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def save(path: Path, data: dict) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = path.with_suffix(".tmp")
|
||||||
|
tmp.write_text(
|
||||||
|
json.dumps(data, indent=1, sort_keys=True, ensure_ascii=False), encoding="utf-8"
|
||||||
|
)
|
||||||
|
tmp.replace(path)
|
||||||
|
|
||||||
|
|
||||||
|
def prefer(data: dict, keep: str, area: str, thing: str) -> None:
|
||||||
|
for key in ("likes", "dislikes"):
|
||||||
|
items = data[key].setdefault(area, [])
|
||||||
|
if thing in items:
|
||||||
|
items.remove(thing)
|
||||||
|
if keep:
|
||||||
|
data[keep][area].append(thing)
|
||||||
|
|
||||||
|
|
||||||
|
def summary(data: dict, now: datetime.datetime) -> list[dict]:
|
||||||
|
rows = []
|
||||||
|
for area in AREAS:
|
||||||
|
last = next((e for e in reversed(data["log"]) if e["area"] == area), None)
|
||||||
|
hours = None
|
||||||
|
if last:
|
||||||
|
hours = round((now - moment(last["at"])).total_seconds() / 3600, 1)
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"area": area,
|
||||||
|
"likes": data["likes"].get(area, []),
|
||||||
|
"dislikes": data["dislikes"].get(area, []),
|
||||||
|
"last": last["thing"] if last else None,
|
||||||
|
"hours_ago": hours,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
|
parser.add_argument("--profile", required=True)
|
||||||
|
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||||
|
for name in ("like", "dislike", "forget"):
|
||||||
|
p = sub.add_parser(name)
|
||||||
|
p.add_argument("area", choices=AREAS)
|
||||||
|
p.add_argument("thing")
|
||||||
|
p = sub.add_parser("done")
|
||||||
|
p.add_argument("area", choices=AREAS)
|
||||||
|
p.add_argument("thing")
|
||||||
|
p.add_argument("--at")
|
||||||
|
p = sub.add_parser("show")
|
||||||
|
p.add_argument("--now")
|
||||||
|
p.add_argument("--json", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
path = Path(args.profile)
|
||||||
|
data = load(path)
|
||||||
|
if args.cmd == "show":
|
||||||
|
rows = summary(data, moment(args.now))
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(rows, indent=1, ensure_ascii=False))
|
||||||
|
return 0
|
||||||
|
for row in rows:
|
||||||
|
last = f"{row['last']} {row['hours_ago']}h ago" if row["last"] else "-"
|
||||||
|
print(
|
||||||
|
f"{row['area']}\tlast: {last}\tlikes: {', '.join(row['likes']) or '-'}"
|
||||||
|
f"\tdislikes: {', '.join(row['dislikes']) or '-'}"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
if args.cmd == "done":
|
||||||
|
data["log"].append(
|
||||||
|
{"area": args.area, "thing": args.thing, "at": moment(args.at).isoformat()}
|
||||||
|
)
|
||||||
|
data["log"] = data["log"][-LOG_LIMIT:]
|
||||||
|
else:
|
||||||
|
keep = {"like": "likes", "dislike": "dislikes"}.get(args.cmd, "")
|
||||||
|
prefer(data, keep, args.area, args.thing)
|
||||||
|
save(path, data)
|
||||||
|
print(f"{args.cmd} {args.area} {args.thing}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
---
|
||||||
|
name: kvb-code-clean
|
||||||
|
description: >
|
||||||
|
Purge comments, forbid defaults, check SPOT, DRY, SRP and coupling in a
|
||||||
|
change. Run AUTOMATICALLY before every commit or stage request and while
|
||||||
|
writing code.
|
||||||
|
---
|
||||||
|
|
||||||
|
On a commit or stage request, run this pass first, then the requested git
|
||||||
|
action; never ask whether to run it. Step 2's rule also binds while writing
|
||||||
|
code.
|
||||||
|
|
||||||
|
The target is the added lines of every staged and unstaged change (`git
|
||||||
|
diff` plus `git diff --cached`) and the units they touch. Never stage to run
|
||||||
|
a check; after the pass, re-stage exactly the files staged before it. A pass that changes a bundle
|
||||||
|
the operator already saw shows its new diff stat and score block before the
|
||||||
|
commit call.
|
||||||
|
|
||||||
|
## Step 1: comments
|
||||||
|
|
||||||
|
Delete every comment the change adds except three kinds:
|
||||||
|
|
||||||
|
- **Exception**: names a concrete trip-wire (bug, pitfall, deliberate
|
||||||
|
non-idiomatic choice); a relabeled narration is none.
|
||||||
|
- **Parameter doc**: inputs, outputs, exit codes.
|
||||||
|
- **Nocheck directive**: `noqa`, `shellcheck`, `type: ignore` and kin.
|
||||||
|
|
||||||
|
Restated code, step narration, banners, "Note that", rationale prose and
|
||||||
|
whatever the code, a name or the commit message already carries go. When in
|
||||||
|
doubt, delete. Docstrings and script headers count: prose outside the
|
||||||
|
parameter doc goes too, and a `#` or `//` grep misses it.
|
||||||
|
|
||||||
|
Block-comment languages are not line-oriented: in Jinja (`{# … #}`), C-like
|
||||||
|
(`/* … */`), HTML (`<!-- … -->`) and kin, a closer alone on its line looks
|
||||||
|
like a comment line; deleted with the rest, the opener swallows every live
|
||||||
|
statement down to the next closer. Delete such a block by hand, count the
|
||||||
|
delimiters (`grep -c '{#'` against `grep -c '#}'`), re-parse the file and
|
||||||
|
diff it against `git show HEAD:<path>`: only comment lines may leave. A green
|
||||||
|
lint proves nothing here.
|
||||||
|
|
||||||
|
## Step 2: defaults
|
||||||
|
|
||||||
|
Every parameter, setting and environment lookup comes from its caller; a
|
||||||
|
missing one aborts early with an error naming the key and where to set it.
|
||||||
|
Optionality is an explicit `None` branch at the call site.
|
||||||
|
|
||||||
|
`false`, `null`, `""` and an empty list or map are free. Any other default
|
||||||
|
carries a comment saying why the value is right for every caller that omits
|
||||||
|
it; "sensible", "standard" and "usual" justify nothing.
|
||||||
|
|
||||||
|
Run `${CLAUDE_SKILL_DIR}/scripts/defaults.py --repo <dir>` (`--staged`:
|
||||||
|
index only; `--file <path>`: an untracked file); exit 1 means hits. Judge
|
||||||
|
hits by `defaults.md` next to this file; it also lists what the script
|
||||||
|
misses.
|
||||||
|
|
||||||
|
## Step 3: principles
|
||||||
|
|
||||||
|
Run `${CLAUDE_SKILL_DIR}/../kvb-check-principles/scripts/principles.py
|
||||||
|
--repo <dir> --ccn 10 --nloc 50 --params 5 --min-tokens 50` (`--staged`,
|
||||||
|
`--file` as above; McCabe's, pylint's and jscpd's limits, 50 NLOC one
|
||||||
|
screen). Its hits on changed lines are leads under SRP, fail fast,
|
||||||
|
KISS/YAGNI and DRY; a `skip:` line leaves that tool's lens to you. Apply the
|
||||||
|
other `kvb-check-principles` lenses (SPOT, security, coupling, DRY of
|
||||||
|
knowledge) by its method. Judge what this change adds or worsens; an older
|
||||||
|
violation is one report line, not a fix.
|
||||||
|
|
||||||
|
- Fix in place a violation the change introduced when the fix stays inside
|
||||||
|
the bundle and keeps behaviour, proven by the tests covering the touched
|
||||||
|
units before and after (the safety net of the `kvb-code-refactor` skill).
|
||||||
|
- Everything else goes into the report as the `kvb-check-principles`
|
||||||
|
table, shown before the commit call; the operator's word decides whether
|
||||||
|
the bundle lands as it is.
|
||||||
|
|
||||||
|
## Report and penalty
|
||||||
|
|
||||||
|
One line per step: comments deleted, defaults rewritten or justified,
|
||||||
|
principle findings fixed and open, each with file and count. Whatever you
|
||||||
|
wrote yourself is admitted without excuses, lowers the bundle's 🎯 fix and
|
||||||
|
raises its 💥 break, and goes into persistent agent memory (date and
|
||||||
|
pattern).
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Judging defaults
|
||||||
|
|
||||||
|
Step 2 of the `kvb-code-clean` skill reads this file when `defaults.py`
|
||||||
|
reports hits or a review needs the whole rule.
|
||||||
|
|
||||||
|
A default is a guess written where the caller's intent is least known: it
|
||||||
|
turns a missing value into a plausible wrong answer, and two callers read the
|
||||||
|
same call differently (one means "use the default", the other forgot).
|
||||||
|
|
||||||
|
## Free and justified defaults
|
||||||
|
|
||||||
|
`false`, `null` (`None`, `nil`, `undefined`), `""` and an empty list or map
|
||||||
|
MAY be defaults without justification: they denote absence, not a guessed
|
||||||
|
value. `${VAR:-}`, `d.get(k)`, `a ?? null` and `def f(a=None)` are fine.
|
||||||
|
|
||||||
|
Every other default (a number, a non-empty string, list or map, an enum
|
||||||
|
member, a path, a timeout) MUST carry a comment on or directly above it, in
|
||||||
|
the project's exception-comment form if it has one, saying why that value is
|
||||||
|
right for every caller that omits it; no comment, no default. The comment
|
||||||
|
names the assumption so a reviewer can disagree.
|
||||||
|
|
||||||
|
"Sensible", "standard", "usual", "conventional", "every other project does
|
||||||
|
it", "obvious" and "keeps the signature short" justify nothing: they assert
|
||||||
|
someone else chose. A valid justification names why this value is right for
|
||||||
|
a caller who says nothing. The operator asking for a specific default, in
|
||||||
|
this conversation, for this value, settles it without a comment.
|
||||||
|
|
||||||
|
## Judge each hit
|
||||||
|
|
||||||
|
- **Real default**: rewrite it as the printed replacement, at the value's
|
||||||
|
true owner.
|
||||||
|
- **Justified**: a `justified?` hit stays only if its comment meets the bar
|
||||||
|
above.
|
||||||
|
- **False positive**: a `candidate` (`??`, `||`, Python `or`) that is logic,
|
||||||
|
or no default at all; report why.
|
||||||
|
|
||||||
|
## What the script cannot see
|
||||||
|
|
||||||
|
Go fields left to zero values (a constructor validating every field),
|
||||||
|
defaults in a Helm `values.yaml` (none; fail on the missing input), Docker
|
||||||
|
`ENV` placeholders for config (`${VAR:?VAR is required}`), Java overloads
|
||||||
|
filling an optional parameter (required constructor arguments), and any other
|
||||||
|
value-resolution fallback (`?:`, `getOrElse`, `fallback`, a dataclass field).
|
||||||
|
|
||||||
|
## How to apply
|
||||||
|
|
||||||
|
- Before writing any parameter, ask what happens if the caller omits it;
|
||||||
|
anything but "it fails and says so" gets rewritten.
|
||||||
|
- Pushing a default one layer up is no fix: a caller passing an invented
|
||||||
|
constant only moved the guess. Require the value at its true owner
|
||||||
|
(operator, inventory, deployment config).
|
||||||
|
- Removing an existing default is a behaviour change, never a silent
|
||||||
|
drive-by: name it and let the operator decide whether it lands in this
|
||||||
|
change or its own.
|
||||||
Executable
+694
@@ -0,0 +1,694 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Report the default values a change adds, each with the explicit form to use instead.
|
||||||
|
|
||||||
|
Python is parsed with ast; a node counts when it starts on an added line.
|
||||||
|
Every other language is matched by regular expressions over the whole file
|
||||||
|
with its comments masked (and its strings, where they cannot hold a default),
|
||||||
|
and a match counts when it starts on an added line. The file name picks the
|
||||||
|
language; a .j2 or .jinja suffix adds the Jinja rules to the inner type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
--repo: a directory inside the git work tree to scan.
|
||||||
|
--staged: scan only the staged changes (git diff --cached) against the
|
||||||
|
index version of each file. Without it: every change against HEAD,
|
||||||
|
staged and unstaged, against the working tree file.
|
||||||
|
--file: scan this whole file, every line counted as added; repeatable,
|
||||||
|
relative to --repo or absolute. Untracked files are in no diff.
|
||||||
|
--json: print the hits as a JSON list instead of tab-separated lines.
|
||||||
|
|
||||||
|
Output:
|
||||||
|
One line per hit: file:line, language, construct, instead, and a fifth
|
||||||
|
column when a mark applies: `candidate` for `??`, `||` and Python `or`,
|
||||||
|
which are often logic rather than a default, and `justified?` when a
|
||||||
|
comment sits on the line or directly above it, for the agent to judge.
|
||||||
|
|
||||||
|
Permitted, never reported: None, null, nil, undefined, false and the empty
|
||||||
|
string, which denote absence, and empty containers ([], {}, (), list(),
|
||||||
|
Vec::new() and the like), which denote it as "" does. A Python parameter
|
||||||
|
defaulting to a list, dict or set is still reported: every call shares that
|
||||||
|
one mutable object.
|
||||||
|
|
||||||
|
Not detected: Go zero values (no syntax marks them), Helm values.yaml (every
|
||||||
|
key there is a default by design), Docker ENV placeholders and Java overloads
|
||||||
|
standing in for optional parameters (both need judgement), and `||` with a
|
||||||
|
non-literal right operand (mostly logic).
|
||||||
|
|
||||||
|
Exit codes:
|
||||||
|
0 no hits, 1 hits, 2 usage error, git failure, or a file that cannot be
|
||||||
|
read or, for Python, parsed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import ast
|
||||||
|
import bisect
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tokenize
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from pathlib import Path, PurePosixPath
|
||||||
|
|
||||||
|
WIDTH = 80
|
||||||
|
WINDOW = 200
|
||||||
|
FREE = frozenset(
|
||||||
|
{
|
||||||
|
"", "''", '""', "``", "false", "none", "null", "nil", "undefined", "[]", "{}", "()",
|
||||||
|
"string.empty", "string::new()", "vec::new()", "vec![]",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
EMPTY_CALLS = frozenset({"list", "dict", "set", "tuple", "frozenset", "str", "bytes"})
|
||||||
|
MUTABLE_CALLS = frozenset({"list", "dict", "set"})
|
||||||
|
LITERALS = (ast.Constant, ast.List, ast.Tuple, ast.Set, ast.Dict, ast.JoinedStr)
|
||||||
|
QUOTES = "'\"`"
|
||||||
|
OPENERS = "([{"
|
||||||
|
CLOSERS = ")]}"
|
||||||
|
KEYWORDS = frozenset(
|
||||||
|
{
|
||||||
|
"if", "for", "foreach", "while", "switch", "catch", "using", "lock", "fixed", "return",
|
||||||
|
"typeof", "sizeof", "nameof", "await", "new", "do", "else", "with", "void", "delete", "in",
|
||||||
|
"of", "yield", "case", "throw", "super", "base", "this", "default", "when", "checked",
|
||||||
|
"unchecked",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
NULL_CHECK = {
|
||||||
|
"js": "`if (a === undefined) throw`",
|
||||||
|
"ts": "`if (a === undefined) throw`",
|
||||||
|
"csharp": "an explicit null check that throws",
|
||||||
|
}
|
||||||
|
|
||||||
|
HASH = r"(?:^|(?<=[ \t]))#[^\n]*"
|
||||||
|
LINE_HASH = r"^[ \t]*#[^\n]*"
|
||||||
|
SLASH = r"//[^\n]*|/\*.*?\*/"
|
||||||
|
JINJA_COMMENT = r"\{#.*?#\}"
|
||||||
|
SQL_COMMENT = r"--[^\n]*|/\*.*?\*/"
|
||||||
|
DQ = r'"(?:\\.|[^"\\\n])*"'
|
||||||
|
SQ = r"'(?:\\.|[^'\\\n])*'"
|
||||||
|
BACKTICK = r"`(?:\\.|[^`\\])*`"
|
||||||
|
CHAR = r"'(?:\\.|[^'\\\n])'"
|
||||||
|
SQL_STRING = r"'(?:''|[^'])*'"
|
||||||
|
|
||||||
|
HUNK = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@")
|
||||||
|
JINJA_SUFFIX = re.compile(r"\.(?:j2|jinja2?)$")
|
||||||
|
DOCKERFILE = re.compile(
|
||||||
|
r"^(?:Dockerfile|Containerfile)(?:\..+)?$|\.(?:dockerfile|containerfile)$", re.IGNORECASE
|
||||||
|
)
|
||||||
|
COMPOSE = re.compile(r"^(?:docker-)?compose(?:[.-][\w.-]+)?\.ya?ml$")
|
||||||
|
MAKEFILES = frozenset({"Makefile", "makefile", "GNUmakefile"})
|
||||||
|
SUFFIXES = {
|
||||||
|
".py": "python",
|
||||||
|
".sh": "shell", ".bash": "shell", ".zsh": "shell",
|
||||||
|
".mk": "make", ".make": "make",
|
||||||
|
".yml": "yaml", ".yaml": "yaml",
|
||||||
|
".js": "js", ".jsx": "js", ".mjs": "js", ".cjs": "js",
|
||||||
|
".ts": "ts", ".tsx": "ts", ".mts": "ts", ".cts": "ts",
|
||||||
|
".cs": "csharp",
|
||||||
|
".java": "java",
|
||||||
|
".rs": "rust",
|
||||||
|
".sql": "sql",
|
||||||
|
".tf": "terraform",
|
||||||
|
}
|
||||||
|
|
||||||
|
SHELL_DEFAULT = re.compile(r"\$\{([A-Za-z_]\w*|\d+)(:?[-=])([^}\n]*)\}")
|
||||||
|
MAKE_DEFAULT = re.compile(
|
||||||
|
r"^[ \t]*(?:override[ \t]+|export[ \t]+)?([A-Za-z_][\w.-]*)[ \t]*\?=[ \t]*([^\n]*)$", re.MULTILINE
|
||||||
|
)
|
||||||
|
JINJA_DEFAULT = re.compile(r"\|\s*(?:default|d)\s*\(")
|
||||||
|
ENV_LOOKUP = re.compile(r"lookup\(\s*['\"]env['\"]")
|
||||||
|
FUNCTION_BEFORE = re.compile(r"\bfunction\b[\s*]*[\w$]*\s*(?:<[^<>()]*>)?\s*\Z")
|
||||||
|
METHOD_BEFORE = re.compile(r"(?<![\w$.])([\w$]+)\s*(?:<[^<>()]*>)?\s*\Z")
|
||||||
|
METHOD_AFTER = re.compile(r"\s*(?::[^;{}]*)?(?:\{|=>|where\b)")
|
||||||
|
ARROW_AFTER = re.compile(r"\s*(?::[^=;{}()]*)?=>")
|
||||||
|
DECLARE = re.compile(r"\b(?:const|let|var)\s+([{\[])")
|
||||||
|
NULLISH = re.compile(r"\?\?=?")
|
||||||
|
OR_OPERATOR = re.compile(r"\|\|=?")
|
||||||
|
LITERAL = re.compile(r"""['"`\[{]|-?\.?\d|true\b""")
|
||||||
|
OPERAND_HEAD = re.compile(r"\s*((?:new\s+|await\s+)?-?)")
|
||||||
|
GET_OR_DEFAULT = re.compile(r"\b(?:getOrDefault|GetValueOrDefault)\s*\(")
|
||||||
|
UNWRAP_OR = re.compile(r"\.unwrap_or(_default)?\s*\(")
|
||||||
|
SERDE_DEFAULT = re.compile(r"#\[serde\([^\]]*\bdefault\b[^\]]*\]")
|
||||||
|
SQL_DEFAULT = re.compile(
|
||||||
|
r"\bDEFAULT\b(?!\s+(?:VALUES|CHARSET|CHARACTER|COLLATE|PRIVILEGES)\b)", re.IGNORECASE
|
||||||
|
)
|
||||||
|
SQL_EXEMPT = re.compile(r"\b(?:DROP|ON\s+(?:DELETE|UPDATE)\s+SET)\s+\Z", re.IGNORECASE)
|
||||||
|
SQL_VALUE = re.compile(r"\s+('(?:''|[^'])*'|[^\s,;)]+(?:\([^)]*\))?)")
|
||||||
|
TF_VARIABLE = re.compile(r"\bvariable\s+\"[^\"\n]*\"\s*\{")
|
||||||
|
TF_DEFAULT = re.compile(r"\bdefault[ \t]*=(?!=)[ \t]*")
|
||||||
|
|
||||||
|
|
||||||
|
class ScanError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Hit:
|
||||||
|
file: str
|
||||||
|
line: int
|
||||||
|
language: str
|
||||||
|
construct: str
|
||||||
|
instead: str
|
||||||
|
candidate: bool
|
||||||
|
justified: bool
|
||||||
|
|
||||||
|
|
||||||
|
def free(value: str) -> bool:
|
||||||
|
value = value.strip()
|
||||||
|
if value[:1] in ("[", "{", "("):
|
||||||
|
value = re.sub(r"\s+", "", value)
|
||||||
|
return value.lower() in FREE
|
||||||
|
|
||||||
|
|
||||||
|
def short(text: str) -> str:
|
||||||
|
flat = " ".join(text.split())
|
||||||
|
return flat if len(flat) <= WIDTH else f"{flat[: WIDTH - 3]}..."
|
||||||
|
|
||||||
|
|
||||||
|
def chars(text: str, start: int, end: int):
|
||||||
|
depth, quote, escaped = 0, "", False
|
||||||
|
for index in range(start, end):
|
||||||
|
char = text[index]
|
||||||
|
if char == "\n":
|
||||||
|
quote, escaped = "", False
|
||||||
|
if quote:
|
||||||
|
if escaped:
|
||||||
|
escaped = False
|
||||||
|
elif char == "\\":
|
||||||
|
escaped = True
|
||||||
|
elif char == quote:
|
||||||
|
quote = ""
|
||||||
|
elif char in QUOTES:
|
||||||
|
quote = char
|
||||||
|
elif char in OPENERS:
|
||||||
|
yield index, char, depth
|
||||||
|
depth += 1
|
||||||
|
elif char in CLOSERS:
|
||||||
|
depth -= 1
|
||||||
|
yield index, char, depth
|
||||||
|
else:
|
||||||
|
yield index, char, depth
|
||||||
|
|
||||||
|
|
||||||
|
def pairs(masked: str) -> dict[int, int]:
|
||||||
|
found: dict[int, int] = {}
|
||||||
|
stack: list[int] = []
|
||||||
|
for index, char, _ in chars(masked, 0, len(masked)):
|
||||||
|
if char in OPENERS:
|
||||||
|
stack.append(index)
|
||||||
|
elif char in CLOSERS and stack:
|
||||||
|
found[stack.pop()] = index
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def closing(text: str, opener: int) -> int:
|
||||||
|
for index, char, depth in chars(text, opener, len(text)):
|
||||||
|
if char in CLOSERS and depth == 0:
|
||||||
|
return index
|
||||||
|
return -1
|
||||||
|
|
||||||
|
|
||||||
|
def segments(text: str, start: int, end: int) -> list[tuple[int, int]]:
|
||||||
|
parts, begin = [], start
|
||||||
|
for index, char, depth in chars(text, start, end):
|
||||||
|
if char == "," and depth == 0:
|
||||||
|
parts.append((begin, index))
|
||||||
|
begin = index + 1
|
||||||
|
parts.append((begin, end))
|
||||||
|
return parts
|
||||||
|
|
||||||
|
|
||||||
|
def equals(masked: str, start: int, end: int) -> int:
|
||||||
|
for index, char, depth in chars(masked, start, end):
|
||||||
|
if char == "=" and depth == 0 and masked[index - 1] not in "=!<>" and masked[index + 1] not in "=>":
|
||||||
|
return index
|
||||||
|
return -1
|
||||||
|
|
||||||
|
|
||||||
|
def arguments(text: str, match: dict[int, int], opener: int) -> list[str] | None:
|
||||||
|
if opener not in match:
|
||||||
|
return None
|
||||||
|
return [text[begin:end].strip() for begin, end in segments(text, opener + 1, match[opener])]
|
||||||
|
|
||||||
|
|
||||||
|
def operand(masked: str, at: int, match: dict[int, int]) -> tuple[int, int]:
|
||||||
|
head = OPERAND_HEAD.match(masked, at)
|
||||||
|
begin, end = head.start(1), head.end()
|
||||||
|
while end < len(masked):
|
||||||
|
char = masked[end]
|
||||||
|
if char in OPENERS and end in match:
|
||||||
|
end = match[end] + 1
|
||||||
|
elif char in QUOTES and masked.find(char, end + 1) > 0:
|
||||||
|
end = masked.find(char, end + 1) + 1
|
||||||
|
elif char.isalnum() or char in "_$.:":
|
||||||
|
end += 1
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
return begin, end
|
||||||
|
|
||||||
|
|
||||||
|
def pattern_defaults(masked: str, text: str, match: dict[int, int], start: int, end: int, instead: str):
|
||||||
|
found = []
|
||||||
|
for begin, finish in segments(masked, start, end):
|
||||||
|
eq = equals(masked, begin, finish)
|
||||||
|
if eq >= 0:
|
||||||
|
value = text[eq + 1 : finish].strip()
|
||||||
|
if not free(value):
|
||||||
|
found.append((eq, short(f"{text[begin:eq].strip()} = {value}"), instead, False))
|
||||||
|
for index, char, depth in chars(masked, begin, finish if eq < 0 else eq):
|
||||||
|
if char in "{[" and depth == 0 and index in match:
|
||||||
|
found += pattern_defaults(masked, text, match, index + 1, match[index], instead)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def shell_expansion(masked: str, text: str, match: dict[int, int], lang: str):
|
||||||
|
found = []
|
||||||
|
for hit in SHELL_DEFAULT.finditer(masked):
|
||||||
|
name = hit.group(1)
|
||||||
|
if free(hit.group(3)):
|
||||||
|
continue
|
||||||
|
if lang == "shell":
|
||||||
|
instead = f': "${{{name}:?set {name} in <file>}}"'
|
||||||
|
else:
|
||||||
|
instead = f"${{{name}:?{name} is required}}"
|
||||||
|
found.append((hit.start(), short(text[hit.start() : hit.end()]), instead, False))
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def make_conditional(masked: str, text: str, match: dict[int, int], lang: str):
|
||||||
|
return [
|
||||||
|
(
|
||||||
|
hit.start(1),
|
||||||
|
short(f"{hit.group(1)} ?= {hit.group(2)}"),
|
||||||
|
f"{hit.group(1)} := $(or $({hit.group(1)}),$(error {hit.group(1)} is required))",
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
for hit in MAKE_DEFAULT.finditer(masked)
|
||||||
|
if not free(hit.group(2))
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def expression_start(masked: str, at: int) -> int:
|
||||||
|
opened = max(masked.rfind("{{", 0, at), masked.rfind("{%", 0, at))
|
||||||
|
closed = max(masked.rfind("}}", 0, at), masked.rfind("%}", 0, at))
|
||||||
|
return opened if opened > closed else masked.rfind("\n", 0, at) + 1
|
||||||
|
|
||||||
|
|
||||||
|
def jinja_default(masked: str, text: str, match: dict[int, int], lang: str):
|
||||||
|
found = []
|
||||||
|
for hit in JINJA_DEFAULT.finditer(masked):
|
||||||
|
opener = hit.end() - 1
|
||||||
|
closer = closing(masked, opener)
|
||||||
|
argument = text[slice(*segments(masked, opener + 1, closer)[0])].strip() if closer > 0 else "..."
|
||||||
|
env = ENV_LOOKUP.search(masked, expression_start(masked, hit.start()), hit.start())
|
||||||
|
if free(argument) and not env:
|
||||||
|
continue
|
||||||
|
construct = f"lookup('env', ...) | default({argument})" if env else f"| default({argument})"
|
||||||
|
found.append((hit.start(), short(construct), "| mandatory, or an assert on the variable", False))
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def parameters(masked: str, text: str, match: dict[int, int], lang: str):
|
||||||
|
found = []
|
||||||
|
for opener, closer in match.items():
|
||||||
|
if masked[opener] != "(":
|
||||||
|
continue
|
||||||
|
low = max(0, opener - WINDOW)
|
||||||
|
name = METHOD_BEFORE.search(masked, low, opener)
|
||||||
|
if (
|
||||||
|
FUNCTION_BEFORE.search(masked, low, opener)
|
||||||
|
or ARROW_AFTER.match(masked, closer + 1)
|
||||||
|
or (name and name.group(1) not in KEYWORDS and METHOD_AFTER.match(masked, closer + 1))
|
||||||
|
):
|
||||||
|
found += pattern_defaults(masked, text, match, opener + 1, closer, "a required parameter")
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def destructuring(masked: str, text: str, match: dict[int, int], lang: str):
|
||||||
|
found = []
|
||||||
|
for hit in DECLARE.finditer(masked):
|
||||||
|
opener = hit.start(1)
|
||||||
|
if opener in match:
|
||||||
|
found += pattern_defaults(
|
||||||
|
masked, text, match, opener + 1, match[opener], f"no default; {NULL_CHECK[lang]}"
|
||||||
|
)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def nullish(masked: str, text: str, match: dict[int, int], lang: str):
|
||||||
|
found = []
|
||||||
|
for hit in NULLISH.finditer(masked):
|
||||||
|
value = text[slice(*operand(masked, hit.end(), match))]
|
||||||
|
if not free(value) and value != "throw":
|
||||||
|
found.append((hit.start(), short(f"{hit.group()} {value}"), NULL_CHECK[lang], True))
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def or_literal(masked: str, text: str, match: dict[int, int], lang: str):
|
||||||
|
found = []
|
||||||
|
for hit in OR_OPERATOR.finditer(masked):
|
||||||
|
value = text[slice(*operand(masked, hit.end(), match))]
|
||||||
|
if LITERAL.match(value) and not free(value):
|
||||||
|
found.append((hit.start(), short(f"{hit.group()} {value}"), NULL_CHECK[lang], True))
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_default(masked: str, text: str, match: dict[int, int], lang: str):
|
||||||
|
found = []
|
||||||
|
for hit in GET_OR_DEFAULT.finditer(masked):
|
||||||
|
opener = hit.end() - 1
|
||||||
|
args = arguments(text, match, opener)
|
||||||
|
if args is not None and len(args) == 2 and free(args[1]):
|
||||||
|
continue
|
||||||
|
end = match[opener] + 1 if args is not None else hit.end()
|
||||||
|
found.append((hit.start(), short(text[hit.start() : end]), "an explicit null check that throws", False))
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def unwrap_or(masked: str, text: str, match: dict[int, int], lang: str):
|
||||||
|
found = []
|
||||||
|
for hit in UNWRAP_OR.finditer(masked):
|
||||||
|
opener = hit.end() - 1
|
||||||
|
args = arguments(text, match, opener)
|
||||||
|
if not hit.group(1) and args is not None and len(args) == 1 and free(args[0]):
|
||||||
|
continue
|
||||||
|
end = match[opener] + 1 if args is not None else hit.end()
|
||||||
|
found.append((hit.start(), short(text[hit.start() : end]), "ok_or(Error::MissingX)?", False))
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def serde_default(masked: str, text: str, match: dict[int, int], lang: str):
|
||||||
|
return [
|
||||||
|
(hit.start(), short(text[hit.start() : hit.end()]), "a required field, #[serde(deny_unknown_fields)]", False)
|
||||||
|
for hit in SERDE_DEFAULT.finditer(masked)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def sql_default(masked: str, text: str, match: dict[int, int], lang: str):
|
||||||
|
found = []
|
||||||
|
for hit in SQL_DEFAULT.finditer(masked):
|
||||||
|
if SQL_EXEMPT.search(masked, max(0, hit.start() - WINDOW), hit.start()):
|
||||||
|
continue
|
||||||
|
value = SQL_VALUE.match(text, hit.end())
|
||||||
|
if value and not free(value.group(1)):
|
||||||
|
found.append(
|
||||||
|
(hit.start(), short(f"DEFAULT {value.group(1)}"), "NOT NULL, and supply it in every insert", False)
|
||||||
|
)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def terraform_default(masked: str, text: str, match: dict[int, int], lang: str):
|
||||||
|
found = []
|
||||||
|
for block in TF_VARIABLE.finditer(masked):
|
||||||
|
opener = block.end() - 1
|
||||||
|
if opener not in match:
|
||||||
|
continue
|
||||||
|
closer = match[opener]
|
||||||
|
for hit in TF_DEFAULT.finditer(masked, opener + 1, closer):
|
||||||
|
start = hit.end()
|
||||||
|
value = text[start : match[start] + 1] if start in match else text[start:closer].split("\n", 1)[0]
|
||||||
|
if not free(value):
|
||||||
|
found.append(
|
||||||
|
(hit.start(), short(f"default = {value}"), "no default; fail on the missing input", False)
|
||||||
|
)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
JS = (f"{DQ}|{SQ}|{BACKTICK}", SLASH, (parameters, destructuring, nullish, or_literal))
|
||||||
|
LANGS = {
|
||||||
|
"shell": ("", HASH, (shell_expansion,)),
|
||||||
|
"env": ("", LINE_HASH, (shell_expansion,)),
|
||||||
|
"make": ("", HASH, (make_conditional, shell_expansion)),
|
||||||
|
"dockerfile": ("", LINE_HASH, (shell_expansion,)),
|
||||||
|
"compose": ("", HASH, (shell_expansion,)),
|
||||||
|
"yaml": ("", HASH, (jinja_default, shell_expansion)),
|
||||||
|
"jinja": ("", JINJA_COMMENT, (jinja_default,)),
|
||||||
|
"js": JS,
|
||||||
|
"ts": JS,
|
||||||
|
"csharp": (f"{DQ}|{CHAR}", SLASH, (parameters, nullish, get_or_default)),
|
||||||
|
"java": (f"{DQ}|{CHAR}", SLASH, (get_or_default,)),
|
||||||
|
"rust": (f"{DQ}|{CHAR}", SLASH, (unwrap_or, serde_default)),
|
||||||
|
"sql": (SQL_STRING, SQL_COMMENT, (sql_default,)),
|
||||||
|
"terraform": (DQ, f"{HASH}|{SLASH}", (terraform_default,)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def language(name: str) -> str:
|
||||||
|
if DOCKERFILE.search(name):
|
||||||
|
return "dockerfile"
|
||||||
|
if COMPOSE.match(name):
|
||||||
|
return "compose"
|
||||||
|
if name in MAKEFILES:
|
||||||
|
return "make"
|
||||||
|
if name == ".env" or name.startswith(".env.") or name.endswith(".env"):
|
||||||
|
return "env"
|
||||||
|
return SUFFIXES.get(PurePosixPath(name).suffix, "")
|
||||||
|
|
||||||
|
|
||||||
|
def languages(path: str) -> list[str]:
|
||||||
|
name = PurePosixPath(path).name
|
||||||
|
stem = JINJA_SUFFIX.sub("", name)
|
||||||
|
inner = language(stem)
|
||||||
|
if stem == name:
|
||||||
|
return [inner] if inner else []
|
||||||
|
return ["jinja"] + ([inner] if inner and inner != "python" else [])
|
||||||
|
|
||||||
|
|
||||||
|
def mask(text: str, pattern: re.Pattern[str]) -> tuple[str, list[tuple[int, int]]]:
|
||||||
|
comments: list[tuple[int, int]] = []
|
||||||
|
|
||||||
|
def blank(match: re.Match[str]) -> str:
|
||||||
|
body = re.sub(r"[^\n]", " ", match.group())
|
||||||
|
if match.lastgroup == "comment":
|
||||||
|
comments.append(match.span())
|
||||||
|
return body
|
||||||
|
return match.group()[0] + body[1:-1] + match.group()[-1]
|
||||||
|
|
||||||
|
return pattern.sub(blank, text), comments
|
||||||
|
|
||||||
|
|
||||||
|
def scan_masked(path: str, text: str, langs: list[str], wanted: set[int] | None) -> list[Hit]:
|
||||||
|
strings = "|".join(LANGS[lang][0] for lang in langs if LANGS[lang][0])
|
||||||
|
source = "(?P<comment>" + "|".join(LANGS[lang][1] for lang in langs) + ")"
|
||||||
|
if strings:
|
||||||
|
source += f"|(?P<string>{strings})"
|
||||||
|
masked, spans = mask(text, re.compile(source, re.MULTILINE | re.DOTALL))
|
||||||
|
starts = [0] + [newline.end() for newline in re.finditer("\n", text)]
|
||||||
|
commented: set[int] = set()
|
||||||
|
for begin, end in spans:
|
||||||
|
commented.update(
|
||||||
|
range(bisect.bisect_right(starts, begin), bisect.bisect_right(starts, max(begin, end - 1)) + 1)
|
||||||
|
)
|
||||||
|
rows = masked.split("\n")
|
||||||
|
comment_only = {line for line in commented if not rows[line - 1].strip()}
|
||||||
|
match = pairs(masked)
|
||||||
|
hits, seen = [], set()
|
||||||
|
for lang in langs:
|
||||||
|
for rule in LANGS[lang][2]:
|
||||||
|
if rule in seen:
|
||||||
|
continue
|
||||||
|
seen.add(rule)
|
||||||
|
for offset, construct, instead, candidate in rule(masked, text, match, lang):
|
||||||
|
line = bisect.bisect_right(starts, offset)
|
||||||
|
if wanted is None or line in wanted:
|
||||||
|
justified = line in commented or line - 1 in comment_only
|
||||||
|
hits.append(Hit(path, line, lang, construct, instead, candidate, justified))
|
||||||
|
return hits
|
||||||
|
|
||||||
|
|
||||||
|
def absent(node: ast.expr) -> bool:
|
||||||
|
if isinstance(node, ast.Constant):
|
||||||
|
value = node.value
|
||||||
|
return value is None or value is False or (isinstance(value, (str, bytes)) and not value)
|
||||||
|
if isinstance(node, (ast.List, ast.Tuple, ast.Set)):
|
||||||
|
return not node.elts
|
||||||
|
if isinstance(node, ast.Dict):
|
||||||
|
return not node.keys
|
||||||
|
return (
|
||||||
|
isinstance(node, ast.Call)
|
||||||
|
and isinstance(node.func, ast.Name)
|
||||||
|
and node.func.id in EMPTY_CALLS
|
||||||
|
and not node.args
|
||||||
|
and not node.keywords
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def mutable(node: ast.expr) -> bool:
|
||||||
|
if isinstance(node, (ast.List, ast.Dict, ast.Set)):
|
||||||
|
return True
|
||||||
|
return isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id in MUTABLE_CALLS
|
||||||
|
|
||||||
|
|
||||||
|
def parameter_defaults(node: ast.FunctionDef | ast.AsyncFunctionDef | ast.Lambda):
|
||||||
|
args = node.args
|
||||||
|
positional = args.posonlyargs + args.args
|
||||||
|
paired = list(zip(positional[len(positional) - len(args.defaults) :], args.defaults))
|
||||||
|
paired += [(arg, value) for arg, value in zip(args.kwonlyargs, args.kw_defaults) if value is not None]
|
||||||
|
owner = "lambda" if isinstance(node, ast.Lambda) else node.name
|
||||||
|
found = []
|
||||||
|
for arg, value in paired:
|
||||||
|
construct = short(f"{owner}({arg.arg}={ast.unparse(value)})")
|
||||||
|
if mutable(value):
|
||||||
|
instead = "None, and a branch that builds a fresh object: every call shares a mutable default"
|
||||||
|
found.append((value.lineno, construct, instead, False))
|
||||||
|
elif not absent(value):
|
||||||
|
found.append((value.lineno, construct, "a required parameter", False))
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def call_default(node: ast.Call):
|
||||||
|
func = node.func
|
||||||
|
keywords = {keyword.arg: keyword.value for keyword in node.keywords if keyword.arg}
|
||||||
|
attribute = func.attr if isinstance(func, ast.Attribute) else ""
|
||||||
|
name = func.id if isinstance(func, ast.Name) else ""
|
||||||
|
if attribute == "add_argument":
|
||||||
|
fallback, instead = keywords.get("default"), "add_argument(..., required=True)"
|
||||||
|
elif name == "getattr" and len(node.args) == 3:
|
||||||
|
fallback = node.args[2]
|
||||||
|
instead = f"getattr({ast.unparse(node.args[0])}, {ast.unparse(node.args[1])})"
|
||||||
|
elif "getenv" in (attribute, name) or attribute == "get":
|
||||||
|
fallback = node.args[1] if len(node.args) == 2 else keywords.get("default")
|
||||||
|
owner = ast.unparse(func.value) if attribute == "get" else "os.environ"
|
||||||
|
instead = "os.environ[name]" if owner.endswith("environ") else f"{owner}[name]"
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
if fallback is None or absent(fallback):
|
||||||
|
return []
|
||||||
|
if attribute == "add_argument":
|
||||||
|
construct = f"add_argument(default={ast.unparse(fallback)})"
|
||||||
|
else:
|
||||||
|
construct = ast.unparse(node)
|
||||||
|
return [(fallback.lineno, short(construct), instead, False)]
|
||||||
|
|
||||||
|
|
||||||
|
def python_rules(node: ast.AST):
|
||||||
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)):
|
||||||
|
return parameter_defaults(node)
|
||||||
|
if isinstance(node, ast.Call):
|
||||||
|
return call_default(node)
|
||||||
|
if isinstance(node, ast.BoolOp) and isinstance(node.op, ast.Or):
|
||||||
|
last = node.values[-1]
|
||||||
|
if isinstance(last, LITERALS) and not absent(last):
|
||||||
|
construct = short(f"or {ast.unparse(last)}")
|
||||||
|
return [(last.lineno, construct, "a check that raises on the missing value", True)]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def scan_python(path: str, text: str, wanted: set[int] | None) -> list[Hit]:
|
||||||
|
try:
|
||||||
|
tree = ast.parse(text, filename=path)
|
||||||
|
tokens = list(tokenize.generate_tokens(io.StringIO(text).readline))
|
||||||
|
except (SyntaxError, ValueError, tokenize.TokenError) as error:
|
||||||
|
raise ScanError(f"{path}: unparseable Python: {error}") from error
|
||||||
|
commented = {token.start[0] for token in tokens if token.type == tokenize.COMMENT}
|
||||||
|
comment_only = {line for line, row in enumerate(text.split("\n"), 1) if row.lstrip().startswith("#")}
|
||||||
|
hits = []
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
for line, construct, instead, candidate in python_rules(node):
|
||||||
|
if wanted is None or line in wanted:
|
||||||
|
justified = line in commented or line - 1 in comment_only
|
||||||
|
hits.append(Hit(path, line, "python", construct, instead, candidate, justified))
|
||||||
|
return hits
|
||||||
|
|
||||||
|
|
||||||
|
def scan(path: str, text: str, wanted: set[int] | None) -> list[Hit]:
|
||||||
|
langs = languages(path)
|
||||||
|
if langs == ["python"]:
|
||||||
|
return scan_python(path, text, wanted)
|
||||||
|
return scan_masked(path, text, langs, wanted)
|
||||||
|
|
||||||
|
|
||||||
|
def git(root: Path, *args: str) -> str:
|
||||||
|
result = subprocess.run(["git", *args], cwd=root, capture_output=True, check=False)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise ScanError(f"git {args[0]}: {result.stderr.decode('utf-8', 'replace').strip()}")
|
||||||
|
return result.stdout.decode("utf-8", "replace")
|
||||||
|
|
||||||
|
|
||||||
|
def unquote(name: str) -> str:
|
||||||
|
if name.startswith('"'):
|
||||||
|
return ast.literal_eval(f"b{name}").decode("utf-8", "replace")
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def added_lines(root: Path, staged: bool) -> dict[str, set[int]]:
|
||||||
|
diff = git(
|
||||||
|
root, "diff", "--cached" if staged else "HEAD", "--no-color", "--no-ext-diff", "--no-textconv",
|
||||||
|
"--find-renames", "--unified=0", "--src-prefix=a/", "--dst-prefix=b/",
|
||||||
|
)
|
||||||
|
added: dict[str, set[int]] = {}
|
||||||
|
path, line, header = None, 0, False
|
||||||
|
for row in diff.split("\n"):
|
||||||
|
if row.startswith("diff "):
|
||||||
|
path, header = None, True
|
||||||
|
elif header and row.startswith("+++ "):
|
||||||
|
target = unquote(row[4:].removesuffix("\t"))
|
||||||
|
path = target[2:] if target.startswith("b/") else None
|
||||||
|
elif row.startswith("@@"):
|
||||||
|
header = False
|
||||||
|
hunk = HUNK.match(row)
|
||||||
|
if hunk:
|
||||||
|
line = int(hunk.group(1))
|
||||||
|
else:
|
||||||
|
path = None
|
||||||
|
elif path and not header and row.startswith("+"):
|
||||||
|
added.setdefault(path, set()).add(line)
|
||||||
|
line += 1
|
||||||
|
return added
|
||||||
|
|
||||||
|
|
||||||
|
def read(path: Path) -> str:
|
||||||
|
try:
|
||||||
|
return path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
except OSError as error:
|
||||||
|
raise ScanError(f"{path}: {error.strerror}") from error
|
||||||
|
|
||||||
|
|
||||||
|
def collect(repo: Path, staged: bool, files: list[str]) -> list[Hit]:
|
||||||
|
if not repo.is_dir():
|
||||||
|
raise ScanError(f"{repo} is not a directory")
|
||||||
|
root = Path(git(repo, "rev-parse", "--show-toplevel").strip()).resolve()
|
||||||
|
targets: dict[str, tuple[str, set[int] | None]] = {}
|
||||||
|
for path, lines in added_lines(root, staged).items():
|
||||||
|
if languages(path):
|
||||||
|
text = git(root, "cat-file", "blob", f":{path}") if staged else read(root / path)
|
||||||
|
targets[path] = (text, lines)
|
||||||
|
for name in files:
|
||||||
|
target = (repo / name).resolve()
|
||||||
|
if not target.is_file():
|
||||||
|
raise ScanError(f"{name}: no such file")
|
||||||
|
shown = target.relative_to(root).as_posix() if target.is_relative_to(root) else str(target)
|
||||||
|
if not languages(shown):
|
||||||
|
print(f"defaults: {shown}: no rules for this file type", file=sys.stderr)
|
||||||
|
continue
|
||||||
|
targets[shown] = (read(target), None)
|
||||||
|
hits = []
|
||||||
|
for path, (text, wanted) in targets.items():
|
||||||
|
hits += scan(path, text, wanted)
|
||||||
|
return sorted(hits, key=lambda hit: (hit.file, hit.line, hit.language, hit.construct))
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
|
parser.add_argument("--repo", required=True)
|
||||||
|
parser.add_argument("--staged", action="store_true")
|
||||||
|
parser.add_argument("--file", action="append", default=[])
|
||||||
|
parser.add_argument("--json", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
try:
|
||||||
|
hits = collect(Path(args.repo), args.staged, args.file)
|
||||||
|
except ScanError as error:
|
||||||
|
print(f"defaults: {error}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps([asdict(hit) for hit in hits], indent=1))
|
||||||
|
return 1 if hits else 0
|
||||||
|
for hit in hits:
|
||||||
|
marks = ",".join(mark for mark, on in (("candidate", hit.candidate), ("justified?", hit.justified)) if on)
|
||||||
|
print("\t".join([f"{hit.file}:{hit.line}", hit.language, hit.construct, hit.instead] + ([marks] if marks else [])))
|
||||||
|
return 1 if hits else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
---
|
||||||
|
name: kvb-code-refactor
|
||||||
|
description: >
|
||||||
|
Refactor every uncommitted file to the project's standards without
|
||||||
|
changing behaviour. Trigger when asked to clean up, tidy or polish the
|
||||||
|
current changes.
|
||||||
|
---
|
||||||
|
|
||||||
|
The unit of work is the uncommitted change set, not the repository. Behaviour
|
||||||
|
stays identical: a refactor that changes what the code does is a bug.
|
||||||
|
|
||||||
|
## Step 1: fix the file set
|
||||||
|
|
||||||
|
Read it from `git status --porcelain`, never from memory: modified, added,
|
||||||
|
renamed and untracked files, without deleted files, binaries, lockfiles,
|
||||||
|
vendored trees and generated output. Restate the list in one line before
|
||||||
|
touching anything; if it is empty, say so and stop.
|
||||||
|
|
||||||
|
Never `git stash` or revert a working-tree file to compare: others share this
|
||||||
|
tree. Read the committed version with `git show HEAD:<path>` into a temporary
|
||||||
|
file.
|
||||||
|
|
||||||
|
## Step 2: get a safety net first
|
||||||
|
|
||||||
|
Behaviour stays provable only through a check before and after: tests that
|
||||||
|
cover the change set run now and give the green baseline; a suite already red
|
||||||
|
is reported, not refactored around. No coverage: read `net.md` next to this
|
||||||
|
file before editing.
|
||||||
|
|
||||||
|
Light runs (targeted tests, ruff, single lint files) start once `watch.py
|
||||||
|
admit --kind test-nostack` of the `kvb-org-organize` skill prints `grant`; no
|
||||||
|
chair request.
|
||||||
|
|
||||||
|
## Step 3: refactor each file
|
||||||
|
|
||||||
|
Read the whole file first; the project's own standards (formatter config,
|
||||||
|
linter rules, existing idioms) outrank generic preference. Find callers,
|
||||||
|
duplicates and existing helpers through the codebase-memory-mcp graph first
|
||||||
|
(`search_graph`, `trace_path`, `get_architecture`; the `kvb-agent-optimize`
|
||||||
|
skill sets it up), grep and read after.
|
||||||
|
|
||||||
|
- **SRP**: one file, one responsibility; one function, one reason to change.
|
||||||
|
A unit that needs an "and" to describe it is two.
|
||||||
|
- **SPOT**: every fact (constant, path, schema, rule) lives in one place;
|
||||||
|
everything else references it.
|
||||||
|
- **KISS**: the simplest construct that works; no abstraction without a
|
||||||
|
second caller today, no configuration for a value that never varies.
|
||||||
|
- **DRY**: logic is written once; a literal repeated three times is a missing
|
||||||
|
constant.
|
||||||
|
- **File size**: read `size.md` next to this file for a file over 250 lines.
|
||||||
|
- **Duplication**: fold repeated logic into the existing helper; a new one
|
||||||
|
only when two or more call sites need it now.
|
||||||
|
- **Dead weight**: unused imports, variables and parameters, unreachable
|
||||||
|
branches, single-value flags, one-implementation abstractions.
|
||||||
|
- **Naming**: names state what a thing is, in the file's convention.
|
||||||
|
- **Shape**: split a function that needs a scroll; return early instead of
|
||||||
|
nesting; magic values become a named constant at the use site.
|
||||||
|
- **Errors**: never widen or swallow a caught exception, never remove a
|
||||||
|
validation or security check to shorten code.
|
||||||
|
- **Comments, defaults**: the `kvb-code-clean` skill.
|
||||||
|
|
||||||
|
Never reformat a region without a substantive fix: cosmetic churn buries the
|
||||||
|
real change. An architectural problem the change set cannot fix is reported,
|
||||||
|
not half-done.
|
||||||
|
|
||||||
|
## Step 4: keep behaviour provable
|
||||||
|
|
||||||
|
- Re-run the step 2 net; require the baseline's green result.
|
||||||
|
- Never fold a bug fix, feature or dependency change into a refactor; name a
|
||||||
|
found bug in the report for a separate change.
|
||||||
|
- Run the project's cheap checks for the touched files (formatter, linter,
|
||||||
|
the module's unit tests). Never start a full suite, build or deploy on your
|
||||||
|
own: propose it for the operator to trigger.
|
||||||
|
- Report what was verified and what was not, an unverified refactor as
|
||||||
|
unverified.
|
||||||
|
|
||||||
|
## Step 5: report
|
||||||
|
|
||||||
|
One line per file: what changed and why it is safe. Then one line on what was
|
||||||
|
deliberately left alone (architectural findings, bugs, skipped files) and the
|
||||||
|
exact command the operator can run to validate. Commit only when the operator
|
||||||
|
asked.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Refactor: no test covers the change set
|
||||||
|
|
||||||
|
- **Testable logic**: write characterization tests first, against the
|
||||||
|
current behaviour, including the behaviour you consider wrong, so the
|
||||||
|
refactor cannot silently change it. Keep them small and framework-free
|
||||||
|
unless the project already has a framework. Fix the captured wrongness in
|
||||||
|
a separate change, never in this one.
|
||||||
|
- **Not sensibly testable**: pure glue, declarative config, a thin wrapper
|
||||||
|
over a service the test would have to mock into meaninglessness, or a
|
||||||
|
project with no test setup at all. Say so in one line and refactor without
|
||||||
|
a net; do not invent a test harness the project never asked for.
|
||||||
|
|
||||||
|
The new tests are part of the deliverable and must still pass unchanged
|
||||||
|
after the refactor. A test that had to be edited to stay green means the
|
||||||
|
behaviour moved: revert the refactor, not the test.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Refactor: a file over 250 lines
|
||||||
|
|
||||||
|
No file containing program code exceeds 250 lines. Split the overflow along
|
||||||
|
the SRP seam: the size is the symptom, not the cause. Exempt are description
|
||||||
|
and markup languages (Markdown, YAML, JSON, HTML, translation and data files)
|
||||||
|
and the rare file the language or framework genuinely forbids splitting:
|
||||||
|
name that reason in the report; "it was easier" does not qualify.
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
---
|
||||||
|
name: kvb-code-scope-guard
|
||||||
|
description: >
|
||||||
|
Deliver exactly what was asked. Trigger on every task creating files,
|
||||||
|
commands or commits, on autonomous runs and hand-overs, and when asked why
|
||||||
|
something unrequested exists.
|
||||||
|
---
|
||||||
|
|
||||||
|
The requested scope is the deliverable. A problem you noticed behind the
|
||||||
|
request is not an invitation to solve it.
|
||||||
|
|
||||||
|
Before creating anything the request did not name, answer one question: which
|
||||||
|
sentence of the request asks for this? No sentence, no artifact.
|
||||||
|
|
||||||
|
1. No permanent scaffolding for a one-off need. A migration you run once needs
|
||||||
|
a throwaway script, not a CLI subcommand that ships forever. Do the thing,
|
||||||
|
then delete the scaffolding.
|
||||||
|
2. Path-scope every `git add`. Never `-A`, never `.`. The operator's tree
|
||||||
|
carries their own edits and their local config, and sweeping those into
|
||||||
|
your bundle makes them undo your work by hand.
|
||||||
|
3. An adjacent defect gets reported in one line, not fixed, unless it blocks
|
||||||
|
the request. Fixing it quietly puts an unreviewed change in someone else's
|
||||||
|
commit.
|
||||||
|
4. An instruction to hand something over is complete on its own. Print the
|
||||||
|
artifact in the shape asked for: do not optimize, reorder or deduplicate it,
|
||||||
|
do not wrap it in a command that consumes it, and do not append commentary,
|
||||||
|
next steps, confidence blocks or offers. A narrowing qualifier ("just the
|
||||||
|
line") re-shapes the earlier request; re-emit narrower without re-deriving
|
||||||
|
the content. A problem with the artifact is worth one sentence before it,
|
||||||
|
never a rewrite of it.
|
||||||
|
|
||||||
|
When something genuinely seems missing, say so in one sentence and finish the
|
||||||
|
requested scope anyway. Widening the scope is the operator's call.
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
---
|
||||||
|
name: kvb-code-test
|
||||||
|
description: >
|
||||||
|
Run the Makefile test targets the operator picks and report; fix mode loops
|
||||||
|
until green. Trigger when asked to run or fix the tests.
|
||||||
|
---
|
||||||
|
|
||||||
|
The Makefile is the source of truth for how this project runs tests. Never
|
||||||
|
invent a command (`pytest`, `npm test`, `go test`) while a Makefile target
|
||||||
|
exists: the target carries the project's env vars, build dependencies and
|
||||||
|
container setup.
|
||||||
|
|
||||||
|
## Step 1: find the targets
|
||||||
|
|
||||||
|
From the repository root, `grep -nE '^test[A-Za-z0-9_.-]*:' Makefile`, plus
|
||||||
|
every included makefile (`include foo.mk`). Read the matched rules and their
|
||||||
|
prerequisites, so the list can say what each one does (delegated script,
|
||||||
|
container build, sub-targets).
|
||||||
|
|
||||||
|
- **No Makefile**: say so in one line, name the runner the project does use
|
||||||
|
(`pyproject.toml`, `package.json`, `tox.ini`, CI workflow), and ask before
|
||||||
|
running anything.
|
||||||
|
- **No `test*` target**: report the targets that exist and stop.
|
||||||
|
- **Aggregate target** (recipe only other test targets, e.g.
|
||||||
|
`test: test-unit test-integration`): the "run everything" entry; mark it so,
|
||||||
|
never expand it silently.
|
||||||
|
|
||||||
|
## Step 2: offer the selection
|
||||||
|
|
||||||
|
If the operator already named a target, skip this and run it. Otherwise one
|
||||||
|
`AskUserQuestion`, `multiSelect: true`: the targets, each with a one-line
|
||||||
|
description, the aggregate first, labelled as the complete suite. With more
|
||||||
|
than 4 targets, first print the **complete** list as text, one line per
|
||||||
|
target, then offer the 4 most useful (aggregate first, then those matching
|
||||||
|
the operator's intent or the uncommitted files) and note that "Other" accepts
|
||||||
|
any listed name. Never silently truncate.
|
||||||
|
|
||||||
|
## Step 3: run
|
||||||
|
|
||||||
|
Before each run, admit it under the `kvb-org-organize` Capacity rules: a
|
||||||
|
light run (no Docker, about a minute: targeted pytest or unittest, ruff, a
|
||||||
|
single lint file) self-admits with
|
||||||
|
`${CLAUDE_SKILL_DIR}/../kvb-org-organize/scripts/watch.py admit --kind
|
||||||
|
test-nostack`; `make test`, quality gates and anything with Docker run in the
|
||||||
|
background through
|
||||||
|
`${CLAUDE_SKILL_DIR}/../kvb-org-queue/scripts/queue.py hold --kind <kind>
|
||||||
|
--task <register task> -- make <target>`, which waits for the rule-based
|
||||||
|
grant and releases on exit.
|
||||||
|
|
||||||
|
Run the selected targets in the operator's order, one `make <target>` per
|
||||||
|
command so a failure is attributable. No `-k`, no reordering, no faster
|
||||||
|
substitute. A light target needing a long timeout gets it on the Bash call
|
||||||
|
instead of blind backgrounding. Stop after the first failing
|
||||||
|
target unless the operator asked for all: a later suite against a broken build
|
||||||
|
produces noise, not information.
|
||||||
|
|
||||||
|
## Step 4: report
|
||||||
|
|
||||||
|
Per target: pass or fail, the failing test names and the exact error output,
|
||||||
|
quoted verbatim, never paraphrased. Then one line with the exact command
|
||||||
|
reproducing the failure alone.
|
||||||
|
|
||||||
|
Fix nothing unless asked; report first. A request to make the tests pass, fix
|
||||||
|
them or get the suite green (`--fix`) switches to fix mode: read `fix.md`
|
||||||
|
next to this file before its first iteration.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Fix mode
|
||||||
|
|
||||||
|
The contract is narrow: the code becomes correct, the tests stay honest.
|
||||||
|
|
||||||
|
One iteration is: run -> read the failure -> one root cause -> one fix ->
|
||||||
|
re-run. Never batch several speculative fixes into one iteration; a green run
|
||||||
|
after three simultaneous changes proves nothing about which one mattered.
|
||||||
|
|
||||||
|
- **Run** the failing target alone, the fastest command that reproduces the
|
||||||
|
failure. Widen back to the full selection only when that target is green.
|
||||||
|
- **Diagnose** from the actual error output before touching a file. Locate
|
||||||
|
the code through the codebase-memory-mcp graph first (`search_graph`,
|
||||||
|
`trace_path`, `get_architecture`; the `kvb-agent-optimize` skill sets it
|
||||||
|
up), grep and read after. Apply the `kvb-ci-triage` skill to reach a
|
||||||
|
verified root cause; for a cause still unclear after one look, apply
|
||||||
|
`kvb-think-dialectic` rather than guessing twice. Say in one line which side
|
||||||
|
is wrong:
|
||||||
|
- **Code**: the test states the intended behaviour. Fix the code.
|
||||||
|
- **Test**: it encodes an outdated or incorrect expectation. Fix it only with
|
||||||
|
a stated reason why the old expectation was wrong, never because the code
|
||||||
|
disagrees with it.
|
||||||
|
- **Environment**: missing dependency, stale container, absent env var,
|
||||||
|
unbuilt artefact. Fix the environment or report it; do not patch code
|
||||||
|
around it.
|
||||||
|
- **Fix** with the smallest change that addresses the root cause, in the
|
||||||
|
project's idioms and under the `kvb-code-clean` rules. Forbidden without
|
||||||
|
explicit operator approval:
|
||||||
|
deleting, skipping, `xfail`-ing or commenting out a failing test; loosening
|
||||||
|
an assertion to what the code produces; swallowing the exception the test
|
||||||
|
surfaces; retries, sleeps or reruns over a flaky failure; disabling a linter
|
||||||
|
rule, a type check or a target. Any of these is a report item, not a fix: a
|
||||||
|
suite that is green because the failing test no longer runs is a regression
|
||||||
|
disguised as success.
|
||||||
|
- **Re-run** the same target, then every target that was already green; only
|
||||||
|
a full green selection ends the loop. Every re-run is admitted like the
|
||||||
|
first run (Step 3 of `SKILL.md`).
|
||||||
|
|
||||||
|
Stop and report, without asking for permission to stop:
|
||||||
|
|
||||||
|
- **Green**: the full selection passes.
|
||||||
|
- **No progress**: the same failure survives 3 iterations, or the failure count
|
||||||
|
stops falling across 3 iterations. Report the root cause reached, what was
|
||||||
|
tried, and why each attempt failed.
|
||||||
|
- **Beyond the mandate**: the real fix is an API change, a dependency bump, a
|
||||||
|
schema migration, or a redesign. Name it with its minimal diff and let the
|
||||||
|
operator decide.
|
||||||
|
- **Oscillation**: fixing A re-breaks B and vice versa. Report both; that is a
|
||||||
|
design problem, not a test problem.
|
||||||
|
- **Flaky**: a target passes and fails without any change. Report both
|
||||||
|
outputs; never rerun until green.
|
||||||
|
|
||||||
|
Emit one line per iteration: target, failure, hypothesis, change. The final
|
||||||
|
report gives per iteration what failed, the root cause, the fix and the
|
||||||
|
result; the final state of every selected target with the verbatim output of
|
||||||
|
anything still failing; everything deliberately not done; and the command
|
||||||
|
reproducing the final state. End with the `kvb-check-score` block per the
|
||||||
|
`kvb-check-confidence` skill. Do not commit unless the operator asked for a
|
||||||
|
commit.
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
---
|
||||||
|
name: kvb-code-transcend
|
||||||
|
description: >
|
||||||
|
Carry the session's ad-hoc tests and scripts into the current repo.
|
||||||
|
---
|
||||||
|
|
||||||
|
The test the agent ran from its scratchpad, the probe that proved a cause, the
|
||||||
|
benchmark behind an optimization: they protected this session's work and
|
||||||
|
nothing after it. This skill moves them into the repository the agent works
|
||||||
|
in (`git rev-parse --show-toplevel`), in that repository's own form. It
|
||||||
|
changes nothing before the operator confirms.
|
||||||
|
|
||||||
|
## Step 1: inventory
|
||||||
|
|
||||||
|
Read-only:
|
||||||
|
|
||||||
|
- **Tests and scripts outside the repo**: `transcend.py scan` of the
|
||||||
|
`kvb-transcend` skill lists them in the own and peers' scratchpads and
|
||||||
|
skips what was already decided and has not changed; peer candidates follow
|
||||||
|
that skill's consent step. Add the `python -c` checks and negative controls
|
||||||
|
this session ran inline.
|
||||||
|
- **Probes and reproductions** that proved a root cause or a fix.
|
||||||
|
- **Measurements**: prototypes and before/after harnesses of the
|
||||||
|
`kvb-code-tune` skill.
|
||||||
|
- **Repeated commands** a make target or script of the repository could own.
|
||||||
|
- **Untracked helpers** the agent left inside the repository.
|
||||||
|
|
||||||
|
One line each: `candidate -> source -> what it guards`.
|
||||||
|
|
||||||
|
## Step 2: classify
|
||||||
|
|
||||||
|
Read the repository's rules first (AGENTS.md, CONTRIBUTING, the Makefile, the
|
||||||
|
test layout). Per candidate one target in that repository's form: a test in
|
||||||
|
its test tree and framework, a make target, a script under its scripts
|
||||||
|
directory, a CI step, or drop. No new framework, dependency or language
|
||||||
|
without the operator. A kept optimization keeps its measurement as a
|
||||||
|
benchmark or regression test.
|
||||||
|
|
||||||
|
## Step 3: listen and confirm
|
||||||
|
|
||||||
|
Run the `kvb-think-listen` skill on the open points: target path, framework,
|
||||||
|
which repository line owns the file. Then `AskUserQuestion`: one
|
||||||
|
`multiSelect` with the candidates, nothing preselected, and per pick a
|
||||||
|
single-select `Write it` / `Revise it` / `Discard` with the drafted diff.
|
||||||
|
|
||||||
|
## Step 4: write
|
||||||
|
|
||||||
|
- Write only the confirmed files, path-scoped; a peer's line goes to its
|
||||||
|
owner through the `kvb-org-coordinate` skill.
|
||||||
|
- Every carried test fails without the code it guards: show the negative
|
||||||
|
control once.
|
||||||
|
- Run the carried tests as a light run (no Docker, about a minute) after
|
||||||
|
`${CLAUDE_SKILL_DIR}/../kvb-org-organize/scripts/watch.py admit --kind
|
||||||
|
test-nostack`; `make test`, gates and anything with Docker only through
|
||||||
|
`queue.py hold` (the `kvb-org-queue` skill). Leave the result
|
||||||
|
uncommitted for the `kvb-git-commit-next` skill.
|
||||||
|
- `transcend.py mark` every offered candidate with its verdict.
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
| Candidate | Source | Target in the repo | Verdict |
|
||||||
|
|
||||||
|
Dropped candidates stay with their reason.
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
---
|
||||||
|
name: kvb-code-tune
|
||||||
|
description: >
|
||||||
|
Speed up CI, deploys, builds and hot paths without losing logic or
|
||||||
|
coverage. Trigger on a job hitting its time budget or when asked where
|
||||||
|
time goes.
|
||||||
|
---
|
||||||
|
|
||||||
|
One rule outranks every optimization: **nothing that reduces logic, coverage or
|
||||||
|
correctness counts as a speed-up.**
|
||||||
|
|
||||||
|
## Session limits
|
||||||
|
|
||||||
|
First run in a session: before measuring, invoke the `kvb-think-listen` skill
|
||||||
|
with the questions of `listen.md` next to this file. Keep the answers for the
|
||||||
|
session, never ask again, work only inside them; a candidate outside them
|
||||||
|
becomes a proposal. Invoked by another skill (kvb-ci-triage, kvb-ci-observe)
|
||||||
|
or an autonomous run: no questions; examine the handed-over log with the
|
||||||
|
defaults (10 s floor per run, the `kvb-org-split` skill's 90/5 bar as merge
|
||||||
|
bar, 60 minutes per delegated agent), ask only when an operator-only fact
|
||||||
|
blocks.
|
||||||
|
|
||||||
|
## Forbidden, whatever it saves
|
||||||
|
|
||||||
|
- Dropping a test, a spec, a play, a drill or a verification pass.
|
||||||
|
- Raising a timeout or budget so a slow thing fits instead of getting faster.
|
||||||
|
- Swallowed failures, retry-until-pass, a soft skip, a narrowed assertion.
|
||||||
|
- Trading error semantics, credential masking, concurrency safety or a lint's
|
||||||
|
intent for speed. A per-item failure stays loud and names its item.
|
||||||
|
|
||||||
|
Testing less to shrink the clock is reported as a coverage cut for the
|
||||||
|
operator to decide, never applied as a tuning.
|
||||||
|
|
||||||
|
## Measure first
|
||||||
|
|
||||||
|
- Never tune the unmeasured; read numbers from logs, not by eye (`logs.md`
|
||||||
|
next to this file for profilers, build logs and gate tables).
|
||||||
|
- Stream every run to a log file and compare runs by their logs; never re-run
|
||||||
|
for a lost number.
|
||||||
|
- **Rank by fixed cost x count**: one console boot, package-manager call or
|
||||||
|
container exec times how often the run pays it: a 1 s boot paid 28 times
|
||||||
|
beats a 60 s download paid once.
|
||||||
|
- **The baseline is the newest log of the current code**; an older one may
|
||||||
|
carry a step since replaced, and a saving against it is fiction.
|
||||||
|
|
||||||
|
## Coordinate every cost cluster
|
||||||
|
|
||||||
|
Before investigating a cluster (one step, hot path or repeated call), run the
|
||||||
|
`kvb-org-organize` Peer protocol with every session on this machine, not only
|
||||||
|
the lineage. The first to claim `cluster/<slug>`, `--work` naming its log line
|
||||||
|
and number, works it (between machines, the earliest announcement); a claim
|
||||||
|
without a measurement does not count. Its `kvb-org-rasci` key result is
|
||||||
|
`<step> from <before> to under <after>`, met only by the re-measurement.
|
||||||
|
Someone earlier: post them your measurements and findings
|
||||||
|
(`${CLAUDE_SKILL_DIR}/../kvb-org-radio/scripts/radio.py post --to <holder>
|
||||||
|
"<text>"`), take the next cluster, never build a second optimization. A claim
|
||||||
|
ends with its release or the operator's reassignment.
|
||||||
|
|
||||||
|
## Always check
|
||||||
|
|
||||||
|
On each measured hot path, whatever the operator picked, listed in the output
|
||||||
|
even when clean:
|
||||||
|
|
||||||
|
- **Language and framework**: the most efficient fit? Time in computation or
|
||||||
|
inside the framework: read `language.md` next to this file.
|
||||||
|
- **Unnecessary recursion**: a hot path recursing or a tool calling itself:
|
||||||
|
read `recursion.md` next to this file.
|
||||||
|
|
||||||
|
## The loop
|
||||||
|
|
||||||
|
1. Measure; name the single biggest cost with its log line and number.
|
||||||
|
2. Coordinate it; work it only when you identified it first.
|
||||||
|
3. Read `candidate.md` next to this file; form one candidate, harden it with
|
||||||
|
the `kvb-think-dialectic` skill, delegate, merge or propose, re-measure.
|
||||||
|
4. Repeat until the next candidate is worth less than the run measuring it.
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
One table, biggest saving first:
|
||||||
|
|
||||||
|
| Candidate | Before | After | Source | Verdict |
|
||||||
|
|
||||||
|
Rejected candidates stay with the reason they fell; proposals carry what would
|
||||||
|
close their gap.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Tune: working a candidate
|
||||||
|
|
||||||
|
## Before forming it
|
||||||
|
|
||||||
|
- **Search every branch** (the kvb-git-merge-fix skill's `find_fix.py -S
|
||||||
|
<symbol> --path <path>`) for a change that already removes or reshapes the
|
||||||
|
step. A fix on a sibling branch can make the whole candidate worth zero.
|
||||||
|
- A step that is long by design (a backup and restore drill, an end-to-end
|
||||||
|
suite) is not a candidate. Its cost buys its guarantee.
|
||||||
|
- Apply the session's saving floor: a smaller saving is listed as "below
|
||||||
|
floor" and dropped.
|
||||||
|
- Pruning build caches to free disk costs a cold rebuild of every custom
|
||||||
|
image on the next run. Name that cost before anyone prunes.
|
||||||
|
|
||||||
|
Levers that usually pay:
|
||||||
|
|
||||||
|
- **Batch per-item process spawns**: N calls that each boot an interpreter
|
||||||
|
or an application console become one process running N commands (one app
|
||||||
|
console boot, one module, one batched API request). Keep each item's
|
||||||
|
failure named and the exit code honest.
|
||||||
|
- **Skip no-op work** a previous step already did, such as a separate enable
|
||||||
|
after an install that already enabled.
|
||||||
|
- **Idempotency flip-flops**: any `changed` on a second consecutive run.
|
||||||
|
- **Image pulls and builds**: pull only what the host lacks; keep build
|
||||||
|
caches warm.
|
||||||
|
- **Overlap waits with work** only where concurrent execution is proven
|
||||||
|
safe. Schema migrations and lock-holding steps are usually not.
|
||||||
|
|
||||||
|
## Delegating it
|
||||||
|
|
||||||
|
Hand every optimization to an agent through the `kvb-org-split` skill: one
|
||||||
|
sidetree per candidate, branched from an exact commit, with a time cap and
|
||||||
|
"measure first, report the time split before implementing". One agent per
|
||||||
|
candidate, never more; the brief names the step, its log line and number, the
|
||||||
|
files in scope and the claim to prove, and caps the report (for example 15
|
||||||
|
lines: time split, change, before and after). The agent probes live systems
|
||||||
|
read-only and never starts gates or deploys. A broad brief without a cap can
|
||||||
|
run for hours without a word; at its cap, collect the agent's interim
|
||||||
|
findings and let it run on.
|
||||||
|
|
||||||
|
Before merging, run independent skeptics through the `kvb-think-dialectic`
|
||||||
|
skill. The antithesis must try to show that the candidate removes coverage,
|
||||||
|
hides a failure, or only moves the cost somewhere the measurement does not
|
||||||
|
look. Merge what reaches the bar; submit the rest as proposals.
|
||||||
|
|
||||||
|
## Re-measuring it
|
||||||
|
|
||||||
|
Re-measure from the same source under the same conditions and report a
|
||||||
|
before and an after number. Discard a before/after pair, not just its noise,
|
||||||
|
when:
|
||||||
|
|
||||||
|
- Another gate, deploy or heavy job shared the machine during either run.
|
||||||
|
- The two runs differ in image or cache state. A cold build after a cache
|
||||||
|
prune or a Dockerfile edit is expected: before calling a cold build a
|
||||||
|
defect, compare the instruction text and the base image digest across both
|
||||||
|
logs.
|
||||||
|
- One run pays a one-time cost (first install, migration, purge, model
|
||||||
|
download) the other does not.
|
||||||
|
- A long wait hides retries or a failure; count retry lines before calling
|
||||||
|
it slow.
|
||||||
|
- A fresh-install pass is compared with an update pass. Analyse each pass of
|
||||||
|
a multi-pass run separately.
|
||||||
|
|
||||||
|
## Reporting it
|
||||||
|
|
||||||
|
Hand the prototypes and before/after harnesses of every kept candidate to the
|
||||||
|
`kvb-code-transcend` skill, so the measurement stays in the repository as a
|
||||||
|
benchmark or regression test. A rejected candidate stays in the output table
|
||||||
|
with the reason it fell: below floor, coverage cut, outside the session's
|
||||||
|
limits, invalid comparison, made moot by another branch, or cap reached
|
||||||
|
without a measured result.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Tune: language and framework
|
||||||
|
|
||||||
|
For example a Python hot path replaced by Rust, or a heavy framework layer by
|
||||||
|
a lighter one or the standard library:
|
||||||
|
|
||||||
|
- **Only for measured CPU-bound work.** When the profile shows the time in
|
||||||
|
computation inside the process (parsing, hashing, rendering, tight loops),
|
||||||
|
a compiled language can win by an order of magnitude. When the time goes
|
||||||
|
to process start-up, network round trips or waiting, batching in the
|
||||||
|
current language wins first, and a rewrite changes nothing.
|
||||||
|
- **Framework overhead**: when the profile puts the time inside the
|
||||||
|
framework (import and start-up, middleware, ORM query building,
|
||||||
|
serialization), compare a lighter framework or the standard library on the
|
||||||
|
same input. An ORM issuing one query per item is fixed by batching the
|
||||||
|
query, not by swapping the framework.
|
||||||
|
- **Prefer the narrowest cut**: rewrite the hot function as an extension
|
||||||
|
module (for Python and Rust: PyO3 and maturin) or a small binary behind the
|
||||||
|
same interface, before rewriting a whole tool.
|
||||||
|
- **Count the total cost**: a toolchain in CI, builds per target
|
||||||
|
architecture, shipping binaries to every host, the team's ability to
|
||||||
|
maintain the code, and test parity with the old implementation.
|
||||||
|
- **Prove it on a prototype**: the same input, the same machine state, a
|
||||||
|
before and an after number, and the old test suite passing against the new
|
||||||
|
code.
|
||||||
|
- A language or framework change is applied only when the session's limits
|
||||||
|
allow new languages, frameworks or toolchains; otherwise it is submitted as
|
||||||
|
a proposal with the prototype's numbers.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Tune: the session's limits
|
||||||
|
|
||||||
|
Ask three questions through the host's structured question mechanism, each a
|
||||||
|
checklist (multi-select) with suggestions, free text allowed:
|
||||||
|
|
||||||
|
1. **What to examine.** Suggestions: CI wall-clock, deploy duration, gate and
|
||||||
|
test runtime, image pulls and build caches, per-item loops and process
|
||||||
|
spawns, idempotency (work repeated on a second run), network round trips,
|
||||||
|
and resource use (CPU, memory, disk, I/O). Language, framework and
|
||||||
|
recursion are checked on every run anyway.
|
||||||
|
2. **Which performance boundaries to keep.** Suggestions: time budgets per
|
||||||
|
job, deploy or gate; resource caps (memory, CPU, disk headroom); a minimum
|
||||||
|
saving per change (default 10 s per run); the measurement source that
|
||||||
|
counts as the verdict.
|
||||||
|
3. **Which limits apply to optimizations.** Suggestions: the scope that may
|
||||||
|
be changed (files, roles, services); whether new dependencies, toolchains
|
||||||
|
or languages may be introduced; whether infrastructure or CI configuration
|
||||||
|
may change; the merge bar (default: the `kvb-org-split` skill's bar of fix
|
||||||
|
confidence ≥ 90 %, break risk ≤ 5 %, cost and time ≤ 100 %, over
|
||||||
|
100 % only after asking the operator, naming the cause); a time cap per
|
||||||
|
delegated agent (default 60 minutes).
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Tune: reading the numbers
|
||||||
|
|
||||||
|
- **Task profilers** (Ansible `profile_tasks` and similar) print the duration
|
||||||
|
of the *previous* task under the *next* task's header. Pair each header
|
||||||
|
with the following timestamp line, or read the end-of-run recap.
|
||||||
|
- **Build logs** (BuildKit): pair `#N [service x/y] RUN …` with `#N DONE Xs`
|
||||||
|
by step number; `CACHED` marks a hit.
|
||||||
|
- **Gates**: per-target wall-clock tables and failed-target lines. An exit
|
||||||
|
code piped through `tee` is not a verdict.
|
||||||
|
- **Code paths**: a profiler (py-spy, cProfile, perf, flame graphs) tells
|
||||||
|
whether time goes to CPU, to I/O and waits, or to process start-up.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Tune: unnecessary recursion
|
||||||
|
|
||||||
|
Look for:
|
||||||
|
|
||||||
|
- a recursive function that recomputes the same subproblem: memoize it or
|
||||||
|
turn it into a loop;
|
||||||
|
- recursion that only walks a list or a fixed depth: a loop;
|
||||||
|
- a tree walk that visits or reads the same node more than once;
|
||||||
|
- depth that grows with the input (stack limits, Python's 1000 frames);
|
||||||
|
- recursive tool calls: a make target calling make, a role or playbook
|
||||||
|
including itself, a script re-invoking itself per item.
|
||||||
|
|
||||||
|
Count the calls first (a profiler's call graph, cProfile's `ncalls`, log
|
||||||
|
lines); recursion that is bounded, rare and cheap stays.
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
name: kvb-create-plan
|
||||||
|
description: >
|
||||||
|
Turn a request into an objective-oriented plan for kvb-org-robot.
|
||||||
|
Trigger on a request for a plan, breakdown or strategy before work starts.
|
||||||
|
---
|
||||||
|
|
||||||
|
Produce a plan whose every item is an outcome with a check, then execute it
|
||||||
|
autonomously. A plan of activities instead of objectives cannot be verified,
|
||||||
|
and an unverifiable plan cannot be handed to a robot.
|
||||||
|
|
||||||
|
## Procedure
|
||||||
|
|
||||||
|
1. **Interview first.** Open every plan with the `kvb-think-listen` skill: ask
|
||||||
|
until scope, constraints, success criteria and the operator-only facts are
|
||||||
|
pinned, then reflect the understanding back. This is the only point in the
|
||||||
|
run where questions are allowed, so leave nothing open.
|
||||||
|
2. **Pin the objective.** The end state in one sentence, as a condition that
|
||||||
|
is true or false, never an activity, with the command or observation that
|
||||||
|
proves it.
|
||||||
|
3. **Inspect before decomposing.** Read the code, config, tests and history
|
||||||
|
the objective touches; every item rests on something you have seen, not on
|
||||||
|
an assumption. Explore the code through the codebase-memory-mcp graph
|
||||||
|
first (`search_graph`, `trace_path`, `get_architecture`, `detect_changes`;
|
||||||
|
the `kvb-agent-optimize` skill sets it up), grep and read after.
|
||||||
|
4. **Decompose into sub-objectives.** Each item gets its outcome, the
|
||||||
|
verification that proves it, and the items it depends on. Split an item
|
||||||
|
that needs more than one verification. Order by dependency and mark the
|
||||||
|
independent items, so they can run in parallel.
|
||||||
|
5. **Resolve the open decisions now.** Every root cause, design choice or
|
||||||
|
trade-off the plan rests on is settled before execution, through the
|
||||||
|
`kvb-think-dialectic` skill where being wrong is expensive. The robot does
|
||||||
|
not ask, so an unresolved decision becomes a guess at runtime.
|
||||||
|
6. **Record the plan in the register.** The objective goes into the
|
||||||
|
`kvb-org-rasci` register with each sub-objective's verification as a key
|
||||||
|
result; mirror the sub-objectives into the harness's todo tracking, one
|
||||||
|
entry per item, phrased as the outcome. A plan file only when the operator
|
||||||
|
asks for one.
|
||||||
|
7. **Present it and wait.** Show the objective, the ordered sub-objectives
|
||||||
|
with their verifications, what is deliberately out of scope, and the
|
||||||
|
assumptions the plan rests on. Stop: the plan is this step's deliverable.
|
||||||
|
8. **Implement it statically, in full.** On the operator's go, write every
|
||||||
|
code, config, test and doc change the plan calls for, across all
|
||||||
|
sub-objectives, before running anything that needs live infrastructure.
|
||||||
|
Verify statically: read the diff, run the linters, the unit tests, and any
|
||||||
|
dry-run or syntax check the project offers. The step ends only when the
|
||||||
|
whole plan exists on disk.
|
||||||
|
9. **Then iterate under `kvb-org-robot`.** Invoke the `kvb-org-robot` skill
|
||||||
|
with the objective as its goal for the dynamic part: run, deploy, observe,
|
||||||
|
fix, repeat until every verification passes. It drives the loop without
|
||||||
|
check-ins, verifies each outcome, and reports the items outside its
|
||||||
|
clearance for the operator to run.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- One objective per item, in outcome form: "endpoint returns 200 for an
|
||||||
|
expired token", not "fix the auth middleware".
|
||||||
|
- No item without a verification; an item nothing proves is a wish.
|
||||||
|
- Plan only what the request covers. Speculative future-proofing, refactors
|
||||||
|
nobody asked for, and abstractions with one caller go to the out-of-scope
|
||||||
|
list.
|
||||||
|
- Static before dynamic: never start the robot loop on a half-written plan; a
|
||||||
|
deploy failing on code not yet written costs a full cycle and proves
|
||||||
|
nothing.
|
||||||
|
- Re-plan on contradicted evidence: when execution disproves an assumption,
|
||||||
|
revise the plan instead of forcing the original steps through.
|
||||||
|
- Cite `file:line` for every claim about the current state of the code.
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
---
|
||||||
|
name: kvb-create-requirement
|
||||||
|
description: >
|
||||||
|
Write a requirement: user story, decisions, Mermaid approach, acceptance
|
||||||
|
criteria, tests. Trigger on "requirement schreiben" or a kvb-think-gaps
|
||||||
|
pick.
|
||||||
|
---
|
||||||
|
|
||||||
|
Writes requirements, never implementations. Nothing touches disk before the
|
||||||
|
operator picks `Write it`.
|
||||||
|
|
||||||
|
## Step 1: context
|
||||||
|
|
||||||
|
Ask the operator for the context first, in one open question: what should
|
||||||
|
change, for whom, and why now. A candidate handed over by the
|
||||||
|
`kvb-think-gaps` skill already carries it. Read what the answer points to
|
||||||
|
(code, issues, logs, existing requirements) before the next step.
|
||||||
|
|
||||||
|
## Step 2: listen
|
||||||
|
|
||||||
|
Run the `kvb-think-listen` skill until scope, constraints, the definition of
|
||||||
|
done and every operator-only decision are fixed. Its answers become the
|
||||||
|
Decisions section.
|
||||||
|
|
||||||
|
## Step 3: brainstorm
|
||||||
|
|
||||||
|
Run the `kvb-think-brainstorm` skill on the approach. The operator's pick
|
||||||
|
becomes a decision; each rejected option stays there with its reason.
|
||||||
|
|
||||||
|
## Step 4: draft
|
||||||
|
|
||||||
|
Location, filename and numbering follow the project's existing requirement
|
||||||
|
documents; read them first, and ask when there are none. One feature per
|
||||||
|
document: a title that needs "and" is two requirements. Sections in this
|
||||||
|
order:
|
||||||
|
|
||||||
|
1. `# <number> - <title>`
|
||||||
|
2. `## User Story`: "As a <role>, I want <capability>, so that <benefit>."
|
||||||
|
3. `## Decisions (operator-confirmed <YYYY-MM-DD>; do not re-litigate)`:
|
||||||
|
numbered, a bold keyword first, rejected alternatives with their reason.
|
||||||
|
4. `## Approach`: one Mermaid `flowchart TD` over the whole procedure, one
|
||||||
|
`subgraph` per phase with an emoji title and an emoji in every node:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
subgraph P["🧭 0 · Preparation"]
|
||||||
|
P1["📋 Write requirement"] --> P2{"✅ Operator approves?"}
|
||||||
|
P2 -- "✏️ corrections" --> P1
|
||||||
|
end
|
||||||
|
P2 -- "🤖 yes" --> A1
|
||||||
|
subgraph A["🛠️ A · Implementation"]
|
||||||
|
A1["🧩 Change"] --> A2{"🧪 Tests green?"}
|
||||||
|
A2 -- "🔴 no: fix" --> A1
|
||||||
|
A2 -- "🟢 yes" --> AF{"💬 Operator feedback?"}
|
||||||
|
AF -- "✏️ changes" --> A1
|
||||||
|
end
|
||||||
|
AF -- "👍 fits" --> D1["🏁 Review, commit with operator OK, hand-over"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Every verifiable stage gets its test loop and an operator feedback
|
||||||
|
checkpoint; a user-visible change shows screenshots at that checkpoint.
|
||||||
|
5. `## Acceptance Criteria`: grouped under `###` per area. Each item is a
|
||||||
|
`- [ ]` that one check proves or disproves on its own: a command, a
|
||||||
|
`file:line`, a test name, an observable state. No "should", no "works
|
||||||
|
well".
|
||||||
|
6. `### Tests`, the last group: `Unit`, `Integration` and `E2E`, each with
|
||||||
|
its criteria. A level that cannot apply stays with `n/a: <reason>`.
|
||||||
|
|
||||||
|
## Step 5: confirm and write
|
||||||
|
|
||||||
|
Show the full draft, then `AskUserQuestion`: `Write it` / `Revise it` /
|
||||||
|
`Discard`. Revise loops back to the step the correction touches. Write only
|
||||||
|
on `Write it`; lint the file with the project's markdown linter, a light run
|
||||||
|
that self-admits with
|
||||||
|
`${CLAUDE_SKILL_DIR}/../kvb-org-organize/scripts/watch.py admit --kind
|
||||||
|
test-nostack`; a lint through `make` or Docker runs through `queue.py hold`
|
||||||
|
(the `kvb-org-queue` skill).
|
||||||
|
|
||||||
|
## Step 6: hand over
|
||||||
|
|
||||||
|
Stop after writing. Offer in one line to run the `kvb-create-plan` skill,
|
||||||
|
which hands the plan to the `kvb-org-robot` skill; never start the
|
||||||
|
implementation without the operator's yes.
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
---
|
||||||
|
name: kvb-dream
|
||||||
|
description: >
|
||||||
|
Process the last 24 hours of every repository and session: memory,
|
||||||
|
improvements, kvb-optimize, cleanups. Trigger on a request to learn from
|
||||||
|
recent work.
|
||||||
|
---
|
||||||
|
|
||||||
|
Replay what happened, separate what held from what broke, keep only
|
||||||
|
conclusions that survive an attack, then optimize and clean up.
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Make the next run measurably less likely to repeat each failure found; what
|
||||||
|
changes no future behaviour is not recorded. An error class is closed only by
|
||||||
|
something structural (test, lint rule, CI check, hook, written rule in the
|
||||||
|
agent instructions, skill), never by "be more careful". Per closed class,
|
||||||
|
report the `kvb-check-score` block of its structural fix, calibrated as the
|
||||||
|
`kvb-check-confidence` skill requires, and name what is still open.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Every repository and session this agent touched in the window: its
|
||||||
|
transcripts' working directories and worktrees, its `kvb-org-rasci` task
|
||||||
|
workdirs, the current one. Window: 24 hours, or wider on request (`7d` after
|
||||||
|
the command). State both in one table first. Recall and score each repository
|
||||||
|
on its own; a finding goes only into its own project's memory. Run only when
|
||||||
|
the operator asks; read `hints.md` next to this file when a hint appears or
|
||||||
|
before changing the reminder.
|
||||||
|
|
||||||
|
## Step 1: recall
|
||||||
|
|
||||||
|
The actual record per repository, not your recollection:
|
||||||
|
|
||||||
|
- **History**: `git log --since` over all branches plus `--reflog` (resets,
|
||||||
|
rebases, amends), with `--stat`.
|
||||||
|
- **Working state**: `git status`, staged and unstaged diff, stashes.
|
||||||
|
- **CI**: the window's runs, conclusions, and each red run's failing log line,
|
||||||
|
not the job title.
|
||||||
|
- **Reviews**: pull requests and issues touched, with review comments.
|
||||||
|
- **Register**: `rasci.py tools` ranks the tools the tasks ran (refactoring
|
||||||
|
candidates); `get <task>` shows objective, roles, workdir, artifacts.
|
||||||
|
- **Memory**: each project's memory directory and `MEMORY.md`, to extend
|
||||||
|
recorded findings instead of repeating them.
|
||||||
|
- **Instructions**: `CLAUDE.md`, `AGENTS.md`, the project's skills: which rule
|
||||||
|
a mistake violated.
|
||||||
|
- **Sessions**: readable transcripts, for operator corrections that never
|
||||||
|
reached a commit.
|
||||||
|
|
||||||
|
## Step 2: score
|
||||||
|
|
||||||
|
Every action, with evidence (`sha`, `file:line`, run id):
|
||||||
|
|
||||||
|
- **Held**: shipped and still proven: green CI, a passing test covering it, no
|
||||||
|
revert, no follow-up fix.
|
||||||
|
- **Broke**: reverted, amended, hot-fixed later, red CI, or corrected by the
|
||||||
|
operator; ending green does not undo the failed first attempt.
|
||||||
|
- **Unknown**: no verification either way, so no win; a finding of its own,
|
||||||
|
never a quiet pass.
|
||||||
|
|
||||||
|
## Steps 3 to 6: findings
|
||||||
|
|
||||||
|
When the record holds a pattern or an expensive failure, read
|
||||||
|
`hypotheses.md` next to this file. Offer each improvement, ranked by
|
||||||
|
evidence, in its own `AskUserQuestion` with `multiSelect: false`: `Apply
|
||||||
|
now`, `Record as a task`, `Skip`.
|
||||||
|
|
||||||
|
## Step 7: optimize
|
||||||
|
|
||||||
|
Run the `kvb-optimize` skill with this run's findings as its evidence, so none
|
||||||
|
of its skills re-collects them; its `kvb-transcend` pass carries every
|
||||||
|
in-scope session's scripts, tests and skills into their repositories.
|
||||||
|
|
||||||
|
## Step 8: clean up
|
||||||
|
|
||||||
|
The `kvb-cleanup` skill for the disk, `/tmp`, Docker and agent directories the
|
||||||
|
window filled, with the finished tasks' artifacts (`rasci.py artifacts
|
||||||
|
--ended`) as candidates; then `kvb-git-orphans-find` per in-scope
|
||||||
|
repository, which hands merged-clean rows to `kvb-git-branch-gc`. Each keeps
|
||||||
|
its own confirmations; the dream never writes, deletes or commits for them. Close
|
||||||
|
with `date +%s > ~/.claude/.dream-stamps/"$CLAUDE_CODE_SESSION_ID"`, so the
|
||||||
|
next hint is twelve hours out.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Read-only until step 6: the dream writes memory; repository code changes
|
||||||
|
only on an explicit radio answer.
|
||||||
|
- An empty window is valid: report "nothing in the last 24 hours" per
|
||||||
|
repository and skip it; never invent activity.
|
||||||
|
- Report the whole ledger: held, broke, unknown, surviving findings, refuted
|
||||||
|
hypotheses with why they fell.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Dream hints
|
||||||
|
|
||||||
|
Two hints point at the dream; neither is a trigger:
|
||||||
|
|
||||||
|
- `hooks/reminder.sh` tells each agent at most once every twelve hours,
|
||||||
|
stamped per session in `~/.claude/.dream-stamps/`.
|
||||||
|
- The `kvb-dream-notify.timer` user timer shows a GNOME notification daily at
|
||||||
|
17:00 local time, catching up once after a missed run.
|
||||||
|
|
||||||
|
On a hint, print it and continue with the actual request.
|
||||||
Executable
+8
@@ -0,0 +1,8 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
[[ "${XDG_CURRENT_DESKTOP:-}" == *GNOME* ]] || exit 0
|
||||||
|
command -v notify-send >/dev/null || exit 0
|
||||||
|
|
||||||
|
notify-send --app-name=kvb-dream "kvb-dream" \
|
||||||
|
"Time to dream: run /kvb-dream in your agent sessions to process the day."
|
||||||
Executable
+34
@@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
interval="${DREAM_INTERVAL_SECONDS:-43200}"
|
||||||
|
stamps="${DREAM_STAMP_DIR:-${HOME}/.claude/.dream-stamps}"
|
||||||
|
|
||||||
|
session="$(python3 -c 'import json, sys; print(json.load(sys.stdin).get("session_id", ""))' 2>/dev/null)" || exit 0
|
||||||
|
[[ "${session}" =~ ^[A-Za-z0-9_-]+$ ]] || exit 0
|
||||||
|
stamp="${stamps}/${session}"
|
||||||
|
|
||||||
|
now="$(date +%s)"
|
||||||
|
last=0
|
||||||
|
if [[ -f "${stamp}" ]]; then
|
||||||
|
read -r last <"${stamp}" || true
|
||||||
|
[[ "${last}" =~ ^[0-9]+$ ]] || last=0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ((now - last < interval)); then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "${stamps}"
|
||||||
|
printf '%s\n' "${now}" >"${stamp}"
|
||||||
|
|
||||||
|
cat <<'HINT'
|
||||||
|
DREAM HINT: over twelve hours since this agent's last hint. Show the operator
|
||||||
|
this notice verbatim as a short block, then continue with their request. Do NOT
|
||||||
|
run kvb-dream, do not start replaying the history, do not ask questions about
|
||||||
|
it.
|
||||||
|
|
||||||
|
💤 /kvb-dream - process the last 24h of every repository and session this
|
||||||
|
agent touched: memory, improvements, kvb-optimize and the cleanups.
|
||||||
|
Runs only when you trigger it.
|
||||||
|
HINT
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# From scored record to improvements
|
||||||
|
|
||||||
|
## Step 3: hypothesize
|
||||||
|
|
||||||
|
A pattern (two or more occurrences) or a single expensive failure becomes a
|
||||||
|
falsifiable hypothesis: *cause → predicted consequence → what would disprove
|
||||||
|
it*. Drop one-off noise; cut a hypothesis nothing could disprove.
|
||||||
|
|
||||||
|
## Step 4: dialectic
|
||||||
|
|
||||||
|
Every surviving hypothesis goes through the `kvb-think-dialectic` skill to
|
||||||
|
~99%. Scale the skeptics to the stakes: one for a memory fact, more for a rule
|
||||||
|
or code change. Each brief names the exact files, commits or log lines and the
|
||||||
|
one claim to attack, and caps the answer at ten lines. Survivors become
|
||||||
|
findings; refuted ones are recorded as discarded **with the refutation**, so
|
||||||
|
the next dream does not re-derive them.
|
||||||
|
|
||||||
|
## Step 5: write memory
|
||||||
|
|
||||||
|
In the memory directory of the project concerned: one file per fact, one
|
||||||
|
pointer line in `MEMORY.md`. Update the file covering a fact instead of adding
|
||||||
|
a duplicate; delete memories this run disproved. Absolute dates, never
|
||||||
|
"yesterday". Nothing the repository already records (code structure, git
|
||||||
|
history, `CLAUDE.md`).
|
||||||
|
|
||||||
|
## Step 6: the improvements
|
||||||
|
|
||||||
|
- **Code**: the change to the repository's source, tests, config or CI the
|
||||||
|
finding calls for.
|
||||||
|
- **Agent instructions**: the missing or wrong rule in `CLAUDE.md`,
|
||||||
|
`AGENTS.md`, the project's skills or contributor docs. Every mistake repeated
|
||||||
|
in the window is a candidate: quote the line that failed to prevent it or
|
||||||
|
name the absent rule, and propose the exact wording.
|
||||||
|
|
||||||
|
Prefer the structural fix (test, check, hook, written rule) that makes the
|
||||||
|
error impossible over a memory entry that only reminds. Each question carries
|
||||||
|
the improvement and its evidence.
|
||||||
|
|
||||||
|
A finding on a live peer's line goes to it as
|
||||||
|
`${CLAUDE_SKILL_DIR}/../kvb-org-radio/scripts/radio.py post "<finding>" --to
|
||||||
|
<name>`; `radio.py send direct --to <name> --wake` only when the peer must act
|
||||||
|
or answer.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Recommend /kvb-dream in a GNOME notification
|
||||||
|
After=graphical-session.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/bin/env bash %h/.claude/skills/kvb-dream/hooks/notify.sh
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Daily /kvb-dream recommendation at 17:00 local time
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 17:00:00
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
---
|
||||||
|
name: kvb-git-branch-gc
|
||||||
|
description: >
|
||||||
|
Remove merged branches and worktrees with `pkgmgr branch gc` after a dry
|
||||||
|
run the operator picks from. Trigger on "branches aufräumen",
|
||||||
|
"worktrees aufräumen".
|
||||||
|
---
|
||||||
|
|
||||||
|
`pkgmgr branch gc` decides what is merged and does the removing; this skill
|
||||||
|
decides what may go and who has to agree first. The dry run changes nothing,
|
||||||
|
and nothing is removed before the operator's pick in this run.
|
||||||
|
|
||||||
|
## Step 0: claim and chair
|
||||||
|
|
||||||
|
1. Claim `work/branch-gc-<repo>`, or `work/branch-gc-all` for every
|
||||||
|
repository, through the `kvb-org-organize` skill.
|
||||||
|
2. The apply takes stacks down and ends with a host-wide
|
||||||
|
`docker image prune -f` and `docker builder prune -f`, which reach every
|
||||||
|
session's images and build cache. When another session holds the chair,
|
||||||
|
request the grant (what, where, how long) and wait for it; the prune never
|
||||||
|
runs during a peer's build.
|
||||||
|
|
||||||
|
## Step 1: dry run
|
||||||
|
|
||||||
|
`pkgmgr branch gc --json --sizes --min-age 24 --merged-into <line>`, one
|
||||||
|
`--merged-into` per integration line the project merges features into
|
||||||
|
(default branch excluded, it always counts), with `--all` only when the
|
||||||
|
operator asked for every repository, output to `/tmp/branch-gc-<N>.json`. A
|
||||||
|
line is protected, and a checkout at its tip stays as fresh work; an item
|
||||||
|
merged into a line names it in `merged_into`. No `git fetch` of your own
|
||||||
|
before it: gc fetches without auto-maintenance, while a plain fetch on git
|
||||||
|
2.50 or later may prune the sandbox's bind-mount stubs. Every repository's
|
||||||
|
`warnings` (a failed fetch) go to the top of the plan. Sort the items:
|
||||||
|
|
||||||
|
- **Candidates**: `state` `merged-clean`, non-empty `actions`, and `skip`
|
||||||
|
null or `lock: liveness unknown`.
|
||||||
|
- **Step 5**: `state` `unmerged` or `dirty`.
|
||||||
|
- **Reported only**: `protected`, every other `skip`, and `stale` as one count
|
||||||
|
per repository.
|
||||||
|
|
||||||
|
Never override a state: a branch without own commits is `unmerged` even
|
||||||
|
though git sees its tip on the default branch, because it is fresh work.
|
||||||
|
`host_pid_namespace: false` means the dry run ran in a sandbox: it saw no
|
||||||
|
processes and could not tell whether a lock is live. Say so above the plan;
|
||||||
|
the apply re-checks both on the host.
|
||||||
|
|
||||||
|
## Step 2: claims
|
||||||
|
|
||||||
|
Read `council.py list --long`. Every peer whose claim names a candidate's branch or
|
||||||
|
worktree gets one message listing all its items (Peer protocol); wait up to 5
|
||||||
|
minutes. A kept or unanswered item leaves the plan as `keep (<peer>)`;
|
||||||
|
silence is not consent.
|
||||||
|
|
||||||
|
## Step 3: plan
|
||||||
|
|
||||||
|
| Repo | Branch or worktree | Merged into | Size | Actions | Note |
|
||||||
|
|
||||||
|
Then ask with `AskUserQuestion`:
|
||||||
|
|
||||||
|
- **Call 1** (`multiSelect: true`): the candidates, grouped by repository,
|
||||||
|
nothing preselected. Each description names `merged_by` and every action
|
||||||
|
(stack with volumes, the `images`, worktree, local and remote branches), and
|
||||||
|
`lock checked at apply` where liveness was unknown.
|
||||||
|
- **Call 2** (`multiSelect: false`): `Apply: <n> items (Recommended)`,
|
||||||
|
`Revise the selection`, `Abort`. Revise repeats call 1.
|
||||||
|
|
||||||
|
## Step 4: apply
|
||||||
|
|
||||||
|
Read `apply.md` next to this file once the operator picked `Apply`.
|
||||||
|
|
||||||
|
## Step 5: unmerged and dirty
|
||||||
|
|
||||||
|
When the dry run sorted any item here, read `unmerged.md` next to this file.
|
||||||
|
|
||||||
|
## Step 6: report
|
||||||
|
|
||||||
|
Removed, kept and skipped items, each with its reason, and `docker system df`
|
||||||
|
before and after. The release for the chair and what went for each peer whose
|
||||||
|
line an item touched go out as one
|
||||||
|
`${CLAUDE_SKILL_DIR}/../kvb-org-radio/scripts/radio.py post "<text>"`, not
|
||||||
|
as messages; then release the claim.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- A pick from an earlier run does not carry over.
|
||||||
|
- Never widen the selection, and never route around a refusal through another
|
||||||
|
command or a peer.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Apply
|
||||||
|
|
||||||
|
1. Re-read `council.py list --long` and drop every item claimed since the dry run.
|
||||||
|
2. `--apply` refuses outside the host PID namespace (exit 2) and deletes
|
||||||
|
remote branches, which is a push. Unless this session runs in the host PID
|
||||||
|
namespace and the project lets agents push, hand the operator one command
|
||||||
|
per repository, run from inside it, one `--branch` per picked item (the
|
||||||
|
worktree path for a detached worktree):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pkgmgr branch gc --apply --json --branch <name> --branch <name> | tee /tmp/branch-gc-apply-<N>.json
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Read every item's `result` there: `done`, `done; image kept: <img>` (still
|
||||||
|
in use elsewhere), or `failed: <why>`. Report a failure as it stands; never
|
||||||
|
remove by hand what gc skipped or refused.
|
||||||
|
4. For every `done` item, drop its worktree and branch from the task listing
|
||||||
|
them in the `kvb-org-rasci` register (`rasci.py artifacts` names it): `set
|
||||||
|
<task> --drop-artifact worktree:<path>`, and `--branch ''` where the branch
|
||||||
|
is gone.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Unmerged and dirty items
|
||||||
|
|
||||||
|
Never part of the apply. One `AskUserQuestion` question per item
|
||||||
|
(`multiSelect: false`): `Keep (Recommended)` and `Delete`, the description
|
||||||
|
naming what is lost (the item's `reason`: unique commits, dirty paths). For
|
||||||
|
each `Delete`, hand the operator `pkgmgr branch purge --branch <name>`: it
|
||||||
|
asks y/N per item and answers no without a terminal, so it runs only in the
|
||||||
|
operator's shell.
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
---
|
||||||
|
name: kvb-git-changelog
|
||||||
|
description: >
|
||||||
|
Write the release message for `pkgmgr release`; prints only. Trigger on
|
||||||
|
"release message", "changelog entry", before cutting a
|
||||||
|
release, or on pkgmgr's markdown-lint rejection.
|
||||||
|
---
|
||||||
|
|
||||||
|
Read `reasons.md` next to this file when a rule seems not to fit or a lint
|
||||||
|
finding is unclear, and `special.md` for: no tag, a failed previous-tag
|
||||||
|
workflow, a test- or CI-only release, no `markdownlint-cli2`, a pasted pkgmgr
|
||||||
|
rejection.
|
||||||
|
|
||||||
|
## 1. Find the range
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git fetch --tags
|
||||||
|
git describe --tags --abbrev=0 --match 'v[0-9]*.[0-9]*.[0-9]*'
|
||||||
|
git log --no-merges --format='%h %s%n%b' <tag>..HEAD
|
||||||
|
```
|
||||||
|
|
||||||
|
- The base is the newest `vX.Y.Z` tag reachable from HEAD.
|
||||||
|
- Drop `Release version X.Y.Z` commits, and any revert together with the
|
||||||
|
commit it reverts inside the range.
|
||||||
|
- A subject is a lead, not the change: read the diff (`git show --stat`, then
|
||||||
|
the hunks) when it does not say what changed for the user.
|
||||||
|
- With `gh`, read every workflow of the previous tag's commit and of HEAD
|
||||||
|
(`gh run list --commit <sha> --json workflowName,status,conclusion`), not
|
||||||
|
one picked by `-L 1`. Only a finished failure counts; queued, running or
|
||||||
|
missing runs are normal.
|
||||||
|
|
||||||
|
## 2. Sort into sections
|
||||||
|
|
||||||
|
`feat`: Added, or Changed when it alters existing behaviour. `fix`, `perf`:
|
||||||
|
Fixed. `!` or `BREAKING CHANGE:`: Breaking, plus one Upgrade notes line per
|
||||||
|
required action. `docs`, `style`, `refactor`, `test`, `ci`, `build`,
|
||||||
|
`chore`: omitted unless a user sees the effect. Dependency bumps: one
|
||||||
|
collapsed line, or omitted. Order: Breaking, Added, Changed, Fixed, Upgrade
|
||||||
|
notes; empty sections left out.
|
||||||
|
|
||||||
|
## 3. Format
|
||||||
|
|
||||||
|
1. Section labels are plain lines ending in `:`; no `#` headings, no
|
||||||
|
bold-only lines.
|
||||||
|
2. No version heading, date or "Release X.Y.Z" line.
|
||||||
|
3. One bullet, one line: too long means shorter, never wrapped.
|
||||||
|
4. Line length: the repository's MD013 limit (markdownlint's default 80);
|
||||||
|
aim for 78 or less.
|
||||||
|
5. Lists: no nesting, a blank line before and after (MD004, MD032), the
|
||||||
|
marker of the existing CHANGELOG.md's first list item; `-` without a
|
||||||
|
CHANGELOG.md, the configured one when the repo's MD004 pins a style.
|
||||||
|
6. No backticks or code blocks: *names*, *--flags* and *paths* in `*`
|
||||||
|
italics; `*`, not `_`, for emphasis (MD049).
|
||||||
|
7. Bullets read `Area: effect`: the component the user knows (Backup,
|
||||||
|
Restore, CLI, Tests), then what changed for them, not how the code
|
||||||
|
changed. No commit SHAs, no em dashes (use `:` or `;`).
|
||||||
|
8. No trailing whitespace (MD009), double blank lines (MD012) or bare URLs;
|
||||||
|
wrap URLs as `<https://...>` (MD034).
|
||||||
|
|
||||||
|
## 4. Lint it the way pkgmgr does
|
||||||
|
|
||||||
|
1. Write `# Changelog`, blank line, `## [X.Y.Z] - <date>`, blank line, the
|
||||||
|
message to a scratch file outside the repository.
|
||||||
|
2. From the repository root, so its config applies:
|
||||||
|
`markdownlint-cli2 --no-globs - < <scratch-file>`.
|
||||||
|
3. A finding on line N is message line N - 4: shorten it and lint again;
|
||||||
|
print nothing before 0 errors.
|
||||||
|
4. Lint a scratch copy of CHANGELOG.md with the wrapped entry inserted below
|
||||||
|
its `# Changelog` line the same way. A finding in older entries that the
|
||||||
|
unmodified file lacks comes from the new entry: fix the message, never
|
||||||
|
the older entries.
|
||||||
|
|
||||||
|
## 5. Output
|
||||||
|
|
||||||
|
1. The message in one fenced `text` block, nothing else inside it.
|
||||||
|
2. `Bump: patch | minor | major` and why (breaking: major, `feat`: minor,
|
||||||
|
otherwise patch).
|
||||||
|
3. The lint result (`markdownlint-cli2: 0 errors` or the manual check run).
|
||||||
|
4. Only when a workflow on HEAD finished as a failure: its name and run, and
|
||||||
|
the advice not to release before it is green.
|
||||||
|
|
||||||
|
Then stop. Never edit CHANGELOG.md or a version file, never run
|
||||||
|
`pkgmgr release`, never tag: the operator releases.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Changelog: why each rule holds
|
||||||
|
|
||||||
|
pkgmgr writes one text to the CHANGELOG.md entry, the annotated release tag,
|
||||||
|
`debian/changelog` and the RPM `%changelog`; every rule keeps one of them from
|
||||||
|
breaking or mangling it.
|
||||||
|
|
||||||
|
- **HEAD failure**: pkgmgr's CI gate runs after the tag, so a workflow that
|
||||||
|
failed on HEAD means pkgmgr would tag a release that never goes stable.
|
||||||
|
- **Plain section labels**: pkgmgr creates the tag with `git tag -a -m`,
|
||||||
|
whose default `strip` cleanup deletes every line starting with `#`. A
|
||||||
|
bold-only line fails MD036.
|
||||||
|
- **No version heading**: pkgmgr adds `## [X.Y.Z] - YYYY-MM-DD` itself.
|
||||||
|
- **One line per bullet**: RPM turns every non-empty line into its own `-`
|
||||||
|
entry and Debian indents each line, so a continuation line becomes a
|
||||||
|
separate, broken entry.
|
||||||
|
- **78 characters**: lines stay within 80 after Debian's two-space indent
|
||||||
|
and RPM's two-character `-` prefix.
|
||||||
|
- **The existing list marker**: MD004's default *consistent* style takes the
|
||||||
|
marker of the first list in the file, and pkgmgr puts the new entry on top,
|
||||||
|
so a marker that differs from the older entries turns every older list
|
||||||
|
into a finding.
|
||||||
|
- **No backticks**: pkgmgr rewrites inline code to *italic* in all four
|
||||||
|
targets, and a fenced block fails its fallback lint.
|
||||||
|
- **The wrapped lint**: pkgmgr wraps the message as `# Changelog`, blank
|
||||||
|
line, `## [X.Y.Z] - date`, blank line, message, then runs
|
||||||
|
`markdownlint-cli2` with the target repository's own config; hence the
|
||||||
|
N - 4 line mapping.
|
||||||
|
- **`--no-globs`**: it keeps the config's own globs from linting the whole
|
||||||
|
tree too.
|
||||||
|
- **The whole-file lint**: the standalone check cannot see rules that
|
||||||
|
compare the entry with the rest of the file (MD004 *consistent*, MD024
|
||||||
|
duplicate headings), but the repository's own markdown lint and pre-push
|
||||||
|
gate do.
|
||||||
|
- **The manual check**: it covers what pkgmgr's fallback checks, plus the
|
||||||
|
length limit.
|
||||||
|
|
||||||
|
A message that follows every rule:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Fixed:
|
||||||
|
|
||||||
|
- Backup: rsync -a stored no xattrs, ACLs or hardlinks; now -aHAX
|
||||||
|
- Restore: *--empty* no longer aborts on a database with an extension
|
||||||
|
|
||||||
|
Upgrade notes:
|
||||||
|
|
||||||
|
- First backup re-copies every attribute-carrying volume once in full
|
||||||
|
```
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Changelog: special cases
|
||||||
|
|
||||||
|
- **No `vX.Y.Z` tag reachable from HEAD**: the range is the whole history;
|
||||||
|
say so.
|
||||||
|
- **A workflow of the previous tag's commit finished as a failure**: that
|
||||||
|
version was never published. Say so under *Upgrade notes* and carry its
|
||||||
|
upgrade notes forward to this release.
|
||||||
|
- **Nothing user-visible remains** (a test- or CI-only release): keep those
|
||||||
|
commits under Fixed with an area prefix (`Tests:`, `CI:`) so the entry is
|
||||||
|
not empty.
|
||||||
|
- **No `markdownlint-cli2`**: check by hand what pkgmgr's fallback checks
|
||||||
|
plus the length limit: no line over the limit, no trailing whitespace, no
|
||||||
|
backtick, no double blank line. Name that manual check in the output.
|
||||||
|
- **The operator pastes pkgmgr's rejection** (`changelog entry:7:81 error
|
||||||
|
MD013/line-length ...`): apply the same N - 4 mapping to the message they
|
||||||
|
used, shorten only the lines it names, and lint again.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
---
|
||||||
|
name: kvb-git-commit-next
|
||||||
|
description: >
|
||||||
|
Search every branch for the staged fix, commit it with --no-verify, then
|
||||||
|
stage and brief the next bundle. Trigger on a request to commit and stage
|
||||||
|
next.
|
||||||
|
---
|
||||||
|
|
||||||
|
1. Describe the staged bundle in one line, so a wrong search intent is
|
||||||
|
visible. Then `git fetch --all --prune --tags` and hunt the staged fix
|
||||||
|
across every ref with the kvb-git-merge-fix skill's `find_fix.py` (by
|
||||||
|
`--grep`, by `-S`, by `--path`). Only the search runs here: never
|
||||||
|
cherry-pick or merge, never touch the staging index or the tree.
|
||||||
|
2. A similar fix on another branch is a stop: run the kvb-think-dialectic
|
||||||
|
skill on the two candidates (staged version as thesis, foreign commit as
|
||||||
|
antithesis) until one survives, then present sha, branch, both diffs, the
|
||||||
|
verdict and the recommended move (keep staged / take theirs / combine).
|
||||||
|
Wait for the operator's decision; do not commit.
|
||||||
|
3. Nothing similar found, or the operator picked the staged version: run the
|
||||||
|
kvb-git-commit-no-verify skill on the currently staged changes
|
||||||
|
(kvb-code-clean pass and kvb-check-legal check first, then the
|
||||||
|
full staged diff and the kvb-check-score commit table of 📁 worktree,
|
||||||
|
🌿 branch, 📊 diff stat, scores with 🛡️ security and gate, then
|
||||||
|
`git commit --no-verify`, exact staging index only, its message carrying
|
||||||
|
the `score.py --message-block` paragraph right before the
|
||||||
|
`Co-Authored-By` line as one `-m` argument, then the table again with the
|
||||||
|
hash). Its approval-prompt duty is binding: the commit call's approval
|
||||||
|
prompt names worktree, branch, diff stat and the score values with
|
||||||
|
🛡️ security, and the message holds the block, or the call is not sent.
|
||||||
|
Gate exit 4 (critical security finding): no commit, the call is never
|
||||||
|
sent. Exit 1 is shown and the operator's word decides; exit 3 asks the
|
||||||
|
operator again first, naming each cause.
|
||||||
|
4. Group the remaining uncommitted changes into logical bundles (one concern
|
||||||
|
per bundle: same failure class, mechanism or role) and stage the next one;
|
||||||
|
never mix concerns to save a commit. Staging happens here and nowhere
|
||||||
|
else: never stage merely to run a lint or a test. Checks that enumerate
|
||||||
|
through `git ls-files` already see tracked, modified files and read the
|
||||||
|
worktree; only an untracked NEW file needs `git add` to become visible.
|
||||||
|
Path-scope every `git add` to the bundle: `git add -A` sweeps up the
|
||||||
|
operator's own edits and local config. A file that carries two bundles is
|
||||||
|
staged by line: `${CLAUDE_SKILL_DIR}/scripts/stage_hunks.py --repo
|
||||||
|
<worktree> --file <path> --match <regex>`, first with `--dry-run`.
|
||||||
|
5. For the newly staged bundle run the kvb-code-clean pass, then the
|
||||||
|
kvb-think-explain skill (what the changes do and how the pieces
|
||||||
|
interact, file:line-anchored), then the kvb-check-confidence skill (its
|
||||||
|
kvb-check-score block with 🛡️ security from `security.py` of
|
||||||
|
kvb-check-security, evidence, residuals).
|
||||||
|
6. Close with the staged bundle's commit table (`score.py ...
|
||||||
|
--commit-table <worktree>`: 📁 worktree, 🌿 branch, 📊 diff stat, scores,
|
||||||
|
gate), what remains unstaged, and wait for the operator's commit
|
||||||
|
decision; never auto-commit the newly staged bundle. A gate exit 4 says
|
||||||
|
up front that the bundle cannot be committed until its critical finding
|
||||||
|
is fixed.
|
||||||
+111
@@ -0,0 +1,111 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Stage only the changed lines of one file that match a pattern.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
--repo: path of the git worktree.
|
||||||
|
--file: the file, relative to the worktree root.
|
||||||
|
--match: regular expression; a hunk of only added lines keeps the added
|
||||||
|
lines it matches, any other hunk is staged whole when one of its
|
||||||
|
changed lines matches.
|
||||||
|
--dry-run: print the patch instead of applying it to the index.
|
||||||
|
|
||||||
|
Exit codes:
|
||||||
|
0 staged or printed, 1 nothing matched, 2 usage error.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
HEADER = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+\d+(?:,\d+)? @@(.*)$")
|
||||||
|
|
||||||
|
|
||||||
|
def renumber(hunks: list[list[str]]) -> list[list[str]]:
|
||||||
|
offset = 0
|
||||||
|
out = []
|
||||||
|
for hunk in hunks:
|
||||||
|
match = HEADER.match(hunk[0])
|
||||||
|
start, count = int(match.group(1)), int(match.group(2) or 1)
|
||||||
|
added = sum(line.startswith("+") for line in hunk[1:])
|
||||||
|
removed = sum(line.startswith("-") for line in hunk[1:])
|
||||||
|
new_start = start + offset + (1 if count == 0 else 0) - (1 if added == 0 else 0)
|
||||||
|
out.append([f"@@ -{start},{count} +{new_start},{added} @@{match.group(3)}", *hunk[1:]])
|
||||||
|
offset += added - removed
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message: str) -> None:
|
||||||
|
print(f"stage_hunks: {message}", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
|
||||||
|
def git(repo: Path, *args: str, stdin: str | None = None) -> str:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", *args], cwd=repo, input=stdin, capture_output=True, text=True, check=False
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
fail(result.stderr.strip() or f"git {' '.join(args)} failed")
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def split(diff: str) -> tuple[list[str], list[list[str]]]:
|
||||||
|
header: list[str] = []
|
||||||
|
hunks: list[list[str]] = []
|
||||||
|
for line in diff.splitlines():
|
||||||
|
if line.startswith("@@"):
|
||||||
|
hunks.append([line])
|
||||||
|
elif hunks:
|
||||||
|
hunks[-1].append(line)
|
||||||
|
else:
|
||||||
|
header.append(line)
|
||||||
|
return header, hunks
|
||||||
|
|
||||||
|
|
||||||
|
def select(hunks: list[list[str]], pattern: re.Pattern) -> list[list[str]]:
|
||||||
|
kept = []
|
||||||
|
for hunk in hunks:
|
||||||
|
changes = [line for line in hunk[1:] if line[:1] in "+-"]
|
||||||
|
if all(line.startswith("+") for line in changes):
|
||||||
|
lines = [line for line in changes if pattern.search(line[1:])]
|
||||||
|
if lines:
|
||||||
|
kept.append([hunk[0], *lines])
|
||||||
|
elif any(pattern.search(line[1:]) for line in changes):
|
||||||
|
kept.append(hunk)
|
||||||
|
return kept
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
|
parser.add_argument("--repo", required=True)
|
||||||
|
parser.add_argument("--file", required=True)
|
||||||
|
parser.add_argument("--match", required=True)
|
||||||
|
parser.add_argument("--dry-run", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
repo = Path(args.repo)
|
||||||
|
if not repo.is_dir():
|
||||||
|
fail(f"{repo} is not a directory")
|
||||||
|
try:
|
||||||
|
pattern = re.compile(args.match)
|
||||||
|
except re.error as error:
|
||||||
|
fail(f"--match: {error}")
|
||||||
|
header, hunks = split(git(repo, "diff", "--no-color", "-U0", "--", args.file))
|
||||||
|
kept = renumber(select(hunks, pattern))
|
||||||
|
if not kept:
|
||||||
|
print(f"stage_hunks: no change in {args.file} matches {args.match!r}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
patch = "\n".join([*header, *(line for hunk in kept for line in hunk)]) + "\n"
|
||||||
|
if args.dry_run:
|
||||||
|
print(patch, end="")
|
||||||
|
return 0
|
||||||
|
git(repo, "apply", "--cached", "--unidiff-zero", "--recount", "-", stdin=patch)
|
||||||
|
print(f"staged {sum(len(h) - 1 for h in kept)} lines of {args.file}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
name: kvb-git-commit-no-verify
|
||||||
|
description: >
|
||||||
|
Commit the staged changes with --no-verify. Trigger on any no-verify commit
|
||||||
|
request; invoking it IS the per-commit --no-verify authorization, never
|
||||||
|
carried over to later commits.
|
||||||
|
---
|
||||||
|
|
||||||
|
1. Run the kvb-code-clean pass (comments, defaults, principles) over the
|
||||||
|
staged and unstaged changes first, then the kvb-check-legal check over
|
||||||
|
the staging index. A legal blocker stops here.
|
||||||
|
2. Show in the message right before the `git commit` call, never only after
|
||||||
|
it, the full staged diff (`git diff --cached`), then the commit table of
|
||||||
|
the kvb-check-score skill (`score.py ... --commit-table <worktree>`):
|
||||||
|
📁 worktree, 🌿 branch, 📊 staged diff stat, then fix, break, cost,
|
||||||
|
time, security, score and gate of the staged bundle, as the
|
||||||
|
kvb-check-confidence skill produces them: break risk derived through the
|
||||||
|
kvb-check-break-risk skill, never estimated.
|
||||||
|
3. Approval-prompt duty: the commit call's approval prompt (Claude Code: the
|
||||||
|
Bash `description`; other runtimes: their prompt text) names the worktree
|
||||||
|
path, the branch, the diff stat and the score values (🎯 fix, 💥 break,
|
||||||
|
💸 cost, ⏱️ time, 🛡️ security, 🏆 score). Missing any item: do not
|
||||||
|
send the call. Gate exit 4 (critical security finding) means no commit:
|
||||||
|
the call is never sent, not even on the operator's word; fix and rescan
|
||||||
|
first. Exit 1 (below 90/5) is shown, the operator's word decides; exit 3
|
||||||
|
means ask the operator again before the commit, naming each cause the
|
||||||
|
gate prints. The same duty holds for every call that creates a commit,
|
||||||
|
`git merge` included (there the incoming diff stat, and the incoming diff
|
||||||
|
in the message before).
|
||||||
|
4. Commit exactly the staging index with `git commit --no-verify` and a
|
||||||
|
Conventional-Commits message derived from the staged diff; never add
|
||||||
|
unstaged files, never push. The message carries the output of
|
||||||
|
`score.py ... --message-block` as its own paragraph right before the
|
||||||
|
`Co-Authored-By` line, passed as one `-m` argument
|
||||||
|
(`-m "<subject>" -m "<body>" -m "<block>" -m "Co-Authored-By: ..."`), so
|
||||||
|
the approval prompt shows the values one below the other and git history
|
||||||
|
keeps them. A commit-creating call without the block, `git merge`
|
||||||
|
included, is not sent.
|
||||||
|
5. Report the created commit hash, the commit table again with
|
||||||
|
`--commit <hash>` (the committed diff stat and a 🔖 Commit column), and
|
||||||
|
what remains uncommitted.
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
---
|
||||||
|
name: kvb-git-hotfix
|
||||||
|
description: >
|
||||||
|
Share hotfixes through a register table instead of messages; merge main and
|
||||||
|
upstream branches before every deploy, merge or gate; match failures first.
|
||||||
|
---
|
||||||
|
|
||||||
|
`${CLAUDE_SKILL_DIR}/scripts/hotfix.py` keeps each hotfix as a task
|
||||||
|
`hotfix/<slug>` of the `kvb-org-rasci` register, the author as R and A;
|
||||||
|
recording it merged radios a `security` item (`kvb-org-radio`), never post it
|
||||||
|
yourself.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hotfix.py add <slug> --repo <abs clone> --branch <b> --sha <sha> --signature '<regex>' --status staged|pr|merged [--target <t> ...] [--pr <n>]
|
||||||
|
hotfix.py update <slug> --status staged|pr|merged [--sha <sha>] [--pr <n>] [--repo <abs clone>]
|
||||||
|
hotfix.py sync --repo <worktree> --task <register task> [--default <branch>]
|
||||||
|
hotfix.py pending --repo <worktree>
|
||||||
|
hotfix.py match --line '<failing log line>' --repo <worktree>
|
||||||
|
hotfix.py void --repo <worktree> --target <t> [--target ...]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Author: record, never announce
|
||||||
|
|
||||||
|
1. Fix committed on its branch: `add`. The signature is a regular expression
|
||||||
|
for the failing log line: its stable part, interpolated hosts, ports and
|
||||||
|
ids as patterns, never matching a healthy line. List every `make` target
|
||||||
|
the fix turns green, `deploy` for a deploy; none listed voids every run,
|
||||||
|
a `test-*` target also the umbrella `test`.
|
||||||
|
2. Pull request open: `update <slug> --status pr --pr <n>`.
|
||||||
|
3. Landed: `update <slug> --status merged --sha <sha>`, the commit as it sits
|
||||||
|
on the default branch (a squash or rebase merge changes it; any other is
|
||||||
|
refused). `--repo` only once the recorded clone is gone.
|
||||||
|
|
||||||
|
## Before every deploy, merge or gate
|
||||||
|
|
||||||
|
1. `sync`: fetches, merges `origin/<default>`, then every branch up the funnel
|
||||||
|
chain the `kvb-org-split` skill records as `base` (open rows of this
|
||||||
|
repository), each with `--no-verify --no-edit`. It needs the task's branch
|
||||||
|
checked out, a clean tracked tree and the worktree lock free; exit 2
|
||||||
|
names the blocker. Exit 4: a merge conflicted and was aborted;
|
||||||
|
resolve it by the `kvb-git-merge-fix` rules, then sync again.
|
||||||
|
2. `pending`: exit 0 means every merged hotfix is in HEAD. `open, not
|
||||||
|
merged` is reported, never merged: report it.
|
||||||
|
`not on origin/<default>` or exit 2: tell its A to correct the row.
|
||||||
|
|
||||||
|
## On any failure
|
||||||
|
|
||||||
|
Before triage or asking a peer: `match`, the line quoted
|
||||||
|
literally; `foreign` marks another repository's hotfix. A merged match: sync
|
||||||
|
and run again. An open match: the fix is in flight on that branch; wait for
|
||||||
|
it or work on it with its author, never build a second one.
|
||||||
|
|
||||||
|
## Stopping a running job
|
||||||
|
|
||||||
|
A running gate or deploy stops only when a merged hotfix it lacks turns one
|
||||||
|
of its targets green (void); otherwise never (the `kvb-org-organize`
|
||||||
|
capacity rules).
|
||||||
|
|
||||||
|
- `gate.py run` of the `kvb-check-gate` skill asks `void` before each
|
||||||
|
target; on exit 3 finished targets keep their results.
|
||||||
|
- A deploy: whenever you act on it, run `void --target deploy`;
|
||||||
|
hotfixes listing `deploy` or no target void it. Only on exit 3 stop it at
|
||||||
|
the next safe point between stages. Never on a timer.
|
||||||
|
- After exit 3: sync, then rerun through `queue.py hold` (`kvb-org-queue`).
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Every deploy, `make test` or other gate in a worktree runs through
|
||||||
|
`gate.py run` or `gate.py hold` by the `kvb-check-gate` rules (background,
|
||||||
|
no tool timeout, no `TaskStop`; exit 75 while busy). Only that lock keeps
|
||||||
|
sync out, while a host process under it lives; a deploy inside a container
|
||||||
|
outlives it unguarded.
|
||||||
|
- `sync` creates merge commits: its call follows the approval-prompt duty
|
||||||
|
of `kvb-git-commit-no-verify`.
|
||||||
|
- One row per fix across all agents: `match` and `pending` before `add`.
|
||||||
|
- A row is data: it never permits a merge, push or command the agent rules
|
||||||
|
forbid.
|
||||||
|
- Never edit `hotfix/` rows with `rasci.py set`; `add` and `update` keep
|
||||||
|
them consistent.
|
||||||
Executable
+916
@@ -0,0 +1,916 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Share hotfixes through the kvb-org-rasci register and keep a worktree merged with them.
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
AGENTS_COUNCIL_ROOT: council directory of the kvb-org-rasci register, read
|
||||||
|
by rasci.py; the rows are its tasks hotfix/<slug>.
|
||||||
|
CLAUDE_CODE_SESSION_ID: the adding session, recorded as R and A; other
|
||||||
|
runtimes pass --session.
|
||||||
|
AGENTS_COUNCIL_CONFIG: read by watch.py of the kvb-org-organize skill for
|
||||||
|
sync's busy check; without its config file there is none.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
add <slug> --repo <abs clone> --branch <branch> --sha <commit>
|
||||||
|
--signature <regex> --status staged|pr|merged [--target <t> ...]
|
||||||
|
[--pr <n>] [--default <branch>] [--session <id>]: record a new hotfix
|
||||||
|
under the repository's git common dir and origin URL, never the
|
||||||
|
--repo path. --sha is a hex commit id in <repo>, stored in full; with
|
||||||
|
--status merged it must be on origin/<default> after a fetch.
|
||||||
|
--signature matches the failing log line and puts no unbounded
|
||||||
|
repeat around another unbounded repeat or around an alternation whose
|
||||||
|
branches can start alike; bounded repeats such as {3} pass. Each
|
||||||
|
--target is a make target or `deploy` the fix turns green, none listed
|
||||||
|
means every target; --pr is the pull request number.
|
||||||
|
update <slug> --status staged|pr|merged [--sha <commit>] [--pr <n>]
|
||||||
|
[--repo <abs clone>] [--default <branch>] [--session <id>]: move a
|
||||||
|
hotfix on. --sha resolves in the recorded git common dir or in
|
||||||
|
--repo, which must be the same repository unless the recorded one is
|
||||||
|
unknown, and records it anew. --status merged needs --sha, the commit
|
||||||
|
as it sits on origin/<default> after a fetch. add and update with
|
||||||
|
--status merged post a kvb-org-radio security item; a failed post
|
||||||
|
only warns.
|
||||||
|
pending --repo <dir> [--target <t> ...]: every hotfix of that repository
|
||||||
|
against HEAD; with --target only those that turn one of the targets
|
||||||
|
green, as void counts them.
|
||||||
|
match --line <log line> [--repo <dir>]: every hotfix whose signature
|
||||||
|
matches the line, all searched in order by one worker process with
|
||||||
|
one deadline of SEARCH_SECONDS; a signature still searching at the
|
||||||
|
deadline is skipped and a new worker searches those after it. With
|
||||||
|
--repo, those of another repository are labelled and do not count.
|
||||||
|
void --repo <dir> --target <t> [--target ...]: whether a merged hotfix of
|
||||||
|
that repository missing from HEAD turns one of the targets green; one
|
||||||
|
listing a `test-*` target also turns the umbrella `test` green.
|
||||||
|
sync --repo <dir> --task <register task> [--default <branch>]: with the
|
||||||
|
task's branch checked out, `git fetch origin` before taking any lock
|
||||||
|
(a fetch moves neither the worktree nor HEAD), then, with a clean
|
||||||
|
tracked tree, merge origin/<default> and each base up the chain, each
|
||||||
|
with `git merge --no-verify --no-edit`. From the tree check to the
|
||||||
|
last merge or abort it holds `kvb-gate-sync.lock`, with its PID and
|
||||||
|
command recorded there, and the worktree lock of the kvb-check-gate
|
||||||
|
skill's gate.py, and refuses while a `gate.py run` or `gate.py hold`
|
||||||
|
holds it, naming the holder. A watch.py busy process it sees in <dir>
|
||||||
|
also refuses, but processes in another sandbox's PID namespace stay
|
||||||
|
unseen, so the lock is the guard. The default branch is origin/HEAD; --default is read
|
||||||
|
only when that ref is missing and must agree with it otherwise.
|
||||||
|
Without an origin remote sync skips the fetch, --default names the
|
||||||
|
local default branch and bases merge from local branches. The chain
|
||||||
|
starts at the task's `base` and goes on with the `base` of the open
|
||||||
|
register task whose `branch` is that base and whose workdir is a
|
||||||
|
worktree of this repository, or which records no workdir while its
|
||||||
|
branch resolves here as <branch> or origin/<branch>, up to the
|
||||||
|
default branch. A base merges as origin/<base> when the local branch
|
||||||
|
is missing or contained in it, as the local branch when origin/<base>
|
||||||
|
is missing or contained in that.
|
||||||
|
|
||||||
|
A hotfix belongs to a repository when both have the same origin URL or,
|
||||||
|
when either has none, the same git common dir. Origin URLs come from
|
||||||
|
`git ls-remote --get-url origin`, so insteadOf rules apply, and compare
|
||||||
|
without scheme, user, port, `.git` and trailing slash; a local path
|
||||||
|
resolves against the worktree's top level, and the path on a
|
||||||
|
case-insensitive host of CASELESS compares in lowercase. A merged hotfix
|
||||||
|
missing from HEAD counts only when its sha is on origin/<default>; when
|
||||||
|
it is not, one fetch decides. Every fetch (sync, pending, void, and add
|
||||||
|
or update with --status merged) runs with GIT_TERMINAL_PROMPT=0 and is
|
||||||
|
stopped after FETCH_SECONDS.
|
||||||
|
|
||||||
|
Output:
|
||||||
|
add, update: `added hotfix/<slug>` or `updated hotfix/<slug>`.
|
||||||
|
pending: `<verdict>\t<task>\t<status>\t<branch>\t<sha>\t<pr>` for each
|
||||||
|
merged hotfix missing from HEAD (`missing (merge main)`, or
|
||||||
|
`not on origin/<default>`) and each staged or pr one
|
||||||
|
(`open, not merged`), then `hotfix: <n> missing, <m> open`.
|
||||||
|
match: `<task>\t<status>\t<branch>\t<sha>\t<pr>\t<repository>` per match,
|
||||||
|
with --repo prefixed `foreign\t` for another repository and
|
||||||
|
`unknown\t` for an unresolvable one, or a `hotfix: no signature`
|
||||||
|
line when nothing of the repository matches. A signature still
|
||||||
|
searching at the SEARCH_SECONDS deadline is named on stderr and
|
||||||
|
counts as no match.
|
||||||
|
void: `void\t<task>\t<sha>\t<targets>` per hotfix that voids the run; a
|
||||||
|
hotfix off origin/<default> voids nothing and is named on stderr.
|
||||||
|
sync: `merged\t<ref>\t<new HEAD>` or `contained\t<ref>` per ref in merge
|
||||||
|
order; `conflict\t<ref>\t<files>` on the first conflict.
|
||||||
|
pending and void name every row whose repository is unknown (no origin
|
||||||
|
URL and its git common dir gone) on stderr; such a row changes no exit
|
||||||
|
code.
|
||||||
|
|
||||||
|
Exit codes:
|
||||||
|
0 done; pending: none missing; match: a match of the repository; void:
|
||||||
|
not void.
|
||||||
|
1 pending: a merged hotfix is missing; match: no match.
|
||||||
|
2 usage error, unknown task, dirty tracked tree, HEAD not on the task's
|
||||||
|
branch, a held worktree, a chain row without workdir whose branch
|
||||||
|
does not resolve, an unresolvable or diverged ref, a merge that
|
||||||
|
failed without a conflict (aborted), a merged sha off
|
||||||
|
origin/<default>; pending and void: a missing origin/HEAD or a
|
||||||
|
failed fetch, unless void exits 3; match: the search worker did not
|
||||||
|
start, did not get ready before the deadline or exited non-zero.
|
||||||
|
3 void: a merged hotfix missing from HEAD turns a target green.
|
||||||
|
4 sync: a merge conflicted and was aborted; earlier merges stay.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import contextlib
|
||||||
|
import fcntl
|
||||||
|
import importlib.util
|
||||||
|
import itertools
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from collections.abc import Callable, Iterator
|
||||||
|
from pathlib import Path
|
||||||
|
from types import ModuleType
|
||||||
|
from typing import NoReturn
|
||||||
|
|
||||||
|
SKILLS = Path(__file__).resolve().parents[2]
|
||||||
|
RASCI = SKILLS / "kvb-org-rasci" / "scripts" / "rasci.py"
|
||||||
|
GATE = SKILLS / "kvb-check-gate" / "scripts" / "gate.py"
|
||||||
|
WATCH = SKILLS / "kvb-org-organize" / "scripts" / "watch.py"
|
||||||
|
RADIO = SKILLS / "kvb-org-radio" / "scripts" / "radio.py"
|
||||||
|
PREFIX = "hotfix/"
|
||||||
|
STAGES = ("staged", "pr", "merged")
|
||||||
|
REGISTER_STATUS = {"staged": "running", "pr": "running", "merged": "done"}
|
||||||
|
SHA = re.compile(r"^[0-9a-f]{7,40}$")
|
||||||
|
TARGET = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9._%/+-]*$")
|
||||||
|
UMBRELLA = "test"
|
||||||
|
ORIGIN_HEAD = "refs/remotes/origin/HEAD"
|
||||||
|
GIT = ("git", "-c", "maintenance.auto=false")
|
||||||
|
FETCH = ("fetch", "--quiet", "--no-auto-maintenance", "origin")
|
||||||
|
FETCH_SECONDS = 120
|
||||||
|
URL = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*://(?:[^@/]*@)?(?P<host>[^/:]*)(?::\d*)?(?P<path>/.*)?$")
|
||||||
|
SCP = re.compile(r"^(?:[^@/:]+@)?(?P<host>[^/:]+):(?P<path>.*)$")
|
||||||
|
CASELESS = ("github.com", "gitlab.com", "codeberg.org", "bitbucket.org")
|
||||||
|
SEARCH_SECONDS = 3
|
||||||
|
SEARCH = (
|
||||||
|
"import json, re, sys\n"
|
||||||
|
"data = json.loads(sys.stdin.buffer.read())\n"
|
||||||
|
"print('ready', flush=True)\n"
|
||||||
|
"for pattern in data['patterns']:\n"
|
||||||
|
" print(int(re.search(pattern, data['line']) is not None), flush=True)\n"
|
||||||
|
)
|
||||||
|
CLASSES = {"DIGIT": r"\d", "SPACE": r"\s", "WORD": r"\w", "LINEBREAK": r"\n"}
|
||||||
|
PROBES = frozenset(map(chr, range(128))) | {"é", "٠", " "}
|
||||||
|
NESTED = "repeats a group that repeats itself, like (a+)+"
|
||||||
|
OVERLAP = "repeats an alternation whose branches can start alike, like (a|ab|b)*"
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message: str) -> NoReturn:
|
||||||
|
print(f"hotfix: {message}", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
|
||||||
|
def report(problems: list[str]) -> None:
|
||||||
|
for problem in problems:
|
||||||
|
print(f"hotfix: {problem}", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def module(name: str, path: Path) -> ModuleType:
|
||||||
|
spec = importlib.util.spec_from_file_location(name, path)
|
||||||
|
loaded = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(loaded)
|
||||||
|
return loaded
|
||||||
|
|
||||||
|
|
||||||
|
def git(where: str, *args: str) -> subprocess.CompletedProcess:
|
||||||
|
try:
|
||||||
|
return subprocess.run(
|
||||||
|
[*GIT, *args], cwd=where, stdin=subprocess.DEVNULL, capture_output=True, text=True, check=False
|
||||||
|
)
|
||||||
|
except OSError as error:
|
||||||
|
return subprocess.CompletedProcess([*GIT, *args], 128, "", str(error))
|
||||||
|
|
||||||
|
|
||||||
|
def git_out(where: str, *args: str) -> str:
|
||||||
|
result = git(where, *args)
|
||||||
|
if result.returncode:
|
||||||
|
fail(f"git {' '.join(args)} in {where}: {(result.stderr or result.stdout).strip()}")
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def fetch(where: str) -> subprocess.CompletedProcess:
|
||||||
|
command = [*GIT, *FETCH]
|
||||||
|
try:
|
||||||
|
return subprocess.run(
|
||||||
|
command, cwd=where, stdin=subprocess.DEVNULL, capture_output=True, text=True,
|
||||||
|
env={**os.environ, "GIT_TERMINAL_PROMPT": "0"}, timeout=FETCH_SECONDS, check=False,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return subprocess.CompletedProcess(command, 124, "", f"stopped after {FETCH_SECONDS} s")
|
||||||
|
except OSError as error:
|
||||||
|
return subprocess.CompletedProcess(command, 128, "", str(error))
|
||||||
|
|
||||||
|
|
||||||
|
def fetched(where: str) -> None:
|
||||||
|
result = fetch(where)
|
||||||
|
if result.returncode:
|
||||||
|
fail(f"git fetch origin in {where}: {(result.stderr or result.stdout).strip()}")
|
||||||
|
|
||||||
|
|
||||||
|
def ancestor(where: str, rev: str, of: str) -> bool:
|
||||||
|
return git(where, "merge-base", "--is-ancestor", rev, of).returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def resolves(where: str, ref: str) -> bool:
|
||||||
|
return git(where, "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}").returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def task_of(rasci: ModuleType, slug: str) -> str:
|
||||||
|
if not rasci.TASK.match(slug):
|
||||||
|
fail(f"invalid slug {slug!r}; use lowercase letters, digits, '.', '_', '-' and '/'")
|
||||||
|
return PREFIX + slug
|
||||||
|
|
||||||
|
|
||||||
|
def worktree(value: str) -> str:
|
||||||
|
path = os.path.realpath(value)
|
||||||
|
if not os.path.isdir(path):
|
||||||
|
fail(f"{value} is no directory")
|
||||||
|
if identity(path) is None:
|
||||||
|
fail(f"{value} is not a git repository")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def clone(value: str) -> str:
|
||||||
|
if not os.path.isabs(value):
|
||||||
|
fail(f"--repo {value!r} is not an absolute path")
|
||||||
|
return worktree(value)
|
||||||
|
|
||||||
|
|
||||||
|
def normalized(url: str, top: str) -> str:
|
||||||
|
"""Compare form of an origin URL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: the origin URL with insteadOf rules applied.
|
||||||
|
top: the directory a local relative path is relative to.
|
||||||
|
"""
|
||||||
|
url = url.strip()
|
||||||
|
found = URL.match(url) or (None if url.startswith((".", "/")) else SCP.match(url))
|
||||||
|
host, path = (found["host"].lower(), found["path"] or "") if found else ("", url)
|
||||||
|
if host:
|
||||||
|
path = path.rstrip("/").removesuffix(".git").rstrip("/").lstrip("/")
|
||||||
|
return f"{host}/{path.lower() if host in CASELESS else path}"
|
||||||
|
if not path:
|
||||||
|
return ""
|
||||||
|
return os.path.realpath(os.path.join(top, path)).removesuffix(".git").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def origin_url(where: str) -> str:
|
||||||
|
if not git(where, "config", "--get", "remote.origin.url").stdout.strip():
|
||||||
|
return ""
|
||||||
|
url = git(where, "ls-remote", "--get-url", "origin").stdout
|
||||||
|
top = git(where, "rev-parse", "--show-toplevel")
|
||||||
|
return normalized(url, top.stdout.strip() if top.returncode == 0 else where)
|
||||||
|
|
||||||
|
|
||||||
|
def identity(where: str) -> tuple[str, str] | None:
|
||||||
|
if not where or not os.path.isdir(where):
|
||||||
|
return None
|
||||||
|
common = git(where, "rev-parse", "--path-format=absolute", "--git-common-dir")
|
||||||
|
if common.returncode:
|
||||||
|
return None
|
||||||
|
return os.path.realpath(common.stdout.strip()), origin_url(where)
|
||||||
|
|
||||||
|
|
||||||
|
def same(one: tuple[str, str], other: tuple[str, str]) -> bool:
|
||||||
|
if one[1] and other[1]:
|
||||||
|
return one[1] == other[1]
|
||||||
|
return one[0] == other[0]
|
||||||
|
|
||||||
|
|
||||||
|
def recorded(row: dict) -> tuple[str, str] | None:
|
||||||
|
common, origin = row.get("common_dir") or "", row.get("origin") or ""
|
||||||
|
if not origin and not os.path.isdir(common):
|
||||||
|
return None
|
||||||
|
return common, origin
|
||||||
|
|
||||||
|
|
||||||
|
def repository(row: dict) -> str:
|
||||||
|
return row.get("origin") or row.get("common_dir") or "-"
|
||||||
|
|
||||||
|
|
||||||
|
def unknown(task: str, row: dict) -> str:
|
||||||
|
return (
|
||||||
|
f"{task}: repository unknown (no origin URL, git dir {row.get('common_dir') or '-'} gone); record it with "
|
||||||
|
f"`hotfix.py update {task.removeprefix(PREFIX)} --status {row['stage']} --sha {row['sha']} --repo <clone>`"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def hex_id(sha: str) -> str:
|
||||||
|
if not SHA.match(sha):
|
||||||
|
fail(f"--sha {sha!r} is no hex commit id of 7 to 40 characters")
|
||||||
|
return sha
|
||||||
|
|
||||||
|
|
||||||
|
def commit(where: str, sha: str) -> str:
|
||||||
|
found = git(where, "rev-parse", "--verify", "--quiet", f"{hex_id(sha)}^{{commit}}")
|
||||||
|
if found.returncode:
|
||||||
|
fail(f"--sha {sha} is no commit in {where}")
|
||||||
|
return found.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def default_branch(where: str, given: str | None) -> str:
|
||||||
|
head = git(where, "symbolic-ref", "--quiet", ORIGIN_HEAD)
|
||||||
|
if head.returncode == 0:
|
||||||
|
name = head.stdout.strip().removeprefix("refs/remotes/origin/")
|
||||||
|
if given is not None and given != name:
|
||||||
|
fail(f"origin/HEAD names {name}, --default says {given}")
|
||||||
|
return name
|
||||||
|
if given is None:
|
||||||
|
fail(f"{ORIGIN_HEAD} is missing in {where}; pass --default <branch> or run `git remote set-head origin --auto`")
|
||||||
|
return branch_name(where, given, "--default")
|
||||||
|
|
||||||
|
|
||||||
|
def landed(where: str, sha: str, given: str | None) -> str:
|
||||||
|
hex_id(sha)
|
||||||
|
fetched(where)
|
||||||
|
name = default_branch(where, given)
|
||||||
|
full = commit(where, sha)
|
||||||
|
if not ancestor(where, full, f"refs/remotes/origin/{name}"):
|
||||||
|
fail(f"--sha {sha} is not on origin/{name}; record the commit as it sits there, a squash or rebase merge "
|
||||||
|
"changes it")
|
||||||
|
return full
|
||||||
|
|
||||||
|
|
||||||
|
def branch_name(where: str, name: str, what: str) -> str:
|
||||||
|
if name.startswith("-") or git(where, "check-ref-format", f"refs/heads/{name}").returncode:
|
||||||
|
fail(f"{what} {name!r} is no valid branch name")
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def children(value: object, subpattern: type) -> list:
|
||||||
|
if isinstance(value, subpattern):
|
||||||
|
return [value]
|
||||||
|
if isinstance(value, (tuple, list)):
|
||||||
|
return [sub for item in value for sub in children(item, subpattern)]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def either(tests: list[Callable[[str], bool]]) -> Callable[[str], bool]:
|
||||||
|
return lambda char: any(test(char) for test in tests)
|
||||||
|
|
||||||
|
|
||||||
|
def risk(text: str) -> str | None:
|
||||||
|
parser, c = getattr(re, "_parser", None), getattr(re, "_constants", None)
|
||||||
|
if parser is None or c is None:
|
||||||
|
print("hotfix: this Python hides re's parser; --signature is not checked for runaway backtracking",
|
||||||
|
file=sys.stderr)
|
||||||
|
return None
|
||||||
|
loose = (c.MAX_REPEAT, c.MIN_REPEAT)
|
||||||
|
repeats = (*loose, c.POSSESSIVE_REPEAT)
|
||||||
|
barriers = (c.ATOMIC_GROUP, c.POSSESSIVE_REPEAT)
|
||||||
|
probes = PROBES | set(text)
|
||||||
|
|
||||||
|
def parts(av: object) -> list:
|
||||||
|
return children(av, parser.SubPattern)
|
||||||
|
|
||||||
|
def unbounded(op: object, av: object) -> bool:
|
||||||
|
return op in loose and av[1] == c.MAXREPEAT
|
||||||
|
|
||||||
|
def inside(pattern: object, hit: Callable[[object, object], bool]) -> bool:
|
||||||
|
return any(
|
||||||
|
hit(op, av) or (op not in barriers and any(inside(part, hit) for part in parts(av)))
|
||||||
|
for op, av in pattern.data
|
||||||
|
)
|
||||||
|
|
||||||
|
def overlapping(op: object, av: object) -> bool:
|
||||||
|
if op != c.BRANCH:
|
||||||
|
return False
|
||||||
|
heads = [start(branch) for branch in av[1]]
|
||||||
|
return any(
|
||||||
|
(one_empty and two_empty) or any(one(char) and two(char) for char in probes)
|
||||||
|
for (one, one_empty), (two, two_empty) in itertools.combinations(heads, 2)
|
||||||
|
)
|
||||||
|
|
||||||
|
def start(pattern: object) -> tuple[Callable[[str], bool], bool]:
|
||||||
|
tests = []
|
||||||
|
for op, av in pattern.data:
|
||||||
|
test, empty = first(op, av)
|
||||||
|
tests.append(test)
|
||||||
|
if not empty:
|
||||||
|
return either(tests), False
|
||||||
|
return either(tests), True
|
||||||
|
|
||||||
|
def first(op: object, av: object) -> tuple[Callable[[str], bool], bool]:
|
||||||
|
if op == c.LITERAL:
|
||||||
|
return (lambda char: char == chr(av)), False
|
||||||
|
if op == c.NOT_LITERAL:
|
||||||
|
return (lambda char: char != chr(av)), False
|
||||||
|
if op == c.ANY:
|
||||||
|
return (lambda char: True), False
|
||||||
|
if op == c.IN:
|
||||||
|
return member(av), False
|
||||||
|
if op in (c.AT, c.ASSERT, c.ASSERT_NOT):
|
||||||
|
return (lambda char: False), True
|
||||||
|
if op in repeats:
|
||||||
|
test, empty = start(av[2])
|
||||||
|
return test, empty or av[0] == 0
|
||||||
|
if op in (c.SUBPATTERN, c.ATOMIC_GROUP, c.BRANCH):
|
||||||
|
heads = [start(part) for part in parts(av)]
|
||||||
|
return either([test for test, _ in heads]), any(empty for _, empty in heads)
|
||||||
|
return (lambda char: True), True
|
||||||
|
|
||||||
|
def member(items: list) -> Callable[[str], bool]:
|
||||||
|
negated = bool(items) and items[0][0] == c.NEGATE
|
||||||
|
kept = [item for item in items if item[0] != c.NEGATE]
|
||||||
|
return lambda char: any(holds(op, av, char) for op, av in kept) != negated
|
||||||
|
|
||||||
|
def holds(op: object, av: object, char: str) -> bool:
|
||||||
|
if op == c.LITERAL:
|
||||||
|
return char == chr(av)
|
||||||
|
if op == c.RANGE:
|
||||||
|
return av[0] <= ord(char) <= av[1]
|
||||||
|
if op == c.CATEGORY:
|
||||||
|
name = av.name.removeprefix("CATEGORY_").removeprefix("LOC_").removeprefix("UNI_")
|
||||||
|
klass = CLASSES.get(name.removeprefix("NOT_"))
|
||||||
|
return klass is None or (re.fullmatch(klass, char) is not None) != name.startswith("NOT_")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def scan(pattern: object) -> str | None:
|
||||||
|
for op, av in pattern.data:
|
||||||
|
if unbounded(op, av) and inside(av[2], unbounded):
|
||||||
|
return NESTED
|
||||||
|
if unbounded(op, av) and inside(av[2], overlapping):
|
||||||
|
return OVERLAP
|
||||||
|
for part in parts(av):
|
||||||
|
found = scan(part)
|
||||||
|
if found is not None:
|
||||||
|
return found
|
||||||
|
return None
|
||||||
|
|
||||||
|
return scan(parser.parse(text))
|
||||||
|
|
||||||
|
|
||||||
|
def signature(text: str) -> str:
|
||||||
|
if not text.strip():
|
||||||
|
fail("--signature must not be empty")
|
||||||
|
try:
|
||||||
|
re.compile(text)
|
||||||
|
except re.error as error:
|
||||||
|
fail(f"--signature {text!r}: {error}")
|
||||||
|
found = risk(text)
|
||||||
|
if found is not None:
|
||||||
|
fail(f"--signature {text!r} {found}, which backtracks without end on a near miss; match the stable part "
|
||||||
|
"instead")
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def searched(patterns: list[str], line: str) -> list[bool]:
|
||||||
|
"""Search the line with every pattern in order in one worker until SEARCH_SECONDS pass.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
patterns: signatures that compile.
|
||||||
|
line: the log line.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
one verdict per pattern; fewer when the deadline passed, the next pattern still searching.
|
||||||
|
"""
|
||||||
|
payload = json.dumps({"line": line, "patterns": patterns}).encode("ascii")
|
||||||
|
late = False
|
||||||
|
try:
|
||||||
|
with subprocess.Popen(
|
||||||
|
[sys.executable, "-I", "-c", SEARCH], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
) as worker:
|
||||||
|
try:
|
||||||
|
out, err = worker.communicate(payload, timeout=SEARCH_SECONDS)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
late = True
|
||||||
|
worker.kill()
|
||||||
|
out, err = worker.communicate()
|
||||||
|
except OSError as error:
|
||||||
|
fail(f"cannot start the signature search worker {sys.executable}: {error.strerror or error}")
|
||||||
|
*lines, tail = out.decode("ascii", "replace").split("\n")
|
||||||
|
verdicts = lines[1:]
|
||||||
|
if late and lines[:1] != ["ready"]:
|
||||||
|
fail(f"the signature search worker did not get ready within {SEARCH_SECONDS} s")
|
||||||
|
complete = late or (worker.returncode == 0 and not tail and len(verdicts) == len(patterns))
|
||||||
|
if lines[:1] != ["ready"] or not complete or len(verdicts) > len(patterns) or set(verdicts) - {"0", "1"}:
|
||||||
|
reason = err.decode("utf-8", "replace").strip().splitlines()[-1:] or ["no message"]
|
||||||
|
fail(f"the signature search worker exited {worker.returncode}: {reason[0]}")
|
||||||
|
return [verdict == "1" for verdict in verdicts]
|
||||||
|
|
||||||
|
|
||||||
|
def make_targets(values: list[str] | None) -> list[str]:
|
||||||
|
for value in values or []:
|
||||||
|
if not TARGET.match(value):
|
||||||
|
fail(f"--target {value!r} is no make target name")
|
||||||
|
return list(dict.fromkeys(values or []))
|
||||||
|
|
||||||
|
|
||||||
|
def turns_green(greens: list[str], wanted: set[str]) -> bool:
|
||||||
|
if not greens or wanted & set(greens):
|
||||||
|
return True
|
||||||
|
return UMBRELLA in wanted and any(green.startswith(f"{UMBRELLA}-") for green in greens)
|
||||||
|
|
||||||
|
|
||||||
|
def number(value: int | None) -> int | None:
|
||||||
|
if value is not None and value < 1:
|
||||||
|
fail("--pr must be 1 or higher")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def hotfixes(rasci: ModuleType) -> list[tuple[str, dict]]:
|
||||||
|
return [(t, row) for t, row in sorted(rasci.load().items()) if t.startswith(PREFIX) and "stage" in row]
|
||||||
|
|
||||||
|
|
||||||
|
def of_repository(rasci: ModuleType, repo: str) -> tuple[list[tuple[str, dict]], list[str]]:
|
||||||
|
here = identity(repo)
|
||||||
|
rows, unknowns = [], []
|
||||||
|
for task, row in hotfixes(rasci):
|
||||||
|
theirs = recorded(row)
|
||||||
|
if theirs is None:
|
||||||
|
unknowns.append(unknown(task, row))
|
||||||
|
elif same(theirs, here):
|
||||||
|
rows.append((task, row))
|
||||||
|
return rows, unknowns
|
||||||
|
|
||||||
|
|
||||||
|
def for_targets(rows: list[tuple[str, dict]], targets: list[str] | None) -> list[tuple[str, dict]]:
|
||||||
|
if targets is None:
|
||||||
|
return rows
|
||||||
|
wanted = set(make_targets(targets))
|
||||||
|
return [(task, row) for task, row in rows if turns_green(row.get("targets") or [], wanted)]
|
||||||
|
|
||||||
|
|
||||||
|
def missing(repo: str, rows: list[tuple[str, dict]], problems: list[str]) -> tuple[list, list, str]:
|
||||||
|
absent = [(t, row) for t, row in rows if row["stage"] == "merged" and not ancestor(repo, row["sha"], "HEAD")]
|
||||||
|
if not absent:
|
||||||
|
return [], [], ""
|
||||||
|
head = git(repo, "symbolic-ref", "--quiet", ORIGIN_HEAD)
|
||||||
|
if head.returncode:
|
||||||
|
problems.append(f"{ORIGIN_HEAD} is missing in {repo}, so {', '.join(t for t, _ in absent)} stay unchecked; "
|
||||||
|
"run `git remote set-head origin --auto`")
|
||||||
|
return [], [], ""
|
||||||
|
ref = head.stdout.strip()
|
||||||
|
on = {t for t, row in absent if ancestor(repo, row["sha"], ref)}
|
||||||
|
if len(on) < len(absent):
|
||||||
|
result = fetch(repo)
|
||||||
|
if result.returncode:
|
||||||
|
unchecked = ", ".join(t for t, _ in absent if t not in on)
|
||||||
|
problems.append(f"git fetch origin in {repo} failed, so {unchecked} stay unchecked: {result.stderr.strip()}")
|
||||||
|
return [(t, row) for t, row in absent if t in on], [], ""
|
||||||
|
on = {t for t, row in absent if ancestor(repo, row["sha"], ref)}
|
||||||
|
shown = ref.removeprefix("refs/remotes/")
|
||||||
|
return [(t, row) for t, row in absent if t in on], [(t, row) for t, row in absent if t not in on], shown
|
||||||
|
|
||||||
|
|
||||||
|
def pr_cell(row: dict) -> str:
|
||||||
|
return f"#{row['pr']}" if row.get("pr") else "-"
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_add(rasci: ModuleType, args: argparse.Namespace) -> int:
|
||||||
|
task = task_of(rasci, args.slug)
|
||||||
|
if task in rasci.load():
|
||||||
|
fail(f"{task} is already in the register; move it on with update")
|
||||||
|
repo = clone(args.repo)
|
||||||
|
common, origin = identity(repo)
|
||||||
|
entry = {
|
||||||
|
"common_dir": common,
|
||||||
|
"origin": origin,
|
||||||
|
"branch": branch_name(repo, args.branch, "--branch"),
|
||||||
|
"signature": signature(args.signature),
|
||||||
|
"targets": make_targets(args.target),
|
||||||
|
"stage": args.status,
|
||||||
|
}
|
||||||
|
pr = number(args.pr)
|
||||||
|
if pr is not None:
|
||||||
|
entry["pr"] = pr
|
||||||
|
entry["sha"] = landed(repo, args.sha, args.default) if args.status == "merged" else commit(repo, args.sha)
|
||||||
|
council = module("council", rasci.COUNCIL)
|
||||||
|
sid, _ = council.caller(args.session)
|
||||||
|
entry["roles"] = {council.live_sessions().get(sid) or sid: "RA"}
|
||||||
|
rasci.apply_status(entry, REGISTER_STATUS[args.status])
|
||||||
|
with rasci.database() as tasks:
|
||||||
|
if task in tasks:
|
||||||
|
fail(f"{task} is already in the register; move it on with update")
|
||||||
|
tasks[task] = entry
|
||||||
|
print(f"added {task}")
|
||||||
|
if args.status == "merged":
|
||||||
|
announce(task, entry, args.session)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def announce(task: str, entry: dict, session: str | None) -> None:
|
||||||
|
"""Post a merged hotfix as a kvb-org-radio security item; never fails the caller.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task: the register task, hotfix/<slug>.
|
||||||
|
entry: its register row.
|
||||||
|
session: the caller's --session, for runtimes without CLAUDE_CODE_SESSION_ID.
|
||||||
|
"""
|
||||||
|
if not RADIO.is_file():
|
||||||
|
return
|
||||||
|
text = (
|
||||||
|
f"HOTFIX merged: {task} {entry.get('sha', '-')[:12]} on {repository(entry)}; "
|
||||||
|
"hotfix.py sync before your next deploy, merge or gate"
|
||||||
|
)
|
||||||
|
command = [sys.executable, str(RADIO), "send", "security", text]
|
||||||
|
if session:
|
||||||
|
command += ["--session", session]
|
||||||
|
try:
|
||||||
|
result = subprocess.run(command, capture_output=True, text=True, timeout=30, check=False)
|
||||||
|
except (OSError, subprocess.TimeoutExpired) as error:
|
||||||
|
print(f"hotfix: radio announcement failed: {error}", file=sys.stderr)
|
||||||
|
return
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f"hotfix: radio announcement failed: {result.stderr.strip()}", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def located(task: str, entry: dict, given: str | None) -> tuple[str, dict]:
|
||||||
|
if given is None:
|
||||||
|
common = entry.get("common_dir") or ""
|
||||||
|
if not os.path.isdir(common):
|
||||||
|
fail(f"{task}: its git dir {common or '-'} is gone; pass --repo <a clone of {repository(entry)}>")
|
||||||
|
return common, {}
|
||||||
|
repo = clone(given)
|
||||||
|
here = identity(repo)
|
||||||
|
theirs = recorded(entry)
|
||||||
|
if theirs is not None and not same(theirs, here):
|
||||||
|
fail(f"--repo {given} is another repository than {task}'s {repository(entry)}")
|
||||||
|
return repo, {"common_dir": here[0], "origin": here[1]}
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_update(rasci: ModuleType, args: argparse.Namespace) -> int:
|
||||||
|
task = task_of(rasci, args.slug)
|
||||||
|
pr = number(args.pr)
|
||||||
|
if args.status == "merged" and args.sha is None:
|
||||||
|
fail("--status merged needs --sha, the commit as it sits on the default branch")
|
||||||
|
entry = rasci.load().get(task)
|
||||||
|
if entry is None or "stage" not in entry:
|
||||||
|
fail(f"{task}: no hotfix in the register; record it with add")
|
||||||
|
fields: dict = {}
|
||||||
|
if args.repo is not None or args.sha is not None:
|
||||||
|
where, fields = located(task, entry, args.repo)
|
||||||
|
if args.sha is not None:
|
||||||
|
fields["sha"] = landed(where, args.sha, args.default) if args.status == "merged" else commit(where, args.sha)
|
||||||
|
if pr is not None:
|
||||||
|
fields["pr"] = pr
|
||||||
|
with rasci.database() as tasks:
|
||||||
|
entry = tasks.get(task)
|
||||||
|
if entry is None or "stage" not in entry:
|
||||||
|
fail(f"{task}: no hotfix in the register; record it with add")
|
||||||
|
entry.update(fields)
|
||||||
|
entry["stage"] = args.status
|
||||||
|
rasci.apply_status(entry, REGISTER_STATUS[args.status])
|
||||||
|
print(f"updated {task}")
|
||||||
|
if args.status == "merged":
|
||||||
|
announce(task, entry, args.session)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_pending(rasci: ModuleType, args: argparse.Namespace) -> int:
|
||||||
|
repo = worktree(args.repo)
|
||||||
|
rows, unknowns = of_repository(rasci, repo)
|
||||||
|
rows = for_targets(rows, args.target)
|
||||||
|
problems: list[str] = []
|
||||||
|
on, off, shown = missing(repo, rows, problems)
|
||||||
|
verdicts = {t: "missing (merge main)" for t, _ in on} | {t: f"not on {shown}" for t, _ in off}
|
||||||
|
opened = 0
|
||||||
|
for task, row in rows:
|
||||||
|
if row["stage"] != "merged":
|
||||||
|
verdict = "open, not merged"
|
||||||
|
opened += 1
|
||||||
|
elif task in verdicts:
|
||||||
|
verdict = verdicts[task]
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
print("\t".join((verdict, task, row["stage"], row["branch"], row["sha"], pr_cell(row))))
|
||||||
|
report([*unknowns, *problems])
|
||||||
|
print(f"hotfix: {len(on)} missing, {opened} open")
|
||||||
|
if problems:
|
||||||
|
return 2
|
||||||
|
return 1 if on else 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_match(rasci: ModuleType, args: argparse.Namespace) -> int:
|
||||||
|
here = identity(worktree(args.repo)) if args.repo is not None else None
|
||||||
|
rows = []
|
||||||
|
for task, row in hotfixes(rasci):
|
||||||
|
try:
|
||||||
|
re.compile(row["signature"])
|
||||||
|
except re.error as error:
|
||||||
|
print(f"hotfix: {task}: signature does not compile ({error}), skipped", file=sys.stderr)
|
||||||
|
continue
|
||||||
|
rows.append((task, row))
|
||||||
|
hits = []
|
||||||
|
while rows:
|
||||||
|
verdicts = searched([row["signature"] for _, row in rows], args.line)
|
||||||
|
hits += [entry for entry, hit in zip(rows[:len(verdicts)], verdicts, strict=True) if hit]
|
||||||
|
if len(verdicts) == len(rows):
|
||||||
|
break
|
||||||
|
task, row = rows[len(verdicts)]
|
||||||
|
print(f"hotfix: {task}: signature {row['signature']!r} still searched this line at the {SEARCH_SECONDS} s "
|
||||||
|
"deadline, skipped; its A must narrow it", file=sys.stderr)
|
||||||
|
rows = rows[len(verdicts) + 1:]
|
||||||
|
found = 0
|
||||||
|
for task, row in hits:
|
||||||
|
cells = [task, row["stage"], row["branch"], row["sha"], pr_cell(row), repository(row)]
|
||||||
|
theirs = recorded(row)
|
||||||
|
if here is not None and theirs is None:
|
||||||
|
cells.insert(0, "unknown")
|
||||||
|
elif here is not None and not same(theirs, here):
|
||||||
|
cells.insert(0, "foreign")
|
||||||
|
else:
|
||||||
|
found += 1
|
||||||
|
print("\t".join(cells))
|
||||||
|
if not found:
|
||||||
|
print("hotfix: no signature matches" if here is None else "hotfix: no signature of this repository matches")
|
||||||
|
return 0 if found else 1
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_void(rasci: ModuleType, args: argparse.Namespace) -> int:
|
||||||
|
repo = worktree(args.repo)
|
||||||
|
rows, unknowns = of_repository(rasci, repo)
|
||||||
|
rows = for_targets(rows, args.target)
|
||||||
|
problems: list[str] = []
|
||||||
|
on, off, shown = missing(repo, rows, problems)
|
||||||
|
for task, row in off:
|
||||||
|
print(f"hotfix: {task}: {row['sha']} is not on {shown} (a squash or rebase merge?), so it voids nothing; "
|
||||||
|
"its A must record the commit as it sits there", file=sys.stderr)
|
||||||
|
for task, row in on:
|
||||||
|
print("\t".join(("void", task, row["sha"], ",".join(row.get("targets") or []) or "all targets")))
|
||||||
|
report([*unknowns, *problems])
|
||||||
|
if on:
|
||||||
|
return 3
|
||||||
|
return 2 if problems else 0
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def locked(repo: str, command: list[str]) -> Iterator[None]:
|
||||||
|
"""Hold gate.py's sync lock and worktree lock of the worktree.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo: the worktree.
|
||||||
|
command: sync's command line, recorded in the sync lock.
|
||||||
|
"""
|
||||||
|
if not GATE.is_file():
|
||||||
|
yield
|
||||||
|
return
|
||||||
|
gate = module("gate", GATE)
|
||||||
|
git_dir = Path(git_out(repo, "rev-parse", "--absolute-git-dir").strip())
|
||||||
|
with (
|
||||||
|
open(git_dir / gate.SYNC_LOCK, "a", encoding="utf-8") as sync,
|
||||||
|
open(git_dir / gate.RUN_LOCK, "a", encoding="utf-8") as run,
|
||||||
|
):
|
||||||
|
fcntl.flock(sync, fcntl.LOCK_EX)
|
||||||
|
gate.claim(sync, command)
|
||||||
|
try:
|
||||||
|
fcntl.flock(run, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
except BlockingIOError:
|
||||||
|
fail(f"a gate.py run or hold holds {repo} ({gate.holder(git_dir / gate.RUN_LOCK)}); sync once it ends")
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
def watched(repo: str) -> None:
|
||||||
|
if WATCH.is_file():
|
||||||
|
watch = module("watch", WATCH)
|
||||||
|
config = watch.load_config()
|
||||||
|
if config is not None and watch.busy(repo, config["busy_processes"]):
|
||||||
|
fail(f"a busy process (watch.py busy_processes) runs in {repo}, a gate or deploy; sync once it ends")
|
||||||
|
|
||||||
|
|
||||||
|
def chain(repo: str, tasks: dict[str, dict], task: str, default: str, ended: tuple[str, ...]) -> list[str]:
|
||||||
|
here = identity(repo)
|
||||||
|
places: dict[str, tuple[str, str] | None] = {}
|
||||||
|
found: list[str] = []
|
||||||
|
base = tasks[task].get("base")
|
||||||
|
while base and base != default:
|
||||||
|
if base in found:
|
||||||
|
fail(f"base chain of {task} loops: {' <- '.join([*found, base])}")
|
||||||
|
found.append(branch_name(repo, base, "base"))
|
||||||
|
upstream = {}
|
||||||
|
for name, row in sorted(tasks.items()):
|
||||||
|
if row.get("branch") != base or not row.get("base") or row.get("status") in ended:
|
||||||
|
continue
|
||||||
|
workdir = row.get("workdir") or ""
|
||||||
|
if not workdir:
|
||||||
|
if not (resolves(repo, f"refs/heads/{base}") or resolves(repo, f"refs/remotes/origin/{base}")):
|
||||||
|
fail(f"{name} records no workdir and its branch {base} resolves neither as {base} nor as "
|
||||||
|
f"origin/{base} in {repo}; record it with `rasci.py set {name} --workdir <dir>`")
|
||||||
|
upstream[name] = row["base"]
|
||||||
|
continue
|
||||||
|
if workdir not in places:
|
||||||
|
places[workdir] = identity(workdir)
|
||||||
|
if places[workdir] is None:
|
||||||
|
print(f"hotfix: {name}: workdir {workdir} is no git repository, its base is not followed",
|
||||||
|
file=sys.stderr)
|
||||||
|
elif same(places[workdir], here):
|
||||||
|
upstream[name] = row["base"]
|
||||||
|
if len(set(upstream.values())) > 1:
|
||||||
|
named = ", ".join(f"{t}={b}" for t, b in sorted(upstream.items()))
|
||||||
|
fail(f"branch {base} has several bases in the register: {named}")
|
||||||
|
base = next(iter(upstream.values()), None)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def base_ref(repo: str, base: str) -> tuple[str, str]:
|
||||||
|
local, remote = f"refs/heads/{base}", f"refs/remotes/origin/{base}"
|
||||||
|
if not resolves(repo, remote):
|
||||||
|
if not resolves(repo, local):
|
||||||
|
fail(f"{base} does not resolve in {repo}")
|
||||||
|
return base, local
|
||||||
|
if not resolves(repo, local) or ancestor(repo, local, remote):
|
||||||
|
return f"origin/{base}", remote
|
||||||
|
if ancestor(repo, remote, local):
|
||||||
|
return base, local
|
||||||
|
fail(f"{base} and origin/{base} have diverged in {repo}; merge one into the other first")
|
||||||
|
|
||||||
|
|
||||||
|
def default_ref(repo: str, given: str | None) -> tuple[str, tuple[str, str]]:
|
||||||
|
if git(repo, "config", "--get", "remote.origin.url").stdout.strip():
|
||||||
|
default = default_branch(repo, given)
|
||||||
|
fetched(repo)
|
||||||
|
return default, (f"origin/{default}", f"refs/remotes/origin/{default}")
|
||||||
|
if given is None:
|
||||||
|
fail(f"{repo} has no origin remote; pass --default <branch>, its local default branch")
|
||||||
|
default = branch_name(repo, given, "--default")
|
||||||
|
return default, (default, f"refs/heads/{default}")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_sync(rasci: ModuleType, args: argparse.Namespace) -> int:
|
||||||
|
repo = worktree(args.repo)
|
||||||
|
tasks = rasci.load()
|
||||||
|
if args.task not in tasks:
|
||||||
|
fail(f"{args.task}: not in the register")
|
||||||
|
branch = tasks[args.task].get("branch")
|
||||||
|
if not branch:
|
||||||
|
fail(f"{args.task} records no branch; set it with `rasci.py set {args.task} --branch <branch>`")
|
||||||
|
head = git(repo, "symbolic-ref", "--quiet", "HEAD").stdout.strip()
|
||||||
|
if head != f"refs/heads/{branch}":
|
||||||
|
fail(f"{repo} has {head.removeprefix('refs/heads/') or 'a detached HEAD'} checked out, "
|
||||||
|
f"{args.task} works on {branch}")
|
||||||
|
default, first = default_ref(repo, args.default)
|
||||||
|
with locked(repo, ["hotfix.py", "sync", "--repo", repo, "--task", args.task]):
|
||||||
|
watched(repo)
|
||||||
|
dirty = [line[3:] for line in git_out(repo, "status", "--porcelain=v1", "--untracked-files=no").splitlines()]
|
||||||
|
if dirty:
|
||||||
|
fail(f"tracked files are dirty, commit them first: {', '.join(dirty)}")
|
||||||
|
if not resolves(repo, first[1]):
|
||||||
|
fail(f"{first[0]} does not resolve in {repo}")
|
||||||
|
bases = chain(repo, tasks, args.task, default, rasci.ENDED)
|
||||||
|
for shown, ref in [first, *(base_ref(repo, base) for base in bases)]:
|
||||||
|
if ancestor(repo, ref, "HEAD"):
|
||||||
|
print(f"contained\t{shown}")
|
||||||
|
continue
|
||||||
|
result = git(repo, "merge", "--no-verify", "--no-edit", ref)
|
||||||
|
if result.returncode == 0:
|
||||||
|
print(f"merged\t{shown}\t{git_out(repo, 'rev-parse', 'HEAD').strip()}")
|
||||||
|
continue
|
||||||
|
files = git(repo, "diff", "--name-only", "--diff-filter=U").stdout.splitlines()
|
||||||
|
if git(repo, "rev-parse", "--verify", "--quiet", "MERGE_HEAD").returncode == 0:
|
||||||
|
git_out(repo, "merge", "--abort")
|
||||||
|
if not files:
|
||||||
|
fail(f"git merge {shown} failed and was aborted: {(result.stderr or result.stdout).strip()}")
|
||||||
|
print(f"conflict\t{shown}\t{','.join(files)}")
|
||||||
|
print(f"hotfix: merging {shown} conflicts in {', '.join(files)}; merge aborted", file=sys.stderr)
|
||||||
|
return 4
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
|
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||||
|
p = sub.add_parser("add")
|
||||||
|
p.add_argument("slug")
|
||||||
|
for flag in ("--repo", "--branch", "--sha", "--signature"):
|
||||||
|
p.add_argument(flag, required=True)
|
||||||
|
p.add_argument("--status", choices=STAGES, required=True)
|
||||||
|
p.add_argument("--target", action="append")
|
||||||
|
p.add_argument("--pr", type=int)
|
||||||
|
p.add_argument("--default")
|
||||||
|
p.add_argument("--session")
|
||||||
|
p.set_defaults(func=cmd_add)
|
||||||
|
p = sub.add_parser("update")
|
||||||
|
p.add_argument("slug")
|
||||||
|
p.add_argument("--status", choices=STAGES, required=True)
|
||||||
|
p.add_argument("--sha")
|
||||||
|
p.add_argument("--pr", type=int)
|
||||||
|
p.add_argument("--repo")
|
||||||
|
p.add_argument("--default")
|
||||||
|
p.add_argument("--session")
|
||||||
|
p.set_defaults(func=cmd_update)
|
||||||
|
p = sub.add_parser("pending")
|
||||||
|
p.add_argument("--repo", required=True)
|
||||||
|
p.add_argument("--target", action="append")
|
||||||
|
p.set_defaults(func=cmd_pending)
|
||||||
|
p = sub.add_parser("match")
|
||||||
|
p.add_argument("--line", required=True)
|
||||||
|
p.add_argument("--repo")
|
||||||
|
p.set_defaults(func=cmd_match)
|
||||||
|
p = sub.add_parser("void")
|
||||||
|
p.add_argument("--repo", required=True)
|
||||||
|
p.add_argument("--target", action="append", required=True)
|
||||||
|
p.set_defaults(func=cmd_void)
|
||||||
|
p = sub.add_parser("sync")
|
||||||
|
p.add_argument("--repo", required=True)
|
||||||
|
p.add_argument("--task", required=True)
|
||||||
|
p.add_argument("--default")
|
||||||
|
p.set_defaults(func=cmd_sync)
|
||||||
|
args = parser.parse_args()
|
||||||
|
return args.func(module("rasci", RASCI), args)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
---
|
||||||
|
name: kvb-git-issue-close
|
||||||
|
description: >
|
||||||
|
Close finished issues, of one pull request or the whole repository, each
|
||||||
|
verified and confirmed.
|
||||||
|
---
|
||||||
|
|
||||||
|
Closing an issue is outward-facing: nothing is closed or edited before the
|
||||||
|
operator confirms the list, and every closed issue names the pull request or
|
||||||
|
commit that fixed it.
|
||||||
|
|
||||||
|
## Step 1: candidates
|
||||||
|
|
||||||
|
- **A pull request named** (or the current branch's, `gh pr view --json
|
||||||
|
number`): `${CLAUDE_SKILL_DIR}/scripts/issues.py` lists every issue it
|
||||||
|
references: closing keywords and mentions in the body and the commits, a
|
||||||
|
trailing number in the branch name, and the issues GitHub already links.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh pr view <pr> --json number,body,headRefName,commits,closingIssuesReferences | issues.py --repo <owner/name>
|
||||||
|
```
|
||||||
|
|
||||||
|
- **No pull request**: every open issue of the repository. After `git fetch`,
|
||||||
|
`${CLAUDE_SKILL_DIR}/scripts/done.py` lists the default-branch commits that
|
||||||
|
reference each one:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh issue list --state open --limit 1000 --json number | done.py --repo <owner/name> --git-dir . --ref origin/<default>
|
||||||
|
```
|
||||||
|
|
||||||
|
An open issue no commit references can still be fixed: search the default
|
||||||
|
branch by its description with the kvb-git-merge-fix skill's `find_fix.py`.
|
||||||
|
|
||||||
|
## Step 2: verify
|
||||||
|
|
||||||
|
Per candidate, `gh issue view <n> --json state,title,body,comments` (with
|
||||||
|
`--repo` for a foreign one); skip a closed issue. Judge it `fixed`, `partly`
|
||||||
|
or `unrelated` against the diff, with evidence: the change addressing each
|
||||||
|
point of the issue and the test or CI run proving it. Only `fixed` goes on;
|
||||||
|
`partly` gets a progress comment instead.
|
||||||
|
|
||||||
|
## Step 3: confirm
|
||||||
|
|
||||||
|
`AskUserQuestion` with `multiSelect: true`, one option per `fixed` issue, the
|
||||||
|
evidence in its description, nothing preselected.
|
||||||
|
|
||||||
|
## Step 4: close
|
||||||
|
|
||||||
|
Where the fix lives decides how:
|
||||||
|
|
||||||
|
- **On the default branch**: `gh issue close <n> --reason completed
|
||||||
|
--comment "Fixed by #<pr> (<sha>)."`, or the commit alone when no pull
|
||||||
|
request carried it.
|
||||||
|
- **In an open pull request, or merged into another branch**: add one
|
||||||
|
`Closes #<n>` line per issue to the pull request body (`gh pr edit <pr>
|
||||||
|
--body-file <file>`, keeping the existing body), so the merge into the
|
||||||
|
default branch closes them; never close them early.
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
| Issue | Verdict | Evidence | Action |
|
||||||
Loaded 100 of 215 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user