Compare commits
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
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ npm-debug.log*
|
|||||||
.claude/
|
.claude/
|
||||||
.mcp.json
|
.mcp.json
|
||||||
|
|
||||||
|
.state/
|
||||||
|
|
||||||
# Editor / OS
|
# Editor / OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.swp
|
*.swp
|
||||||
@@ -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.
|
||||||
@@ -1,5 +1,81 @@
|
|||||||
# Changelog
|
# 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
|
## [0.1.0] - 2026-10-30
|
||||||
|
|
||||||
Added:
|
Added:
|
||||||
|
|||||||
@@ -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,9 +7,106 @@ 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 🗺️
|
## 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 `confidence` block; those arrows are left out. The locked third-party skills (caveman, ponytail, archify) are not drawn.
|
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
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
@@ -17,60 +114,105 @@ flowchart LR
|
|||||||
commit_req([commit request])
|
commit_req([commit request])
|
||||||
stage_req([staging or fix ready])
|
stage_req([staging or fix ready])
|
||||||
edit_hook([edit in a git worktree])
|
edit_hook([edit in a git worktree])
|
||||||
reminders([reminder hooks])
|
reminders([12 h agent hint, 17:00 GNOME popup])
|
||||||
|
full_hour([full hour])
|
||||||
code_task([code task])
|
code_task([code task])
|
||||||
|
spawn([subagent spawn])
|
||||||
end
|
end
|
||||||
subgraph commit [Commit]
|
subgraph cat_think [think]
|
||||||
commit_next[commit-next]
|
active_listening[kvb-think-listen]
|
||||||
commit_no_verify[commit-no-verify]
|
explain[kvb-think-explain]
|
||||||
comments_clean[comments-clean]
|
dialectic[kvb-think-dialectic]
|
||||||
advocate[advocate]
|
criticize[kvb-think-criticize]
|
||||||
confidence[confidence]
|
brainstorm[kvb-think-brainstorm]
|
||||||
break_risk[break-risk]
|
gaps[kvb-think-gaps]
|
||||||
explain[explain]
|
choice[kvb-think-choice]
|
||||||
end
|
end
|
||||||
subgraph coordination [Coordination]
|
subgraph cat_create [create]
|
||||||
democracy[democracy]
|
plan[kvb-create-plan]
|
||||||
coordinate[coordinate]
|
requirement[kvb-create-requirement]
|
||||||
funnel[funnel]
|
|
||||||
cleanup[cleanup]
|
|
||||||
end
|
end
|
||||||
subgraph ci [CI]
|
subgraph cat_code [code]
|
||||||
observe[observe]
|
scope_guard[kvb-code-scope-guard]
|
||||||
triage[triage]
|
clean[kvb-code-clean]
|
||||||
ci_artifacts[ci-artifacts]
|
refactor[kvb-code-refactor]
|
||||||
tune[tune]
|
test[kvb-code-test]
|
||||||
merge_fix[merge-fix]
|
tune[kvb-code-tune]
|
||||||
|
code_transcend[kvb-code-transcend]
|
||||||
end
|
end
|
||||||
subgraph reasoning [Reasoning]
|
subgraph cat_ci [ci]
|
||||||
active_listening[active-listening]
|
triage[kvb-ci-triage]
|
||||||
dialectic[dialectic]
|
ci_artifacts[kvb-ci-artifacts]
|
||||||
criticize[criticize]
|
observe[kvb-ci-observe]
|
||||||
sceptic[sceptic]
|
|
||||||
auto_feature[auto-feature]
|
|
||||||
end
|
end
|
||||||
subgraph autonomy [Autonomy]
|
subgraph cat_git [git]
|
||||||
plan[plan]
|
commit_next[kvb-git-commit-next]
|
||||||
robot[robot]
|
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
|
end
|
||||||
subgraph self_improvement [Self-improvement]
|
subgraph cat_check [check]
|
||||||
dream[dream]
|
advocate[kvb-check-legal]
|
||||||
autotune[autotune]
|
medusa[kvb-check-security]
|
||||||
autoskill[autoskill]
|
sceptic[kvb-check-principles]
|
||||||
essence[essence]
|
confidence[kvb-check-confidence]
|
||||||
shortcuts[shortcuts]
|
break_risk[kvb-check-break-risk]
|
||||||
|
gate[kvb-check-gate]
|
||||||
|
efficiency[kvb-check-efficiency]
|
||||||
|
speed[kvb-check-speed]
|
||||||
|
score[kvb-check-score]
|
||||||
end
|
end
|
||||||
subgraph quality [Quality]
|
subgraph cat_org [org]
|
||||||
test[test]
|
democracy[kvb-org-organize]
|
||||||
refactor[refactor]
|
funnel[kvb-org-split]
|
||||||
no_defaults[no-defaults]
|
coordinate[kvb-org-coordinate]
|
||||||
scope_guard[scope-guard]
|
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
|
end
|
||||||
subgraph standalone [Standalone]
|
subgraph cat_agent [agent]
|
||||||
changelog[changelog]
|
agent_optimize[kvb-agent-optimize]
|
||||||
help_skills[help-skills]
|
relax[kvb-agent-relax]
|
||||||
status_matrix[status-matrix]
|
sleep[kvb-agent-sleep]
|
||||||
medusa[medusa]
|
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
|
end
|
||||||
|
|
||||||
commit_req -.-> commit_next
|
commit_req -.-> commit_next
|
||||||
@@ -78,25 +220,71 @@ flowchart LR
|
|||||||
stage_req -.-> confidence
|
stage_req -.-> confidence
|
||||||
edit_hook -. council gate .-> democracy
|
edit_hook -. council gate .-> democracy
|
||||||
reminders -.-> dream
|
reminders -.-> dream
|
||||||
reminders -.-> autotune
|
full_hour -.-> coach
|
||||||
code_task -.-> no_defaults
|
code_task -.-> clean
|
||||||
code_task -.-> scope_guard
|
code_task -.-> scope_guard
|
||||||
|
spawn -.-> subagent_model
|
||||||
|
|
||||||
commit_next --> merge_fix
|
commit_next --> merge_fix
|
||||||
commit_next --> commit_no_verify
|
commit_next --> commit_no_verify
|
||||||
commit_next --> explain
|
commit_next --> explain
|
||||||
commit_next --> dialectic
|
commit_next --> dialectic
|
||||||
commit_no_verify --> comments_clean
|
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
|
commit_no_verify --> advocate
|
||||||
advocate --> dialectic
|
advocate --> dialectic
|
||||||
confidence --> break_risk
|
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
|
confidence -. objective .-> autotune
|
||||||
|
|
||||||
coordinate --> democracy
|
coordinate --> democracy
|
||||||
cleanup --> democracy
|
cleanup --> democracy
|
||||||
|
cleanup --> orphans_find
|
||||||
|
branch_gc --> democracy
|
||||||
|
orphans_find --> branch_gc
|
||||||
|
orphans_find --> choice
|
||||||
|
orphans_find --> operator
|
||||||
funnel --> democracy
|
funnel --> democracy
|
||||||
funnel --> dialectic
|
funnel --> dialectic
|
||||||
|
funnel --> subagent_model
|
||||||
|
dialectic --> subagent_model
|
||||||
democracy --> merge_fix
|
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 --> triage
|
||||||
observe --> ci_artifacts
|
observe --> ci_artifacts
|
||||||
@@ -114,26 +302,69 @@ flowchart LR
|
|||||||
criticize --> dialectic
|
criticize --> dialectic
|
||||||
criticize --> active_listening
|
criticize --> active_listening
|
||||||
sceptic --> dialectic
|
sceptic --> dialectic
|
||||||
sceptic --> no_defaults
|
gaps --> sceptic
|
||||||
auto_feature --> sceptic
|
gaps --> active_listening
|
||||||
auto_feature --> active_listening
|
gaps --> requirement
|
||||||
|
requirement --> active_listening
|
||||||
|
requirement --> brainstorm
|
||||||
|
requirement -. offer .-> plan
|
||||||
|
brainstorm --> dialectic
|
||||||
|
|
||||||
plan --> robot
|
plan --> robot
|
||||||
plan --> active_listening
|
plan --> active_listening
|
||||||
robot --> dialectic
|
robot --> dialectic
|
||||||
|
|
||||||
dream --> dialectic
|
dream --> dialectic
|
||||||
dream --> autotune
|
dream --> optimize
|
||||||
dream --> autoskill
|
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
|
autoskill --> autotune
|
||||||
autoskill --> shortcuts
|
|
||||||
autotune --> essence
|
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 --> triage
|
||||||
test --> dialectic
|
test --> dialectic
|
||||||
test --> no_defaults
|
test --> clean
|
||||||
test --> comments_clean
|
refactor --> clean
|
||||||
refactor --> comments_clean
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Requirements 📦
|
## Requirements 📦
|
||||||
@@ -155,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.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 — `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). A PreToolUse gate from the `democracy` skill blocks edits to files in a git worktree until the session holds a claim (`skills/democracy/scripts/council.py`), and its resource watch (`skills/democracy/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. 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())
|
||||||
+74
-17
@@ -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, register the autotune
|
|
||||||
// and dream reminder hooks, the democracy claim gate and the resource watch. 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) {
|
||||||
@@ -52,29 +101,37 @@ for (const skill of REMINDERS) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
data.hooks.PreToolUse = data.hooks.PreToolUse || [];
|
data.hooks.PreToolUse = data.hooks.PreToolUse || [];
|
||||||
if (!JSON.stringify(data.hooks.PreToolUse).includes("democracy/scripts/council.py")) {
|
if (!JSON.stringify(data.hooks.PreToolUse).includes("kvb-org-organize/scripts/council.py")) {
|
||||||
const council = path.join(path.dirname(settingsPath), "skills", "democracy", "scripts", "council.py");
|
const council = path.join(path.dirname(settingsPath), "skills", "kvb-org-organize", "scripts", "council.py");
|
||||||
data.hooks.PreToolUse.push({
|
data.hooks.PreToolUse.push({
|
||||||
matcher: "Edit|Write|MultiEdit|NotebookEdit",
|
matcher: "Edit|Write|MultiEdit|NotebookEdit",
|
||||||
hooks: [{ type: "command", command: `python3 ${JSON.stringify(council)} gate` }],
|
hooks: [{ type: "command", command: `python3 ${JSON.stringify(council)} gate` }],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const watch = path.join(path.dirname(settingsPath), "skills", "democracy", "scripts", "watch.py");
|
const SCRIPT_HOOKS = [
|
||||||
const WATCH_HOOKS = [
|
["kvb-org-organize/scripts/watch.py", "UserPromptSubmit", null, "watch", {}],
|
||||||
["UserPromptSubmit", null, "watch"],
|
["kvb-org-radio/scripts/radio.py", "UserPromptSubmit", null, "hook", {}],
|
||||||
["PostToolUse", "Bash", "watch"],
|
["kvb-org-radio/scripts/radio.py", "PostToolBatch", null, "tool", {}],
|
||||||
["PreToolUse", "Bash", "block"],
|
["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 [event, matcher, sub] of WATCH_HOOKS) {
|
for (const [script, event, matcher, sub, extra] of SCRIPT_HOOKS) {
|
||||||
data.hooks[event] = data.hooks[event] || [];
|
data.hooks[event] = data.hooks[event] || [];
|
||||||
const registered = data.hooks[event].some((entry) =>
|
const registered = data.hooks[event].some((entry) =>
|
||||||
(entry.hooks || []).some((hook) =>
|
(entry.hooks || []).some((hook) =>
|
||||||
String(hook.command).includes("democracy/scripts/watch.py") && String(hook.command).endsWith(` ${sub}`)));
|
String(hook.command).includes(script) && String(hook.command).endsWith(` ${sub}`)));
|
||||||
if (registered) continue;
|
if (registered) continue;
|
||||||
const entry = { hooks: [{ type: "command", command: `python3 ${JSON.stringify(watch)} ${sub}` }] };
|
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);
|
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, the ${REMINDERS.join(" + ")} reminders, the democracy claim gate and resource watch 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}`);
|
||||||
@@ -53,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."
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
---
|
|
||||||
name: auto-feature
|
|
||||||
description: >
|
|
||||||
Derive missing work (sceptic pass for structural flaws, usability pass for
|
|
||||||
missing features) and write requirement documents after single-select
|
|
||||||
confirmation. Trigger on /auto-feature, "was fehlt noch", "welche features
|
|
||||||
brauchen wir", or when the operator asks what to build next.
|
|
||||||
---
|
|
||||||
|
|
||||||
Two blind spots produce different work: the code has defects of structure,
|
|
||||||
the product has gaps of use. This skill runs both lenses, ranks what comes
|
|
||||||
out, and lets the operator choose — it writes requirement documents, never
|
|
||||||
implementations.
|
|
||||||
|
|
||||||
## Step 1: fix the scope
|
|
||||||
|
|
||||||
Name the target in one line: repository, subsystem, role, or user-facing
|
|
||||||
surface. Read the project's existing requirement documents first — their
|
|
||||||
location, filename convention, and section structure are the template, and
|
|
||||||
an already-written requirement is not a finding.
|
|
||||||
|
|
||||||
## Step 2: the sceptic pass
|
|
||||||
|
|
||||||
Invoke the `sceptic` skill on the target. Its findings become requirement
|
|
||||||
candidates by severity: a violated SPOT, SRP, or trust boundary is work the
|
|
||||||
product needs whether or not a user ever notices. Carry each finding's
|
|
||||||
`file:line` evidence forward — a requirement without it is a guess.
|
|
||||||
|
|
||||||
## Step 3: the usability pass
|
|
||||||
|
|
||||||
Independently, look for what the product does not do yet. Anchor every gap
|
|
||||||
in something observable in the repository, never in an invented persona:
|
|
||||||
|
|
||||||
- **Entry** — can someone install, configure, and run it without reading the
|
|
||||||
source? Missing `--help`, missing example config, an undocumented required
|
|
||||||
variable.
|
|
||||||
- **Recovery** — does a failure tell the user what to do next? Errors that
|
|
||||||
print a stack trace instead of the remediation, no dry-run, no way to undo
|
|
||||||
or uninstall, no idempotent re-run.
|
|
||||||
- **Feedback** — does a long operation say anything while it works? Silent
|
|
||||||
minutes, no progress, no exit summary.
|
|
||||||
- **Empty and edge states** — first run with no data, zero results, an
|
|
||||||
expired credential, a partially completed run.
|
|
||||||
- **Discoverability** — a capability that exists but nothing surfaces:
|
|
||||||
undocumented flags, features reachable only by reading tests.
|
|
||||||
- **Accessibility and locale** — output that assumes colour, a terminal
|
|
||||||
width, or one language.
|
|
||||||
|
|
||||||
Each gap gets one line: `observed evidence -> who is blocked -> smallest
|
|
||||||
change that unblocks them`. Drop anything you cannot anchor.
|
|
||||||
|
|
||||||
## Step 4: rank and ask
|
|
||||||
|
|
||||||
Merge both lists, drop duplicates where a structural finding and a usability
|
|
||||||
gap describe the same defect, and rank by blocked users first, blast radius
|
|
||||||
second.
|
|
||||||
|
|
||||||
Then apply `active-listening`: answer everything you can from the code, the
|
|
||||||
docs, and the git history yourself, and put only the genuinely open decisions
|
|
||||||
to the operator with `AskUserQuestion`, `multiSelect: false`, so every
|
|
||||||
question renders as radio buttons. At most four questions per call, each
|
|
||||||
option a real alternative — never one option plus filler:
|
|
||||||
|
|
||||||
1. **Which candidates** become requirements this run, ranked, each option
|
|
||||||
carrying its evidence in the description.
|
|
||||||
2. **Which shape** the chosen one takes — for a feature typically
|
|
||||||
`Minimal version that unblocks the user`, `Full version`, `Fix the
|
|
||||||
underlying structure instead`, `Document the limitation and defer`.
|
|
||||||
3. **Scope boundary** — what is explicitly out of scope, when the candidate
|
|
||||||
could grow in more than one direction.
|
|
||||||
4. **Placement** — which requirement file or directory receives it, when the
|
|
||||||
project has more than one.
|
|
||||||
|
|
||||||
Never assume an answer, never fold two decisions into one option, and never
|
|
||||||
write a file before the answers are in.
|
|
||||||
|
|
||||||
## Step 5: write the requirements
|
|
||||||
|
|
||||||
One document per feature — a requirement that needs "and" in its title is
|
|
||||||
two requirements. Follow the project's existing template; when there is
|
|
||||||
none, keep it to: problem with its evidence, the decided scope and its
|
|
||||||
boundary, definition of done as checkable criteria, and the alternatives the
|
|
||||||
operator rejected with the reason.
|
|
||||||
|
|
||||||
Show the drafted document and ask for a final `Write it` / `Revise it` /
|
|
||||||
`Discard` before touching disk.
|
|
||||||
|
|
||||||
## Rules
|
|
||||||
|
|
||||||
- Requirements only: no implementation, no refactor, no staging as part of
|
|
||||||
this skill.
|
|
||||||
- Every requirement traces to evidence — a `file:line`, a failing path, a
|
|
||||||
missing artifact. A requirement whose justification is "would be nice" is
|
|
||||||
not written.
|
|
||||||
- Record the rejected alternatives inside the document; the next reader
|
|
||||||
needs to know what was already considered and dismissed.
|
|
||||||
- Close with the `confidence` skill's numbers for the analysis: how sure the
|
|
||||||
gaps are real, and what the passes did not cover.
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
---
|
|
||||||
name: autoskill
|
|
||||||
description: >
|
|
||||||
Turn operator repetition into a skill. Trigger when the operator gives the
|
|
||||||
same instruction, correction or prompt pattern about the third time in a
|
|
||||||
conversation, or says they keep repeating themselves, and the pattern is
|
|
||||||
reusable.
|
|
||||||
---
|
|
||||||
|
|
||||||
Its objective is the `autotune` skill's **Objective** section: a skill is worth
|
|
||||||
creating only when it cuts tokens or time and raises quality; judge every
|
|
||||||
candidate against its 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,148 +0,0 @@
|
|||||||
---
|
|
||||||
name: autotune
|
|
||||||
description: >
|
|
||||||
Generate new skills or optimize existing ones (fewer tokens, fewer errors,
|
|
||||||
persistent learnings), each proposal confirmed by single-select question.
|
|
||||||
Trigger ONLY on an explicit request: /autotune, "autotune", "tune the
|
|
||||||
skills"; never on your own initiative.
|
|
||||||
---
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
The shared objective of the self-improving skills (autotune, autoskill,
|
|
||||||
confidence): every skill, rule, or report the agent produces about its own
|
|
||||||
working is judged on three axes at once. A proposal that cannot name the axis
|
|
||||||
it moves is not an optimization, it is a preference.
|
|
||||||
|
|
||||||
- **Tokens** — fewer input and output tokens for the same result: read the range
|
|
||||||
instead of the whole file, grep the saved log instead of re-running the
|
|
||||||
command, route to an authoritative doc instead of duplicating it, report in
|
|
||||||
lines instead of paragraphs.
|
|
||||||
- **Time** — fewer round trips and less waiting: independent calls in parallel,
|
|
||||||
one command that answers the question instead of three that circle it, no
|
|
||||||
polling for work the harness will report on its own.
|
|
||||||
- **Quality** — fewer errors and less rework: verification instead of
|
|
||||||
assumption, and learnings that persist so the same mistake is not paid for
|
|
||||||
twice.
|
|
||||||
|
|
||||||
- A change must improve at least one axis and damage none. Output that got
|
|
||||||
cheaper but lost correctness is a regression, not an optimization.
|
|
||||||
- Quality is never the currency: validation, error handling, tests, and security
|
|
||||||
steps are not what gets cut to save tokens or time.
|
|
||||||
- Measure, do not guess: name the observed friction — the re-read, the retry,
|
|
||||||
the correction the operator had to give twice — and what it cost. A guessed
|
|
||||||
percentage is not a measurement.
|
|
||||||
- The cheapest step is the one that does not run: drop work before optimizing
|
|
||||||
it.
|
|
||||||
- Persist it or pay again: an optimization that lives only in this session is
|
|
||||||
re-derived in the next one, so it belongs in a skill or a memory entry.
|
|
||||||
|
|
||||||
## 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. Route that compaction
|
|
||||||
through the `essence` skill instead of trimming by hand: it runs a health
|
|
||||||
pass for dead pointers and rules duplicated across skills before cutting
|
|
||||||
anything, ranks by what loads in every session, and shows the diff before it
|
|
||||||
writes. It asks for its own scope — answer with the skill being optimized.
|
|
||||||
- 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 **Objective** section decides 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,135 +0,0 @@
|
|||||||
---
|
|
||||||
name: changelog
|
|
||||||
description: >
|
|
||||||
Write the release message since the last release for `pkgmgr release`;
|
|
||||||
prints it only, never edits CHANGELOG.md, tags or releases. Trigger on
|
|
||||||
/changelog, "release message", "changelog entry", before cutting a release,
|
|
||||||
or on pkgmgr's markdown-lint rejection.
|
|
||||||
---
|
|
||||||
|
|
||||||
The message is one text that pkgmgr writes to four places: the CHANGELOG.md
|
|
||||||
entry, the annotated release tag, `debian/changelog` and the RPM `%changelog`.
|
|
||||||
Every rule below exists because one of those four would otherwise break or
|
|
||||||
mangle it.
|
|
||||||
|
|
||||||
## 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
|
|
||||||
```
|
|
||||||
|
|
||||||
- pkgmgr tags every release `vX.Y.Z`; the newest one reachable from HEAD is
|
|
||||||
the base. No such tag: the range is the whole history, say so.
|
|
||||||
- Drop the `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 the subject does not say what changed for the user.
|
|
||||||
- When `gh` is available, check the previous tag's CI run
|
|
||||||
(`gh run list --branch <tag> -L 1`). A failed run means that version was
|
|
||||||
never published: say so under *Upgrade notes* and carry its upgrade notes
|
|
||||||
forward to this release.
|
|
||||||
|
|
||||||
## 2. Sort into sections
|
|
||||||
|
|
||||||
| Commit | Section |
|
|
||||||
| --- | --- |
|
|
||||||
| `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 |
|
|
||||||
|
|
||||||
When 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.
|
|
||||||
|
|
||||||
Order: Breaking, Added, Changed, Fixed, Upgrade notes. Leave out empty
|
|
||||||
sections.
|
|
||||||
|
|
||||||
## 3. Format
|
|
||||||
|
|
||||||
```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
|
|
||||||
```
|
|
||||||
|
|
||||||
1. **Section labels are plain lines ending in `:`**, never `#` headings and
|
|
||||||
never a bold-only line. pkgmgr creates the tag with `git tag -a -m`, whose
|
|
||||||
default `strip` cleanup deletes every line starting with `#`. A bold-only
|
|
||||||
line fails MD036.
|
|
||||||
2. **No version heading, date or "Release X.Y.Z" line.** pkgmgr adds
|
|
||||||
`## [X.Y.Z] - YYYY-MM-DD` itself.
|
|
||||||
3. **One bullet, one line. Too long means shorter, never wrapped.** RPM turns
|
|
||||||
every non-empty line into its own `-` entry and Debian indents each line,
|
|
||||||
so a continuation line becomes a separate, broken entry.
|
|
||||||
4. **Line length: the repository's MD013 limit**, which is markdownlint's
|
|
||||||
default of 80 unless the repo's config changes it. Aim for 78 or less, so
|
|
||||||
lines stay within 80 after Debian's two-space indent and RPM's two-character
|
|
||||||
`-` prefix.
|
|
||||||
5. **Lists use the marker CHANGELOG.md already uses**, no nesting, with a
|
|
||||||
blank line before and after every list (MD004, MD032). 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. Take the marker of the first
|
|
||||||
list item in the existing CHANGELOG.md; use `-` only when there is no
|
|
||||||
CHANGELOG.md yet, and the configured one when the repo's MD004 pins a style.
|
|
||||||
6. **No backticks and no code blocks.** pkgmgr rewrites inline code to
|
|
||||||
*italic* in all four targets, and a fenced block fails its fallback lint.
|
|
||||||
Write *names*, *--flags* and *paths* in `*` italics yourself; use `*`, not
|
|
||||||
`_`, for emphasis (MD049).
|
|
||||||
7. **Bullet text is `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. **Plain hygiene**: no trailing whitespace (MD009), no double blank lines
|
|
||||||
(MD012), no bare URLs: wrap them as `<https://...>` (MD034).
|
|
||||||
|
|
||||||
## 4. Lint it the way pkgmgr does
|
|
||||||
|
|
||||||
pkgmgr wraps the message as `# Changelog`, blank line, `## [X.Y.Z] - date`,
|
|
||||||
blank line, message, then runs `markdownlint-cli2` with the target repo's own
|
|
||||||
config. Reproduce that exactly:
|
|
||||||
|
|
||||||
1. Write that wrapped document to a scratch file outside the repository.
|
|
||||||
2. From the repository root, so its config is picked up, run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
markdownlint-cli2 --no-globs - < <scratch-file>
|
|
||||||
```
|
|
||||||
|
|
||||||
*--no-globs* keeps the config's own globs from linting the whole tree too.
|
|
||||||
3. A finding on line N points at message line N - 4. Shorten that line and
|
|
||||||
lint again; print nothing until the result is 0 errors.
|
|
||||||
4. 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. Copy CHANGELOG.md to a
|
|
||||||
scratch file, insert the wrapped heading and message below its
|
|
||||||
`# Changelog` line, and lint that copy the same way. A finding in the older
|
|
||||||
entries that the unmodified CHANGELOG.md does not produce comes from the
|
|
||||||
new entry; fix the message, never the older entries.
|
|
||||||
|
|
||||||
Without `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.
|
|
||||||
|
|
||||||
When 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.
|
|
||||||
|
|
||||||
## 5. Output
|
|
||||||
|
|
||||||
1. The message in one fenced `text` block, nothing else inside it.
|
|
||||||
2. One line: `Bump: patch | minor | major` and why (breaking → major,
|
|
||||||
`feat` → minor, otherwise patch).
|
|
||||||
3. One line with the lint result (`markdownlint-cli2: 0 errors` or the manual
|
|
||||||
check that ran).
|
|
||||||
|
|
||||||
Then stop. Never edit CHANGELOG.md or a version file, never run
|
|
||||||
`pkgmgr release`, never tag: the operator releases.
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
---
|
|
||||||
name: cleanup
|
|
||||||
description: >
|
|
||||||
Free disk space agents filled: ask peer sessions what they still need,
|
|
||||||
inventory /tmp, Docker and agent dirs, and delete only what the operator
|
|
||||||
picks. Trigger on /cleanup, "aufräumen", "bereinigen", "platte voll", "no
|
|
||||||
space left on device".
|
|
||||||
---
|
|
||||||
|
|
||||||
Nothing is deleted before the operator has picked it. Every candidate carries
|
|
||||||
its owner, its size, and its risk.
|
|
||||||
|
|
||||||
## Step 0: the resource chair
|
|
||||||
|
|
||||||
Shared resources belong to the resource chair of the `democracy` skill. If
|
|
||||||
another session holds the chair, send it the cleanup request and stop, unless
|
|
||||||
the operator ordered the cleanup in this session; then tell the chair before
|
|
||||||
step 1, so its grants and queue stay consistent. The `democracy` resource
|
|
||||||
watch already runs the configured low-risk cleanup at its stop threshold; this
|
|
||||||
skill covers everything that needs the operator's pick.
|
|
||||||
|
|
||||||
## Step 1: ask the peers
|
|
||||||
|
|
||||||
List every agent the host can message (in Claude Code: `ListAgents`). Only
|
|
||||||
peers sharing this machine's filesystem and Docker daemon count: local
|
|
||||||
sessions and your own subagents. Cloud sessions and remote sessions on other
|
|
||||||
machines hold nothing here; skip them.
|
|
||||||
|
|
||||||
Send each local peer one message (`SendMessage`) and go on with step 2 while
|
|
||||||
they answer:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Cleanup planned by <own session name>. Reply to me with everything you still
|
|
||||||
need, one item per line:
|
|
||||||
- paths under /tmp, caches, worktrees
|
|
||||||
- containers, compose or stack projects, volumes, images, networks
|
|
||||||
- running jobs (deploys, tests, builds) and when they finish
|
|
||||||
Reply "nothing" if you need none of it. Items you do not answer for stay.
|
|
||||||
```
|
|
||||||
|
|
||||||
## Step 2: inventory
|
|
||||||
|
|
||||||
Read-only, in parallel, sizes included:
|
|
||||||
|
|
||||||
- **Scope**: the directories agents may write to, taken from the host's
|
|
||||||
sandbox or permission config (in Claude Code:
|
|
||||||
`sandbox.filesystem.allowWrite` in `.claude/settings.json`) plus the session
|
|
||||||
scratch dirs. Anything outside is out of scope, even when readable.
|
|
||||||
- **/tmp**: `du -sh` per top-level entry the current user owns. System entries
|
|
||||||
(`.X11-unix`, `.ICE-unix`, `systemd-private-*`, `ssh-*`, `tmux-*`, sockets)
|
|
||||||
are never candidates.
|
|
||||||
- **Docker**: `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.
|
|
||||||
- **Other agent dirs**: package and tool caches (pip, npm, pre-commit, act,
|
|
||||||
gh, buildx), worktrees with `git status --short` and whether their branch is
|
|
||||||
merged or pushed, Ansible and build temp dirs.
|
|
||||||
- **Live use**: open handles (`fuser`, `lsof`) and running processes by name
|
|
||||||
only (`ps -o pid,etime,comm`). Never print full command lines or
|
|
||||||
environments: process arguments carry credentials. A sandboxed agent often
|
|
||||||
sees only its own processes, so an empty handle list proves nothing there;
|
|
||||||
then only entries untouched for more than 24 hours (`find -mtime +0`) are
|
|
||||||
candidates, and newer ones stay as `keep (recent, possibly in use)`.
|
|
||||||
- **Archive duties**: memory and project instructions that require copying
|
|
||||||
something out before deletion (logs, reports, generated maps). Such an item
|
|
||||||
is `archive first`, never a plain delete.
|
|
||||||
|
|
||||||
Prefer the project's own cleanup and teardown targets (Makefile, scripts) over
|
|
||||||
raw commands where they exist, and obey project rules that forbid raw
|
|
||||||
commands.
|
|
||||||
|
|
||||||
## Step 3: attribute
|
|
||||||
|
|
||||||
Give every candidate an owner: a peer's reply, a session id in the path, a
|
|
||||||
worktree or compose project name, a container label, an open handle, or
|
|
||||||
`unknown`. Then apply, first match wins:
|
|
||||||
|
|
||||||
1. Claimed by a peer: `keep`.
|
|
||||||
2. Attributable to a peer that has not answered: `keep (no answer from
|
|
||||||
<peer>)`. Silence is not consent.
|
|
||||||
3. Archive duty: `archive first`.
|
|
||||||
4. Everything else is a candidate with a risk class:
|
|
||||||
|
|
||||||
| Risk | Meaning | Typical items |
|
|
||||||
| ---- | ------- | ------------- |
|
|
||||||
| low | regenerated automatically, cheap | stopped containers without volumes, dangling images, unused networks, scratch dirs and logs of finished sessions |
|
|
||||||
| medium | regenerated, but slow, rate-limited or network-bound | unused tagged images (re-pull, registry limits), build cache (cold rebuilds), package caches |
|
|
||||||
| high | data loss or a broken run possible | volumes, stopped containers with volumes, running unclaimed containers or stacks, worktrees with uncommitted or unpushed work, 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 class, and every `keep` row grouped by
|
|
||||||
reason, so the operator sees what was protected and why.
|
|
||||||
|
|
||||||
Then ask with `AskUserQuestion` in two calls.
|
|
||||||
|
|
||||||
**Call 1, the selection** (`multiSelect: true`, checklists, at most four
|
|
||||||
questions): `/tmp`, `Docker`, `Other dirs`, and a separate `High risk`
|
|
||||||
question that holds every high-risk candidate. Size, risk and worst case go
|
|
||||||
into each option's description. An area with more than four options is
|
|
||||||
grouped by owner or risk, or split into a further call. Only ticked items are
|
|
||||||
selected; nothing is preselected.
|
|
||||||
|
|
||||||
**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.
|
|
||||||
|
|
||||||
**Call 2, the confirmation** (`multiSelect: false`, radio buttons), computed
|
|
||||||
from the ticks: `Execute: <n> items, <size> (Recommended)`, `Revise the
|
|
||||||
selection`, `Abort`. The execute description names the worst case of the
|
|
||||||
ticked items. Revise repeats call 1; nothing is deleted before `Execute`.
|
|
||||||
|
|
||||||
## Step 5: execute
|
|
||||||
|
|
||||||
- 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>`.
|
|
||||||
- Locally built images free little on their own: their layers stay behind as
|
|
||||||
build cache. After removing images, 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.
|
|
||||||
- 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.
|
|
||||||
- Running stacks go down through the project's teardown target, never by
|
|
||||||
removing their containers or volumes underneath them.
|
|
||||||
- Archive-first items: copy out, verify the copy, then delete.
|
|
||||||
- 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.
|
|
||||||
|
|
||||||
## Step 6: report
|
|
||||||
|
|
||||||
`df -h` and `docker system df` before and after, freed size per area, every
|
|
||||||
skipped or failed item with its reason. Tell each peer that answered which of
|
|
||||||
the removed items lay near its own.
|
|
||||||
|
|
||||||
## Rules
|
|
||||||
|
|
||||||
- Questions, inventory and proposal change nothing. Deletion needs the
|
|
||||||
operator's selection in this run; a selection from an earlier run does not
|
|
||||||
carry over.
|
|
||||||
- Never delete outside the agent write scope, another user's files, or system
|
|
||||||
entries in `/tmp`.
|
|
||||||
- Never invent a peer's answer or a size. Unmeasured is `unknown`, and
|
|
||||||
`unknown` is high risk.
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
---
|
|
||||||
name: comments-clean
|
|
||||||
description: >
|
|
||||||
Purge explanatory comments from staged and unstaged changes. Run
|
|
||||||
AUTOMATICALLY before every commit or stage request (commit, commit
|
|
||||||
no-verify, stage next, socon, sobu, socobu, "commite ..."); also on
|
|
||||||
/comments-clean or comment complaints.
|
|
||||||
---
|
|
||||||
|
|
||||||
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. Block-comment languages are not line-oriented. In Jinja (`{# … #}`), C-like
|
|
||||||
(`/* … */`), HTML (`<!-- … -->`) and their kin, a closing delimiter alone on
|
|
||||||
its own line looks exactly like a comment line and gets deleted with the
|
|
||||||
rest — after which the opening delimiter swallows every live statement down
|
|
||||||
to the next closer. Never strip these languages with a line filter. Delete
|
|
||||||
the whole block by hand, then count the delimiters (`grep -c '{#'` against
|
|
||||||
`grep -c '#}'`) and re-parse the file.
|
|
||||||
4. Re-run the project's comment lint if one exists. A green lint does NOT prove
|
|
||||||
the deletion was safe: a swallowed block is still syntactically valid, so
|
|
||||||
both the lint and the language parser can pass over code that no longer
|
|
||||||
executes. Diff against the pre-edit revision (`git show HEAD:<path>`) and
|
|
||||||
confirm that only comment lines left.
|
|
||||||
5. Do not stage in order to run a check (the `commit-next` skill's step 4
|
|
||||||
says why). Re-stage after the audit exactly the files that were staged
|
|
||||||
before it.
|
|
||||||
6. 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,36 +0,0 @@
|
|||||||
---
|
|
||||||
name: commit-next
|
|
||||||
description: >
|
|
||||||
Check every branch for the staged fix, commit the staged bundle with
|
|
||||||
--no-verify, then stage and brief the next bundle. Trigger on /commit-next
|
|
||||||
or when the operator asks to commit and stage the next bundle in one step.
|
|
||||||
---
|
|
||||||
|
|
||||||
1. `git fetch --all --prune --tags`, then hunt the staged fix across every
|
|
||||||
ref with the merge-fix skill's `find_fix.py` (by `--grep`, by `-S`, by
|
|
||||||
`--path`). Describe the staged bundle in one line first so a
|
|
||||||
wrong search intent is visible. Only the search runs here — never
|
|
||||||
cherry-pick or merge, and never touch the staging index or the tree.
|
|
||||||
2. A similar fix on another branch is a stop: run the 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
|
|
||||||
commit-no-verify skill on the currently staged changes (comments-clean
|
|
||||||
audit and advocate check first, then `git commit --no-verify`, exact
|
|
||||||
staging index only).
|
|
||||||
4. 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. Staging
|
|
||||||
happens here and nowhere else: never stage merely to run a lint or a
|
|
||||||
test. Repository 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
|
|
||||||
their local config.
|
|
||||||
5. 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).
|
|
||||||
6. 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 staged changes with --no-verify. Trigger on /commit-no-verify or
|
|
||||||
any request for a no-verify commit; invoking it IS the per-commit
|
|
||||||
authorization --no-verify requires, never carried over to later commits.
|
|
||||||
---
|
|
||||||
|
|
||||||
1. Run the comments-clean audit over the staged and unstaged changes first,
|
|
||||||
then the advocate check over the staging index. An advocate blocker stops
|
|
||||||
here.
|
|
||||||
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,40 +0,0 @@
|
|||||||
---
|
|
||||||
name: confidence
|
|
||||||
description: >
|
|
||||||
Report calibrated fix confidence and break risk unprompted whenever you
|
|
||||||
stage changes, declare a fix or bundle ready, or summarize work the operator
|
|
||||||
may commit or deploy.
|
|
||||||
---
|
|
||||||
|
|
||||||
Every stage summary, fix report, or ready-to-commit bundle ends with a
|
|
||||||
confidence block.
|
|
||||||
|
|
||||||
Invoke the `break-risk` skill every time, before writing the block; 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,
|
|
||||||
derived via the `break-risk` skill. 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.
|
|
||||||
|
|
||||||
A threshold the operator named ("95% confidence, break risk ≤5%") is a gate on
|
|
||||||
the work, handled as the `break-risk` skill's last section says, for both
|
|
||||||
numbers.
|
|
||||||
|
|
||||||
Objective: the `autotune` skill's **Objective** section. Two figures with their residuals replace a
|
|
||||||
hedging paragraph and save the operator a verification round trip.
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
---
|
|
||||||
name: coordinate
|
|
||||||
description: >
|
|
||||||
Coordinate a failure or fix with the agents on related branches through
|
|
||||||
merges: claim, ask, report. Trigger on /coordinate and when a fix touches
|
|
||||||
code other branches share.
|
|
||||||
---
|
|
||||||
|
|
||||||
Run the `democracy` skill's **Lineage** and **Peer protocol** sections for the
|
|
||||||
failure or fix at hand. They hold the whole procedure; this skill only keeps
|
|
||||||
`/coordinate` working.
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
---
|
|
||||||
name: criticize
|
|
||||||
description: >
|
|
||||||
Give constructive criticism of a problem, plan, design, decision, text or
|
|
||||||
code from diverse aspects, each point hardened by dialectic. Trigger on
|
|
||||||
/criticize, "kritisiere", "kritisch betrachten", "konstruktive Kritik", "was
|
|
||||||
haeltst du davon", or requests for honest feedback.
|
|
||||||
---
|
|
||||||
|
|
||||||
The stance is critical but constructive: the goal is a better result, not a
|
|
||||||
verdict on its author. This skill judges any artifact from many angles; it is
|
|
||||||
not limited to code (`sceptic` hunts violated engineering principles in code,
|
|
||||||
`code-review` hunts bugs). Read-only: report the critique, change nothing
|
|
||||||
unless the operator asks afterwards.
|
|
||||||
|
|
||||||
## Before you start: listen
|
|
||||||
|
|
||||||
Invoke the `active-listening` skill. Pin down what is being criticized, what it
|
|
||||||
is meant to achieve, for whom, and under which constraints. Derive what you can
|
|
||||||
from the artifact and its surroundings first; ask only what only the operator
|
|
||||||
knows. A critique against the wrong goal is noise.
|
|
||||||
|
|
||||||
## Steelman first
|
|
||||||
|
|
||||||
Restate the artifact and its intent in its strongest form, in two or three
|
|
||||||
sentences. Criticize that version, never a weaker one you made up. If you cannot
|
|
||||||
steelman it, you have not understood it yet; go back to listening.
|
|
||||||
|
|
||||||
## The aspects
|
|
||||||
|
|
||||||
Sweep every aspect that applies, and name the ones you skipped and why:
|
|
||||||
|
|
||||||
1. **Goal fit** - does it solve the stated problem for the stated audience, or
|
|
||||||
a neighbouring one?
|
|
||||||
2. **Correctness** - are the facts, the logic, and the claimed cause-effect
|
|
||||||
chains right? Which claim lacks evidence?
|
|
||||||
3. **Completeness** - missing cases, missing stakeholders, missing failure
|
|
||||||
paths, missing definition of done.
|
|
||||||
4. **Consistency** - contradictions inside the artifact or against its
|
|
||||||
neighbours; the same truth stated twice in two versions.
|
|
||||||
5. **Simplicity** - mechanism, scope, or wording the problem never asked for;
|
|
||||||
the smaller design that does the same.
|
|
||||||
6. **Feasibility and cost** - effort, dependencies, order of steps, what blocks
|
|
||||||
what, what it costs to run and to maintain.
|
|
||||||
7. **Risk** - security at trust boundaries, data loss, reversibility, blast
|
|
||||||
radius, what happens when it fails halfway.
|
|
||||||
8. **People** - usability, accessibility, the reader's or user's actual path,
|
|
||||||
who gains, who carries the burden.
|
|
||||||
9. **Alternatives** - at least one materially different approach, compared
|
|
||||||
honestly on the aspects above.
|
|
||||||
10. **Ethics and compliance** - only where it touches personal data, licences,
|
|
||||||
law, or fairness.
|
|
||||||
|
|
||||||
## Harden every point with dialectic
|
|
||||||
|
|
||||||
Run each candidate critique through the `dialectic` skill before reporting it:
|
|
||||||
|
|
||||||
- **Thesis**: the critique point with its evidence.
|
|
||||||
- **Antithesis**: independent defenders argue that the artifact is right as it
|
|
||||||
is, or that the constraint behind it is real; independent skeptics attack the
|
|
||||||
suggested improvement for new problems it would cause.
|
|
||||||
- **Synthesis**: keep the point, refine it, or drop it.
|
|
||||||
|
|
||||||
Batch minor points into one dialectic round; run consequential points (high
|
|
||||||
severity, expensive to act on, or challenging a premise) one by one.
|
|
||||||
|
|
||||||
## Output
|
|
||||||
|
|
||||||
One line of scope, then the steelman. Then:
|
|
||||||
|
|
||||||
- **Keep**: up to three strengths that must survive any change.
|
|
||||||
- **Critique**: one ranked table, highest impact first:
|
|
||||||
|
|
||||||
| Severity | Aspect | Observation | Why it matters | Suggestion | Evidence |
|
|
||||||
|
|
||||||
`Evidence` is `proven` (read, run, reproduced, cited) or `derived` (reasoned).
|
|
||||||
|
|
||||||
Close with at most three lines: how many candidate points dialectic dropped and
|
|
||||||
why, the single change that helps most, and the `confidence` skill's numbers for
|
|
||||||
the critique as a whole.
|
|
||||||
|
|
||||||
## Rules
|
|
||||||
|
|
||||||
- Every critique carries a concrete suggestion. A complaint without one becomes
|
|
||||||
an open question or is dropped.
|
|
||||||
- Criticize the work, never the person. Direct wording, no sarcasm, no
|
|
||||||
softening of a real problem, no inflation of a minor one.
|
|
||||||
- A point that attacks a stated constraint or goal is labelled "challenges a
|
|
||||||
premise" and kept apart from points that work within it.
|
|
||||||
- No taste or formatting nitpicks unless the operator asks for them.
|
|
||||||
- Answer in the operator's language.
|
|
||||||
- The critique ends at the report. Do not fix, stage, or rewrite as part of
|
|
||||||
this skill.
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
---
|
|
||||||
name: democracy
|
|
||||||
description: >
|
|
||||||
Keep parallel agents from duplicating work: the first to claim it owns it;
|
|
||||||
line representatives and a resource chair coordinate. Run AUTOMATICALLY
|
|
||||||
before work another agent might also do (failure, cluster, feature, gate,
|
|
||||||
deploy, cleanup), and on /democracy.
|
|
||||||
---
|
|
||||||
|
|
||||||
Two agents doing the same work waste the second run and then collide in the
|
|
||||||
merge. The remedy is an election nobody has to hold: whoever starts first owns
|
|
||||||
the work. The structure is anarcho-syndicalist: councils (soviets) of
|
|
||||||
delegates with a bound mandate, organised as a matrix, recallable by the
|
|
||||||
operator at any time.
|
|
||||||
|
|
||||||
## Offices
|
|
||||||
|
|
||||||
- **President of a piece of work**: the first agent that starts it. It does
|
|
||||||
the whole piece. Every later agent hands its findings to the president and
|
|
||||||
moves on to other work; it never builds a second version.
|
|
||||||
- **Representative of an organisational line**: the first agent that works in
|
|
||||||
the line. Lines come in as many dimensions as are useful: functional domain
|
|
||||||
(security, GUI, identity, i18n), repository, branch line, host, cloud
|
|
||||||
account. An agent belongs to one line per dimension.
|
|
||||||
- **Organiser of a work cluster**: the first agent that starts a cluster
|
|
||||||
cutting across lines (a failure cluster from `triage`, a cost cluster from
|
|
||||||
`tune`, a feature touching several domains). It works the strand end to end
|
|
||||||
and informs the representative of every line the strand touches before it
|
|
||||||
changes that line's files.
|
|
||||||
- **Chair**: the earliest-elected president still running. It chairs the
|
|
||||||
council of all representatives and organisers and allocates the resources of
|
|
||||||
the whole system everyone runs on. There is exactly one allocator at any
|
|
||||||
time.
|
|
||||||
|
|
||||||
Every office is a mandate, not a rank: it coordinates and allocates, it never
|
|
||||||
takes over or directs the content of another agent's work. The operator
|
|
||||||
outranks every office and appoints or recalls at will; an operator
|
|
||||||
appointment outranks the election.
|
|
||||||
|
|
||||||
## Election
|
|
||||||
|
|
||||||
The first to claim wins, and the claim is atomic, so two agents can never both
|
|
||||||
be first. `${CLAUDE_SKILL_DIR}/scripts/council.py` keeps the claims in
|
|
||||||
`/tmp/agents-council`; other runtimes call the same script from their skills
|
|
||||||
directory with `--session <id>`.
|
|
||||||
|
|
||||||
1. **Name the office**: `work/<slug>`, `cluster/<slug>`,
|
|
||||||
`<dimension>/<line>` for a representative, or `chair`. The slug names the
|
|
||||||
work concretely: the issue number, the failing line, the cost's log line.
|
|
||||||
2. **Look first**: `council.py list`. A claim for the same work under another
|
|
||||||
slug counts as first.
|
|
||||||
3. **Claim**: `council.py claim <office> --work "<what, evidence, branch or
|
|
||||||
worktree>"`. Exit 0: elected, or already yours. Exit 3: a live agent holds
|
|
||||||
it, and its claim is printed. Exit 4: the holder's session is dead (see
|
|
||||||
Succession).
|
|
||||||
4. **Elected**: continue with the Peer protocol; its message is the
|
|
||||||
announcement.
|
|
||||||
5. **Not elected**: send the holder your findings and stop on that work.
|
|
||||||
|
|
||||||
A Claude Code session cannot edit a file in a git worktree before it holds a
|
|
||||||
claim: a PreToolUse hook (`council.py gate`) blocks the edit.
|
|
||||||
|
|
||||||
Peers on other machines cannot see this host's claims. Between machines the
|
|
||||||
earliest announcement by message time wins, and each host's claim directory
|
|
||||||
records the result.
|
|
||||||
|
|
||||||
## Chair and resource allocation
|
|
||||||
|
|
||||||
- The first agent elected on the system while `chair` is vacant also claims
|
|
||||||
`chair`, in the same way.
|
|
||||||
- An operator-appointed allocator holds the chair: its claim says
|
|
||||||
`appointed: operator`, and no election replaces it while its session runs and
|
|
||||||
the operator has not ended the appointment.
|
|
||||||
- Before starting anything that consumes shared capacity (gates, deploys,
|
|
||||||
iteration loops, swarm or cloud resources, cleanup of shared data), an agent
|
|
||||||
sends the chair what, where, for how long and its expected disk need, waits
|
|
||||||
for the grant, and reports the release. The chair keeps one queue, answers
|
|
||||||
every request with its position, enforces the limits the operator set,
|
|
||||||
checks every grant with `watch.py admit --need-gb <N>` (or `--kind <slot>`),
|
|
||||||
and never cancels a running grant to free capacity.
|
|
||||||
- Handover is explicit: the outgoing chair sends every peer the new holder,
|
|
||||||
the queue and the running grants, and keeps allocating until the incoming
|
|
||||||
chair confirms. Two allocators never run at once.
|
|
||||||
|
|
||||||
## Resource watch
|
|
||||||
|
|
||||||
The chair keeps the system from running out instead of reacting once it has.
|
|
||||||
`${CLAUDE_SKILL_DIR}/scripts/watch.py` does the measuring as hooks in every
|
|
||||||
session, so no agent probes the disk by hand; it reads its thresholds, job
|
|
||||||
patterns, cleanup commands and cache directories from
|
|
||||||
`~/.config/agents-council/config.json` (keys in the script's docstring) and
|
|
||||||
stays silent below the thresholds.
|
|
||||||
|
|
||||||
- **Warn**: the chair and every session get the biggest consumers; the chair
|
|
||||||
plans a cleanup and grants no large slot.
|
|
||||||
- **Stop**: new heavy jobs are blocked in every session (teardown commands
|
|
||||||
never), and the hook runs the configured low-risk cleanup by itself.
|
|
||||||
- **Critical**: additionally a desktop alarm to the operator. A running stack
|
|
||||||
is torn down only with the operator's OK.
|
|
||||||
- A measured fall that would reach the critical threshold within the
|
|
||||||
projection window raises the stage by one at once.
|
|
||||||
- Caches together stay under their budget; the least recently used go first.
|
|
||||||
|
|
||||||
`watch.py status` shows the last measurement. A `RESOURCE` line in a hook's
|
|
||||||
context is addressed to the chair; any other session only adjusts its own
|
|
||||||
plans to it.
|
|
||||||
|
|
||||||
## Succession
|
|
||||||
|
|
||||||
- A piece of work or a cluster ends with its result, a proposal, or an
|
|
||||||
abandoned attempt, reported to the peers who heard the claim. The holder
|
|
||||||
then runs `council.py release <office>`, and the next agent that starts the
|
|
||||||
work is elected afresh.
|
|
||||||
- A representative or the chair that ends hands its office to the next-oldest
|
|
||||||
live representative (by claim time) and announces it.
|
|
||||||
- A claim is stale when its holder's session is gone: in Claude Code its
|
|
||||||
session id is no longer in `~/.claude/sessions`; other runtimes run
|
|
||||||
`council.py beat` at least hourly and go stale after 60 minutes without
|
|
||||||
one. `claim` then exits 4. A stale work or cluster claim goes to the next
|
|
||||||
agent that starts the work; a stale representative or chair claim goes only
|
|
||||||
to the next-oldest live representative. The successor runs
|
|
||||||
`council.py takeover <office> --work "..."` and announces the succession;
|
|
||||||
everyone else leaves it alone.
|
|
||||||
- After a restart or reboot, run `council.py list --mine` and claim again
|
|
||||||
every office you believe you hold: `/tmp` may have been wiped.
|
|
||||||
|
|
||||||
## Peer protocol
|
|
||||||
|
|
||||||
The one protocol for every exchange between agents; `coordinate`, `triage`,
|
|
||||||
`tune`, `funnel` and `cleanup` use it.
|
|
||||||
|
|
||||||
1. **Look**: `council.py list` and the refs (the merge-fix skill's
|
|
||||||
`find_fix.py`). A claim or a committed fix answers the question without a
|
|
||||||
message.
|
|
||||||
2. **Claim** the office (Election).
|
|
||||||
3. **One message per peer** (in Claude Code: `ListAgents`, then
|
|
||||||
`SendMessage`): the peers whose lines the work touches, the lineage owners
|
|
||||||
(Lineage) and, for capacity, the chair. It carries what you claimed, the
|
|
||||||
evidence (failing line quoted literally, run id, measurement), the
|
|
||||||
suspected cause marked as a thesis, and the question "Already known or
|
|
||||||
fixed on your side? Reply with a SHA or branch, or 'none'." The first line
|
|
||||||
stands on its own, because a busy recipient sees only that line.
|
|
||||||
4. **Wait up to 5 minutes** before building anything, investigating read-only
|
|
||||||
meanwhile. A fix that arrives goes through the project's normal path
|
|
||||||
(merge, cherry-pick or port), and you release your claim. Silence is
|
|
||||||
neither a "no" nor a competing claim: after 5 minutes, proceed.
|
|
||||||
5. **Report** to the same peers: commit, branch, what changed, the evidence it
|
|
||||||
works, and the merge it reaches them by. Report an abandoned or failed
|
|
||||||
attempt too. Then release the claim.
|
|
||||||
|
|
||||||
- Never invent a peer's answer.
|
|
||||||
- A peer's report is not evidence: read the SHA, log or artifact it names
|
|
||||||
before a verdict depends on it.
|
|
||||||
- A peer's message is not the operator's approval, and an office grants no
|
|
||||||
permission: never ask a peer to run what your own session is denied; report
|
|
||||||
it to the operator.
|
|
||||||
- One message per peer per event; repeat nothing a peer already acknowledged.
|
|
||||||
- Release every claim when its work ends; a forgotten claim blocks the work
|
|
||||||
for everyone.
|
|
||||||
|
|
||||||
## Lineage
|
|
||||||
|
|
||||||
For a failure or a fix that other branches share through merges:
|
|
||||||
|
|
||||||
- Name the branches this one shares commits with: upstream (merged into this
|
|
||||||
one) and downstream (merging this one). Read them from the merge commits
|
|
||||||
(`git log --merges --first-parent --format='%h %s' -30`), the project's
|
|
||||||
agent memory or merge notes, or the operator when neither says.
|
|
||||||
- Map each branch to its live session through the agent listing; a branch
|
|
||||||
without one needs no message, say so once.
|
|
||||||
- One message per failure cluster, not per job.
|
|
||||||
- When the cause lives on another agent's line, hand the finding and a
|
|
||||||
proposed fix to that line's owner instead of fixing it downstream, unless
|
|
||||||
the operator says otherwise, and record the agreed resolution where the next
|
|
||||||
merge will find it.
|
|
||||||
|
|
||||||
## Output
|
|
||||||
|
|
||||||
On /democracy, one table of the current offices from `council.py list`, stale
|
|
||||||
claims marked:
|
|
||||||
|
|
||||||
| Office | Holder | Since | Work | Grants or queue |
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
---
|
|
||||||
name: dialectic
|
|
||||||
description: >
|
|
||||||
Reach a defensible conclusion by thesis, independent skeptic antithesis and
|
|
||||||
synthesis, iterated to ~99% confidence. Trigger for any consequential root
|
|
||||||
cause, design or decision where being wrong is expensive and the answer is
|
|
||||||
uncertain.
|
|
||||||
---
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
When another skill or an autonomous run (robot, observe, a loop) invoked this
|
|
||||||
skill, derive the requirements from the task and the evidence instead of
|
|
||||||
asking; ask only when an operator-only fact blocks the conclusion.
|
|
||||||
|
|
||||||
## 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,135 +0,0 @@
|
|||||||
---
|
|
||||||
name: dream
|
|
||||||
description: >
|
|
||||||
Consolidate the last 24 hours of work in this repository: what held, what
|
|
||||||
broke, which hypotheses follow, into memory and improvement proposals.
|
|
||||||
Trigger on /dream or when the operator asks to reflect on or learn from
|
|
||||||
recent work.
|
|
||||||
---
|
|
||||||
|
|
||||||
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 --path-format=absolute --git-common-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 --path-format=absolute --git-common-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
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
---
|
|
||||||
name: essence
|
|
||||||
description: >
|
|
||||||
Compact skills and memory to their essence after a health pass that finds
|
|
||||||
dead weight. Trigger on /essence or when skills or MEMORY.md grow too long,
|
|
||||||
duplicate, or eat too much context.
|
|
||||||
---
|
|
||||||
|
|
||||||
Shorter, not thinner. Every fact that still earns its place survives; what goes
|
|
||||||
is repetition, narration, and entries nothing loads any more.
|
|
||||||
|
|
||||||
## What actually costs context
|
|
||||||
|
|
||||||
Compact by how often a thing is loaded, not by how large it is:
|
|
||||||
|
|
||||||
1. **`MEMORY.md`** and every skill's **`description`** frontmatter load into
|
|
||||||
*every* session. A wasted line here is paid on every single turn. Highest
|
|
||||||
leverage by a wide margin.
|
|
||||||
2. **Skill bodies** load when the skill fires. Worth tightening, paid only on use.
|
|
||||||
3. **Memory files** load on recall. Lowest leverage — and the place where
|
|
||||||
cutting evidence does the most damage.
|
|
||||||
|
|
||||||
A 200-line skill body that fires twice a month costs less than four bloated
|
|
||||||
`MEMORY.md` pointers. Rank the work accordingly and say so in the report.
|
|
||||||
|
|
||||||
## Step 1: ask for the scope
|
|
||||||
|
|
||||||
Never assume the target. Ask once with `AskUserQuestion` and
|
|
||||||
`multiSelect: true`, so the operator can tick several:
|
|
||||||
|
|
||||||
- **Skills repository** — the portable `skills/*/SKILL.md` set.
|
|
||||||
- **This project** — its `.claude/skills` and `.agents/skills`, plus the
|
|
||||||
project's memory directory and `MEMORY.md`.
|
|
||||||
- **Home** — `~/.claude` and `~/.agents` (skills, rules, commands, agents).
|
|
||||||
|
|
||||||
Take nothing outside what was ticked.
|
|
||||||
|
|
||||||
## Step 2: doctor
|
|
||||||
|
|
||||||
Before compacting anything, find what is already broken or dead. Report each
|
|
||||||
finding with its path:
|
|
||||||
|
|
||||||
- A `MEMORY.md` pointer whose file is gone, and a memory file no pointer names.
|
|
||||||
- A dead `[[wikilink]]` — no memory of that name exists.
|
|
||||||
- A memory asserting a `file:line`, function, or flag that no longer exists.
|
|
||||||
Verify against the current tree; a stale citation is worse than no memory,
|
|
||||||
because it reads as checked.
|
|
||||||
- The same rule stated in two or more skills. One is authoritative, the rest
|
|
||||||
are drift.
|
|
||||||
- A first-party skill shadowing a locked/vendored one.
|
|
||||||
- Frontmatter missing `name` or `description`, or a `description` that says
|
|
||||||
what the skill does but never when it fires — such a skill costs context in
|
|
||||||
every session and never triggers.
|
|
||||||
|
|
||||||
Dead entries are deleted, not compacted. Fix the broken ones or report them;
|
|
||||||
never compact around a stale citation, or the error survives in denser form.
|
|
||||||
|
|
||||||
## Step 3: compact
|
|
||||||
|
|
||||||
Cut:
|
|
||||||
|
|
||||||
- Anything the repository already records — code structure, git history, what
|
|
||||||
`CLAUDE.md`/`AGENTS.md` already say.
|
|
||||||
- A rule the agent follows anyway without being told.
|
|
||||||
- The second and third statement of one rule; keep the authoritative one and
|
|
||||||
point at it.
|
|
||||||
- Narration, hedging, and worked examples beyond the one that carries the point.
|
|
||||||
|
|
||||||
Keep, always:
|
|
||||||
|
|
||||||
- The **why** and the evidence behind a memory — the date, the incident, the
|
|
||||||
quoted correction. A rule stripped of its evidence degrades into an
|
|
||||||
exhortation and stops changing behaviour.
|
|
||||||
- Any fact with no other home. If it exists nowhere else, it is not redundancy.
|
|
||||||
- Trigger wording in a `description`. Tighten it, never drop the "when".
|
|
||||||
|
|
||||||
Dates stay absolute. Never introduce "recently" while shortening.
|
|
||||||
|
|
||||||
## Step 4: show the diff, then write
|
|
||||||
|
|
||||||
Writing happens in place — an essence that lives beside the original saves
|
|
||||||
nothing, because the original is still what loads.
|
|
||||||
|
|
||||||
Preconditions, per target:
|
|
||||||
|
|
||||||
- A git repository must have a clean status for the paths being rewritten, so
|
|
||||||
every edit is revertable with git alone. Not clean: report and stop.
|
|
||||||
- `~/.claude` and `~/.agents` are usually not under git. Copy each file to
|
|
||||||
`<file>.pre-essence` first.
|
|
||||||
|
|
||||||
Then present, per file: the diff, and the lines and characters saved. Ask for
|
|
||||||
one confirmation covering the batch. Write only after it.
|
|
||||||
|
|
||||||
## Step 5: verify
|
|
||||||
|
|
||||||
- Re-run the target's own validation if it has one (in a skills repository,
|
|
||||||
its test suite: frontmatter, lock, shadowing).
|
|
||||||
- Re-read each rewritten file and confirm every kept fact is still stated, not
|
|
||||||
merely implied by something shorter.
|
|
||||||
- Report the before/after loaded-context figure for the always-loaded surface
|
|
||||||
(`MEMORY.md` plus all descriptions) separately from the rest. That number is
|
|
||||||
the point of the run.
|
|
||||||
|
|
||||||
## Rules
|
|
||||||
|
|
||||||
- Deleting is the goal; rewriting shorter is the fallback. Prefer removing an
|
|
||||||
entry over polishing it.
|
|
||||||
- Never merge two memories whose evidence differs. Two incidents supporting one
|
|
||||||
rule are stronger than one; two rules sharing one incident is a fabrication.
|
|
||||||
- A skill that has never fired is a candidate for deletion, not compaction —
|
|
||||||
say so and let the operator decide.
|
|
||||||
- Nothing is written without the Step 4 confirmation, and nothing outside the
|
|
||||||
scope ticked in Step 1.
|
|
||||||
- `caveman-compress` is not a rival: it rewrites the wording of one named file,
|
|
||||||
this removes what should not be there at all. Run this one first — compressing
|
|
||||||
a dead entry is pure waste.
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
---
|
|
||||||
name: funnel
|
|
||||||
description: >
|
|
||||||
Keep agent work in its own git worktree until it reaches 90% fix confidence
|
|
||||||
and under 5% break risk; below that bar it becomes a proposal. Trigger when
|
|
||||||
delegating work to agents, when agents work in parallel, or on /funnel.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Funnel
|
|
||||||
|
|
||||||
The main line only receives changes that have earned it. Everything else stays
|
|
||||||
in a sidetree until it does, or leaves as a proposal.
|
|
||||||
|
|
||||||
## The bar
|
|
||||||
|
|
||||||
- **Fix confidence of at least 90 %** and **break risk under 5 %**, both
|
|
||||||
reported by the `confidence` skill, the break risk derived through the
|
|
||||||
`break-risk` skill.
|
|
||||||
- The operator may set a different bar. Never lower it to make a change fit.
|
|
||||||
|
|
||||||
## The stages
|
|
||||||
|
|
||||||
1. **Brief.** One concern per agent. Name the goal, the exact base commit, the
|
|
||||||
scope that may change, the evidence expected back, a hard time cap (default
|
|
||||||
60 minutes) and what the agent must not do: no pushes, no gates or deploys
|
|
||||||
unless the resource chair of the `democracy` skill granted them, read-only
|
|
||||||
probes on shared live systems.
|
|
||||||
2. **Sidetree.** The agent works in its own worktree on its own branch,
|
|
||||||
created from the exact base commit, so its edits never land under a running
|
|
||||||
gate or deploy in the main line. Use the host's worktree isolation when it
|
|
||||||
has one.
|
|
||||||
3. **Evidence.** The agent runs targeted tests, adds a test for the new
|
|
||||||
behaviour, and makes a negative control: the test fails without the change.
|
|
||||||
Numbers come from the same source before and after.
|
|
||||||
4. **Self-assessment.** The agent reports fix confidence and break risk with
|
|
||||||
their residuals and commits on its branch.
|
|
||||||
5. **Independent review.** Before anything is merged, run the `dialectic` skill
|
|
||||||
with skeptics who did not write the change. A hole they find returns the
|
|
||||||
change to stage 3.
|
|
||||||
6. **Merge.** At or above the bar, merge the sidetree branch into the main line
|
|
||||||
following the repository's commit rules, then re-verify on the main line
|
|
||||||
with the project's gate.
|
|
||||||
7. **Tear everything down.** Once the branch is merged, remove everything the
|
|
||||||
sidetree brought into being, so the next round starts from a clean
|
|
||||||
machine. See "After the merge" below.
|
|
||||||
|
|
||||||
## After the merge
|
|
||||||
|
|
||||||
A merged sidetree leaves nothing behind. Work through the list in this order,
|
|
||||||
and verify each step instead of assuming it:
|
|
||||||
|
|
||||||
1. **Stop its jobs**: background runs, watchers, scheduled loops and agents
|
|
||||||
that belong to the sidetree.
|
|
||||||
2. **Tear down its stacks**: compose projects and swarm or test clusters
|
|
||||||
together with their volumes and networks, and the containers and images
|
|
||||||
built only for it. Leave anything another worktree still uses.
|
|
||||||
3. **Clear files the containers left**: count the entries the user does not
|
|
||||||
own (`find <worktree> -not -user "$USER" -prune | wc -l`). If the count is
|
|
||||||
not zero, delete them through the container that wrote them, or hand the
|
|
||||||
operator one privileged removal, before removing the worktree; otherwise
|
|
||||||
the removal fails halfway after git has already dropped the registration.
|
|
||||||
4. **Remove the worktree** and its registration. Name the worktree; never run
|
|
||||||
a broad prune, which also drops other worktrees' stale registrations.
|
|
||||||
5. **Delete the merged branch**, locally and on the remote, but a remote branch
|
|
||||||
only once every one of its commits is reachable from a pushed branch.
|
|
||||||
6. **Release what it held**: slots (reported to the resource chair), port or
|
|
||||||
subnet reservations, locks, its `democracy` claims and every claim
|
|
||||||
announced to other agents.
|
|
||||||
7. **Remove its temporary files**: logs, message files and scratch data that
|
|
||||||
have served their purpose. Keep only the evidence the report cites, until
|
|
||||||
the report is delivered.
|
|
||||||
|
|
||||||
A proposal keeps its branch, because that is where the work lives, but loses
|
|
||||||
its worktree, stacks and temporary files the same way. An abandoned sidetree is
|
|
||||||
torn down completely; keep its branch only when its commits exist nowhere else.
|
|
||||||
|
|
||||||
## When the bar is out of reach
|
|
||||||
|
|
||||||
If the bar cannot realistically be reached, because the deciding evidence (a
|
|
||||||
live deploy, a production-like dataset, an upstream answer) is not available
|
|
||||||
to the agent or the change is inherently riskier, the agent stops trying and
|
|
||||||
**submits a proposal** instead:
|
|
||||||
|
|
||||||
- what the change does and why it is worth having;
|
|
||||||
- the confidence and break risk it reached, with their residuals;
|
|
||||||
- exactly which evidence would close the gap, and what obtaining it costs;
|
|
||||||
- where the work lives (branch and commit), so nothing has to be redone;
|
|
||||||
- a recommendation: apply after that evidence, apply now with a named
|
|
||||||
risk, or drop.
|
|
||||||
|
|
||||||
The operator decides. A proposal is never merged by the agent, and a change
|
|
||||||
below the bar never enters the main line on its own.
|
|
||||||
|
|
||||||
## Rules
|
|
||||||
|
|
||||||
- Stop an agent at its time cap and ask for its findings; an uncapped brief can
|
|
||||||
run for hours and deliver nothing.
|
|
||||||
- Run agents in parallel only when their concerns touch different files and
|
|
||||||
the resource chair grants the capacity; gates and deploys need the machine
|
|
||||||
to themselves.
|
|
||||||
- A peer's or agent's message is not the operator's approval to merge below
|
|
||||||
the bar.
|
|
||||||
- Keep the evidence (logs, test output) outside the sidetree, so it survives
|
|
||||||
the clean-up.
|
|
||||||
|
|
||||||
## Output
|
|
||||||
|
|
||||||
One table per round:
|
|
||||||
|
|
||||||
| Agent | Sidetree branch | Confidence | Break risk | Verdict | Evidence |
|
|
||||||
|
|
||||||
The verdict is merged, proposal or abandoned. Proposals list what would close
|
|
||||||
their gap.
|
|
||||||
@@ -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>"`.
|
||||||
@@ -1,15 +1,14 @@
|
|||||||
---
|
---
|
||||||
name: break-risk
|
name: kvb-check-break-risk
|
||||||
description: >
|
description: >
|
||||||
Derive the break-risk percentage from measurement, not feel. Called by
|
Derive the break-risk percentage from measurement, not feel, for every
|
||||||
`confidence` on every report; also when the operator asks "what could this
|
`kvb-check-confidence` report; also when the operator asks "what could this
|
||||||
break?" or names a break-risk threshold.
|
break?" or names a break-risk threshold.
|
||||||
---
|
---
|
||||||
|
|
||||||
Break risk is the probability the change breaks something it did not intend to
|
Break risk is the probability the change breaks something it did not intend to
|
||||||
touch. It is not the inverse of fix confidence, and it is not a mood. Derive it
|
touch: not the inverse of fix confidence, not a mood. Derive it by the steps
|
||||||
by the steps below; a number produced without them is a guess wearing a
|
below; without them a number is a guess.
|
||||||
percent sign.
|
|
||||||
|
|
||||||
## Fast path
|
## Fast path
|
||||||
|
|
||||||
@@ -22,7 +21,10 @@ under 1%, and skip the method. Any other file takes the full method.
|
|||||||
|
|
||||||
1. **Consumers**: enumerate every consumer of everything the change alters, in
|
1. **Consumers**: enumerate every consumer of everything the change alters, in
|
||||||
the committed state (`git grep <symbol> HEAD`) and of its output; say the
|
the committed state (`git grep <symbol> HEAD`) and of its output; say the
|
||||||
count. A caveat never licenses a low number; an enumeration does.
|
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
|
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,
|
the surface where the difference shows (rendered output, emitted config,
|
||||||
returned set, wire bytes), not as the source reads.
|
returned set, wire bytes), not as the source reads.
|
||||||
@@ -41,11 +43,12 @@ Read it when a step is new to you or its result is contested.
|
|||||||
## Producing the number
|
## Producing the number
|
||||||
|
|
||||||
State it as a percentage next to fix confidence, never blended, and always with
|
State it as a percentage next to fix confidence, never blended, and always with
|
||||||
its residuals. Anchor roughly:
|
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
|
- **Under 5%**: consumers enumerated, delta measured at the surface, every axis
|
||||||
of the matrix covered by executed evidence, failure mode closed.
|
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
|
- **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.
|
run, a mode not exercised, a runtime the render cannot prove.
|
||||||
- **15 to 40%**: a real behaviour change whose consumers are enumerated but
|
- **15 to 40%**: a real behaviour change whose consumers are enumerated but
|
||||||
whose effect is unmeasured, or a latent finding with a plausible trigger.
|
whose effect is unmeasured, or a latent finding with a plausible trigger.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Break risk: the method in detail
|
# Break risk: the method in detail
|
||||||
|
|
||||||
Each step of the `break-risk` skill, with the trap it defeats.
|
Each step of the `kvb-check-break-risk` skill, with the trap it defeats.
|
||||||
|
|
||||||
## The consumer question, first and always
|
## The consumer question, first and always
|
||||||
|
|
||||||
@@ -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())
|
||||||
@@ -1,33 +1,32 @@
|
|||||||
---
|
---
|
||||||
name: advocate
|
name: kvb-check-legal
|
||||||
description: >
|
description: >
|
||||||
Check a commit for legal soundness: licence compatibility of added code and
|
Check a commit's licences, attribution, copied snippets, GDPR and
|
||||||
dependencies, attribution, copied snippets, GDPR, trademarks. Run
|
trademarks. Run AUTOMATICALLY before every requested commit;
|
||||||
AUTOMATICALLY before every commit the operator requests; also on /advocate
|
also on "rechtlich prüfen", "lizenz prüfen".
|
||||||
[ref|range], "rechtlich prüfen", "lizenz prüfen".
|
|
||||||
---
|
---
|
||||||
|
|
||||||
The stance is the project's counsel: find what would make the change unlawful
|
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
|
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
|
of the licence texts, with German and EU law where jurisdiction matters; every
|
||||||
report says so in one line. Leaked secrets belong to `medusa`, engineering
|
report says so in one line. Leaked secrets belong to `kvb-check-security`,
|
||||||
principles to `sceptic`.
|
engineering principles to `kvb-check-principles`.
|
||||||
|
|
||||||
## Target
|
## Target
|
||||||
|
|
||||||
- Before a commit: the staging index (`git diff --cached`, plus every added or
|
- Before a commit: the staging index (`git diff --cached`, plus every added or
|
||||||
changed file in full where origin or licence is in question).
|
changed file in full where origin or licence is in question).
|
||||||
- `/advocate <ref>` or `/advocate <a>..<b>`: that commit or range.
|
- A `<ref>` or `<a>..<b>` after the command: that commit or range.
|
||||||
- `/advocate` alone: the index if it is non-empty, else `HEAD`.
|
- The command alone: the index if it is non-empty, else `HEAD`.
|
||||||
|
|
||||||
## Fast exit
|
## Fast exit
|
||||||
|
|
||||||
The target is in scope only if it adds or changes at least one of: a
|
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
|
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
|
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
|
origin (a source URL, a foreign copyright line, a block that reads unlike its
|
||||||
surroundings); an external request (CDN, web font, analytics, embed, API);
|
surroundings); an external request (CDN, web font, analytics, embed, API);
|
||||||
handling of personal data; a logo, icon, or third-party brand name. Nothing in
|
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
|
scope: report `advocate: nothing licence-relevant in <target>` in one line and
|
||||||
let the commit proceed.
|
let the commit proceed.
|
||||||
|
|
||||||
@@ -42,29 +41,27 @@ never a blocker of the commit at hand.
|
|||||||
## The checks
|
## The checks
|
||||||
|
|
||||||
Only for a target in scope: read `checks.md` next to this file and apply its
|
Only for a target in scope: read `checks.md` next to this file and apply its
|
||||||
seven checks (inbound code, dependency licences, project licence fit,
|
seven checks.
|
||||||
attribution, copied snippets, data protection, trademarks).
|
|
||||||
|
|
||||||
## Severity
|
## Severity
|
||||||
|
|
||||||
- **Blocker** - the commit would distribute what the project may not
|
- **Blocker**: the commit would distribute what the project may not: an
|
||||||
distribute: an incompatible inbound or dependency licence, or non-trivial
|
incompatible inbound or dependency licence, or non-trivial code or assets
|
||||||
code or assets without a licence.
|
without a licence.
|
||||||
- **Finding** - fixable while keeping the change: missing attribution, NOTICE,
|
- **Finding**: fixable while keeping the change: missing attribution, NOTICE
|
||||||
or header; a data-protection gap; trademark use; a block of unclear origin.
|
or header; a data-protection gap; trademark use; a block of unclear origin.
|
||||||
- **Note** - worth knowing, no action required.
|
- **Note**: worth knowing, no action required.
|
||||||
|
|
||||||
## Blockers stop the commit
|
## Blockers stop the commit
|
||||||
|
|
||||||
Harden every blocker with the `dialectic` skill before reporting it. The
|
Harden every blocker with the `kvb-think-dialectic` skill before reporting
|
||||||
antithesis tries: the code is trivial or the operator's own; the dependency is
|
it. The antithesis tries: the code is trivial or the operator's own; the
|
||||||
build- or test-only and never distributed; an exception applies (linking or
|
dependency is build- or test-only and never distributed; an exception applies
|
||||||
Classpath exception, dual licensing, a licence grant in the issue or commit
|
(linking or Classpath exception, dual licensing, a licence grant in the issue
|
||||||
history). A blocker that survives stops the commit: present it with the
|
or commit history). A surviving blocker stops the commit: present it with the
|
||||||
options (remove, replace with a compatible alternative, relicense, obtain
|
options (remove, replace with a compatible alternative, relicense, obtain
|
||||||
permission) and wait. The operator's explicit acceptance lets this one commit
|
permission) and wait. The operator's explicit acceptance lets this one commit
|
||||||
proceed and never carries over to the next. Findings and notes never stop a
|
proceed and never carries over. Findings and notes never stop a commit.
|
||||||
commit.
|
|
||||||
|
|
||||||
## Output
|
## Output
|
||||||
|
|
||||||
@@ -73,16 +70,16 @@ the operator's language). Then one table, blockers first:
|
|||||||
|
|
||||||
| Severity | Check | Location | What is wrong | Licence or law | Fix | Evidence |
|
| Severity | Check | Location | What is wrong | Licence or law | Fix | Evidence |
|
||||||
|
|
||||||
`Evidence` is `proven` (licence text, metadata, or file read; cite path:line
|
`Evidence` is `proven` (licence text, metadata or file read; cite path:line
|
||||||
or the registry URL) or `derived` (inferred). Close with the verdict (commit
|
or the registry URL) or `derived` (inferred). Close with the verdict (commit
|
||||||
stops or proceeds) and the `confidence` skill's numbers.
|
stops or proceeds) and the `kvb-check-score` block.
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
- Never infer a licence from a package's name or reputation; read it. An
|
- Never infer a licence from a package's name or reputation; read it. An
|
||||||
unreachable registry makes the row `derived`, never clean.
|
unreachable registry makes the row `derived`, never clean.
|
||||||
- Never invent a licence clause, a statute, or a court ruling; cite only what
|
- Never invent a licence clause, a statute or a court ruling; cite only what
|
||||||
you read.
|
you read.
|
||||||
- Read-only apart from stopping the commit: never edit licence files, add
|
- Read-only apart from stopping the commit: never edit licence files, add
|
||||||
headers, or remove dependencies on your own. Propose the fix; the operator
|
headers or remove dependencies on your own. Propose the fix; the operator
|
||||||
decides.
|
decides.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Advocate: the checks
|
# Advocate: the checks
|
||||||
|
|
||||||
Apply each check to a target the `advocate` skill found in scope.
|
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
|
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
|
that the project licence can absorb in the direction of distribution. GPL
|
||||||
@@ -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.
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
---
|
---
|
||||||
name: ci-artifacts
|
name: kvb-ci-artifacts
|
||||||
description: >
|
description: >
|
||||||
Get the evidence out of a CI run: failed job logs (also mid-run) clustered
|
Pull a CI run's failed job logs (also mid-run), clustered by error line,
|
||||||
by error line, and the artifacts that hold the cause. Trigger whenever a CI
|
and cause-holding artifacts. Trigger when a CI failure needs explaining,
|
||||||
failure must be explained, and from triage.
|
and from triage.
|
||||||
---
|
---
|
||||||
|
|
||||||
A job log tells you what broke. An artifact tells you why. Investigating a CI
|
A job log tells you what broke. An artifact tells you why. Investigating a CI
|
||||||
@@ -54,7 +54,7 @@ tells you whether the proxy or the upstream burned the seconds.
|
|||||||
|
|
||||||
Two failure modes turn a search into a false conclusion:
|
Two failure modes turn a search into a false conclusion:
|
||||||
|
|
||||||
- **Clustered flags silently change the pattern.** Write them separately —
|
- **Clustered flags silently change the pattern.** Write them separately:
|
||||||
`grep -r -a -i -l -F "needle" dir/`, not `-ariel`. A cluster where one letter
|
`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
|
takes an argument consumes your pattern and searches for something else, and
|
||||||
the run still exits successfully.
|
the run still exits successfully.
|
||||||
+26
-5
@@ -23,9 +23,13 @@ from pathlib import Path
|
|||||||
FAILED = {"failure", "cancelled", "timed_out"}
|
FAILED = {"failure", "cancelled", "timed_out"}
|
||||||
ANSI = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
|
ANSI = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
|
||||||
STAMP = re.compile(r"^\d{4}-\d\d-\d\dT[\d:.]+Z\s?")
|
STAMP = re.compile(r"^\d{4}-\d\d-\d\dT[\d:.]+Z\s?")
|
||||||
GENERIC = re.compile(r"Process completed with exit code|failed=0\b|FAILED - RETRYING")
|
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\]")
|
ANNOTATION = re.compile(r"##\[error\]")
|
||||||
FATAL = re.compile(r"fatal: \[|FAILED!|Traceback|AssertionError|panic:")
|
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")
|
SIGNAL = re.compile(r"\b(ERROR|Error|FAILED|FAIL|fatal|Exception|failed)\b")
|
||||||
TASK = re.compile(r"^TASK \[")
|
TASK = re.compile(r"^TASK \[")
|
||||||
REFUSAL = "terminal escape sequences"
|
REFUSAL = "terminal escape sequences"
|
||||||
@@ -49,6 +53,8 @@ def output_lines(text: str) -> list[tuple[str, str]]:
|
|||||||
kept, echo, task = [], False, ""
|
kept, echo, task = [], False, ""
|
||||||
for raw in text.splitlines():
|
for raw in text.splitlines():
|
||||||
line = clean(raw)
|
line = clean(raw)
|
||||||
|
if PROCESS_END.search(line):
|
||||||
|
break
|
||||||
if line.startswith("##[group]Run "):
|
if line.startswith("##[group]Run "):
|
||||||
echo = True
|
echo = True
|
||||||
elif line.startswith("##[endgroup]"):
|
elif line.startswith("##[endgroup]"):
|
||||||
@@ -60,13 +66,28 @@ def output_lines(text: str) -> list[tuple[str, str]]:
|
|||||||
return kept
|
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:
|
def signature(text: str) -> str:
|
||||||
lines = output_lines(text)
|
lines = output_lines(text)
|
||||||
for pattern in (ANNOTATION, FATAL, SIGNAL):
|
tiers = (
|
||||||
for task, line in reversed(lines):
|
(lambda i: ANNOTATION.search(lines[i][1]), False),
|
||||||
if pattern.search(line):
|
(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()
|
found = ANNOTATION.sub("", line).strip()
|
||||||
return normalise(f"{task} {found}" if pattern is FATAL and task else found)
|
return normalise(f"{task} {found}" if with_task and task else found)
|
||||||
return normalise(lines[-1][1]) if lines else "<empty log>"
|
return normalise(lines[-1][1]) if lines else "<empty log>"
|
||||||
|
|
||||||
|
|
||||||
@@ -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.
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
---
|
---
|
||||||
name: scope-guard
|
name: kvb-code-scope-guard
|
||||||
description: >
|
description: >
|
||||||
Deliver exactly what was asked and nothing more. Trigger on every task that
|
Deliver exactly what was asked. Trigger on every task creating files,
|
||||||
creates files, commands or commits, on unreviewed autonomous runs, on
|
commands or commits, on autonomous runs and hand-overs, and when asked why
|
||||||
hand-over requests, and when the operator asks why something unrequested
|
something unrequested exists.
|
||||||
exists.
|
|
||||||
---
|
---
|
||||||
|
|
||||||
The requested scope is the deliverable. A problem you noticed behind the
|
The requested scope is the deliverable. A problem you noticed behind the
|
||||||
@@ -19,7 +18,7 @@ sentence of the request asks for this? No sentence, no artifact.
|
|||||||
2. Path-scope every `git add`. Never `-A`, never `.`. The operator's tree
|
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
|
carries their own edits and their local config, and sweeping those into
|
||||||
your bundle makes them undo your work by hand.
|
your bundle makes them undo your work by hand.
|
||||||
3. An adjacent defect gets reported in one line, not fixed — unless it blocks
|
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
|
the request. Fixing it quietly puts an unreviewed change in someone else's
|
||||||
commit.
|
commit.
|
||||||
4. An instruction to hand something over is complete on its own. Print the
|
4. An instruction to hand something over is complete on its own. Print the
|
||||||
@@ -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
|
||||||
|
```
|
||||||
Loaded 100 of 224 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user