Compare commits
No files matched your search
@@ -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,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:
|
||||
@@ -19,3 +19,7 @@ test:
|
||||
# Update all skills to their latest versions and refresh skills-lock.json.
|
||||
update:
|
||||
@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)
|
||||
@@ -9,7 +9,7 @@ Compatible with Claude Code, Codex, Gemini CLI, Cursor, Copilot, Windsurf, Cline
|
||||
|
||||
## Naming 🏷️
|
||||
|
||||
Every first-party skill is named `kvb-<category>-<name>` with one of these categories: `think`, `create`, `code`, `ci`, `git`, `check`, `org`, `skill`, `coach`. 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.
|
||||
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 🏛️
|
||||
|
||||
@@ -25,6 +25,7 @@ flowchart LR
|
||||
kvb --> git[git]
|
||||
kvb --> check[check]
|
||||
kvb --> org[org]
|
||||
kvb --> agent[agent]
|
||||
kvb --> skill[skill]
|
||||
kvb --> coach[coach]
|
||||
kvb --> none[no category]
|
||||
@@ -49,13 +50,23 @@ flowchart LR
|
||||
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_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]
|
||||
@@ -66,12 +77,23 @@ flowchart LR
|
||||
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]
|
||||
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]
|
||||
@@ -126,8 +148,14 @@ flowchart LR
|
||||
commit_next[kvb-git-commit-next]
|
||||
commit_no_verify[kvb-git-commit-no-verify]
|
||||
merge_fix[kvb-git-merge-fix]
|
||||
issue_close[kvb-git-issue-close]
|
||||
issue_solve[kvb-git-issue-solve]
|
||||
pr_create[kvb-git-pr-create]
|
||||
pr_label[kvb-git-pr-label]
|
||||
update_branches[kvb-git-update-branches]
|
||||
branch_gc[kvb-git-branch-gc]
|
||||
changelog[kvb-git-changelog]
|
||||
hotfix[kvb-git-hotfix]
|
||||
end
|
||||
subgraph cat_check [check]
|
||||
advocate[kvb-check-legal]
|
||||
@@ -135,6 +163,10 @@ flowchart LR
|
||||
sceptic[kvb-check-principles]
|
||||
confidence[kvb-check-confidence]
|
||||
break_risk[kvb-check-break-risk]
|
||||
gate[kvb-check-gate]
|
||||
efficiency[kvb-check-efficiency]
|
||||
speed[kvb-check-speed]
|
||||
score[kvb-check-score]
|
||||
end
|
||||
subgraph cat_org [org]
|
||||
democracy[kvb-org-organize]
|
||||
@@ -147,6 +179,18 @@ flowchart LR
|
||||
wsjf[kvb-org-wsjf]
|
||||
robot[kvb-org-robot]
|
||||
operator[kvb-org-operator]
|
||||
priority[kvb-org-priority]
|
||||
evening[kvb-org-evening]
|
||||
sunset[kvb-org-sunset]
|
||||
sunrise[kvb-org-sunrise]
|
||||
radio[kvb-org-radio]
|
||||
queue[kvb-org-queue]
|
||||
end
|
||||
subgraph cat_agent [agent]
|
||||
agent_optimize[kvb-agent-optimize]
|
||||
relax[kvb-agent-relax]
|
||||
sleep[kvb-agent-sleep]
|
||||
wakeup[kvb-agent-wakeup]
|
||||
end
|
||||
subgraph cat_skill [skill]
|
||||
autotune[kvb-skill-tune]
|
||||
@@ -155,6 +199,7 @@ flowchart LR
|
||||
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]
|
||||
@@ -182,10 +227,34 @@ flowchart LR
|
||||
commit_next --> commit_no_verify
|
||||
commit_next --> explain
|
||||
commit_next --> dialectic
|
||||
issue_close --> confidence
|
||||
issue_solve --> issue_close
|
||||
issue_solve --> funnel
|
||||
issue_solve --> pr_create
|
||||
pr_create --> issue_close
|
||||
pr_create --> pr_label
|
||||
pr_create -. push .-> operator
|
||||
update_branches --> active_listening
|
||||
update_branches -. conflicts .-> merge_fix
|
||||
update_branches -. pushes .-> operator
|
||||
commit_no_verify --> comments_clean
|
||||
commit_no_verify --> advocate
|
||||
advocate --> dialectic
|
||||
confidence --> break_risk
|
||||
confidence --> gate
|
||||
pr_label --> gate
|
||||
stage_req -.-> score
|
||||
score --> confidence
|
||||
score --> break_risk
|
||||
score --> efficiency
|
||||
score --> speed
|
||||
speed --> efficiency
|
||||
efficiency -. heavy commands .-> queue
|
||||
gate --> democracy
|
||||
gate --> hotfix
|
||||
triage --> hotfix
|
||||
democracy --> hotfix
|
||||
hotfix --> rasci
|
||||
confidence -. objective .-> autotune
|
||||
|
||||
coordinate --> democracy
|
||||
@@ -204,6 +273,8 @@ flowchart LR
|
||||
artefacts --> rasci
|
||||
democracy --> wsjf
|
||||
wsjf --> rasci
|
||||
priority --> rasci
|
||||
priority -. pins outrank .-> wsjf
|
||||
operator --> rasci
|
||||
dream --> rasci
|
||||
|
||||
@@ -248,7 +319,19 @@ flowchart LR
|
||||
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
|
||||
@@ -297,7 +380,7 @@ Copy the skills into a specific project (`<repo>/.agents/skills` and `<repo>/.cl
|
||||
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 — `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. 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.
|
||||
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`. 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.
|
||||
|
||||
|
||||
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())
|
||||
+36
-10
@@ -14,7 +14,12 @@ const MARKETPLACES = {
|
||||
caveman: { source: { source: "github", repo: "JuliusBrussee/caveman" } },
|
||||
ponytail: { source: { source: "github", repo: "DietrichGebert/ponytail" } },
|
||||
};
|
||||
const PLUGINS = { "caveman@caveman": true, "ponytail@ponytail": true };
|
||||
const PLUGINS = {
|
||||
"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",
|
||||
@@ -28,6 +33,7 @@ const RENAMED = {
|
||||
"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 = {};
|
||||
@@ -48,6 +54,20 @@ for (const [name, entry] of Object.entries(MARKETPLACES)) {
|
||||
if (!data.extraKnownMarketplaces[name]) data.extraKnownMarketplaces[name] = entry;
|
||||
}
|
||||
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 || {};
|
||||
const retired = (hook) =>
|
||||
@@ -90,22 +110,28 @@ if (!JSON.stringify(data.hooks.PreToolUse).includes("kvb-org-organize/scripts/co
|
||||
}
|
||||
|
||||
const SCRIPT_HOOKS = [
|
||||
["kvb-org-organize/scripts/watch.py", "UserPromptSubmit", null, "watch"],
|
||||
["kvb-org-organize/scripts/watch.py", "PostToolUse", "Bash", "watch"],
|
||||
["kvb-org-organize/scripts/watch.py", "PreToolUse", "Bash", "block"],
|
||||
["kvb-org-perform/scripts/log.py", "PreToolUse", "Agent|Task", "spawn"],
|
||||
["kvb-org-perform/scripts/log.py", "PostToolUse", "Bash", "persist"],
|
||||
["kvb-org-organize/scripts/watch.py", "UserPromptSubmit", null, "watch", {}],
|
||||
["kvb-org-radio/scripts/radio.py", "UserPromptSubmit", null, "hook", {}],
|
||||
["kvb-org-radio/scripts/radio.py", "PostToolBatch", null, "tool", {}],
|
||||
["kvb-org-radio/scripts/radio.py", "Stop", null, "listen", { asyncRewake: true, timeout: 21600 }],
|
||||
["kvb-org-organize/scripts/watch.py", "PostToolUse", "Bash", "watch", {}],
|
||||
["kvb-org-organize/scripts/watch.py", "PreToolUse", "Bash", "block", {}],
|
||||
["kvb-org-queue/scripts/queue.py", "PreToolUse", "Bash", "block", {}],
|
||||
["kvb-org-perform/scripts/log.py", "PreToolUse", "Agent|Task", "spawn", {}],
|
||||
["kvb-org-perform/scripts/log.py", "PostToolUse", "Bash", "persist", {}],
|
||||
];
|
||||
for (const [script, event, matcher, sub] of SCRIPT_HOOKS) {
|
||||
for (const [script, event, matcher, sub, extra] of SCRIPT_HOOKS) {
|
||||
data.hooks[event] = data.hooks[event] || [];
|
||||
const registered = data.hooks[event].some((entry) =>
|
||||
(entry.hooks || []).some((hook) =>
|
||||
String(hook.command).includes(script) && String(hook.command).endsWith(` ${sub}`)));
|
||||
if (registered) continue;
|
||||
const file = path.join(path.dirname(settingsPath), "skills", ...script.split("/"));
|
||||
const entry = { hooks: [{ type: "command", command: `python3 ${JSON.stringify(file)} ${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);
|
||||
}
|
||||
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(data, null, 2) + "\n");
|
||||
console.log(`skills: enabled caveman + ponytail plugins, the ${REMINDERS.join(" + ")} reminders, the kvb-org-organize claim gate, the resource watch and the kvb-org-perform log 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}`);
|
||||
@@ -64,6 +64,15 @@ if [[ "${TARGET}" == "${HOME}" ]]; then
|
||||
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
|
||||
fi
|
||||
|
||||
log "skills: done. Restart your agent to load the skills."
|
||||
+1
-1
@@ -30,6 +30,6 @@ fi
|
||||
log "skills: updating from ${lockfile}..."
|
||||
jq -r '.skills[].source' "${lockfile}" | sort -u | while read -r source; do
|
||||
log "skills: updating ${source}..."
|
||||
npx --yes skills add --yes --agent claude-code "${source}" </dev/null
|
||||
npx --yes skills add "${source}" --yes --agent claude-code </dev/null
|
||||
done
|
||||
log "skills: update complete."
|
||||
@@ -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: kvb-check-break-risk
|
||||
description: >
|
||||
Derive the break-risk percentage from measurement, not feel. Called by
|
||||
`kvb-check-confidence` on every report; also when the operator asks "what could this
|
||||
Derive the break-risk percentage from measurement, not feel, for every
|
||||
`kvb-check-confidence` report; also when the operator asks "what could this
|
||||
break?" or names a break-risk threshold.
|
||||
---
|
||||
|
||||
Break risk is the probability the change breaks something it did not intend to
|
||||
touch. It is not the inverse of fix confidence, and it is not a mood. Derive it
|
||||
by the steps below; a number produced without them is a guess wearing a
|
||||
percent sign.
|
||||
touch: not the inverse of fix confidence, not a mood. Derive it by the steps
|
||||
below; without them a number is a guess.
|
||||
|
||||
## Fast path
|
||||
|
||||
@@ -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
|
||||
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
|
||||
the surface where the difference shows (rendered output, emitted config,
|
||||
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
|
||||
|
||||
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
|
||||
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.
|
||||
- **15 to 40%**: a real behaviour change whose consumers are enumerated but
|
||||
whose effect is unmeasured, or a latent finding with a plausible trigger.
|
||||
|
||||
@@ -2,26 +2,30 @@
|
||||
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 work the operator
|
||||
may commit or deploy.
|
||||
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 a
|
||||
confidence block.
|
||||
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.
|
||||
|
||||
Invoke the `kvb-check-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 `kvb-check-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.
|
||||
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 efficiency from the `kvb-check-efficiency`
|
||||
skill and speed from the `kvb-check-speed` skill, to
|
||||
`${CLAUDE_SKILL_DIR}/../kvb-check-score/scripts/score.py --fix <n> --break
|
||||
<n> --efficiency <n|-> --speed <n|->` and show its score line and gate line
|
||||
verbatim. Exit 1 fails the 90/5 rule; exit 3 means ask the operator again
|
||||
before the commit, naming the cause 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:
|
||||
|
||||
@@ -33,8 +37,10 @@ Calibration rules:
|
||||
(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 `kvb-check-break-risk` skill's last section says, for both
|
||||
numbers.
|
||||
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.
|
||||
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,65 @@
|
||||
---
|
||||
name: kvb-check-efficiency
|
||||
description: >
|
||||
Token cost of skill edits, CPU and RAM of code edits.
|
||||
---
|
||||
|
||||
Efficiency is the share of its base's resources a change uses: under 100 %
|
||||
it saves, over 100 % it costs more. One script call gives one line; paste
|
||||
that line, never a paragraph about it. `<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>
|
||||
```
|
||||
|
||||
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 `efficiency new`, which counts as over 100. Skills have no speed.
|
||||
|
||||
## 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. Efficiency is the mean of the CPU and the RAM
|
||||
ratio. The same run 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: speed 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 efficiency and speed 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: speed comes
|
||||
from the CPU-time ratio instead of wall time.
|
||||
|
||||
Output:
|
||||
`🌱 efficiency <p> % (cpu <b>→<a> s, ram <b>→<a> MB) · ⚡ speed <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 speed and ends in `(estimated: other load <x> %)`, or
|
||||
`unknown` for <x>. Efficiency 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 efficiency and speed 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"🌱 efficiency {mark}{found['efficiency']} % (cpu {before['cpu']:.2f}→{after['cpu']:.2f} s, "
|
||||
f"ram {before['ram']:.1f}→{after['ram']:.1f} MB) · "
|
||||
f"⚡ speed {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:]))
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compare the estimated tokens of changed skill text files, and the listing words, with a base ref.
|
||||
|
||||
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.
|
||||
--json: print the figures as JSON instead of the line.
|
||||
|
||||
Output:
|
||||
`🌱 efficiency <p> % (skills <b>→<a> tokens; listing <b>→<a> words)`, p = after / before * 100,
|
||||
or `🌱 efficiency 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. --json prints efficiency (null when
|
||||
new), the skills and listing totals and the tokens per skill.
|
||||
|
||||
Exit codes:
|
||||
0 efficiency at most 100, 2 usage error, 3 over 100 or new.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib.util
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path, PurePosixPath
|
||||
from types import ModuleType
|
||||
|
||||
CHARS_PER_TOKEN = 4
|
||||
LIMIT = 100
|
||||
SKILLS = "skills"
|
||||
|
||||
|
||||
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_budget(repo: Path) -> ModuleType:
|
||||
path = repo / "scripts" / "budget.py"
|
||||
if not path.is_file():
|
||||
fail(f"{path} is missing; it counts the listing")
|
||||
spec = importlib.util.spec_from_file_location("budget", 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 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).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}")
|
||||
budget = load_budget(repo)
|
||||
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: dict[str, list[int]] = {}
|
||||
for path in sorted({p for p in changed if p.endswith(".md")}):
|
||||
before = at_base(repo, args.base, path) if path in base_files else ""
|
||||
side = chars.setdefault(PurePosixPath(path).parts[1], [0, 0])
|
||||
side[0] += len(before)
|
||||
side[1] += len(in_tree(repo, path))
|
||||
before = tokens(sum(c[0] for c in chars.values()))
|
||||
after = tokens(sum(c[1] for c in chars.values()))
|
||||
listing_before = sum(budget.words(at_base(repo, args.base, p)) for p in sorted(base_files) if is_skill_md(p))
|
||||
listing_after = sum(budget.counts(repo, index=False).values())
|
||||
|
||||
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()},
|
||||
}))
|
||||
else:
|
||||
listing = f"listing {listing_before}→{listing_after} words"
|
||||
if efficiency is None:
|
||||
print(f"🌱 efficiency new (+{after} tokens; {listing})")
|
||||
else:
|
||||
print(f"🌱 efficiency {efficiency} % (skills {before}→{after} tokens; {listing})")
|
||||
return 3 if efficiency is None or efficiency > LIMIT else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -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 %, efficiency and speed ≤ 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())
|
||||
@@ -2,31 +2,31 @@
|
||||
name: kvb-check-legal
|
||||
description: >
|
||||
Check a commit's licences, attribution, copied snippets, GDPR and
|
||||
trademarks. Run AUTOMATICALLY before every commit the operator requests;
|
||||
also on /kvb-check-legal [ref|range], "rechtlich prüfen", "lizenz prüfen".
|
||||
trademarks. Run AUTOMATICALLY before every requested commit;
|
||||
also on "rechtlich prüfen", "lizenz prüfen".
|
||||
---
|
||||
|
||||
The stance is the project's counsel: find what would make the change unlawful
|
||||
to distribute before it is distributed. This is not legal advice but a reading
|
||||
of the licence texts, with German and EU law where jurisdiction matters; every
|
||||
report says so in one line. Leaked secrets belong to `kvb-check-security`, engineering
|
||||
principles to `kvb-check-principles`.
|
||||
report says so in one line. Leaked secrets belong to `kvb-check-security`,
|
||||
engineering principles to `kvb-check-principles`.
|
||||
|
||||
## Target
|
||||
|
||||
- Before a commit: the staging index (`git diff --cached`, plus every added or
|
||||
changed file in full where origin or licence is in question).
|
||||
- `/kvb-check-legal <ref>` or `/kvb-check-legal <a>..<b>`: that commit or range.
|
||||
- `/kvb-check-legal` alone: the index if it is non-empty, else `HEAD`.
|
||||
- A `<ref>` or `<a>..<b>` after the command: that commit or range.
|
||||
- The command alone: the index if it is non-empty, else `HEAD`.
|
||||
|
||||
## Fast exit
|
||||
|
||||
The target is in scope only if it adds or changes at least one of: a
|
||||
dependency manifest or lockfile; a vendored, generated, or third-party file; a
|
||||
LICENSE, NOTICE, or COPYING file or a licence header; code with a foreign
|
||||
dependency manifest or lockfile; a vendored, generated or third-party file; a
|
||||
LICENSE, NOTICE or COPYING file or a licence header; code with a foreign
|
||||
origin (a source URL, a foreign copyright line, a block that reads unlike its
|
||||
surroundings); an external request (CDN, web font, analytics, embed, API);
|
||||
handling of personal data; a logo, icon, or third-party brand name. Nothing in
|
||||
handling of personal data; a logo, icon or third-party brand name. Nothing in
|
||||
scope: report `advocate: nothing licence-relevant in <target>` in one line and
|
||||
let the commit proceed.
|
||||
|
||||
@@ -41,29 +41,27 @@ never a blocker of the commit at hand.
|
||||
## The checks
|
||||
|
||||
Only for a target in scope: read `checks.md` next to this file and apply its
|
||||
seven checks (inbound code, dependency licences, project licence fit,
|
||||
attribution, copied snippets, data protection, trademarks).
|
||||
seven checks.
|
||||
|
||||
## Severity
|
||||
|
||||
- **Blocker** - the commit would distribute what the project may not
|
||||
distribute: an incompatible inbound or dependency licence, or non-trivial
|
||||
code or assets without a licence.
|
||||
- **Finding** - fixable while keeping the change: missing attribution, NOTICE,
|
||||
- **Blocker**: the commit would distribute what the project may not: an
|
||||
incompatible inbound or dependency licence, or non-trivial code or assets
|
||||
without a licence.
|
||||
- **Finding**: fixable while keeping the change: missing attribution, NOTICE
|
||||
or header; a data-protection gap; trademark use; a block of unclear origin.
|
||||
- **Note** - worth knowing, no action required.
|
||||
- **Note**: worth knowing, no action required.
|
||||
|
||||
## Blockers stop the commit
|
||||
|
||||
Harden every blocker with the `kvb-think-dialectic` skill before reporting it. The
|
||||
antithesis tries: the code is trivial or the operator's own; the dependency is
|
||||
build- or test-only and never distributed; an exception applies (linking or
|
||||
Classpath exception, dual licensing, a licence grant in the issue or commit
|
||||
history). A blocker that survives stops the commit: present it with the
|
||||
Harden every blocker with the `kvb-think-dialectic` skill before reporting
|
||||
it. The antithesis tries: the code is trivial or the operator's own; the
|
||||
dependency is build- or test-only and never distributed; an exception applies
|
||||
(linking or Classpath exception, dual licensing, a licence grant in the issue
|
||||
or commit history). A surviving blocker stops the commit: present it with the
|
||||
options (remove, replace with a compatible alternative, relicense, obtain
|
||||
permission) and wait. The operator's explicit acceptance lets this one commit
|
||||
proceed and never carries over to the next. Findings and notes never stop a
|
||||
commit.
|
||||
proceed and never carries over. Findings and notes never stop a commit.
|
||||
|
||||
## Output
|
||||
|
||||
@@ -72,16 +70,16 @@ the operator's language). Then one table, blockers first:
|
||||
|
||||
| Severity | Check | Location | What is wrong | Licence or law | Fix | Evidence |
|
||||
|
||||
`Evidence` is `proven` (licence text, metadata, or file read; cite path:line
|
||||
`Evidence` is `proven` (licence text, metadata or file read; cite path:line
|
||||
or the registry URL) or `derived` (inferred). Close with the verdict (commit
|
||||
stops or proceeds) and the `kvb-check-confidence` skill's numbers.
|
||||
stops or proceeds) and the `kvb-check-score` block.
|
||||
|
||||
## Rules
|
||||
|
||||
- Never infer a licence from a package's name or reputation; read it. An
|
||||
unreachable registry makes the row `derived`, never clean.
|
||||
- Never invent a licence clause, a statute, or a court ruling; cite only what
|
||||
- Never invent a licence clause, a statute or a court ruling; cite only what
|
||||
you read.
|
||||
- Read-only apart from stopping the commit: never edit licence files, add
|
||||
headers, or remove dependencies on your own. Propose the fix; the operator
|
||||
headers or remove dependencies on your own. Propose the fix; the operator
|
||||
decides.
|
||||
@@ -2,84 +2,81 @@
|
||||
name: kvb-check-principles
|
||||
description: >
|
||||
Find violated principles (SPOT, DRY, KISS, SRP, trust boundaries) in a
|
||||
repository, path or diff. Trigger on /kvb-check-principles, "sei
|
||||
skeptisch", "kritische analyse", or "review the architecture".
|
||||
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`),
|
||||
not bloat (`ponytail-audit`), not leaked secrets (`kvb-check-security`). Read-only —
|
||||
report the findings, change nothing unless the operator asks afterwards.
|
||||
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, a directory, a role, or a diff.
|
||||
Without a target, take the whole repository and rank by blast radius. State
|
||||
the scope in one line before reporting, and name explicitly what you did not
|
||||
read — an audit that silently covered half the tree reads as clean.
|
||||
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, a version, a hostname, a
|
||||
schema, a list of names that exists twice is a divergence waiting to
|
||||
happen. Name both locations and the drift they will cause.
|
||||
2. **DRY** — repeated *knowledge*, not repeated text. Two blocks that look
|
||||
alike but 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. Look for the module that both
|
||||
parses and persists, the function whose name contains "and", the role
|
||||
that configures and deploys and monitors. The test: name the two
|
||||
stakeholders who would each demand a change here.
|
||||
4. **KISS** — mechanism the problem never 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 crossing into
|
||||
a query, a shell, a template, or a path; authorization checked in the
|
||||
caller instead of the resource; a secret in a default, a log, or an
|
||||
image layer; a permission wider than the operation needs. Defensive
|
||||
findings only.
|
||||
6. **Silent defaults** — a fallback that substitutes a guess for a value the
|
||||
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**: 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. **Silent defaults**: a fallback substituting a guess for a value the
|
||||
caller failed to supply, turning a misconfiguration into wrong behaviour
|
||||
instead of a loud failure. See the `kvb-code-no-defaults` skill.
|
||||
instead of a loud failure (the `kvb-code-no-defaults` skill).
|
||||
|
||||
## Method
|
||||
|
||||
- **Evidence before verdict.** Every finding carries `file:line` you actually
|
||||
read. Grep for the sibling occurrences before calling something a
|
||||
duplicate — one hit is not a pattern.
|
||||
- **Name the failure.** A finding must state what concretely goes wrong:
|
||||
which value drifts, which caller breaks, which input reaches which sink.
|
||||
A finding whose worst case is "feels wrong" gets deleted, not softened.
|
||||
- **Attack your own finding.** For anything consequential or expensive to
|
||||
fix, run it through the `kvb-think-dialectic` skill before reporting it — the
|
||||
strongest counter-argument is usually "these two are not the same
|
||||
knowledge" or "this boundary is already checked upstream".
|
||||
- **Respect intent.** A deliberate marker (`ponytail:`, a documented
|
||||
exception, a nocheck directive with a reason) is a known ceiling, not a
|
||||
finding. Report it only when the ceiling has since been exceeded.
|
||||
- **Graph first**: explore through the codebase-memory-mcp graph
|
||||
(`search_graph`, `trace_path`, `get_architecture`, `detect_changes`; the
|
||||
`kvb-agent-optimize` skill sets it up), 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".
|
||||
Skeptics scale with the stakes; each brief names the `file:line` and the
|
||||
claim and caps the answer at five lines.
|
||||
- **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 ranked table, worst blast radius first:
|
||||
One table, worst blast radius first:
|
||||
|
||||
| Severity | Principle | Location | What is wrong | Smallest fix |
|
||||
|
||||
Then three closing lines at most: what the scope excluded, which findings
|
||||
survived `kvb-think-dialectic` versus which are still derived-only, and the single
|
||||
finding to fix first. No per-finding paragraphs — the operator asks when a
|
||||
row needs unpacking.
|
||||
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. No per-finding paragraphs; the operator asks when a row needs it.
|
||||
|
||||
## Rules
|
||||
|
||||
- Never report formatting, naming taste, or missing comments; a linter owns
|
||||
those and the operator did not ask.
|
||||
- Distinguish proven (grepped, read, reproduced) from derived (reasoned
|
||||
statically) per finding, and close with the `kvb-check-confidence` skill's numbers
|
||||
for the audit as a whole.
|
||||
- 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: if you ranked and cut, say how many findings were dropped
|
||||
and by what rule.
|
||||
- Skepticism ends at the report. Do not fix, stage, or refactor as part of
|
||||
this skill.
|
||||
- Skepticism ends at the report: no fix, stage or refactor in this skill.
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
name: kvb-check-score
|
||||
description: >
|
||||
Score fix, break, efficiency and speed; 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.
|
||||
One script call prints two lines; paste them verbatim, they replace prose
|
||||
numbers.
|
||||
|
||||
```bash
|
||||
python3 ${CLAUDE_SKILL_DIR}/scripts/score.py --fix <0-100> --break <0-100> --efficiency <n|-|new> --speed <n|->
|
||||
```
|
||||
|
||||
| Emoji | Value | Source |
|
||||
| --- | --- | --- |
|
||||
| 🎯 | fix confidence | `kvb-check-confidence` |
|
||||
| 💥 | break risk | `kvb-check-break-risk` |
|
||||
| 🌱 | efficiency | `tokens.py` (skills) or `measure.py` (code) of `kvb-check-efficiency` |
|
||||
| ⚡ | speed | `measure.py`, see `kvb-check-speed` |
|
||||
| 🏆 | score | this script |
|
||||
|
||||
`-` means not measured or not relevant (skills have no speed) 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 / efficiency * 100 / speed`,
|
||||
capped to 0..100 and rounded.
|
||||
|
||||
## Gate
|
||||
|
||||
The second line is the verdict:
|
||||
|
||||
- `✅ gate: 90/5, efficiency and speed <= 100` (exit 0): commit as usual.
|
||||
90/5 still needs `gate: green` of `kvb-check-gate`.
|
||||
- `⛔ 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: efficiency <e> % > 100: ask the operator before the commit`, the
|
||||
same for speed (exit 3): the change costs more than its base. Before the
|
||||
commit, ask the operator with both lines and what the extra cost buys;
|
||||
commit only on a yes, which never carries over to another commit.
|
||||
- Exit 2: usage error.
|
||||
|
||||
## Commit blocks
|
||||
|
||||
A commit block puts one line above the two:
|
||||
|
||||
```text
|
||||
📁 <worktree> · 🌿 <branch> · 📊 <git diff --cached --shortstat>
|
||||
🎯 fix 95 % · 💥 break 2 % · 🌱 efficiency 97 % · ⚡ speed – · 🏆 score 96
|
||||
✅ gate: 90/5, efficiency and speed <= 100
|
||||
```
|
||||
Executable
+118
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fold fix confidence, break risk, efficiency and speed into one score line and one gate line.
|
||||
|
||||
Args:
|
||||
--fix: fix confidence in percent, 0 to 100.
|
||||
--break: break risk in percent, 0 to 100.
|
||||
--efficiency: efficiency in percent above 0; `-` when not measured or not
|
||||
relevant, `new` when the change has no base. Both count as 100 in the score.
|
||||
--speed: speed in percent above 0, `-` or `new` as for --efficiency.
|
||||
|
||||
Output:
|
||||
Line 1: `🎯 fix <f> % · 💥 break <b> % · 🌱 efficiency <e> % · ⚡ speed <s> % · 🏆 score <n>`,
|
||||
`–` in place of `<x> %` for `-`, `new` for `new`. Score is
|
||||
fix * (100 - break) / 100 * 100 / efficiency * 100 / speed, 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 efficiency or speed over 100 or new: ask the operator before the commit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import math
|
||||
import sys
|
||||
|
||||
FIX_MIN = 90
|
||||
BREAK_MAX = 5
|
||||
COST_MAX = 100
|
||||
DASH = "-"
|
||||
NEW = "new"
|
||||
|
||||
|
||||
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 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(fix: float, risk: float, efficiency: float | str, speed: float | str) -> int:
|
||||
raw = fix * (100 - risk) / 100 * factor(efficiency) * factor(speed)
|
||||
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(fix: float, risk: float, efficiency: float | str, speed: float | str) -> tuple[str, int]:
|
||||
failed = []
|
||||
if fix < FIX_MIN:
|
||||
failed.append(f"fix {number(fix)} % < {FIX_MIN}")
|
||||
if risk > BREAK_MAX:
|
||||
failed.append(f"break {number(risk)} % > {BREAK_MAX}")
|
||||
if failed:
|
||||
return f"⛔ gate: {', '.join(failed)}", 1
|
||||
costly = [text for text in (over("efficiency", efficiency), over("speed", speed)) if text]
|
||||
if costly:
|
||||
return f"⚠️ gate: {', '.join(costly)}: ask the operator before the commit", 3
|
||||
return f"✅ gate: {FIX_MIN}/{BREAK_MAX}, efficiency and speed <= {COST_MAX}", 0
|
||||
|
||||
|
||||
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)
|
||||
args = parser.parse_args()
|
||||
total = score(args.fix, args.risk, args.efficiency, args.speed)
|
||||
print(
|
||||
f"🎯 fix {number(args.fix)} % · 💥 break {number(args.risk)} % · "
|
||||
f"🌱 efficiency {shown(args.efficiency)} · ⚡ speed {shown(args.speed)} · 🏆 score {total}"
|
||||
)
|
||||
verdict, code = gate(args.fix, args.risk, args.efficiency, args.speed)
|
||||
print(verdict)
|
||||
return code
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
name: kvb-check-security
|
||||
description: >
|
||||
Run the MEDUSA scanner (medusa-security) on a repository, working tree or
|
||||
secret stores. Use for security scans, vetting a repo, leaked secrets in
|
||||
chat or shell history, or /kvb-check-security. Defensive only.
|
||||
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).
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
name: kvb-check-speed
|
||||
description: >
|
||||
Wall-clock speed of changed code versus its base commit.
|
||||
---
|
||||
|
||||
Speed 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 `⚡ speed <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:
|
||||
`⚡ speed ~<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: kvb-ci-artifacts
|
||||
description: >
|
||||
Pull failed job logs (also mid-run), clustered by error line, and the
|
||||
cause-holding artifacts out of a CI run. Trigger when a CI failure needs
|
||||
explaining, and from triage.
|
||||
Pull a CI run's failed job logs (also mid-run), clustered by error line,
|
||||
and cause-holding artifacts. Trigger when a CI failure needs explaining,
|
||||
and from triage.
|
||||
---
|
||||
|
||||
A job log tells you what broke. An artifact tells you why. Investigating a CI
|
||||
@@ -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:
|
||||
|
||||
- **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
|
||||
takes an argument consumes your pattern and searches for something else, and
|
||||
the run still exits successfully.
|
||||
|
||||
@@ -2,59 +2,60 @@
|
||||
name: kvb-ci-observe
|
||||
description: >
|
||||
Watch a CI run, pipeline, build or log on a cadence and triage new failures.
|
||||
Trigger on /kvb-ci-observe <target> or a request to keep monitoring and fix
|
||||
regressions.
|
||||
Trigger on a request to keep monitoring and fix regressions.
|
||||
---
|
||||
|
||||
Keep a target under continuous watch and act only when it degrades. The loop is
|
||||
the watcher; triage is the response. Do not re-investigate failures already
|
||||
resolved this session, and do not idle-poll work the harness will notify you
|
||||
about.
|
||||
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 as the baseline (the failures already known or already
|
||||
fixed this session). Everything the watch reports is diffed against that
|
||||
baseline, so a stale baseline re-triages solved failures.
|
||||
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 this with `/loop` in dynamic mode (no fixed interval unless the operator
|
||||
gave one): after each observation, schedule the next wake-up yourself and pass
|
||||
the same `/kvb-ci-observe` prompt back so the next firing repeats the watch.
|
||||
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.** Pull its current state (job list, check conclusions, the
|
||||
log's tail); for a GitHub Actions run, the kvb-ci-artifacts skill's
|
||||
`ci_failures.py <run>`. Enumerate the failures exactly 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 not in the
|
||||
baseline and was not already triaged this session. If nothing is new, log one
|
||||
short line ("still green" / "N known failures, no change") and reschedule.
|
||||
3. **Triage each new failure.** Invoke the `kvb-ci-triage` skill on the new set only —
|
||||
which applies `kvb-think-dialectic` per failing job to a ~99% root cause and a real
|
||||
fix. Never mask (no retry-until-pass, no disabled check, no soft-skip). Fold
|
||||
each newly fixed failure into the baseline so it is not re-triaged.
|
||||
4. **Reschedule.** Pick the next delay from what you are waiting on: an active
|
||||
run that changes every few minutes gets a check matched to its cadence; a
|
||||
quiet or long-running target gets a long fallback (1200s+). Keep watching
|
||||
until the target reaches a terminal state (run completed, pipeline finished)
|
||||
AND every failure it produced has a verified fix, then stop the loop.
|
||||
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: if the harness will notify you
|
||||
when tracked work finishes (a background task, a spawned agent), wait for that
|
||||
notification instead of polling for it; reserve the timed wake-up for external
|
||||
state the harness cannot see (a remote CI run, a deploy queue).
|
||||
- Executed evidence beats reasoning: read the actual failing log line and the
|
||||
artifact, not the job title — kvb-ci-triage and kvb-think-dialectic carry this through.
|
||||
- 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.
|
||||
- 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
|
||||
actually returned.
|
||||
returned.
|
||||
+56
-101
@@ -1,114 +1,69 @@
|
||||
---
|
||||
name: kvb-ci-triage
|
||||
description: >
|
||||
Triage a failing CI run to a verified root cause and a real fix, one
|
||||
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 to green by root cause, not by guesswork. Treat every
|
||||
failing job as its own investigation, and do not declare a fix until that job's
|
||||
root cause is proven.
|
||||
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
|
||||
|
||||
For a GitHub Actions run, the kvb-ci-artifacts skill's `ci_failures.py <run>`
|
||||
does the mechanics of steps 0 to 2 in one call: distance to HEAD, the failed
|
||||
jobs' logs, and a first clustering. The judgement in those steps stays yours.
|
||||
|
||||
0. **Check the run against HEAD before anything else.** A CI run is a snapshot
|
||||
of one commit, and the branch has moved since. Run
|
||||
`git log --oneline <run-sha>..HEAD | wc -l` as the first command of the
|
||||
triage. Zero means the run speaks for the current tree and you proceed.
|
||||
Non-zero means it does not, and you scope the job set down **before**
|
||||
enumerating, clustering, or spawning anything — every job you investigate
|
||||
against stale code is work thrown away, and a fix proposed there risks
|
||||
reverting someone else's. Two cheap discriminators do it without opening an
|
||||
artifact: `git diff --name-only <run-sha>..HEAD -- <source dir> | cut -d/ -f2 | sort -u`
|
||||
for which components already moved, and 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 rest of this procedure applies to; say how
|
||||
many jobs you dropped and why.
|
||||
1. **Enumerate every failing job.** Pull the run's job list and isolate each job
|
||||
whose conclusion is failure, cancelled, or timed-out. A downstream aggregate
|
||||
job that fails only because an upstream job did is not a separate root cause —
|
||||
note it and move on.
|
||||
2. **Cluster the failures by error message.** Group the failing jobs by their
|
||||
actual failing line, not by job name. Jobs whose messages differ in anything
|
||||
but the interpolated identifiers (host, app, port, path) belong in separate
|
||||
clusters.
|
||||
3. **Coordinate every cluster.** Run the `kvb-org-organize` skill's Lineage and Peer
|
||||
protocol for each cluster before fixing it: claim it, ask the lineage
|
||||
whether someone already fixes it, and take their fix or keep yours. Keep
|
||||
investigating while you wait for answers. In the `kvb-org-rasci` register
|
||||
the cluster's objective is the root cause fixed, its key results are the
|
||||
failing jobs passing again, one per job.
|
||||
4. **Download artifacts per cluster.** Use the `kvb-ci-artifacts` skill for the
|
||||
mechanics — it covers per-job logs while a run is still in progress, which
|
||||
bundle to pull, and the grep hygiene that keeps a false positive from
|
||||
becoming a root cause. For each cluster, fetch every artifact of
|
||||
one representative job: reports, rescue diagnostics, inventories, container
|
||||
logs. Pull a second member's artifacts only when the representative's
|
||||
evidence does not explain the whole cluster. The job log usually names an
|
||||
artifact path and nothing more; the assertion text, the server-side
|
||||
exception and the state dumps are inside the artifact. Do this even when the
|
||||
log looks conclusive, because a log that explains the symptom rarely
|
||||
explains the cause.
|
||||
5. **One funnel per cluster.** Hand every cluster you claimed in step 3 to the
|
||||
`kvb-org-split` skill: one agent per cluster, working in its own worktree on its own
|
||||
branch created from the exact current HEAD, with that cluster as its only
|
||||
concern, the artifact paths of step 4 in its brief and a time cap for its
|
||||
interim report.
|
||||
Steps 6 to 8 run inside that sidetree, so no cluster's edits land under
|
||||
another cluster's work or under a running gate or deploy. Run clusters in
|
||||
parallel only when their fixes touch different files and the machine has
|
||||
room. A split cluster (step 7) gets a funnel of its own.
|
||||
6. **Dialectic per cluster.** For EACH cluster, invoke the `kvb-think-dialectic` skill:
|
||||
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.
|
||||
7. **Distinguish shared vs hidden causes.** A cluster is a hypothesis, not a
|
||||
proof: if the dialectic shows one cluster splitting into two causes, split it.
|
||||
When a job hides a second failure behind the first, keep going until the job
|
||||
is actually green, not just past the first error.
|
||||
8. **Fix at the root, through the funnel.** Apply the real fix for each proven
|
||||
root cause in the cluster's sidetree, with a test and a negative control as
|
||||
the `kvb-org-split` skill demands. The fix enters the main line only at the funnel's
|
||||
bar (at least 90 % fix confidence, under 5 % break risk) 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, every proposal, and
|
||||
every abandoned attempt to the same agents through the `kvb-org-organize` Peer protocol,
|
||||
then remove the sidetree.
|
||||
9. **Tune what ran out of time.** Only for a cluster whose jobs died on their
|
||||
time budget (timed out, or cancelled by the runner's limit), or when the
|
||||
operator asks: hand the job log that ran furthest to the `kvb-code-tune` skill. Its
|
||||
candidates run through funnels of their own, separate from the cluster's fix
|
||||
sidetree, and never delay or block the fix.
|
||||
10. **Follow the run.** While the run is still in progress, re-check it
|
||||
periodically and triage each newly failed job as it appears. The run is done
|
||||
only when it has finished and every failure has a verified fix.
|
||||
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
|
||||
|
||||
- Executed evidence beats reasoning: read the actual failing log line and the
|
||||
artifact, not the job title.
|
||||
- A derived source is not evidence. A conversation summary, your own earlier
|
||||
message, a cluster table you built ten minutes ago, or a subagent's report
|
||||
proves nothing on its own. Only the job log, the artifact, the code at the
|
||||
run's commit, and command output you just ran count. Quote them literally; if
|
||||
you cannot quote it, you do not know it.
|
||||
- Re-derive after a context compaction. A summary carried across a compaction
|
||||
boundary preserves your conclusions but not the evidence under them, and a
|
||||
cluster assignment that was wrong stays wrong while reading as settled fact.
|
||||
Before acting on any pre-compaction attribution, rebuild it from the job logs.
|
||||
- Classify each failure: a real defect (fix it), a flake or infra hiccup (prove
|
||||
it is transient before dismissing), or already-fixed-by-a-later-commit. For
|
||||
that last class the comparison is `<run-sha>..HEAD`, never the run's commit
|
||||
alone: the run cannot contain a fix that landed after it, so looking there
|
||||
can only ever confirm the failure. Name the commit that carries the fix.
|
||||
- Report a per-job verdict: root cause, the artifact path and line that prove
|
||||
it, the fix, the cluster's sidetree branch, and the funnel verdict (merged or
|
||||
proposal). A verdict that cites only the job log has not finished step 4.
|
||||
- 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 %, efficiency and speed ≤ 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.
|
||||
+64
-173
@@ -1,199 +1,90 @@
|
||||
---
|
||||
name: kvb-cleanup
|
||||
description: >
|
||||
Free disk space agents filled: ask peers, inventory /tmp, Docker and agent
|
||||
dirs, and delete only what the operator picks. Trigger on /kvb-cleanup, "aufräumen", "bereinigen", "platte voll", "no
|
||||
space left on device".
|
||||
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".
|
||||
---
|
||||
|
||||
Nothing is deleted before the operator has picked it. Every candidate carries
|
||||
its owner, its size, and its risk.
|
||||
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 resource chair
|
||||
## Step 0: the chair
|
||||
|
||||
Shared resources belong to the resource chair of the `kvb-org-organize` 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 `kvb-org-organize` resource
|
||||
watch already runs the configured low-risk cleanup at its stop threshold; this
|
||||
skill covers everything that needs the operator's pick.
|
||||
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: ask the peers
|
||||
## Step 1: inventory
|
||||
|
||||
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.
|
||||
Read-only, in parallel, with sizes, only in the agent write scope (Claude
|
||||
Code: `sandbox.filesystem.allowWrite`) and session scratch dirs, even where
|
||||
more is readable; never another user's files. Prefer the project's cleanup
|
||||
and teardown targets; obey rules forbidding raw commands.
|
||||
|
||||
Send each local peer one message (`SendMessage`) and go on with step 2 while
|
||||
they answer:
|
||||
- **/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.
|
||||
- **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): workdir, branch, artifacts, status and worker per
|
||||
`kvb-org-rasci` task.
|
||||
- **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).
|
||||
|
||||
```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.
|
||||
Also search your own worktrees and volumes for unused ones (branch merged or
|
||||
pushed, no uncommitted changes, stack not needed), list them with their size
|
||||
as deletion candidates, delete only your own finished items, and tell me what
|
||||
you removed.
|
||||
```
|
||||
## Step 2: ask the owners
|
||||
|
||||
## Step 2: inventory
|
||||
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.
|
||||
|
||||
Read-only, in parallel, sizes included:
|
||||
## Step 3: classify
|
||||
|
||||
- **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.
|
||||
- **Worktrees**: per worktree its size (`du -sh`), its 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.
|
||||
- **Other agent dirs**: package and tool caches (pip, npm, pre-commit, act,
|
||||
gh, buildx), Ansible and build temp dirs.
|
||||
- **Register**: `rasci.py artifacts` of the `kvb-org-artefacts` skill:
|
||||
workdir, branch and artifacts of every task in the `kvb-org-rasci` register,
|
||||
with its status and worker; `--ended` narrows it to the candidates.
|
||||
- **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.
|
||||
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 (`find -mtime +0` lists
|
||||
the older), `keep (recent, possibly in use)`; archive duty,
|
||||
`archive first`, never a plain delete. Else a candidate:
|
||||
|
||||
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. Listed in the register under a task that is not `done`, `stopped` or `stale`:
|
||||
`keep (task <task>)`. Under an ended task it is a candidate whose owner is
|
||||
the task's worker.
|
||||
3. A job running on it (deploy, gate, test, swarm run, build): `keep
|
||||
(running)`.
|
||||
4. Attributable to a peer that has not answered: `keep (no answer from
|
||||
<peer>)`. Silence is not consent.
|
||||
5. Archive duty: `archive first`.
|
||||
6. 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, clean worktrees whose HEAD is already in an integration line (ignored files such as `node_modules`, build output or evidence are lost, no commit) |
|
||||
| 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` |
|
||||
- **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:
|
||||
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`:
|
||||
|
||||
| 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`.
|
||||
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
|
||||
|
||||
- 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.
|
||||
- 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` skill's 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.
|
||||
- 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.
|
||||
- Archive-first items: copy out, verify the copy, then delete.
|
||||
- Drop every deleted item from its task as the `kvb-org-artefacts` skill
|
||||
says (`set <task> --drop-artifact <kind>:<ref>`), so `artifacts` stays true.
|
||||
- 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.
|
||||
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. 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.
|
||||
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.
|
||||
@@ -2,7 +2,7 @@
|
||||
name: kvb-coach-meditation
|
||||
description: >
|
||||
Short meditation with generative alpha-wave music, animated fractals and
|
||||
breathing cues, tuned by feedback. Trigger on /kvb-coach-meditation.
|
||||
breathing cues, tuned by feedback.
|
||||
---
|
||||
|
||||
A short session in the browser: `assets/meditation.html` plays generative
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
name: kvb-coach
|
||||
description: >
|
||||
Coach the operator's next step: psyche, body, sleep, food, own and
|
||||
delegable tasks. Trigger on /kvb-coach or "was soll ich tun".
|
||||
delegable tasks. Trigger on "was soll ich tun".
|
||||
---
|
||||
|
||||
Be the operator's coach: short, warm, concrete, with emojis, in the
|
||||
|
||||
@@ -3,41 +3,36 @@ name: kvb-code-no-comments
|
||||
description: >
|
||||
Purge explanatory comments from staged and unstaged changes. Run
|
||||
AUTOMATICALLY before every commit or stage request (commit, commit
|
||||
no-verify, stage next, "commite ..."); also on /kvb-code-no-comments or
|
||||
comment complaints.
|
||||
no-verify, stage next, "commite ..."); also on 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.
|
||||
On a commit or stage request, run this audit first, then 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:
|
||||
Audit the added lines of every staged and unstaged change (`git diff` plus
|
||||
`git diff --cached`) and delete every explanatory comment:
|
||||
|
||||
1. Delete: restated code, step narration, section banners, "Note that",
|
||||
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 `kvb-git-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.
|
||||
code, task name or commit message already carries. When in doubt, delete.
|
||||
2. Keep only parameter and exit-code docs, nocheck/noqa/shellcheck
|
||||
directives, and project-sanctioned exception markers naming a concrete
|
||||
trip-wire. A relabeled narration is not an exception; delete it.
|
||||
3. Block-comment languages are not line-oriented. In Jinja (`{# … #}`),
|
||||
C-like (`/* … */`), HTML (`<!-- … -->`) and their kin, a closing delimiter
|
||||
alone on its line looks like a comment line; deleted with the rest, it
|
||||
lets the opening delimiter swallow every live statement down to the next
|
||||
closer. Never strip these with a line filter: delete the whole block by
|
||||
hand, count the delimiters (`grep -c '{#'` against `grep -c '#}'`) and
|
||||
re-parse the file.
|
||||
4. Re-run the project's comment lint if one exists. Green does NOT prove the
|
||||
deletion safe: a swallowed block stays syntactically valid, so lint and
|
||||
parser pass over code that no longer executes. Diff against the pre-edit
|
||||
revision (`git show HEAD:<path>`) and confirm only comment lines left.
|
||||
5. Do not stage to run a check (the `kvb-git-commit-next` skill's step 4 says
|
||||
why). After the audit, re-stage exactly the files staged before it.
|
||||
6. Penalty: anything you wrote yourself goes into the report (file and
|
||||
count, no excuses), lowers your confidence numbers for the affected
|
||||
bundle, and is written 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,103 +1,80 @@
|
||||
---
|
||||
name: kvb-code-no-defaults
|
||||
description: >
|
||||
Forbid default values for parameters, settings and environment lookups;
|
||||
demand explicit values and fail loudly. Apply AUTOMATICALLY on every task
|
||||
that writes, edits, generates, refactors or reviews code, config, templates,
|
||||
schemas or infrastructure.
|
||||
Forbid defaults for parameters, settings and environment lookups;
|
||||
demand explicit values. Apply AUTOMATICALLY whenever code, config,
|
||||
templates or infrastructure are written, edited or reviewed.
|
||||
---
|
||||
|
||||
A default value is a guess, written at the point where the caller's intent is
|
||||
least known, and it turns a missing value into a plausible wrong answer instead
|
||||
of an error. Two callers then read the same call differently: one means "use the
|
||||
default", the other simply forgot. That ambiguity is the defect this rule exists
|
||||
to prevent.
|
||||
|
||||
Never write a substantive default. Require the value. When it is absent, fail
|
||||
immediately, loudly, and with a message that names the missing thing.
|
||||
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). Never
|
||||
write a substantive default: require the value; when it is absent, fail at
|
||||
once and loudly, naming the missing thing.
|
||||
|
||||
## The rule
|
||||
|
||||
1. Every parameter, option, setting, environment lookup, column, and template
|
||||
1. Every parameter, option, setting, environment lookup, column and template
|
||||
variable MUST be supplied explicitly by its caller.
|
||||
2. A missing value MUST abort at the earliest possible moment — argument
|
||||
parsing, config load, container start — never at the point of use, and never
|
||||
by proceeding with a substitute.
|
||||
2. A missing value MUST abort at the earliest moment (argument parsing,
|
||||
config load, container start), never at the point of use and never by
|
||||
proceeding with a substitute.
|
||||
3. The error MUST name the missing key and where to set it.
|
||||
4. Optionality, when a value genuinely may be absent, MUST be modelled as an
|
||||
explicit named case that the call site handles, never as a silent
|
||||
substitution. `None`/`null` handled in a visible branch is fine; a fallback
|
||||
baked into the accessor is not.
|
||||
4. Genuine optionality MUST be an explicit named case the call site handles:
|
||||
`None`/`null` in a visible branch is fine, a fallback baked into the
|
||||
accessor is not.
|
||||
|
||||
## The empty defaults are free
|
||||
## Free and justified defaults
|
||||
|
||||
`false`, `null` (`None`, `nil`, `undefined`), and the empty string `""` MAY be
|
||||
used as defaults without justification. They are the one class that carries no
|
||||
guess: they denote absence itself rather than inventing a value the caller might
|
||||
have meant. `${VAR:-}`, `d.get(k)`, `a ?? null`, and `def f(a=None)` are fine.
|
||||
`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 value — a number, a non-empty string, a non-empty list or
|
||||
map, an enum member, a path, a timeout, a retry count — MUST carry an inline
|
||||
comment stating why that specific value is correct for every caller that omits
|
||||
it. No comment, no default. Use whatever exception-comment form the project
|
||||
sanctions; where none exists, a plain comment on or directly above the default.
|
||||
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.
|
||||
|
||||
The comment is the point: it forces you to name the assumption, so a reviewer
|
||||
can disagree with it. "Sensible", "standard", and "usual" name nothing — if the
|
||||
justification does not survive being written down, the default does not survive
|
||||
either.
|
||||
"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.
|
||||
|
||||
## Banned constructs, by language
|
||||
## Detect
|
||||
|
||||
Each `Never` below means the default is a substantive value. The same construct
|
||||
with `false`, `null`, or `""` is permitted, and with any other value is permitted
|
||||
only when justified in a comment.
|
||||
Before presenting a diff or a review, run
|
||||
`${CLAUDE_SKILL_DIR}/scripts/defaults.py --repo <dir>` (`--staged`: index
|
||||
only; `--file <path>`: a whole untracked file). Each hit prints `file:line`,
|
||||
language, construct and replacement; exit 1 means hits. Judge each:
|
||||
|
||||
| Language | Never | Instead |
|
||||
| --- | --- | --- |
|
||||
| Bash | `${VAR:-x}`, `${VAR:=x}`, `: "${VAR:=x}"` | `: "${VAR:?set VAR in <file>}"` |
|
||||
| Python | `def f(a=1)`, `d.get(k, x)`, `getattr(o, n, x)`, `os.environ.get(k, x)`, `argparse(default=)` | required args, `d[k]`, `os.environ[k]`, `add_argument(required=True)` |
|
||||
| Jinja / Ansible | `\| default(x)`, `lookup('env', k) \| default('')` | `\| mandatory`, `assert` on the var |
|
||||
| JS / TS | `function f(a = 1)`, `const {a = 1} = o`, `a \|\| x`, `a ?? x` | required params, explicit `if (a === undefined) throw` |
|
||||
| Go | relying on zero values | constructor that validates every field |
|
||||
| Rust | `unwrap_or`, `unwrap_or_default`, `#[serde(default)]` | `ok_or(Error::MissingX)?`, `#[serde(deny_unknown_fields)]` |
|
||||
| Java / C# | optional parameters, `??`, `getOrDefault` | required constructor args, explicit null check that throws |
|
||||
| SQL | `DEFAULT` on a column | `NOT NULL` and supply it in every insert |
|
||||
| Make | `VAR ?= x` | `VAR := $(or $(VAR),$(error VAR is required))` |
|
||||
| Docker / Compose | `${VAR:-x}`, `ENV` placeholders standing in for config | `${VAR:?VAR is required}` |
|
||||
| Terraform / Helm | `variable { default = }`, `default` in `values.yaml` | no default; fail on the missing input |
|
||||
- **Real default**: rewrite it as the 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.
|
||||
|
||||
Check by hand 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? If the
|
||||
answer is anything other than "it fails and says so", rewrite it.
|
||||
|
||||
While editing, treat every `default`, `?:`, `??`, `||`, `:-`, `:=`, `or`,
|
||||
`fallback`, and `getOrElse` in a value-resolution position as a finding unless
|
||||
its substitute is `false`, `null` or `""`, or a comment already justifies it.
|
||||
Grep your own diff for them before presenting it.
|
||||
|
||||
Pushing a default one layer up is not a fix: a caller that passes a constant it
|
||||
invented has only moved the guess. Follow the value to its true owner —
|
||||
the operator, the inventory, the deployment config — and require it there.
|
||||
|
||||
Removing an existing default is a behaviour change. Do not do it silently as a
|
||||
drive-by: name it, and let the operator decide whether it lands in this change
|
||||
or its own.
|
||||
|
||||
## What does not count as a justification
|
||||
|
||||
"It is conventional", "every other project does it", "the value is obvious",
|
||||
and "it keeps the signature short" justify nothing — they assert that someone
|
||||
else made the choice, which is the ambiguity this rule exists to remove. A valid
|
||||
justification names why this value is right for a caller who says nothing.
|
||||
|
||||
An operator asking for a specific default, in this conversation, for this value,
|
||||
settles it without a comment.
|
||||
- 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.
|
||||
|
||||
## Penalty
|
||||
|
||||
If this audit finds an unjustified default you wrote yourself: say so in the
|
||||
report, with file and count and no excuses; lower the confidence numbers for the affected
|
||||
bundle; and write the incident to persistent agent memory, recording the date
|
||||
and the construct you reached for, so the calibration survives the session.
|
||||
An unjustified default you wrote: report it with file and count, no excuses;
|
||||
lower the bundle's confidence numbers; write the date and the construct to
|
||||
persistent agent memory so the calibration survives the session.
|
||||
+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[key]" if owner.endswith("environ") else f"{owner}[key]"
|
||||
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}: not parseable as 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())
|
||||
@@ -1,106 +1,82 @@
|
||||
---
|
||||
name: kvb-code-refactor
|
||||
description: >
|
||||
Refactor every uncommitted file (staged, unstaged, untracked) up to the
|
||||
project's standards without changing behaviour. Trigger on /kvb-code-refactor or when
|
||||
the operator asks to clean up, tidy or polish the current changes.
|
||||
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, not a
|
||||
cleanup.
|
||||
stays identical: a refactor that changes what the code does is a bug.
|
||||
|
||||
## Step 1: fix the file set
|
||||
|
||||
Read the set from git, never from memory of what was edited:
|
||||
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.
|
||||
|
||||
```bash
|
||||
git status --porcelain
|
||||
```
|
||||
|
||||
Take modified, added, renamed and untracked files. Drop deleted files, binaries,
|
||||
lockfiles, vendored trees and generated output. Restate the resulting list in
|
||||
one line before touching anything; if it is empty, say so and stop.
|
||||
|
||||
Never `git stash` and never revert a working-tree file to compare - other agents
|
||||
and the operator share this tree. Read the committed version with
|
||||
`git show HEAD:<path>` into a temporary file instead.
|
||||
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
|
||||
|
||||
A refactor is only provably behaviour-preserving if something failed before it
|
||||
and passes after it. Before editing, check what covers the change set:
|
||||
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.
|
||||
|
||||
- **Coverage exists**: run it now and record the green baseline. A suite that
|
||||
was already red gets reported, not refactored around.
|
||||
- **No coverage and the code is testable logic**: write the 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.
|
||||
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, then apply the standards that the surrounding code
|
||||
and the project's own config already establish (formatter config, linter rules,
|
||||
existing idioms) over any generic preference of your own.
|
||||
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.
|
||||
|
||||
Four principles govern the result:
|
||||
- **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**: invoke the `kvb-code-no-comments` skill.
|
||||
|
||||
- **SRP**: one file, one responsibility; one function, one reason to change. A
|
||||
unit that needs an "and" to describe it is two units.
|
||||
- **SPOT**: every fact - a constant, a path, a schema, a rule - lives in exactly
|
||||
one place and 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**: the same logic is written once. A literal repeated three times is a
|
||||
missing constant.
|
||||
|
||||
Concretely:
|
||||
|
||||
- **File size**: no file containing program code exceeds 250 lines. Split the
|
||||
overflow along the SRP seam - it is the symptom, not the cause. Description
|
||||
and markup languages are exempt (Markdown, YAML, JSON, HTML, translation and
|
||||
data files), as is the rare file the language or framework genuinely forbids
|
||||
splitting: name that reason in the report, "it was easier" does not qualify.
|
||||
- **Duplication**: fold repeated logic into the helper that already exists;
|
||||
create a new one only when two or more call sites need it now.
|
||||
- **Dead weight**: unused imports, variables, parameters, branches that cannot
|
||||
be reached, flags with a single value, abstractions with one implementation.
|
||||
- **Naming**: names that state what a thing is, matching the file's convention.
|
||||
- **Shape**: split a function that needs a scroll to read; collapse nesting by
|
||||
returning early; replace magic values with a named constant at its use site.
|
||||
- **Errors**: never widen a caught exception, never swallow one, never remove a
|
||||
validation or a security check to make the code shorter.
|
||||
- **Comments**: invoke the `kvb-code-no-comments` skill rather than reimplementing its
|
||||
rules here.
|
||||
|
||||
Do not reformat regions you have no substantive fix for - cosmetic churn buries
|
||||
the real change in the diff. If a file's problem is architectural and cannot be
|
||||
fixed inside the change set, report it instead of half-doing it.
|
||||
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 and require the same green result as the baseline.
|
||||
- Never fold a bug fix, a feature, or a dependency change into a refactor.
|
||||
Found a real bug? Name it in the report and leave it for a separate change.
|
||||
- Run the cheap checks the project already has for the touched files (formatter,
|
||||
linter, the module's own unit tests). Do not start a full suite, a build, or a
|
||||
deploy on your own initiative - propose it and let the operator trigger it.
|
||||
- State plainly what was verified and what was not. An unverified refactor is
|
||||
reported as unverified.
|
||||
- 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 naming what
|
||||
was deliberately left alone (architectural findings, bugs, files skipped) and
|
||||
the exact command the operator can run to validate. Do not commit unless the
|
||||
operator asked for a commit.
|
||||
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,9 +1,9 @@
|
||||
---
|
||||
name: kvb-code-scope-guard
|
||||
description: >
|
||||
Deliver exactly what was asked and nothing more. Trigger on every task that
|
||||
creates files, commands or commits, on autonomous runs and hand-overs, and
|
||||
when asked why something unrequested exists.
|
||||
Deliver exactly what was asked. Trigger on every task creating files,
|
||||
commands or commits, on autonomous runs and hand-overs, and when asked why
|
||||
something unrequested exists.
|
||||
---
|
||||
|
||||
The requested scope is the deliverable. A problem you noticed behind the
|
||||
@@ -18,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
|
||||
carries their own edits and their local config, and sweeping those into
|
||||
your bundle makes them undo your work by hand.
|
||||
3. An adjacent defect gets reported in one line, not fixed — unless it blocks
|
||||
3. An adjacent defect gets reported in one line, not fixed, unless it blocks
|
||||
the request. Fixing it quietly puts an unreviewed change in someone else's
|
||||
commit.
|
||||
4. An instruction to hand something over is complete on its own. Print the
|
||||
|
||||
+41
-106
@@ -2,129 +2,64 @@
|
||||
name: kvb-code-test
|
||||
description: >
|
||||
Run the Makefile test targets the operator picks and report; fix mode loops
|
||||
until green. Trigger on /kvb-code-test [--fix] or asked to run or fix the
|
||||
tests.
|
||||
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
|
||||
exists: the target carries the project's env vars, build dependencies and
|
||||
container setup.
|
||||
|
||||
## Step 1: find the targets
|
||||
|
||||
From the repository root:
|
||||
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).
|
||||
|
||||
```bash
|
||||
grep -nE '^test[A-Za-z0-9_.-]*:' Makefile
|
||||
```
|
||||
|
||||
Read the matched rules plus their prerequisites, so the selection list can say
|
||||
what each one actually does (delegated script, container build, sub-targets).
|
||||
|
||||
Edge cases, handled explicitly rather than guessed around:
|
||||
|
||||
- **No Makefile**: say so in one line, name the test runner the project does
|
||||
use (from `pyproject.toml`, `package.json`, `tox.ini`, CI workflow), and ask
|
||||
before running anything.
|
||||
- **No `test*` target**: report the targets that do exist and stop.
|
||||
- **Included makefiles** (`include foo.mk`): grep those too.
|
||||
- **Aggregate targets**: a target whose recipe is only other test targets (e.g.
|
||||
`test: test-unit test-integration`) is the "run everything" entry - mark it as
|
||||
such in the list, do not expand it into its parts silently.
|
||||
- **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
|
||||
|
||||
Ask with `AskUserQuestion`, `multiSelect: true`, one question. Options are the
|
||||
discovered targets, each with a one-line description of what it runs. Order the
|
||||
aggregate/full target first and label it as the complete suite.
|
||||
|
||||
The tool takes at most 4 options. With more targets than that:
|
||||
|
||||
- print the **complete** discovered list as text first, one line per target, so
|
||||
nothing is hidden, then
|
||||
- offer the 4 most useful entries (aggregate first, then the ones matching the
|
||||
operator's stated intent or the files currently uncommitted), and note that
|
||||
"Other" accepts any target name from the printed list.
|
||||
|
||||
Never silently truncate. If the operator already named a target in their
|
||||
request, skip the question and run it.
|
||||
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
|
||||
|
||||
Run the selected targets in the order the operator listed them, one `make`
|
||||
invocation per target, each as its own command so a failure is attributable:
|
||||
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.
|
||||
|
||||
```bash
|
||||
make <target>
|
||||
```
|
||||
|
||||
Do not add `-k`, do not reorder, do not substitute a faster equivalent. If a
|
||||
target needs a long timeout (container builds, e2e), set it on the Bash call
|
||||
rather than backgrounding blindly.
|
||||
|
||||
Stop after the first failing target unless the operator asked for all of them -
|
||||
a later suite running against a broken build produces noise, not information.
|
||||
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/fail plus the failing test names and the exact error output,
|
||||
quoted verbatim. Never paraphrase a failure. Then one line with the exact
|
||||
command to reproduce the failure alone.
|
||||
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.
|
||||
|
||||
Do not fix what failed unless the operator asks - report first. A request to
|
||||
make the tests pass, fix them, or get the suite green (`/kvb-code-test --fix`) switches
|
||||
to fix mode.
|
||||
|
||||
## 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. 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-no-defaults` and `kvb-code-no-comments` 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.
|
||||
|
||||
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. State confidence per the `kvb-check-confidence` skill. Do
|
||||
not commit unless the operator asked for a commit.
|
||||
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-no-defaults` and
|
||||
`kvb-code-no-comments` 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.
|
||||
@@ -2,7 +2,6 @@
|
||||
name: kvb-code-transcend
|
||||
description: >
|
||||
Carry the session's ad-hoc tests and scripts into the current repo.
|
||||
Trigger on /kvb-code-transcend.
|
||||
---
|
||||
|
||||
The test the agent ran from its scratchpad, the probe that proved a cause, the
|
||||
@@ -50,9 +49,11 @@ single-select `Write it` / `Revise it` / `Discard` with the drafted diff.
|
||||
owner through the `kvb-org-coordinate` skill.
|
||||
- Every carried test fails without the code it guards: show the negative
|
||||
control once.
|
||||
- Run the repository's tests only on the resource chair's grant (the
|
||||
`kvb-org-organize` skill); leave the result uncommitted for the
|
||||
`kvb-git-commit-next` skill.
|
||||
- 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
|
||||
|
||||
+44
-180
@@ -2,44 +2,23 @@
|
||||
name: kvb-code-tune
|
||||
description: >
|
||||
Speed up CI, deploys, builds and hot paths without losing logic or
|
||||
coverage. Trigger on /kvb-code-tune, a job hitting its time budget, or
|
||||
asked where time goes.
|
||||
coverage. Trigger on a job hitting its time budget or when asked where
|
||||
time goes.
|
||||
---
|
||||
|
||||
# Tune
|
||||
|
||||
One rule outranks every optimization: **nothing that reduces logic, coverage or
|
||||
correctness counts as a speed-up.**
|
||||
|
||||
## Once per session: listen first
|
||||
## Session limits
|
||||
|
||||
The first time this skill runs in a session, invoke the `kvb-think-listen`
|
||||
skill before measuring anything, and ask three questions through the host's
|
||||
structured question mechanism. Offer each as a checklist (multi-select) with
|
||||
suggestions, and allow free text:
|
||||
|
||||
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 (see Always check).
|
||||
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 90 % confidence and
|
||||
under 5 % break risk); a time cap per delegated agent (default 60 minutes).
|
||||
|
||||
Keep the answers for the rest of the session and do not ask again. Work only
|
||||
inside them; a candidate outside them becomes a proposal.
|
||||
|
||||
When another skill (kvb-ci-triage, kvb-ci-observe) or an autonomous run invoked
|
||||
this skill, skip
|
||||
the questions: examine the handed-over log, apply the defaults above, and ask
|
||||
only when an operator-only fact blocks.
|
||||
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
|
||||
|
||||
@@ -49,164 +28,51 @@ only when an operator-only fact blocks.
|
||||
- Trading error semantics, credential masking, concurrency safety or a lint's
|
||||
intent for speed. A per-item failure stays loud and names its item.
|
||||
|
||||
A change that makes the clock smaller by testing less is reported to the
|
||||
operator as a coverage cut for them to decide, never applied as a tuning.
|
||||
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 what has not been measured, and read numbers from logs, not by eye.
|
||||
|
||||
- **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.
|
||||
- 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
|
||||
to recover a lost number.
|
||||
- **Rank by fixed cost x count.** Measure the per-call fixed cost (one
|
||||
application console boot, one package-manager call, one container exec) and
|
||||
multiply by how often the run pays it. A one-second boot paid twenty-eight
|
||||
times is a candidate; a sixty-second download paid once is not.
|
||||
- **The baseline is the newest log of the current code.** An older log may
|
||||
still carry the step a previous optimization replaced, and a saving measured
|
||||
against it is fiction.
|
||||
|
||||
## Invalid comparisons
|
||||
|
||||
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.
|
||||
|
||||
## Before you optimize
|
||||
|
||||
- **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.
|
||||
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
|
||||
|
||||
A cost cluster is one place the time goes: one step, one hot path, one
|
||||
repeated call, named by its log line and its number. Before investigating or
|
||||
implementing a cluster, run the `kvb-org-organize` skill's Peer protocol for it with
|
||||
every agent session on this machine, not only the branch lineage.
|
||||
|
||||
- **The first to identify a cluster works it.** "First" is the `kvb-org-organize`
|
||||
claim `cluster/<slug>`, whose `--work` names the cluster's log line and
|
||||
number; between machines, the earliest announcement by message time. A
|
||||
claim without a measurement does not count. In the `kvb-org-rasci` register
|
||||
the cluster's key result is the measured target (`<step> from <before> to
|
||||
under <after>`), met only by the re-measurement.
|
||||
- **Someone was earlier:** send them your measurements and findings, then move
|
||||
on to the next cluster. Never build a second optimization of the same
|
||||
cluster.
|
||||
- **A claim ends** when its holder reports the result, a proposal or an
|
||||
abandoned attempt and releases it, or when the operator reassigns it.
|
||||
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
|
||||
|
||||
Every run examines these two on each measured hot path, whatever the operator
|
||||
picked in the first question, and lists the result in the output table, a
|
||||
clean result included:
|
||||
On each measured hot path, whatever the operator picked, listed in the output
|
||||
even when clean:
|
||||
|
||||
- **Language and framework**: whether the hot path's language and framework
|
||||
are the most efficient fit for its work (see Language and framework).
|
||||
- **Unnecessary recursion**: 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), and 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.
|
||||
|
||||
## 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.
|
||||
|
||||
## Language and framework
|
||||
|
||||
Check the language and the framework of every measured hot path, for example
|
||||
replacing a Python hot path with Rust, or a heavy framework layer with 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.
|
||||
|
||||
## Delegating an optimization
|
||||
|
||||
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". 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.
|
||||
- **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, and name the single biggest cost with its log line and number.
|
||||
2. Coordinate the cluster with the other agents; work it only when you
|
||||
identified it first, otherwise hand over your findings and take the next.
|
||||
Then form one candidate for it, after the branch search and the floor check.
|
||||
3. Harden it with the `kvb-think-dialectic` skill.
|
||||
4. Funnel it through a sidetree; merge what reaches the bar, submit the rest as
|
||||
proposals. Re-measure from the same source under the same conditions and
|
||||
report a before and an after number.
|
||||
5. Repeat until the next candidate is worth less than the run that measures it.
|
||||
6. 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.
|
||||
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
|
||||
|
||||
@@ -214,7 +80,5 @@ One table, biggest saving first:
|
||||
|
||||
| Candidate | Before | After | Source | Verdict |
|
||||
|
||||
Rejected candidates stay in the table with the reason they fell: below floor,
|
||||
coverage cut, outside the session's limits, invalid comparison, made moot by
|
||||
another branch, or cap reached without a measured result. Proposals carry what
|
||||
would close their gap.
|
||||
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 %, efficiency and speed ≤ 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.
|
||||
@@ -1,67 +1,67 @@
|
||||
---
|
||||
name: kvb-create-plan
|
||||
description: >
|
||||
Turn a request into an objective-oriented plan and hand it to the kvb-org-robot
|
||||
skill. Trigger on /kvb-create-plan or when the operator asks for a plan, breakdown or
|
||||
strategy before work starts.
|
||||
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 that lists activities instead of objectives cannot be
|
||||
verified, and an unverifiable plan cannot be handed to a robot.
|
||||
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
|
||||
all pinned, then reflect the understanding back. This is the only point in
|
||||
the run where questions are allowed, so leave nothing open here.
|
||||
2. **Pin the objective.** State the end state in one sentence, as a condition
|
||||
that is either true or false, never as an activity. Name the command or
|
||||
observation that proves it.
|
||||
3. **Inspect before decomposing.** Read the code, config, tests, and history the
|
||||
objective touches. Every plan item must rest on something you have seen, not
|
||||
on an assumption about how the project works.
|
||||
4. **Decompose into sub-objectives.** Each item gets: the outcome it reaches,
|
||||
the verification that proves it, and the items it depends on. Split an item
|
||||
whenever it needs more than one verification. Order by dependency and mark
|
||||
the items that are independent, so they can run in parallel.
|
||||
5. **Resolve the open decisions now.** Any root cause, design choice, or
|
||||
trade-off the plan rests on gets settled before execution, escalating to 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.** Enter the objective in the
|
||||
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, and mirror the sub-objectives into the harness's own todo tracking,
|
||||
one entry per item, phrased as the outcome. Keep 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 here: the plan is the deliverable of this step.
|
||||
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 whatever dry-run or
|
||||
syntax check the project offers. This step ends only when the whole plan
|
||||
exists on disk, with no sub-objective left unwritten.
|
||||
9. **Then iterate under `kvb-org-robot`.** With the static implementation complete,
|
||||
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 instead of assuming it, and
|
||||
reports the items that fall outside its clearance for the operator to run.
|
||||
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. If you cannot name what proves it, the item is
|
||||
still a wish, not a plan.
|
||||
- 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 belong in the out-of-scope
|
||||
list, not in the plan.
|
||||
- Static before dynamic: never start the robot loop on a half-written plan. A
|
||||
deploy that fails on code you had not written yet costs a full cycle and
|
||||
proves nothing.
|
||||
- Re-plan on contradicted evidence: when execution disproves an assumption the
|
||||
plan rests on, revise the plan instead of forcing the original steps through.
|
||||
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.
|
||||
@@ -2,8 +2,8 @@
|
||||
name: kvb-create-requirement
|
||||
description: >
|
||||
Write a requirement: user story, decisions, Mermaid approach, acceptance
|
||||
criteria, tests. Trigger on /kvb-create-requirement, "requirement
|
||||
schreiben", or a kvb-think-gaps pick.
|
||||
criteria, tests. Trigger on "requirement schreiben" or a kvb-think-gaps
|
||||
pick.
|
||||
---
|
||||
|
||||
Writes requirements, never implementations. Nothing touches disk before the
|
||||
@@ -70,8 +70,11 @@ order:
|
||||
|
||||
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 on the
|
||||
resource chair's grant (the `kvb-org-organize` skill).
|
||||
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
|
||||
|
||||
|
||||
+59
-119
@@ -2,150 +2,90 @@
|
||||
name: kvb-dream
|
||||
description: >
|
||||
Process the last 24 hours of every repository and session: memory,
|
||||
improvements, kvb-optimize, cleanups. Trigger on /kvb-dream or a request
|
||||
to learn from recent work.
|
||||
improvements, kvb-optimize, cleanups. Trigger on a request to learn from
|
||||
recent work.
|
||||
---
|
||||
|
||||
Sleep on the work: replay what happened, separate what held from what broke,
|
||||
keep only the conclusions that survive an attack, then optimize and clean up.
|
||||
Dreaming processes everything. A dream that flatters the dreamer is worthless —
|
||||
an unverified success counts as unknown, not as a win.
|
||||
Replay what happened, separate what held from what broke, keep only
|
||||
conclusions that survive an attack, then optimize and clean up.
|
||||
|
||||
## Objective
|
||||
|
||||
The dream exists to raise the confidence of every future run 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
|
||||
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: the
|
||||
repositories named by its session transcripts (working directories and
|
||||
worktrees), by the workdirs of its `kvb-org-rasci` register tasks, and the
|
||||
current one. The default window is the last 24 hours — the operator may widen
|
||||
it (`/kvb-dream 7d`). State the repositories and the window in one table before
|
||||
you start. Each repository is recalled and scored on its own; a finding goes
|
||||
into the memory of the project it concerns, never mixed with another's.
|
||||
|
||||
Run only when the operator asks. Two hints point at it, neither is a trigger:
|
||||
`hooks/reminder.sh` tells each agent at most once every twelve hours (stamped
|
||||
per session in `~/.claude/.dream-stamps/`), and the `kvb-dream-notify.timer`
|
||||
user timer shows a GNOME notification daily at 17:00 local time, catching up
|
||||
once after a missed run. Print the hint and continue with the actual request.
|
||||
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
|
||||
|
||||
Gather the window's actual record per repository, not your recollection of it:
|
||||
The actual record per repository, not your recollection:
|
||||
|
||||
- **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.
|
||||
- **Register**: the `kvb-org-rasci` register: `rasci.py tools` ranks the
|
||||
scripts and tools the window's tasks ran, the refactoring candidates;
|
||||
`get <task>` shows a task's objective, roles, workdir and artifacts.
|
||||
- **Memory**: each 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.
|
||||
- **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
|
||||
|
||||
Classify every action in the window, each with its evidence (`sha`, `file:line`,
|
||||
run id):
|
||||
Every action, with 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.
|
||||
- **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.
|
||||
|
||||
## Step 3: hypothesize
|
||||
## Steps 3 to 6: findings
|
||||
|
||||
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 `kvb-think-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 the memory directory of the project they concern: 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 the affected 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.
|
||||
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
|
||||
|
||||
With the dialectic done and memory written, run the `kvb-optimize` skill and
|
||||
hand it this run's findings as its evidence, so none of its skills
|
||||
re-collects them.
|
||||
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
|
||||
|
||||
Run the `kvb-cleanup` skill for the disk, `/tmp`, Docker and agent
|
||||
directories the window filled, with the artifacts of the window's finished
|
||||
register tasks (`rasci.py artifacts --ended`) as its candidates, then the
|
||||
`kvb-git-branch-gc` skill for the merged branches and worktrees of every
|
||||
repository in scope.
|
||||
|
||||
Every skill in steps 7 and 8 keeps its own confirmation steps; the dream never
|
||||
writes, deletes or commits on their behalf. Close the run by refreshing this
|
||||
agent's reminder stamp, so its next hint is twelve hours out:
|
||||
`date +%s > ~/.claude/.dream-stamps/"$CLAUDE_CODE_SESSION_ID"`.
|
||||
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 the `kvb-git-branch-gc` skill for every
|
||||
in-scope repository's merged branches and worktrees. 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
|
||||
|
||||
- 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" per
|
||||
repository and skip it. 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.
|
||||
- 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.
|
||||
@@ -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.
|
||||
@@ -2,8 +2,8 @@
|
||||
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 /kvb-git-branch-gc, "branches
|
||||
aufräumen", "worktrees aufräumen".
|
||||
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
|
||||
@@ -17,7 +17,7 @@ and nothing is removed before the operator's pick in this run.
|
||||
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,
|
||||
send it what, where and how long, and wait for the grant; the prune never
|
||||
request the grant (what, where, how long) and wait for it; the prune never
|
||||
runs during a peer's build.
|
||||
|
||||
## Step 1: dry run
|
||||
@@ -40,22 +40,19 @@ before it: gc fetches without auto-maintenance, while a plain fetch on git
|
||||
|
||||
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 inside a sandbox: it saw no
|
||||
`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`. For every candidate whose branch or worktree a peer's
|
||||
claim names, send that peer one message (Peer protocol) and wait up to 5
|
||||
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
|
||||
|
||||
One table:
|
||||
|
||||
| Repo | Branch or worktree | Merged into | Size | Actions | Note |
|
||||
|
||||
Then ask with `AskUserQuestion`:
|
||||
@@ -69,40 +66,19 @@ Then ask with `AskUserQuestion`:
|
||||
|
||||
## Step 4: apply
|
||||
|
||||
1. Re-read `council.py list` 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` in that file: `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 that
|
||||
lists 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.
|
||||
Read `apply.md` next to this file once the operator picked `Apply`.
|
||||
|
||||
## Step 5: unmerged and dirty
|
||||
|
||||
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.
|
||||
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. Report the release to the chair, tell
|
||||
each peer whose line an item touched what went, and release the claim.
|
||||
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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -1,16 +1,15 @@
|
||||
---
|
||||
name: kvb-git-changelog
|
||||
description: >
|
||||
Write the release message for `pkgmgr release`;
|
||||
prints only, never edits, tags or releases. Trigger on
|
||||
/kvb-git-changelog, "release message", "changelog entry", before cutting a release,
|
||||
or on pkgmgr's markdown-lint rejection.
|
||||
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.
|
||||
---
|
||||
|
||||
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.
|
||||
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
|
||||
|
||||
@@ -20,124 +19,65 @@ 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
|
||||
- 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 the subject does not say what changed for the user.
|
||||
- When `gh` is available, read the CI of the previous tag's commit and of HEAD,
|
||||
every workflow (`gh run list --commit <sha> --json
|
||||
workflowName,status,conclusion`), not one picked by `-L 1`.
|
||||
- Previous tag: a workflow that finished as a failure means that version was
|
||||
never published: say so under *Upgrade notes* and carry its upgrade notes
|
||||
forward to this release.
|
||||
- HEAD: a workflow that finished as a failure means pkgmgr would tag a
|
||||
release that never goes stable, because its CI gate runs after the tag.
|
||||
Queued, running or missing runs are normal; report only the failure, in
|
||||
step 5.
|
||||
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
|
||||
|
||||
| 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.
|
||||
`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
|
||||
|
||||
```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,
|
||||
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. **Plain hygiene**: no trailing whitespace (MD009), no double blank lines
|
||||
(MD012), no bare URLs: wrap them as `<https://...>` (MD034).
|
||||
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
|
||||
|
||||
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.
|
||||
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. 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).
|
||||
4. Only when a workflow on HEAD finished as a failure: one line naming it and
|
||||
its run, and the advice not to release before it is green.
|
||||
2. `Bump: patch | minor | major` and why (breaking: major, `feat`: minor,
|
||||
otherwise patch).
|
||||
3. The lint result (`markdownlint-cli2: 0 errors` or the manual check run).
|
||||
4. Only when a workflow on HEAD finished as a failure: its name and run, and
|
||||
the advice not to release before it is green.
|
||||
|
||||
Then stop. Never edit CHANGELOG.md or a version file, never run
|
||||
`pkgmgr release`, never tag: the operator releases.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Changelog: why each rule holds
|
||||
|
||||
pkgmgr writes one text to the CHANGELOG.md entry, the annotated release tag,
|
||||
`debian/changelog` and the RPM `%changelog`; every rule keeps one of them from
|
||||
breaking or mangling it.
|
||||
|
||||
- **HEAD failure**: pkgmgr's CI gate runs after the tag, so a workflow that
|
||||
failed on HEAD means pkgmgr would tag a release that never goes stable.
|
||||
- **Plain section labels**: pkgmgr creates the tag with `git tag -a -m`,
|
||||
whose default `strip` cleanup deletes every line starting with `#`. A
|
||||
bold-only line fails MD036.
|
||||
- **No version heading**: pkgmgr adds `## [X.Y.Z] - YYYY-MM-DD` itself.
|
||||
- **One line per bullet**: RPM turns every non-empty line into its own `-`
|
||||
entry and Debian indents each line, so a continuation line becomes a
|
||||
separate, broken entry.
|
||||
- **78 characters**: lines stay within 80 after Debian's two-space indent
|
||||
and RPM's two-character `-` prefix.
|
||||
- **The existing list marker**: MD004's default *consistent* style takes the
|
||||
marker of the first list in the file, and pkgmgr puts the new entry on top,
|
||||
so a marker that differs from the older entries turns every older list
|
||||
into a finding.
|
||||
- **No backticks**: pkgmgr rewrites inline code to *italic* in all four
|
||||
targets, and a fenced block fails its fallback lint.
|
||||
- **The wrapped lint**: pkgmgr wraps the message as `# Changelog`, blank
|
||||
line, `## [X.Y.Z] - date`, blank line, message, then runs
|
||||
`markdownlint-cli2` with the target repository's own config; hence the
|
||||
N - 4 line mapping.
|
||||
- **`--no-globs`**: it keeps the config's own globs from linting the whole
|
||||
tree too.
|
||||
- **The whole-file lint**: the standalone check cannot see rules that
|
||||
compare the entry with the rest of the file (MD004 *consistent*, MD024
|
||||
duplicate headings), but the repository's own markdown lint and pre-push
|
||||
gate do.
|
||||
- **The manual check**: it covers what pkgmgr's fallback checks, plus the
|
||||
length limit.
|
||||
|
||||
A message that follows every rule:
|
||||
|
||||
```text
|
||||
Fixed:
|
||||
|
||||
- Backup: rsync -a stored no xattrs, ACLs or hardlinks; now -aHAX
|
||||
- Restore: *--empty* no longer aborts on a database with an extension
|
||||
|
||||
Upgrade notes:
|
||||
|
||||
- First backup re-copies every attribute-carrying volume once in full
|
||||
```
|
||||
@@ -0,0 +1,16 @@
|
||||
# Changelog: special cases
|
||||
|
||||
- **No `vX.Y.Z` tag reachable from HEAD**: the range is the whole history;
|
||||
say so.
|
||||
- **A workflow of the previous tag's commit finished as a failure**: that
|
||||
version was never published. Say so under *Upgrade notes* and carry its
|
||||
upgrade notes forward to this release.
|
||||
- **Nothing user-visible remains** (a test- or CI-only release): keep those
|
||||
commits under Fixed with an area prefix (`Tests:`, `CI:`) so the entry is
|
||||
not empty.
|
||||
- **No `markdownlint-cli2`**: check by hand what pkgmgr's fallback checks
|
||||
plus the length limit: no line over the limit, no trailing whitespace, no
|
||||
backtick, no double blank line. Name that manual check in the output.
|
||||
- **The operator pastes pkgmgr's rejection** (`changelog entry:7:81 error
|
||||
MD013/line-length ...`): apply the same N - 4 mapping to the message they
|
||||
used, shorten only the lines it names, and lint again.
|
||||
@@ -2,36 +2,43 @@
|
||||
name: kvb-git-commit-next
|
||||
description: >
|
||||
Search every branch for the staged fix, commit it with --no-verify, then
|
||||
stage and brief the next bundle. Trigger on /kvb-git-commit-next or a
|
||||
request to commit and stage next.
|
||||
stage and brief the next bundle. Trigger on a request to commit and stage
|
||||
next.
|
||||
---
|
||||
|
||||
1. `git fetch --all --prune --tags`, then hunt the staged fix across every
|
||||
ref with the kvb-git-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 kvb-think-dialectic skill on the
|
||||
two candidates (staged version as thesis, foreign commit as antithesis)
|
||||
until one survives, then present sha, branch, both diffs, the verdict and
|
||||
the recommended move (keep staged / take theirs / combine). Wait for the
|
||||
operator's decision; do not commit.
|
||||
1. Describe the staged bundle in one line, so a wrong search intent is
|
||||
visible. Then `git fetch --all --prune --tags` and hunt the staged fix
|
||||
across every ref with the kvb-git-merge-fix skill's `find_fix.py` (by
|
||||
`--grep`, by `-S`, by `--path`). Only the search runs here: never
|
||||
cherry-pick or merge, never touch the staging index or the tree.
|
||||
2. A similar fix on another branch is a stop: run the kvb-think-dialectic
|
||||
skill on the two candidates (staged version as thesis, foreign commit as
|
||||
antithesis) until one survives, then present sha, branch, both diffs, the
|
||||
verdict and the recommended move (keep staged / take theirs / combine).
|
||||
Wait for the operator's decision; do not commit.
|
||||
3. Nothing similar found, or the operator picked the staged version: run the
|
||||
kvb-git-commit-no-verify skill on the currently staged changes (kvb-code-no-comments
|
||||
audit and kvb-check-legal check first, then its four-line block of
|
||||
worktree path, branch, fix confidence and break risk, 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 kvb-think-explain skill (what the changes do
|
||||
and how the pieces interact, file:line-anchored) followed by the
|
||||
kvb-check-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.
|
||||
kvb-git-commit-no-verify skill on the currently staged changes
|
||||
(kvb-code-no-comments audit and kvb-check-legal check first, then the
|
||||
full staged diff and its score block of 📁 worktree, 🌿 branch,
|
||||
📊 diff stat, score line and gate line, then `git commit --no-verify`,
|
||||
exact staging index only). Its approval-prompt duty is binding: the commit
|
||||
call's approval prompt names worktree, branch, diff stat and the score
|
||||
values, or the call is not sent. Gate exit 1 is shown and the operator's
|
||||
word decides; exit 3 asks the operator again first, naming the cause.
|
||||
4. Group the remaining uncommitted changes into logical bundles (one concern
|
||||
per bundle: same failure class, mechanism or role) and stage the next one;
|
||||
never mix concerns to save a commit. Staging happens here and nowhere
|
||||
else: never stage merely to run a lint or a test. Checks that enumerate
|
||||
through `git ls-files` already see tracked, modified files and read the
|
||||
worktree; only an untracked NEW file needs `git add` to become visible.
|
||||
Path-scope every `git add` to the bundle: `git add -A` sweeps up the
|
||||
operator's own edits and local config. A file that carries two bundles is
|
||||
staged by line: `${CLAUDE_SKILL_DIR}/scripts/stage_hunks.py --repo
|
||||
<worktree> --file <path> --match <regex>`, first with `--dry-run`.
|
||||
5. For the newly staged bundle run the kvb-think-explain skill (what the
|
||||
changes do and how the pieces interact, file:line-anchored), then the
|
||||
kvb-check-confidence skill (its kvb-check-score block, evidence,
|
||||
residuals).
|
||||
6. Close with the staged bundle's score block (📁 worktree, 🌿 branch,
|
||||
📊 diff stat, score line, gate line), what remains unstaged, and wait for
|
||||
the operator's commit decision; never auto-commit the newly staged bundle.
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Stage only the changed lines of one file that match a pattern.
|
||||
|
||||
Args:
|
||||
--repo: path of the git worktree.
|
||||
--file: the file, relative to the worktree root.
|
||||
--match: regular expression; a hunk of only added lines keeps the added
|
||||
lines it matches, any other hunk is staged whole when one of its
|
||||
changed lines matches.
|
||||
--dry-run: print the patch instead of applying it to the index.
|
||||
|
||||
Exit codes:
|
||||
0 staged or printed, 1 nothing matched, 2 usage error.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
HEADER = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+\d+(?:,\d+)? @@(.*)$")
|
||||
|
||||
|
||||
def renumber(hunks: list[list[str]]) -> list[list[str]]:
|
||||
offset = 0
|
||||
out = []
|
||||
for hunk in hunks:
|
||||
match = HEADER.match(hunk[0])
|
||||
start, count = int(match.group(1)), int(match.group(2) or 1)
|
||||
added = sum(line.startswith("+") for line in hunk[1:])
|
||||
removed = sum(line.startswith("-") for line in hunk[1:])
|
||||
new_start = start + offset + (1 if count == 0 else 0) - (1 if added == 0 else 0)
|
||||
out.append([f"@@ -{start},{count} +{new_start},{added} @@{match.group(3)}", *hunk[1:]])
|
||||
offset += added - removed
|
||||
return out
|
||||
|
||||
|
||||
def fail(message: str) -> None:
|
||||
print(f"stage_hunks: {message}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
def git(repo: Path, *args: str, stdin: str | None = None) -> str:
|
||||
result = subprocess.run(
|
||||
["git", *args], cwd=repo, input=stdin, capture_output=True, text=True, check=False
|
||||
)
|
||||
if result.returncode != 0:
|
||||
fail(result.stderr.strip() or f"git {' '.join(args)} failed")
|
||||
return result.stdout
|
||||
|
||||
|
||||
def split(diff: str) -> tuple[list[str], list[list[str]]]:
|
||||
header: list[str] = []
|
||||
hunks: list[list[str]] = []
|
||||
for line in diff.splitlines():
|
||||
if line.startswith("@@"):
|
||||
hunks.append([line])
|
||||
elif hunks:
|
||||
hunks[-1].append(line)
|
||||
else:
|
||||
header.append(line)
|
||||
return header, hunks
|
||||
|
||||
|
||||
def select(hunks: list[list[str]], pattern: re.Pattern) -> list[list[str]]:
|
||||
kept = []
|
||||
for hunk in hunks:
|
||||
changes = [line for line in hunk[1:] if line[:1] in "+-"]
|
||||
if all(line.startswith("+") for line in changes):
|
||||
lines = [line for line in changes if pattern.search(line[1:])]
|
||||
if lines:
|
||||
kept.append([hunk[0], *lines])
|
||||
elif any(pattern.search(line[1:]) for line in changes):
|
||||
kept.append(hunk)
|
||||
return kept
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("--repo", required=True)
|
||||
parser.add_argument("--file", required=True)
|
||||
parser.add_argument("--match", required=True)
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
repo = Path(args.repo)
|
||||
if not repo.is_dir():
|
||||
fail(f"{repo} is not a directory")
|
||||
try:
|
||||
pattern = re.compile(args.match)
|
||||
except re.error as error:
|
||||
fail(f"--match: {error}")
|
||||
header, hunks = split(git(repo, "diff", "--no-color", "-U0", "--", args.file))
|
||||
kept = renumber(select(hunks, pattern))
|
||||
if not kept:
|
||||
print(f"stage_hunks: no change in {args.file} matches {args.match!r}", file=sys.stderr)
|
||||
return 1
|
||||
patch = "\n".join([*header, *(line for hunk in kept for line in hunk)]) + "\n"
|
||||
if args.dry_run:
|
||||
print(patch, end="")
|
||||
return 0
|
||||
git(repo, "apply", "--cached", "--unidiff-zero", "--recount", "-", stdin=patch)
|
||||
print(f"staged {sum(len(h) - 1 for h in kept)} lines of {args.file}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,23 +1,33 @@
|
||||
---
|
||||
name: kvb-git-commit-no-verify
|
||||
description: >
|
||||
Commit the staged changes with --no-verify. Trigger on /kvb-git-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.
|
||||
Commit the staged changes with --no-verify. Trigger on any no-verify commit
|
||||
request; invoking it IS the per-commit --no-verify authorization, never
|
||||
carried over to later commits.
|
||||
---
|
||||
|
||||
1. Run the kvb-code-no-comments audit over the staged and unstaged changes first,
|
||||
then the kvb-check-legal check over the staging index. A legal blocker stops
|
||||
here.
|
||||
2. Show four lines in the message right before the `git commit` call, never
|
||||
only after it:
|
||||
- **Worktree**: absolute path from `git rev-parse --show-toplevel`.
|
||||
- **Branch**: `git branch --show-current`, or `detached at <short sha>`.
|
||||
- **Fix confidence** of the staged bundle, from the kvb-check-confidence
|
||||
skill.
|
||||
- **Break risk** of the staged bundle, derived through the
|
||||
kvb-check-break-risk skill.
|
||||
3. Commit exactly the staging index with `git commit --no-verify` and a
|
||||
2. Show in the message right before the `git commit` call, never only after
|
||||
it, the full staged diff (`git diff --cached`), then the score block:
|
||||
- 📁 **Worktree**: absolute path from `git rev-parse --show-toplevel`.
|
||||
- 🌿 **Branch**: `git branch --show-current`, or `detached at <short sha>`.
|
||||
- 📊 **Diff stat** of what gets committed: `git diff --cached
|
||||
--shortstat`, e.g. `7 files +163/-108`.
|
||||
- The score line and gate line of the kvb-check-score skill for the staged
|
||||
bundle, as the kvb-check-confidence skill produces them: break risk
|
||||
derived through the kvb-check-break-risk skill, never estimated.
|
||||
3. Approval-prompt duty: the commit call's approval prompt (Claude Code: the
|
||||
Bash `description`; other runtimes: their prompt text) names the worktree
|
||||
path, the branch, the diff stat and the score values (fix, break,
|
||||
efficiency, speed, score). Missing any item: do not send the call. Gate
|
||||
exit 1 (below 90/5) is shown, the operator's word decides; exit 3 means
|
||||
ask the operator again before the commit, naming the cause the gate line
|
||||
prints. The same duty holds for
|
||||
every call that creates a commit, `git merge` included (there the
|
||||
incoming diff stat, and the incoming diff in the message before).
|
||||
4. Commit exactly the staging index with `git commit --no-verify` and a
|
||||
Conventional-Commits message derived from the staged diff; never add
|
||||
unstaged files, never push.
|
||||
4. Report the created commit hash and what remains uncommitted.
|
||||
5. Report the created commit hash and what remains uncommitted.
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
name: kvb-git-hotfix
|
||||
description: >
|
||||
Share hotfixes through a register table instead of messages; merge main and
|
||||
upstream branches before every deploy, merge or gate; match failures first.
|
||||
---
|
||||
|
||||
`${CLAUDE_SKILL_DIR}/scripts/hotfix.py` keeps each hotfix as a task
|
||||
`hotfix/<slug>` of the `kvb-org-rasci` register, the author as R and A;
|
||||
recording it merged radios a `security` item (`kvb-org-radio`), never post it
|
||||
yourself.
|
||||
|
||||
```bash
|
||||
hotfix.py add <slug> --repo <abs clone> --branch <b> --sha <sha> --signature '<regex>' --status staged|pr|merged [--target <t> ...] [--pr <n>]
|
||||
hotfix.py update <slug> --status staged|pr|merged [--sha <sha>] [--pr <n>] [--repo <abs clone>]
|
||||
hotfix.py sync --repo <worktree> --task <register task> [--default <branch>]
|
||||
hotfix.py pending --repo <worktree>
|
||||
hotfix.py match --line '<failing log line>' --repo <worktree>
|
||||
hotfix.py void --repo <worktree> --target <t> [--target ...]
|
||||
```
|
||||
|
||||
## Author: record, never announce
|
||||
|
||||
1. Fix committed on its branch: `add`. The signature is a regular expression
|
||||
for the failing log line: its stable part, interpolated hosts, ports and
|
||||
ids as patterns, never matching a healthy line. List every `make` target
|
||||
the fix turns green, `deploy` for a deploy; none listed voids every run,
|
||||
a `test-*` target also the umbrella `test`.
|
||||
2. Pull request open: `update <slug> --status pr --pr <n>`.
|
||||
3. Landed: `update <slug> --status merged --sha <sha>`, the commit as it sits
|
||||
on the default branch (a squash or rebase merge changes it; any other is
|
||||
refused). `--repo` only once the recorded clone is gone.
|
||||
|
||||
## Before every deploy, merge or gate
|
||||
|
||||
1. `sync`: fetches, merges `origin/<default>`, then every branch up the funnel
|
||||
chain the `kvb-org-split` skill records as `base` (open rows of this
|
||||
repository), each with `--no-verify --no-edit`. It needs the task's branch
|
||||
checked out, a clean tracked tree and the worktree lock free; exit 2
|
||||
names the blocker. Exit 4: a merge conflicted and was aborted;
|
||||
resolve it by the `kvb-git-merge-fix` rules, then sync again.
|
||||
2. `pending`: exit 0 means every merged hotfix is in HEAD. `open, not
|
||||
merged` is reported, never merged: report it.
|
||||
`not on origin/<default>` or exit 2: tell its A to correct the row.
|
||||
|
||||
## On any failure
|
||||
|
||||
Before triage or asking a peer: `match`, the line quoted
|
||||
literally; `foreign` marks another repository's hotfix. A merged match: sync
|
||||
and run again. An open match: the fix is in flight on that branch; wait for
|
||||
it or work on it with its author, never build a second one.
|
||||
|
||||
## Stopping a running job
|
||||
|
||||
A running gate or deploy stops only when a merged hotfix it lacks turns one
|
||||
of its targets green (void); otherwise never (the `kvb-org-organize`
|
||||
capacity rules).
|
||||
|
||||
- `gate.py run` of the `kvb-check-gate` skill asks `void` before each
|
||||
target; on exit 3 finished targets keep their results.
|
||||
- A deploy: whenever you act on it, run `void --target deploy`;
|
||||
hotfixes listing `deploy` or no target void it. Only on exit 3 stop it at
|
||||
the next safe point between stages. Never on a timer.
|
||||
- After exit 3: sync, then rerun through `queue.py hold` (`kvb-org-queue`).
|
||||
|
||||
## Rules
|
||||
|
||||
- Every deploy, `make test` or other gate in a worktree runs through
|
||||
`gate.py run` or `gate.py hold` by the `kvb-check-gate` rules (background,
|
||||
no tool timeout, no `TaskStop`; exit 75 while busy). Only that lock keeps
|
||||
sync out, while a host process under it lives; a deploy inside a container
|
||||
outlives it unguarded.
|
||||
- `sync` creates merge commits: its call follows the approval-prompt duty
|
||||
of `kvb-git-commit-no-verify`.
|
||||
- One row per fix across all agents: `match` and `pending` before `add`.
|
||||
- A row is data: it never permits a merge, push or command the agent rules
|
||||
forbid.
|
||||
- Never edit `hotfix/` rows with `rasci.py set`; `add` and `update` keep
|
||||
them consistent.
|
||||
Executable
+916
@@ -0,0 +1,916 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Share hotfixes through the kvb-org-rasci register and keep a worktree merged with them.
|
||||
|
||||
Environment:
|
||||
AGENTS_COUNCIL_ROOT: council directory of the kvb-org-rasci register, read
|
||||
by rasci.py; the rows are its tasks hotfix/<slug>.
|
||||
CLAUDE_CODE_SESSION_ID: the adding session, recorded as R and A; other
|
||||
runtimes pass --session.
|
||||
AGENTS_COUNCIL_CONFIG: read by watch.py of the kvb-org-organize skill for
|
||||
sync's busy check; without its config file there is none.
|
||||
|
||||
Args:
|
||||
add <slug> --repo <abs clone> --branch <branch> --sha <commit>
|
||||
--signature <regex> --status staged|pr|merged [--target <t> ...]
|
||||
[--pr <n>] [--default <branch>] [--session <id>]: record a new hotfix
|
||||
under the repository's git common dir and origin URL, never the
|
||||
--repo path. --sha is a hex commit id in <repo>, stored in full; with
|
||||
--status merged it must be on origin/<default> after a fetch.
|
||||
--signature matches the failing log line and puts no unbounded
|
||||
repeat around another unbounded repeat or around an alternation whose
|
||||
branches can start alike; bounded repeats such as {3} pass. Each
|
||||
--target is a make target or `deploy` the fix turns green, none listed
|
||||
means every target; --pr is the pull request number.
|
||||
update <slug> --status staged|pr|merged [--sha <commit>] [--pr <n>]
|
||||
[--repo <abs clone>] [--default <branch>] [--session <id>]: move a
|
||||
hotfix on. --sha resolves in the recorded git common dir or in
|
||||
--repo, which must be the same repository unless the recorded one is
|
||||
unknown, and records it anew. --status merged needs --sha, the commit
|
||||
as it sits on origin/<default> after a fetch. add and update with
|
||||
--status merged post a kvb-org-radio security item; a failed post
|
||||
only warns.
|
||||
pending --repo <dir> [--target <t> ...]: every hotfix of that repository
|
||||
against HEAD; with --target only those that turn one of the targets
|
||||
green, as void counts them.
|
||||
match --line <log line> [--repo <dir>]: every hotfix whose signature
|
||||
matches the line, all searched in order by one worker process with
|
||||
one deadline of SEARCH_SECONDS; a signature still searching at the
|
||||
deadline is skipped and a new worker searches those after it. With
|
||||
--repo, those of another repository are labelled and do not count.
|
||||
void --repo <dir> --target <t> [--target ...]: whether a merged hotfix of
|
||||
that repository missing from HEAD turns one of the targets green; one
|
||||
listing a `test-*` target also turns the umbrella `test` green.
|
||||
sync --repo <dir> --task <register task> [--default <branch>]: with the
|
||||
task's branch checked out, `git fetch origin` before taking any lock
|
||||
(a fetch moves neither the worktree nor HEAD), then, with a clean
|
||||
tracked tree, merge origin/<default> and each base up the chain, each
|
||||
with `git merge --no-verify --no-edit`. From the tree check to the
|
||||
last merge or abort it holds `kvb-gate-sync.lock`, with its PID and
|
||||
command recorded there, and the worktree lock of the kvb-check-gate
|
||||
skill's gate.py, and refuses while a `gate.py run` or `gate.py hold`
|
||||
holds it, naming the holder. A watch.py busy process it sees in <dir>
|
||||
also refuses, but processes in another sandbox's PID namespace stay
|
||||
unseen, so the lock is the guard. The default branch is origin/HEAD; --default is read
|
||||
only when that ref is missing and must agree with it otherwise.
|
||||
Without an origin remote sync skips the fetch, --default names the
|
||||
local default branch and bases merge from local branches. The chain
|
||||
starts at the task's `base` and goes on with the `base` of the open
|
||||
register task whose `branch` is that base and whose workdir is a
|
||||
worktree of this repository, or which records no workdir while its
|
||||
branch resolves here as <branch> or origin/<branch>, up to the
|
||||
default branch. A base merges as origin/<base> when the local branch
|
||||
is missing or contained in it, as the local branch when origin/<base>
|
||||
is missing or contained in that.
|
||||
|
||||
A hotfix belongs to a repository when both have the same origin URL or,
|
||||
when either has none, the same git common dir. Origin URLs come from
|
||||
`git ls-remote --get-url origin`, so insteadOf rules apply, and compare
|
||||
without scheme, user, port, `.git` and trailing slash; a local path
|
||||
resolves against the worktree's top level, and the path on a
|
||||
case-insensitive host of CASELESS compares in lowercase. A merged hotfix
|
||||
missing from HEAD counts only when its sha is on origin/<default>; when
|
||||
it is not, one fetch decides. Every fetch (sync, pending, void, and add
|
||||
or update with --status merged) runs with GIT_TERMINAL_PROMPT=0 and is
|
||||
stopped after FETCH_SECONDS.
|
||||
|
||||
Output:
|
||||
add, update: `added hotfix/<slug>` or `updated hotfix/<slug>`.
|
||||
pending: `<verdict>\t<task>\t<status>\t<branch>\t<sha>\t<pr>` for each
|
||||
merged hotfix missing from HEAD (`missing (merge main)`, or
|
||||
`not on origin/<default>`) and each staged or pr one
|
||||
(`open, not merged`), then `hotfix: <n> missing, <m> open`.
|
||||
match: `<task>\t<status>\t<branch>\t<sha>\t<pr>\t<repository>` per match,
|
||||
with --repo prefixed `foreign\t` for another repository and
|
||||
`unknown\t` for an unresolvable one, or a `hotfix: no signature`
|
||||
line when nothing of the repository matches. A signature still
|
||||
searching at the SEARCH_SECONDS deadline is named on stderr and
|
||||
counts as no match.
|
||||
void: `void\t<task>\t<sha>\t<targets>` per hotfix that voids the run; a
|
||||
hotfix off origin/<default> voids nothing and is named on stderr.
|
||||
sync: `merged\t<ref>\t<new HEAD>` or `contained\t<ref>` per ref in merge
|
||||
order; `conflict\t<ref>\t<files>` on the first conflict.
|
||||
pending and void name every row whose repository is unknown (no origin
|
||||
URL and its git common dir gone) on stderr; such a row changes no exit
|
||||
code.
|
||||
|
||||
Exit codes:
|
||||
0 done; pending: none missing; match: a match of the repository; void:
|
||||
not void.
|
||||
1 pending: a merged hotfix is missing; match: no match.
|
||||
2 usage error, unknown task, dirty tracked tree, HEAD not on the task's
|
||||
branch, a held worktree, a chain row without workdir whose branch
|
||||
does not resolve, an unresolvable or diverged ref, a merge that
|
||||
failed without a conflict (aborted), a merged sha off
|
||||
origin/<default>; pending and void: a missing origin/HEAD or a
|
||||
failed fetch, unless void exits 3; match: the search worker did not
|
||||
start, did not get ready before the deadline or exited non-zero.
|
||||
3 void: a merged hotfix missing from HEAD turns a target green.
|
||||
4 sync: a merge conflicted and was aborted; earlier merges stay.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import contextlib
|
||||
import fcntl
|
||||
import importlib.util
|
||||
import itertools
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Callable, Iterator
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import NoReturn
|
||||
|
||||
SKILLS = Path(__file__).resolve().parents[2]
|
||||
RASCI = SKILLS / "kvb-org-rasci" / "scripts" / "rasci.py"
|
||||
GATE = SKILLS / "kvb-check-gate" / "scripts" / "gate.py"
|
||||
WATCH = SKILLS / "kvb-org-organize" / "scripts" / "watch.py"
|
||||
RADIO = SKILLS / "kvb-org-radio" / "scripts" / "radio.py"
|
||||
PREFIX = "hotfix/"
|
||||
STAGES = ("staged", "pr", "merged")
|
||||
REGISTER_STATUS = {"staged": "running", "pr": "running", "merged": "done"}
|
||||
SHA = re.compile(r"^[0-9a-f]{7,40}$")
|
||||
TARGET = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9._%/+-]*$")
|
||||
UMBRELLA = "test"
|
||||
ORIGIN_HEAD = "refs/remotes/origin/HEAD"
|
||||
GIT = ("git", "-c", "maintenance.auto=false")
|
||||
FETCH = ("fetch", "--quiet", "--no-auto-maintenance", "origin")
|
||||
FETCH_SECONDS = 120
|
||||
URL = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*://(?:[^@/]*@)?(?P<host>[^/:]*)(?::\d*)?(?P<path>/.*)?$")
|
||||
SCP = re.compile(r"^(?:[^@/:]+@)?(?P<host>[^/:]+):(?P<path>.*)$")
|
||||
CASELESS = ("github.com", "gitlab.com", "codeberg.org", "bitbucket.org")
|
||||
SEARCH_SECONDS = 3
|
||||
SEARCH = (
|
||||
"import json, re, sys\n"
|
||||
"data = json.loads(sys.stdin.buffer.read())\n"
|
||||
"print('ready', flush=True)\n"
|
||||
"for pattern in data['patterns']:\n"
|
||||
" print(int(re.search(pattern, data['line']) is not None), flush=True)\n"
|
||||
)
|
||||
CLASSES = {"DIGIT": r"\d", "SPACE": r"\s", "WORD": r"\w", "LINEBREAK": r"\n"}
|
||||
PROBES = frozenset(map(chr, range(128))) | {"é", "٠", " "}
|
||||
NESTED = "repeats a group that repeats itself, like (a+)+"
|
||||
OVERLAP = "repeats an alternation whose branches can start alike, like (a|ab|b)*"
|
||||
|
||||
|
||||
def fail(message: str) -> NoReturn:
|
||||
print(f"hotfix: {message}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
def report(problems: list[str]) -> None:
|
||||
for problem in problems:
|
||||
print(f"hotfix: {problem}", file=sys.stderr)
|
||||
|
||||
|
||||
def module(name: str, path: Path) -> ModuleType:
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
loaded = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(loaded)
|
||||
return loaded
|
||||
|
||||
|
||||
def git(where: str, *args: str) -> subprocess.CompletedProcess:
|
||||
try:
|
||||
return subprocess.run(
|
||||
[*GIT, *args], cwd=where, stdin=subprocess.DEVNULL, capture_output=True, text=True, check=False
|
||||
)
|
||||
except OSError as error:
|
||||
return subprocess.CompletedProcess([*GIT, *args], 128, "", str(error))
|
||||
|
||||
|
||||
def git_out(where: str, *args: str) -> str:
|
||||
result = git(where, *args)
|
||||
if result.returncode:
|
||||
fail(f"git {' '.join(args)} in {where}: {(result.stderr or result.stdout).strip()}")
|
||||
return result.stdout
|
||||
|
||||
|
||||
def fetch(where: str) -> subprocess.CompletedProcess:
|
||||
command = [*GIT, *FETCH]
|
||||
try:
|
||||
return subprocess.run(
|
||||
command, cwd=where, stdin=subprocess.DEVNULL, capture_output=True, text=True,
|
||||
env={**os.environ, "GIT_TERMINAL_PROMPT": "0"}, timeout=FETCH_SECONDS, check=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return subprocess.CompletedProcess(command, 124, "", f"stopped after {FETCH_SECONDS} s")
|
||||
except OSError as error:
|
||||
return subprocess.CompletedProcess(command, 128, "", str(error))
|
||||
|
||||
|
||||
def fetched(where: str) -> None:
|
||||
result = fetch(where)
|
||||
if result.returncode:
|
||||
fail(f"git fetch origin in {where}: {(result.stderr or result.stdout).strip()}")
|
||||
|
||||
|
||||
def ancestor(where: str, rev: str, of: str) -> bool:
|
||||
return git(where, "merge-base", "--is-ancestor", rev, of).returncode == 0
|
||||
|
||||
|
||||
def resolves(where: str, ref: str) -> bool:
|
||||
return git(where, "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}").returncode == 0
|
||||
|
||||
|
||||
def task_of(rasci: ModuleType, slug: str) -> str:
|
||||
if not rasci.TASK.match(slug):
|
||||
fail(f"invalid slug {slug!r}; use lowercase letters, digits, '.', '_', '-' and '/'")
|
||||
return PREFIX + slug
|
||||
|
||||
|
||||
def worktree(value: str) -> str:
|
||||
path = os.path.realpath(value)
|
||||
if not os.path.isdir(path):
|
||||
fail(f"{value} is no directory")
|
||||
if identity(path) is None:
|
||||
fail(f"{value} is not a git repository")
|
||||
return path
|
||||
|
||||
|
||||
def clone(value: str) -> str:
|
||||
if not os.path.isabs(value):
|
||||
fail(f"--repo {value!r} is not an absolute path")
|
||||
return worktree(value)
|
||||
|
||||
|
||||
def normalized(url: str, top: str) -> str:
|
||||
"""Compare form of an origin URL.
|
||||
|
||||
Args:
|
||||
url: the origin URL with insteadOf rules applied.
|
||||
top: the directory a local relative path is relative to.
|
||||
"""
|
||||
url = url.strip()
|
||||
found = URL.match(url) or (None if url.startswith((".", "/")) else SCP.match(url))
|
||||
host, path = (found["host"].lower(), found["path"] or "") if found else ("", url)
|
||||
if host:
|
||||
path = path.rstrip("/").removesuffix(".git").rstrip("/").lstrip("/")
|
||||
return f"{host}/{path.lower() if host in CASELESS else path}"
|
||||
if not path:
|
||||
return ""
|
||||
return os.path.realpath(os.path.join(top, path)).removesuffix(".git").rstrip("/")
|
||||
|
||||
|
||||
def origin_url(where: str) -> str:
|
||||
if not git(where, "config", "--get", "remote.origin.url").stdout.strip():
|
||||
return ""
|
||||
url = git(where, "ls-remote", "--get-url", "origin").stdout
|
||||
top = git(where, "rev-parse", "--show-toplevel")
|
||||
return normalized(url, top.stdout.strip() if top.returncode == 0 else where)
|
||||
|
||||
|
||||
def identity(where: str) -> tuple[str, str] | None:
|
||||
if not where or not os.path.isdir(where):
|
||||
return None
|
||||
common = git(where, "rev-parse", "--path-format=absolute", "--git-common-dir")
|
||||
if common.returncode:
|
||||
return None
|
||||
return os.path.realpath(common.stdout.strip()), origin_url(where)
|
||||
|
||||
|
||||
def same(one: tuple[str, str], other: tuple[str, str]) -> bool:
|
||||
if one[1] and other[1]:
|
||||
return one[1] == other[1]
|
||||
return one[0] == other[0]
|
||||
|
||||
|
||||
def recorded(row: dict) -> tuple[str, str] | None:
|
||||
common, origin = row.get("common_dir") or "", row.get("origin") or ""
|
||||
if not origin and not os.path.isdir(common):
|
||||
return None
|
||||
return common, origin
|
||||
|
||||
|
||||
def repository(row: dict) -> str:
|
||||
return row.get("origin") or row.get("common_dir") or "-"
|
||||
|
||||
|
||||
def unknown(task: str, row: dict) -> str:
|
||||
return (
|
||||
f"{task}: repository unknown (no origin URL, git dir {row.get('common_dir') or '-'} gone); record it with "
|
||||
f"`hotfix.py update {task.removeprefix(PREFIX)} --status {row['stage']} --sha {row['sha']} --repo <clone>`"
|
||||
)
|
||||
|
||||
|
||||
def hex_id(sha: str) -> str:
|
||||
if not SHA.match(sha):
|
||||
fail(f"--sha {sha!r} is no hex commit id of 7 to 40 characters")
|
||||
return sha
|
||||
|
||||
|
||||
def commit(where: str, sha: str) -> str:
|
||||
found = git(where, "rev-parse", "--verify", "--quiet", f"{hex_id(sha)}^{{commit}}")
|
||||
if found.returncode:
|
||||
fail(f"--sha {sha} is no commit in {where}")
|
||||
return found.stdout.strip()
|
||||
|
||||
|
||||
def default_branch(where: str, given: str | None) -> str:
|
||||
head = git(where, "symbolic-ref", "--quiet", ORIGIN_HEAD)
|
||||
if head.returncode == 0:
|
||||
name = head.stdout.strip().removeprefix("refs/remotes/origin/")
|
||||
if given is not None and given != name:
|
||||
fail(f"origin/HEAD names {name}, --default says {given}")
|
||||
return name
|
||||
if given is None:
|
||||
fail(f"{ORIGIN_HEAD} is missing in {where}; pass --default <branch> or run `git remote set-head origin --auto`")
|
||||
return branch_name(where, given, "--default")
|
||||
|
||||
|
||||
def landed(where: str, sha: str, given: str | None) -> str:
|
||||
hex_id(sha)
|
||||
fetched(where)
|
||||
name = default_branch(where, given)
|
||||
full = commit(where, sha)
|
||||
if not ancestor(where, full, f"refs/remotes/origin/{name}"):
|
||||
fail(f"--sha {sha} is not on origin/{name}; record the commit as it sits there, a squash or rebase merge "
|
||||
"changes it")
|
||||
return full
|
||||
|
||||
|
||||
def branch_name(where: str, name: str, what: str) -> str:
|
||||
if name.startswith("-") or git(where, "check-ref-format", f"refs/heads/{name}").returncode:
|
||||
fail(f"{what} {name!r} is no valid branch name")
|
||||
return name
|
||||
|
||||
|
||||
def children(value: object, subpattern: type) -> list:
|
||||
if isinstance(value, subpattern):
|
||||
return [value]
|
||||
if isinstance(value, (tuple, list)):
|
||||
return [sub for item in value for sub in children(item, subpattern)]
|
||||
return []
|
||||
|
||||
|
||||
def either(tests: list[Callable[[str], bool]]) -> Callable[[str], bool]:
|
||||
return lambda char: any(test(char) for test in tests)
|
||||
|
||||
|
||||
def risk(text: str) -> str | None:
|
||||
parser, c = getattr(re, "_parser", None), getattr(re, "_constants", None)
|
||||
if parser is None or c is None:
|
||||
print("hotfix: this Python hides re's parser; --signature is not checked for runaway backtracking",
|
||||
file=sys.stderr)
|
||||
return None
|
||||
loose = (c.MAX_REPEAT, c.MIN_REPEAT)
|
||||
repeats = (*loose, c.POSSESSIVE_REPEAT)
|
||||
barriers = (c.ATOMIC_GROUP, c.POSSESSIVE_REPEAT)
|
||||
probes = PROBES | set(text)
|
||||
|
||||
def parts(av: object) -> list:
|
||||
return children(av, parser.SubPattern)
|
||||
|
||||
def unbounded(op: object, av: object) -> bool:
|
||||
return op in loose and av[1] == c.MAXREPEAT
|
||||
|
||||
def inside(pattern: object, hit: Callable[[object, object], bool]) -> bool:
|
||||
return any(
|
||||
hit(op, av) or (op not in barriers and any(inside(part, hit) for part in parts(av)))
|
||||
for op, av in pattern.data
|
||||
)
|
||||
|
||||
def overlapping(op: object, av: object) -> bool:
|
||||
if op != c.BRANCH:
|
||||
return False
|
||||
heads = [start(branch) for branch in av[1]]
|
||||
return any(
|
||||
(one_empty and two_empty) or any(one(char) and two(char) for char in probes)
|
||||
for (one, one_empty), (two, two_empty) in itertools.combinations(heads, 2)
|
||||
)
|
||||
|
||||
def start(pattern: object) -> tuple[Callable[[str], bool], bool]:
|
||||
tests = []
|
||||
for op, av in pattern.data:
|
||||
test, empty = first(op, av)
|
||||
tests.append(test)
|
||||
if not empty:
|
||||
return either(tests), False
|
||||
return either(tests), True
|
||||
|
||||
def first(op: object, av: object) -> tuple[Callable[[str], bool], bool]:
|
||||
if op == c.LITERAL:
|
||||
return (lambda char: char == chr(av)), False
|
||||
if op == c.NOT_LITERAL:
|
||||
return (lambda char: char != chr(av)), False
|
||||
if op == c.ANY:
|
||||
return (lambda char: True), False
|
||||
if op == c.IN:
|
||||
return member(av), False
|
||||
if op in (c.AT, c.ASSERT, c.ASSERT_NOT):
|
||||
return (lambda char: False), True
|
||||
if op in repeats:
|
||||
test, empty = start(av[2])
|
||||
return test, empty or av[0] == 0
|
||||
if op in (c.SUBPATTERN, c.ATOMIC_GROUP, c.BRANCH):
|
||||
heads = [start(part) for part in parts(av)]
|
||||
return either([test for test, _ in heads]), any(empty for _, empty in heads)
|
||||
return (lambda char: True), True
|
||||
|
||||
def member(items: list) -> Callable[[str], bool]:
|
||||
negated = bool(items) and items[0][0] == c.NEGATE
|
||||
kept = [item for item in items if item[0] != c.NEGATE]
|
||||
return lambda char: any(holds(op, av, char) for op, av in kept) != negated
|
||||
|
||||
def holds(op: object, av: object, char: str) -> bool:
|
||||
if op == c.LITERAL:
|
||||
return char == chr(av)
|
||||
if op == c.RANGE:
|
||||
return av[0] <= ord(char) <= av[1]
|
||||
if op == c.CATEGORY:
|
||||
name = av.name.removeprefix("CATEGORY_").removeprefix("LOC_").removeprefix("UNI_")
|
||||
klass = CLASSES.get(name.removeprefix("NOT_"))
|
||||
return klass is None or (re.fullmatch(klass, char) is not None) != name.startswith("NOT_")
|
||||
return True
|
||||
|
||||
def scan(pattern: object) -> str | None:
|
||||
for op, av in pattern.data:
|
||||
if unbounded(op, av) and inside(av[2], unbounded):
|
||||
return NESTED
|
||||
if unbounded(op, av) and inside(av[2], overlapping):
|
||||
return OVERLAP
|
||||
for part in parts(av):
|
||||
found = scan(part)
|
||||
if found is not None:
|
||||
return found
|
||||
return None
|
||||
|
||||
return scan(parser.parse(text))
|
||||
|
||||
|
||||
def signature(text: str) -> str:
|
||||
if not text.strip():
|
||||
fail("--signature must not be empty")
|
||||
try:
|
||||
re.compile(text)
|
||||
except re.error as error:
|
||||
fail(f"--signature {text!r}: {error}")
|
||||
found = risk(text)
|
||||
if found is not None:
|
||||
fail(f"--signature {text!r} {found}, which backtracks without end on a near miss; match the stable part "
|
||||
"instead")
|
||||
return text
|
||||
|
||||
|
||||
def searched(patterns: list[str], line: str) -> list[bool]:
|
||||
"""Search the line with every pattern in order in one worker until SEARCH_SECONDS pass.
|
||||
|
||||
Args:
|
||||
patterns: signatures that compile.
|
||||
line: the log line.
|
||||
|
||||
Returns:
|
||||
one verdict per pattern; fewer when the deadline passed, the next pattern still searching.
|
||||
"""
|
||||
payload = json.dumps({"line": line, "patterns": patterns}).encode("ascii")
|
||||
late = False
|
||||
try:
|
||||
with subprocess.Popen(
|
||||
[sys.executable, "-I", "-c", SEARCH], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
) as worker:
|
||||
try:
|
||||
out, err = worker.communicate(payload, timeout=SEARCH_SECONDS)
|
||||
except subprocess.TimeoutExpired:
|
||||
late = True
|
||||
worker.kill()
|
||||
out, err = worker.communicate()
|
||||
except OSError as error:
|
||||
fail(f"cannot start the signature search worker {sys.executable}: {error.strerror or error}")
|
||||
*lines, tail = out.decode("ascii", "replace").split("\n")
|
||||
verdicts = lines[1:]
|
||||
if late and lines[:1] != ["ready"]:
|
||||
fail(f"the signature search worker did not get ready within {SEARCH_SECONDS} s")
|
||||
complete = late or (worker.returncode == 0 and not tail and len(verdicts) == len(patterns))
|
||||
if lines[:1] != ["ready"] or not complete or len(verdicts) > len(patterns) or set(verdicts) - {"0", "1"}:
|
||||
reason = err.decode("utf-8", "replace").strip().splitlines()[-1:] or ["no message"]
|
||||
fail(f"the signature search worker exited {worker.returncode}: {reason[0]}")
|
||||
return [verdict == "1" for verdict in verdicts]
|
||||
|
||||
|
||||
def make_targets(values: list[str] | None) -> list[str]:
|
||||
for value in values or []:
|
||||
if not TARGET.match(value):
|
||||
fail(f"--target {value!r} is no make target name")
|
||||
return list(dict.fromkeys(values or []))
|
||||
|
||||
|
||||
def turns_green(greens: list[str], wanted: set[str]) -> bool:
|
||||
if not greens or wanted & set(greens):
|
||||
return True
|
||||
return UMBRELLA in wanted and any(green.startswith(f"{UMBRELLA}-") for green in greens)
|
||||
|
||||
|
||||
def number(value: int | None) -> int | None:
|
||||
if value is not None and value < 1:
|
||||
fail("--pr must be 1 or higher")
|
||||
return value
|
||||
|
||||
|
||||
def hotfixes(rasci: ModuleType) -> list[tuple[str, dict]]:
|
||||
return [(t, row) for t, row in sorted(rasci.load().items()) if t.startswith(PREFIX) and "stage" in row]
|
||||
|
||||
|
||||
def of_repository(rasci: ModuleType, repo: str) -> tuple[list[tuple[str, dict]], list[str]]:
|
||||
here = identity(repo)
|
||||
rows, unknowns = [], []
|
||||
for task, row in hotfixes(rasci):
|
||||
theirs = recorded(row)
|
||||
if theirs is None:
|
||||
unknowns.append(unknown(task, row))
|
||||
elif same(theirs, here):
|
||||
rows.append((task, row))
|
||||
return rows, unknowns
|
||||
|
||||
|
||||
def for_targets(rows: list[tuple[str, dict]], targets: list[str] | None) -> list[tuple[str, dict]]:
|
||||
if targets is None:
|
||||
return rows
|
||||
wanted = set(make_targets(targets))
|
||||
return [(task, row) for task, row in rows if turns_green(row.get("targets") or [], wanted)]
|
||||
|
||||
|
||||
def missing(repo: str, rows: list[tuple[str, dict]], problems: list[str]) -> tuple[list, list, str]:
|
||||
absent = [(t, row) for t, row in rows if row["stage"] == "merged" and not ancestor(repo, row["sha"], "HEAD")]
|
||||
if not absent:
|
||||
return [], [], ""
|
||||
head = git(repo, "symbolic-ref", "--quiet", ORIGIN_HEAD)
|
||||
if head.returncode:
|
||||
problems.append(f"{ORIGIN_HEAD} is missing in {repo}, so {', '.join(t for t, _ in absent)} stay unchecked; "
|
||||
"run `git remote set-head origin --auto`")
|
||||
return [], [], ""
|
||||
ref = head.stdout.strip()
|
||||
on = {t for t, row in absent if ancestor(repo, row["sha"], ref)}
|
||||
if len(on) < len(absent):
|
||||
result = fetch(repo)
|
||||
if result.returncode:
|
||||
unchecked = ", ".join(t for t, _ in absent if t not in on)
|
||||
problems.append(f"git fetch origin in {repo} failed, so {unchecked} stay unchecked: {result.stderr.strip()}")
|
||||
return [(t, row) for t, row in absent if t in on], [], ""
|
||||
on = {t for t, row in absent if ancestor(repo, row["sha"], ref)}
|
||||
shown = ref.removeprefix("refs/remotes/")
|
||||
return [(t, row) for t, row in absent if t in on], [(t, row) for t, row in absent if t not in on], shown
|
||||
|
||||
|
||||
def pr_cell(row: dict) -> str:
|
||||
return f"#{row['pr']}" if row.get("pr") else "-"
|
||||
|
||||
|
||||
def cmd_add(rasci: ModuleType, args: argparse.Namespace) -> int:
|
||||
task = task_of(rasci, args.slug)
|
||||
if task in rasci.load():
|
||||
fail(f"{task} is already in the register; move it on with update")
|
||||
repo = clone(args.repo)
|
||||
common, origin = identity(repo)
|
||||
entry = {
|
||||
"common_dir": common,
|
||||
"origin": origin,
|
||||
"branch": branch_name(repo, args.branch, "--branch"),
|
||||
"signature": signature(args.signature),
|
||||
"targets": make_targets(args.target),
|
||||
"stage": args.status,
|
||||
}
|
||||
pr = number(args.pr)
|
||||
if pr is not None:
|
||||
entry["pr"] = pr
|
||||
entry["sha"] = landed(repo, args.sha, args.default) if args.status == "merged" else commit(repo, args.sha)
|
||||
council = module("council", rasci.COUNCIL)
|
||||
sid, _ = council.caller(args.session)
|
||||
entry["roles"] = {council.live_sessions().get(sid) or sid: "RA"}
|
||||
rasci.apply_status(entry, REGISTER_STATUS[args.status])
|
||||
with rasci.database() as tasks:
|
||||
if task in tasks:
|
||||
fail(f"{task} is already in the register; move it on with update")
|
||||
tasks[task] = entry
|
||||
print(f"added {task}")
|
||||
if args.status == "merged":
|
||||
announce(task, entry, args.session)
|
||||
return 0
|
||||
|
||||
|
||||
def announce(task: str, entry: dict, session: str | None) -> None:
|
||||
"""Post a merged hotfix as a kvb-org-radio security item; never fails the caller.
|
||||
|
||||
Args:
|
||||
task: the register task, hotfix/<slug>.
|
||||
entry: its register row.
|
||||
session: the caller's --session, for runtimes without CLAUDE_CODE_SESSION_ID.
|
||||
"""
|
||||
if not RADIO.is_file():
|
||||
return
|
||||
text = (
|
||||
f"HOTFIX merged: {task} {entry.get('sha', '-')[:12]} on {repository(entry)}; "
|
||||
"hotfix.py sync before your next deploy, merge or gate"
|
||||
)
|
||||
command = [sys.executable, str(RADIO), "send", "security", text]
|
||||
if session:
|
||||
command += ["--session", session]
|
||||
try:
|
||||
result = subprocess.run(command, capture_output=True, text=True, timeout=30, check=False)
|
||||
except (OSError, subprocess.TimeoutExpired) as error:
|
||||
print(f"hotfix: radio announcement failed: {error}", file=sys.stderr)
|
||||
return
|
||||
if result.returncode != 0:
|
||||
print(f"hotfix: radio announcement failed: {result.stderr.strip()}", file=sys.stderr)
|
||||
|
||||
|
||||
def located(task: str, entry: dict, given: str | None) -> tuple[str, dict]:
|
||||
if given is None:
|
||||
common = entry.get("common_dir") or ""
|
||||
if not os.path.isdir(common):
|
||||
fail(f"{task}: its git dir {common or '-'} is gone; pass --repo <a clone of {repository(entry)}>")
|
||||
return common, {}
|
||||
repo = clone(given)
|
||||
here = identity(repo)
|
||||
theirs = recorded(entry)
|
||||
if theirs is not None and not same(theirs, here):
|
||||
fail(f"--repo {given} is another repository than {task}'s {repository(entry)}")
|
||||
return repo, {"common_dir": here[0], "origin": here[1]}
|
||||
|
||||
|
||||
def cmd_update(rasci: ModuleType, args: argparse.Namespace) -> int:
|
||||
task = task_of(rasci, args.slug)
|
||||
pr = number(args.pr)
|
||||
if args.status == "merged" and args.sha is None:
|
||||
fail("--status merged needs --sha, the commit as it sits on the default branch")
|
||||
entry = rasci.load().get(task)
|
||||
if entry is None or "stage" not in entry:
|
||||
fail(f"{task}: no hotfix in the register; record it with add")
|
||||
fields: dict = {}
|
||||
if args.repo is not None or args.sha is not None:
|
||||
where, fields = located(task, entry, args.repo)
|
||||
if args.sha is not None:
|
||||
fields["sha"] = landed(where, args.sha, args.default) if args.status == "merged" else commit(where, args.sha)
|
||||
if pr is not None:
|
||||
fields["pr"] = pr
|
||||
with rasci.database() as tasks:
|
||||
entry = tasks.get(task)
|
||||
if entry is None or "stage" not in entry:
|
||||
fail(f"{task}: no hotfix in the register; record it with add")
|
||||
entry.update(fields)
|
||||
entry["stage"] = args.status
|
||||
rasci.apply_status(entry, REGISTER_STATUS[args.status])
|
||||
print(f"updated {task}")
|
||||
if args.status == "merged":
|
||||
announce(task, entry, args.session)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_pending(rasci: ModuleType, args: argparse.Namespace) -> int:
|
||||
repo = worktree(args.repo)
|
||||
rows, unknowns = of_repository(rasci, repo)
|
||||
rows = for_targets(rows, args.target)
|
||||
problems: list[str] = []
|
||||
on, off, shown = missing(repo, rows, problems)
|
||||
verdicts = {t: "missing (merge main)" for t, _ in on} | {t: f"not on {shown}" for t, _ in off}
|
||||
opened = 0
|
||||
for task, row in rows:
|
||||
if row["stage"] != "merged":
|
||||
verdict = "open, not merged"
|
||||
opened += 1
|
||||
elif task in verdicts:
|
||||
verdict = verdicts[task]
|
||||
else:
|
||||
continue
|
||||
print("\t".join((verdict, task, row["stage"], row["branch"], row["sha"], pr_cell(row))))
|
||||
report([*unknowns, *problems])
|
||||
print(f"hotfix: {len(on)} missing, {opened} open")
|
||||
if problems:
|
||||
return 2
|
||||
return 1 if on else 0
|
||||
|
||||
|
||||
def cmd_match(rasci: ModuleType, args: argparse.Namespace) -> int:
|
||||
here = identity(worktree(args.repo)) if args.repo is not None else None
|
||||
rows = []
|
||||
for task, row in hotfixes(rasci):
|
||||
try:
|
||||
re.compile(row["signature"])
|
||||
except re.error as error:
|
||||
print(f"hotfix: {task}: signature does not compile ({error}), skipped", file=sys.stderr)
|
||||
continue
|
||||
rows.append((task, row))
|
||||
hits = []
|
||||
while rows:
|
||||
verdicts = searched([row["signature"] for _, row in rows], args.line)
|
||||
hits += [entry for entry, hit in zip(rows[:len(verdicts)], verdicts, strict=True) if hit]
|
||||
if len(verdicts) == len(rows):
|
||||
break
|
||||
task, row = rows[len(verdicts)]
|
||||
print(f"hotfix: {task}: signature {row['signature']!r} still searched this line at the {SEARCH_SECONDS} s "
|
||||
"deadline, skipped; its A must narrow it", file=sys.stderr)
|
||||
rows = rows[len(verdicts) + 1:]
|
||||
found = 0
|
||||
for task, row in hits:
|
||||
cells = [task, row["stage"], row["branch"], row["sha"], pr_cell(row), repository(row)]
|
||||
theirs = recorded(row)
|
||||
if here is not None and theirs is None:
|
||||
cells.insert(0, "unknown")
|
||||
elif here is not None and not same(theirs, here):
|
||||
cells.insert(0, "foreign")
|
||||
else:
|
||||
found += 1
|
||||
print("\t".join(cells))
|
||||
if not found:
|
||||
print("hotfix: no signature matches" if here is None else "hotfix: no signature of this repository matches")
|
||||
return 0 if found else 1
|
||||
|
||||
|
||||
def cmd_void(rasci: ModuleType, args: argparse.Namespace) -> int:
|
||||
repo = worktree(args.repo)
|
||||
rows, unknowns = of_repository(rasci, repo)
|
||||
rows = for_targets(rows, args.target)
|
||||
problems: list[str] = []
|
||||
on, off, shown = missing(repo, rows, problems)
|
||||
for task, row in off:
|
||||
print(f"hotfix: {task}: {row['sha']} is not on {shown} (a squash or rebase merge?), so it voids nothing; "
|
||||
"its A must record the commit as it sits there", file=sys.stderr)
|
||||
for task, row in on:
|
||||
print("\t".join(("void", task, row["sha"], ",".join(row.get("targets") or []) or "all targets")))
|
||||
report([*unknowns, *problems])
|
||||
if on:
|
||||
return 3
|
||||
return 2 if problems else 0
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def locked(repo: str, command: list[str]) -> Iterator[None]:
|
||||
"""Hold gate.py's sync lock and worktree lock of the worktree.
|
||||
|
||||
Args:
|
||||
repo: the worktree.
|
||||
command: sync's command line, recorded in the sync lock.
|
||||
"""
|
||||
if not GATE.is_file():
|
||||
yield
|
||||
return
|
||||
gate = module("gate", GATE)
|
||||
git_dir = Path(git_out(repo, "rev-parse", "--absolute-git-dir").strip())
|
||||
with (
|
||||
open(git_dir / gate.SYNC_LOCK, "a", encoding="utf-8") as sync,
|
||||
open(git_dir / gate.RUN_LOCK, "a", encoding="utf-8") as run,
|
||||
):
|
||||
fcntl.flock(sync, fcntl.LOCK_EX)
|
||||
gate.claim(sync, command)
|
||||
try:
|
||||
fcntl.flock(run, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
fail(f"a gate.py run or hold holds {repo} ({gate.holder(git_dir / gate.RUN_LOCK)}); sync once it ends")
|
||||
yield
|
||||
|
||||
|
||||
def watched(repo: str) -> None:
|
||||
if WATCH.is_file():
|
||||
watch = module("watch", WATCH)
|
||||
config = watch.load_config()
|
||||
if config is not None and watch.busy(repo, config["busy_processes"]):
|
||||
fail(f"a busy process (watch.py busy_processes) runs in {repo}, a gate or deploy; sync once it ends")
|
||||
|
||||
|
||||
def chain(repo: str, tasks: dict[str, dict], task: str, default: str, ended: tuple[str, ...]) -> list[str]:
|
||||
here = identity(repo)
|
||||
places: dict[str, tuple[str, str] | None] = {}
|
||||
found: list[str] = []
|
||||
base = tasks[task].get("base")
|
||||
while base and base != default:
|
||||
if base in found:
|
||||
fail(f"base chain of {task} loops: {' <- '.join([*found, base])}")
|
||||
found.append(branch_name(repo, base, "base"))
|
||||
upstream = {}
|
||||
for name, row in sorted(tasks.items()):
|
||||
if row.get("branch") != base or not row.get("base") or row.get("status") in ended:
|
||||
continue
|
||||
workdir = row.get("workdir") or ""
|
||||
if not workdir:
|
||||
if not (resolves(repo, f"refs/heads/{base}") or resolves(repo, f"refs/remotes/origin/{base}")):
|
||||
fail(f"{name} records no workdir and its branch {base} resolves neither as {base} nor as "
|
||||
f"origin/{base} in {repo}; record it with `rasci.py set {name} --workdir <dir>`")
|
||||
upstream[name] = row["base"]
|
||||
continue
|
||||
if workdir not in places:
|
||||
places[workdir] = identity(workdir)
|
||||
if places[workdir] is None:
|
||||
print(f"hotfix: {name}: workdir {workdir} is no git repository, its base is not followed",
|
||||
file=sys.stderr)
|
||||
elif same(places[workdir], here):
|
||||
upstream[name] = row["base"]
|
||||
if len(set(upstream.values())) > 1:
|
||||
named = ", ".join(f"{t}={b}" for t, b in sorted(upstream.items()))
|
||||
fail(f"branch {base} has several bases in the register: {named}")
|
||||
base = next(iter(upstream.values()), None)
|
||||
return found
|
||||
|
||||
|
||||
def base_ref(repo: str, base: str) -> tuple[str, str]:
|
||||
local, remote = f"refs/heads/{base}", f"refs/remotes/origin/{base}"
|
||||
if not resolves(repo, remote):
|
||||
if not resolves(repo, local):
|
||||
fail(f"{base} does not resolve in {repo}")
|
||||
return base, local
|
||||
if not resolves(repo, local) or ancestor(repo, local, remote):
|
||||
return f"origin/{base}", remote
|
||||
if ancestor(repo, remote, local):
|
||||
return base, local
|
||||
fail(f"{base} and origin/{base} have diverged in {repo}; merge one into the other first")
|
||||
|
||||
|
||||
def default_ref(repo: str, given: str | None) -> tuple[str, tuple[str, str]]:
|
||||
if git(repo, "config", "--get", "remote.origin.url").stdout.strip():
|
||||
default = default_branch(repo, given)
|
||||
fetched(repo)
|
||||
return default, (f"origin/{default}", f"refs/remotes/origin/{default}")
|
||||
if given is None:
|
||||
fail(f"{repo} has no origin remote; pass --default <branch>, its local default branch")
|
||||
default = branch_name(repo, given, "--default")
|
||||
return default, (default, f"refs/heads/{default}")
|
||||
|
||||
|
||||
def cmd_sync(rasci: ModuleType, args: argparse.Namespace) -> int:
|
||||
repo = worktree(args.repo)
|
||||
tasks = rasci.load()
|
||||
if args.task not in tasks:
|
||||
fail(f"{args.task}: not in the register")
|
||||
branch = tasks[args.task].get("branch")
|
||||
if not branch:
|
||||
fail(f"{args.task} records no branch; set it with `rasci.py set {args.task} --branch <branch>`")
|
||||
head = git(repo, "symbolic-ref", "--quiet", "HEAD").stdout.strip()
|
||||
if head != f"refs/heads/{branch}":
|
||||
fail(f"{repo} has {head.removeprefix('refs/heads/') or 'a detached HEAD'} checked out, "
|
||||
f"{args.task} works on {branch}")
|
||||
default, first = default_ref(repo, args.default)
|
||||
with locked(repo, ["hotfix.py", "sync", "--repo", repo, "--task", args.task]):
|
||||
watched(repo)
|
||||
dirty = [line[3:] for line in git_out(repo, "status", "--porcelain=v1", "--untracked-files=no").splitlines()]
|
||||
if dirty:
|
||||
fail(f"tracked files are dirty, commit them first: {', '.join(dirty)}")
|
||||
if not resolves(repo, first[1]):
|
||||
fail(f"{first[0]} does not resolve in {repo}")
|
||||
bases = chain(repo, tasks, args.task, default, rasci.ENDED)
|
||||
for shown, ref in [first, *(base_ref(repo, base) for base in bases)]:
|
||||
if ancestor(repo, ref, "HEAD"):
|
||||
print(f"contained\t{shown}")
|
||||
continue
|
||||
result = git(repo, "merge", "--no-verify", "--no-edit", ref)
|
||||
if result.returncode == 0:
|
||||
print(f"merged\t{shown}\t{git_out(repo, 'rev-parse', 'HEAD').strip()}")
|
||||
continue
|
||||
files = git(repo, "diff", "--name-only", "--diff-filter=U").stdout.splitlines()
|
||||
if git(repo, "rev-parse", "--verify", "--quiet", "MERGE_HEAD").returncode == 0:
|
||||
git_out(repo, "merge", "--abort")
|
||||
if not files:
|
||||
fail(f"git merge {shown} failed and was aborted: {(result.stderr or result.stdout).strip()}")
|
||||
print(f"conflict\t{shown}\t{','.join(files)}")
|
||||
print(f"hotfix: merging {shown} conflicts in {', '.join(files)}; merge aborted", file=sys.stderr)
|
||||
return 4
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
p = sub.add_parser("add")
|
||||
p.add_argument("slug")
|
||||
for flag in ("--repo", "--branch", "--sha", "--signature"):
|
||||
p.add_argument(flag, required=True)
|
||||
p.add_argument("--status", choices=STAGES, required=True)
|
||||
p.add_argument("--target", action="append")
|
||||
p.add_argument("--pr", type=int)
|
||||
p.add_argument("--default")
|
||||
p.add_argument("--session")
|
||||
p.set_defaults(func=cmd_add)
|
||||
p = sub.add_parser("update")
|
||||
p.add_argument("slug")
|
||||
p.add_argument("--status", choices=STAGES, required=True)
|
||||
p.add_argument("--sha")
|
||||
p.add_argument("--pr", type=int)
|
||||
p.add_argument("--repo")
|
||||
p.add_argument("--default")
|
||||
p.add_argument("--session")
|
||||
p.set_defaults(func=cmd_update)
|
||||
p = sub.add_parser("pending")
|
||||
p.add_argument("--repo", required=True)
|
||||
p.add_argument("--target", action="append")
|
||||
p.set_defaults(func=cmd_pending)
|
||||
p = sub.add_parser("match")
|
||||
p.add_argument("--line", required=True)
|
||||
p.add_argument("--repo")
|
||||
p.set_defaults(func=cmd_match)
|
||||
p = sub.add_parser("void")
|
||||
p.add_argument("--repo", required=True)
|
||||
p.add_argument("--target", action="append", required=True)
|
||||
p.set_defaults(func=cmd_void)
|
||||
p = sub.add_parser("sync")
|
||||
p.add_argument("--repo", required=True)
|
||||
p.add_argument("--task", required=True)
|
||||
p.add_argument("--default")
|
||||
p.set_defaults(func=cmd_sync)
|
||||
args = parser.parse_args()
|
||||
return args.func(module("rasci", RASCI), args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
name: kvb-git-issue-close
|
||||
description: >
|
||||
Close finished issues, of one pull request or the whole repository, each
|
||||
verified and confirmed.
|
||||
---
|
||||
|
||||
Closing an issue is outward-facing: nothing is closed or edited before the
|
||||
operator confirms the list, and every closed issue names the pull request or
|
||||
commit that fixed it.
|
||||
|
||||
## Step 1: candidates
|
||||
|
||||
- **A pull request named** (or the current branch's, `gh pr view --json
|
||||
number`): `${CLAUDE_SKILL_DIR}/scripts/issues.py` lists every issue it
|
||||
references: closing keywords and mentions in the body and the commits, a
|
||||
trailing number in the branch name, and the issues GitHub already links.
|
||||
|
||||
```bash
|
||||
gh pr view <pr> --json number,body,headRefName,commits,closingIssuesReferences | issues.py --repo <owner/name>
|
||||
```
|
||||
|
||||
- **No pull request**: every open issue of the repository. After `git fetch`,
|
||||
`${CLAUDE_SKILL_DIR}/scripts/done.py` lists the default-branch commits that
|
||||
reference each one:
|
||||
|
||||
```bash
|
||||
gh issue list --state open --limit 1000 --json number | done.py --repo <owner/name> --git-dir . --ref origin/<default>
|
||||
```
|
||||
|
||||
An open issue no commit references can still be fixed: search the default
|
||||
branch by its description with the kvb-git-merge-fix skill's `find_fix.py`.
|
||||
|
||||
## Step 2: verify
|
||||
|
||||
Per candidate, `gh issue view <n> --json state,title,body,comments` (with
|
||||
`--repo` for a foreign one); skip a closed issue. Judge it `fixed`, `partly`
|
||||
or `unrelated` against the diff, with evidence: the change addressing each
|
||||
point of the issue and the test or CI run proving it. Only `fixed` goes on;
|
||||
`partly` gets a progress comment instead.
|
||||
|
||||
## Step 3: confirm
|
||||
|
||||
`AskUserQuestion` with `multiSelect: true`, one option per `fixed` issue, the
|
||||
evidence in its description, nothing preselected.
|
||||
|
||||
## Step 4: close
|
||||
|
||||
Where the fix lives decides how:
|
||||
|
||||
- **On the default branch**: `gh issue close <n> --reason completed
|
||||
--comment "Fixed by #<pr> (<sha>)."`, or the commit alone when no pull
|
||||
request carried it.
|
||||
- **In an open pull request, or merged into another branch**: add one
|
||||
`Closes #<n>` line per issue to the pull request body (`gh pr edit <pr>
|
||||
--body-file <file>`, keeping the existing body), so the merge into the
|
||||
default branch closes them; never close them early.
|
||||
|
||||
## Output
|
||||
|
||||
| Issue | Verdict | Evidence | Action |
|
||||
Executable
+80
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env python3
|
||||
"""List the default-branch commits that reference each open issue.
|
||||
|
||||
Args:
|
||||
stdin: JSON list of open issues, as `gh issue list --state open --json
|
||||
number` prints it.
|
||||
--repo: owner/name of the repository; its own references count, foreign
|
||||
ones do not.
|
||||
--git-dir: path of a local clone, fetched.
|
||||
--ref: the default branch to scan, e.g. origin/main.
|
||||
--json: print the result as JSON instead of tab-separated lines.
|
||||
|
||||
Output:
|
||||
one line per open issue that a commit references: issue, closing or
|
||||
mention, the short shas.
|
||||
|
||||
Exit codes:
|
||||
0 done, 2 usage error.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from issues import PATTERN, fail
|
||||
|
||||
|
||||
def references(log: str, own: str, open_issues: set[int]) -> dict[int, dict]:
|
||||
found: dict[int, dict] = {}
|
||||
for record in filter(None, log.split("\x1e")):
|
||||
sha, _, message = record.strip("\n").partition("\x1f")
|
||||
for match in PATTERN.finditer(message):
|
||||
repo = match.group("repo") or match.group("url") or ""
|
||||
number = int(match.group("num"))
|
||||
if number not in open_issues or (repo and repo.lower() != own.lower()):
|
||||
continue
|
||||
entry = found.setdefault(number, {"closing": False, "commits": []})
|
||||
entry["closing"] = entry["closing"] or bool(match.group("kw"))
|
||||
if sha[:10] not in entry["commits"]:
|
||||
entry["commits"].append(sha[:10])
|
||||
return found
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("--repo", required=True)
|
||||
parser.add_argument("--git-dir", required=True)
|
||||
parser.add_argument("--ref", required=True)
|
||||
parser.add_argument("--json", action="store_true")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
issues = json.load(sys.stdin)
|
||||
open_issues = {int(issue["number"]) for issue in issues}
|
||||
except (ValueError, TypeError, KeyError):
|
||||
fail("stdin is not the JSON of gh issue list --json number")
|
||||
log = subprocess.run(
|
||||
["git", "log", "--format=%H%x1f%B%x1e", args.ref],
|
||||
cwd=args.git_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if log.returncode != 0:
|
||||
fail(log.stderr.strip() or f"git log {args.ref} failed")
|
||||
found = references(log.stdout, args.repo, open_issues)
|
||||
rows = [{"issue": number, **found[number]} for number in sorted(found)]
|
||||
if args.json:
|
||||
print(json.dumps(rows, indent=1))
|
||||
return 0
|
||||
for row in rows:
|
||||
kind = "closing" if row["closing"] else "mention"
|
||||
print(f"{row['issue']}\t{kind}\t{', '.join(row['commits'])}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env python3
|
||||
"""List the issues a pull request references, from `gh pr view --json` on stdin.
|
||||
|
||||
Args:
|
||||
stdin: JSON with number, body, headRefName, commits and
|
||||
closingIssuesReferences, as `gh pr view <pr> --json
|
||||
number,body,headRefName,commits,closingIssuesReferences` prints it.
|
||||
--repo: owner/name of the pull request's repository; its own references
|
||||
are reported without a repository.
|
||||
--json: print the candidates as JSON instead of tab-separated lines.
|
||||
|
||||
Exit codes:
|
||||
0 done, 2 usage error.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
KEYWORD = r"(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)"
|
||||
REF = r"(?:(?P<repo>[\w.-]+/[\w.-]+)#|https://github\.com/(?P<url>[\w.-]+/[\w.-]+)/issues/|(?<![\w/])#)(?P<num>[1-9]\d*)"
|
||||
PATTERN = re.compile(rf"(?P<kw>\b{KEYWORD}\b:?\s+)?{REF}", re.IGNORECASE)
|
||||
BRANCH = re.compile(r"[/-](\d+)$")
|
||||
|
||||
|
||||
def fail(message: str) -> None:
|
||||
print(f"issues: {message}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
def scan(text: str, source: str, found: dict) -> None:
|
||||
for match in PATTERN.finditer(text or ""):
|
||||
repo = match.group("repo") or match.group("url") or ""
|
||||
entry = found.setdefault(
|
||||
(repo, int(match.group("num"))), {"closing": False, "sources": []}
|
||||
)
|
||||
entry["closing"] = entry["closing"] or bool(match.group("kw"))
|
||||
if source not in entry["sources"]:
|
||||
entry["sources"].append(source)
|
||||
|
||||
|
||||
def candidates(pr: dict, own: str) -> list[dict]:
|
||||
found: dict = {}
|
||||
scan(pr.get("body", ""), "body", found)
|
||||
for commit in pr.get("commits", []):
|
||||
text = f"{commit.get('messageHeadline', '')}\n{commit.get('messageBody', '')}"
|
||||
scan(text, f"commit {commit.get('oid', '')[:10]}", found)
|
||||
branch = BRANCH.search(pr.get("headRefName", ""))
|
||||
if branch:
|
||||
found.setdefault(("", int(branch.group(1))), {"closing": False, "sources": []})[
|
||||
"sources"
|
||||
].append("branch")
|
||||
for linked in pr.get("closingIssuesReferences", []):
|
||||
url = re.search(r"github\.com/([\w.-]+/[\w.-]+)/issues/", linked.get("url", ""))
|
||||
key = (url.group(1) if url else "", int(linked["number"]))
|
||||
entry = found.setdefault(key, {"closing": False, "sources": []})
|
||||
entry["closing"] = True
|
||||
entry["sources"].append("linked")
|
||||
merged: dict = {}
|
||||
for (repo, num), entry in found.items():
|
||||
key = ("" if repo.lower() == own.lower() else repo, num)
|
||||
if key == ("", pr.get("number")):
|
||||
continue
|
||||
target = merged.setdefault(key, {"closing": False, "sources": []})
|
||||
target["closing"] = target["closing"] or entry["closing"]
|
||||
target["sources"] += [s for s in entry["sources"] if s not in target["sources"]]
|
||||
return [{"repo": repo, "issue": num, **entry} for (repo, num), entry in sorted(merged.items())]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("--repo", required=True)
|
||||
parser.add_argument("--json", action="store_true")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
pr = json.load(sys.stdin)
|
||||
except ValueError:
|
||||
fail("stdin is not the JSON of gh pr view --json")
|
||||
if not isinstance(pr, dict):
|
||||
fail("stdin is not a pull request object")
|
||||
rows = candidates(pr, args.repo)
|
||||
if args.json:
|
||||
print(json.dumps(rows, indent=1))
|
||||
return 0
|
||||
for row in rows:
|
||||
kind = "closing" if row["closing"] else "mention"
|
||||
print(f"{row['repo'] or '-'}\t{row['issue']}\t{kind}\t{', '.join(row['sources'])}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: kvb-git-issue-solve
|
||||
description: >
|
||||
Solve open issues, each in its own worktree, and open one pull request per
|
||||
issue.
|
||||
---
|
||||
|
||||
Every issue gets its own sidetree, its own evidence and its own pull
|
||||
request, so a red one blocks nothing else.
|
||||
|
||||
## Procedure
|
||||
|
||||
1. **Candidates**: the issues named, else `gh issue list --state open`. Drop
|
||||
what the `kvb-git-issue-close` skill finds already fixed, and what a peer
|
||||
already works on: `council.py list --long`, the register and the radio of the
|
||||
`kvb-org-organize` Peer protocol, no message; when a peer solves it, only
|
||||
comment on the issue who solves it where.
|
||||
2. **Rank**: issues about a failing default branch first, then the rank of
|
||||
the `kvb-org-wsjf` skill; the operator's order wins.
|
||||
3. **Take** each issue before touching it: label it `in analysis` (`gh issue
|
||||
edit <n> --add-label "in analysis"`) and comment once with this session's
|
||||
name, the planned branch and the register task. A missing label is
|
||||
created once: `gh label create "in analysis" --color FBCA04 --description
|
||||
"An agent analyses this issue"`, `gh label create "in progress" --color
|
||||
1D76DB --description "An agent works on a fix"`.
|
||||
4. **Solve** each with the `kvb-org-split` skill: claim `work/issue-<n>`,
|
||||
enter it in the `kvb-org-rasci` register (objective: the issue fixed; key
|
||||
results: a test that failed before and passes after, the pull request
|
||||
open), branch `fix/<scope>/<n>` from the default branch in its own
|
||||
worktree, evidence, self-assessment, independent review. Commits go to
|
||||
that branch only, never to the default branch. Spawn agents only as the
|
||||
issues' stakes justify; each brief names the issue, the files and the
|
||||
claim to prove, and caps the answer at a verdict table of at most 15
|
||||
lines. 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. Parallel
|
||||
work, gates, deploys and anything with Docker run through `queue.py hold`
|
||||
(`kvb-org-queue`); light runs (no Docker, about a minute) self-admit with
|
||||
`watch.py admit --kind test-nostack`. With the first change, swap the label: `gh issue
|
||||
edit <n> --remove-label "in analysis" --add-label "in progress"`.
|
||||
5. **Open the pull request** with the `kvb-git-pr-create` skill, closing
|
||||
exactly its issue; one for a failing default branch is posted with
|
||||
`radio.py post`. Below the split bar the proposal becomes a draft pull
|
||||
request with the residuals and the evidence that would close them.
|
||||
6. **Report** per issue, then release its claim and tear its sidetree down
|
||||
as `kvb-org-split` describes; the branch stays until the merge. An issue
|
||||
dropped or handed on loses its label and gets a comment saying why.
|
||||
|
||||
## Output
|
||||
|
||||
| Issue | Branch | Score | PR | State |
|
||||
@@ -1,14 +1,11 @@
|
||||
---
|
||||
name: kvb-git-merge-fix
|
||||
description: >
|
||||
Find the commits on any branch that carry a described fix and merge them
|
||||
here. Trigger on /kvb-git-merge-fix <description> or a request to bring over
|
||||
a fix from another branch.
|
||||
Find commits on any branch carrying a described fix and merge them here. Trigger on a request to bring over a fix from another branch.
|
||||
---
|
||||
|
||||
The free text after `/kvb-git-merge-fix` is the fix description, exactly like `/loop`
|
||||
takes its prompt. Everything after the skill name is the search intent; there is
|
||||
no other input. If it is missing, ask for it and stop.
|
||||
The free text after the skill name is the fix description and the only input.
|
||||
If it is missing, ask for it and stop.
|
||||
|
||||
## 1. Refuse to work on a dirty tree
|
||||
|
||||
@@ -23,14 +20,16 @@ same tree may own.
|
||||
git fetch --all --prune --tags
|
||||
```
|
||||
|
||||
That is the "pull all branches" step. Do not check branches out one by one; all
|
||||
searching below runs against the fetched refs.
|
||||
Never check branches out one by one; the search runs against the fetched refs.
|
||||
|
||||
## 3. Turn the description into search terms
|
||||
|
||||
Derive from the operator's text: 2-4 keywords for commit messages, plus any
|
||||
symbol, string, or path the fix must have touched. State the derived terms in
|
||||
one line so a wrong reading is visible before the search.
|
||||
symbol, string, or path the fix must have touched. Find those symbols and
|
||||
paths 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. State the derived terms in one line so
|
||||
a wrong reading is visible before the search.
|
||||
|
||||
## 4. Search every ref
|
||||
|
||||
@@ -59,15 +58,18 @@ and its diff plainly is the described fix.
|
||||
- One or a few commits, rest of the branch unwanted: `git cherry-pick -x <sha>…`
|
||||
(`-x` records the origin).
|
||||
- The whole branch is wanted: `git merge --no-ff <branch>`.
|
||||
- Every cherry-pick or merge call creates commits: it follows the
|
||||
approval-prompt duty of `kvb-git-commit-no-verify`.
|
||||
- Conflicts: resolve them by reading both sides. Never `-X ours`/`-X theirs`,
|
||||
never `git checkout --ours/--theirs` wholesale — that silently drops one
|
||||
never `git checkout --ours/--theirs` wholesale: that silently drops one
|
||||
side's fix. Unresolvable → `git cherry-pick --abort` / `git merge --abort`,
|
||||
report what conflicted, change nothing else.
|
||||
|
||||
## 7. Report
|
||||
|
||||
Show `git log --oneline <base>..HEAD` and the resulting diffstat, then apply the
|
||||
kvb-check-confidence skill: what came over, what did not, what still has to be verified.
|
||||
kvb-check-confidence skill and its kvb-check-score block: what came over, what
|
||||
did not, what still has to be verified.
|
||||
Run the project's test or quality gate only when the operator asks for it.
|
||||
|
||||
## Rules
|
||||
@@ -77,4 +79,4 @@ Run the project's test or quality gate only when the operator asks for it.
|
||||
- Never rewrite history to make a cherry-pick fit (no rebase of the target
|
||||
branch, no amend of foreign commits).
|
||||
- If the search finds nothing, say so plainly and list the terms tried. Do not
|
||||
reimplement the fix under this skill — that is a different task.
|
||||
reimplement the fix under this skill; that is a different task.
|
||||
@@ -5,33 +5,69 @@ Usage:
|
||||
find_fix.py [--grep KEYWORD]... [-S LITERAL]... [--path PATH]... [--fetch]
|
||||
|
||||
Prints one line per candidate: sha, up to three branches, subject, files
|
||||
touched. -S also matches commits that removed the literal; read the diff.
|
||||
touched. The filters are alternatives: a commit is a hit when it matches any
|
||||
one of them. -S also matches commits that removed the literal; read the diff.
|
||||
Run it inside the repository; it never checks anything out.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
LIMIT = 30
|
||||
WORKERS = os.cpu_count() or 1
|
||||
SLICES_PER_WORKER = 4
|
||||
|
||||
|
||||
def git(*args: str) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(["git", *args], capture_output=True, text=True, check=False)
|
||||
def git(*args: str, stdin: str | None = None) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(["git", *args], input=stdin, capture_output=True, text=True, check=False)
|
||||
|
||||
|
||||
def search(args: argparse.Namespace) -> list[str]:
|
||||
queries = [["--regexp-ignore-case", f"--grep={k}"] for k in args.grep]
|
||||
queries += [[f"-S{s}"] for s in args.S]
|
||||
queries += [["--", p] for p in args.path]
|
||||
found: list[str] = []
|
||||
def log_all(pool: ThreadPoolExecutor, *query: str) -> Callable[[], list[str]]:
|
||||
job = pool.submit(git, "log", "--all", "--format=%H", *query)
|
||||
return lambda: job.result().stdout.split()
|
||||
|
||||
|
||||
def pickaxe(pool: ThreadPoolExecutor, literal: str, rank: dict[str, int]) -> Callable[[], list[str]]:
|
||||
"""Run `git log --all -S<literal>` as parallel slices of the commit list.
|
||||
|
||||
The slices are strided because the costly commits cluster in time.
|
||||
|
||||
Args:
|
||||
pool: executor the slice runs are submitted to.
|
||||
literal: the -S literal.
|
||||
rank: every commit of `git rev-list --all`, mapped to its position.
|
||||
|
||||
Returns:
|
||||
A callable yielding the matching commits in `git log --all` order.
|
||||
"""
|
||||
commits = list(rank)
|
||||
parts = min(len(commits), WORKERS * SLICES_PER_WORKER)
|
||||
jobs = [
|
||||
pool.submit(
|
||||
git, "log", "--no-walk=unsorted", "--stdin", "--format=%H", f"-S{literal}", stdin="\n".join(commits[i::parts]) + "\n"
|
||||
)
|
||||
for i in range(parts)
|
||||
]
|
||||
return lambda: sorted((sha for job in jobs for sha in job.result().stdout.split()), key=rank.__getitem__)
|
||||
|
||||
|
||||
def search(args: argparse.Namespace, pool: ThreadPoolExecutor) -> list[str]:
|
||||
queries = [log_all(pool, "--regexp-ignore-case", f"--grep={k}") for k in args.grep]
|
||||
if args.S:
|
||||
rank = {sha: i for i, sha in enumerate(git("rev-list", "--all").stdout.split())}
|
||||
queries += [pickaxe(pool, s, rank) for s in args.S]
|
||||
queries += [log_all(pool, "--", p) for p in args.path]
|
||||
found: dict[str, None] = {}
|
||||
for query in queries:
|
||||
for sha in git("log", "--all", "--format=%H", *query).stdout.split():
|
||||
if sha not in found:
|
||||
found.append(sha)
|
||||
return found
|
||||
for sha in query():
|
||||
found.setdefault(sha)
|
||||
return list(found)
|
||||
|
||||
|
||||
def describe(sha: str) -> str:
|
||||
@@ -53,11 +89,18 @@ def main() -> int:
|
||||
parser.error("give at least one --grep, -S or --path")
|
||||
if args.fetch:
|
||||
git("fetch", "--all", "--prune", "--tags")
|
||||
hits = search(args)
|
||||
outside = [sha for sha in hits if git("merge-base", "--is-ancestor", sha, "HEAD").returncode]
|
||||
pool = ThreadPoolExecutor(max_workers=WORKERS)
|
||||
try:
|
||||
head = pool.submit(git, "rev-list", "HEAD")
|
||||
hits = search(args, pool)
|
||||
in_head = set(head.result().stdout.split())
|
||||
outside = [sha for sha in hits if sha not in in_head]
|
||||
print(f"hits={len(hits)} in_head={len(hits) - len(outside)} candidates={len(outside)}")
|
||||
for sha in outside[:LIMIT]:
|
||||
print(describe(sha))
|
||||
for line in pool.map(describe, outside[:LIMIT]):
|
||||
print(line)
|
||||
finally:
|
||||
# A plain `with` would start every queued git slice after Ctrl-C.
|
||||
pool.shutdown(cancel_futures=True)
|
||||
if len(outside) > LIMIT:
|
||||
print(f"... {len(outside) - LIMIT} more, narrow the search")
|
||||
return 0
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
name: kvb-git-pr-create
|
||||
description: >
|
||||
Open a pull request for the current branch: push handed over, Closes lines,
|
||||
labels.
|
||||
---
|
||||
|
||||
Opening a pull request is outward-facing and needs the branch on the remote,
|
||||
which only the operator pushes. Nothing is created before the operator
|
||||
confirms it.
|
||||
|
||||
## Procedure
|
||||
|
||||
1. **Check**: the branch is committed and clean, branched from the default
|
||||
branch (`gh repo view --json defaultBranchRef`), and has no open pull
|
||||
request yet (`gh pr list --head <branch>`).
|
||||
2. **Assess** the branch with the `kvb-check-confidence` skill: its
|
||||
`kvb-check-score` block and residuals.
|
||||
3. **Push**: hand the operator `gipsn` in the worktree through the
|
||||
`kvb-org-operator` skill, and continue once `git ls-remote --heads origin
|
||||
<branch>` shows the branch's head commit.
|
||||
4. **Body**, written to a scratch file: what changed and why, the test
|
||||
evidence, the score block, and one `Closes #<n>` line per issue the
|
||||
branch fixes, each verified as the `kvb-git-issue-close` skill does.
|
||||
5. **Confirm** with one `AskUserQuestion`: title (Conventional Commits),
|
||||
base, state and labels. The state follows the bar of the `kvb-org-split`
|
||||
skill (fix confidence ≥ 90 %, break risk ≤ 5 %, efficiency and speed
|
||||
≤ 100 %): ready at or above it, a draft with the residuals in the body
|
||||
below it. An efficiency or speed over 100 % goes into this question with
|
||||
its cause.
|
||||
6. **Create**: `gh pr create --base <default> --head <branch> --title
|
||||
"<title>" --body-file <file>`, plus `--draft` below the bar.
|
||||
7. **Label** with the `kvb-git-pr-label` skill.
|
||||
|
||||
## Output
|
||||
|
||||
| PR | Base | State | Labels | Closes |
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: kvb-git-pr-label
|
||||
description: >
|
||||
Label pull requests `hotfix` and `90/5` from evidence; kvb-git-pr-create
|
||||
runs it.
|
||||
---
|
||||
|
||||
Two labels, each set only with its evidence and removed when it no longer
|
||||
holds:
|
||||
|
||||
- **`hotfix`**: the pull request targets the default branch and fixes a
|
||||
failure on it, a red default-branch run or an issue that reproduces there;
|
||||
name the run or issue.
|
||||
- **`90/5`**: the pull request meets the bar of the `kvb-org-split` skill (fix
|
||||
confidence ≥ 90 %, break risk ≤ 5 %, efficiency and speed ≤ 100 %; over
|
||||
100 %, ask the operator first, naming the cause), from the
|
||||
`kvb-check-score` block in its body or freshly assessed, and only when
|
||||
`gate.py status` of the `kvb-check-gate` skill prints `gate: green` with
|
||||
the PR's exact head checked out.
|
||||
|
||||
## Procedure
|
||||
|
||||
1. Target: the pull request named, else the current branch's (`gh pr view
|
||||
--json number,baseRefName,headRefName,isDraft,labels,body`); the default
|
||||
branch from `gh repo view --json defaultBranchRef`.
|
||||
2. Judge both labels with their evidence.
|
||||
3. A missing label is created once: `gh label create hotfix --color B60205
|
||||
--description "Fixes a failure on the default branch"`, `gh label create
|
||||
90/5 --color 0E8A16 --description
|
||||
"Fix confidence >= 90 %, break risk <= 5 %, efficiency and speed <= 100 %"`.
|
||||
4. Standalone, confirm the changes with one `AskUserQuestion`; from
|
||||
`kvb-git-pr-create`, its confirmation covers them.
|
||||
5. `gh pr edit <pr> --add-label <l>` or `--remove-label <l>`. A draft that
|
||||
gains `90/5` also gets `gh pr ready <pr>`.
|
||||
|
||||
## Output
|
||||
|
||||
| PR | Label | Change | Evidence |
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: kvb-git-update-branches
|
||||
description: >
|
||||
Merge main into open PRs or branches: the operator's own directly, others
|
||||
after consent.
|
||||
---
|
||||
|
||||
Invoking this skill authorizes the merge commits it makes; pushing stays the
|
||||
operator's. Branches are merged, never rebased.
|
||||
|
||||
## Step 1: scope and candidates
|
||||
|
||||
Pull requests, branches or both, as the operator said; ask when it is open.
|
||||
`git fetch --all --prune`, then list the open branches with lag and owner:
|
||||
|
||||
```bash
|
||||
gh pr list --state open --json number,headRefName,author,isCrossRepository > <tmp>/prs.json
|
||||
branches.py --repo <repo> --remote origin --base main --email "<git user.email>" --login "<gh login>" --prs <tmp>/prs.json
|
||||
```
|
||||
|
||||
`${CLAUDE_SKILL_DIR}/scripts/branches.py` drops branches already merged into
|
||||
the base and marks `mine`: a pull request by the operator's login, or a
|
||||
branch whose own commits all carry the operator's email. A row with
|
||||
`behind 0` is current and skipped.
|
||||
|
||||
## Step 2: consent
|
||||
|
||||
The operator's own rows go on. For every other row run the `kvb-think-listen`
|
||||
skill: one `AskUserQuestion` with `multiSelect: true` listing branch, pull
|
||||
request, owner and lag, nothing preselected. Only the picks are updated.
|
||||
|
||||
## Step 3: update
|
||||
|
||||
- **Fork pull request**: no local merge; its `gh pr update-branch <n>` goes
|
||||
to the operator.
|
||||
- **Any other branch**: in the worktree that already has it checked out,
|
||||
else a temporary one under `/tmp` (the repository's own worktree targets
|
||||
where it has them), `git merge --no-verify --no-edit origin/main`, with the
|
||||
approval-prompt duty of `kvb-git-commit-no-verify`. On a conflict, `git
|
||||
merge --abort` and report the files; the `kvb-git-merge-fix` skill or the
|
||||
operator resolves it later.
|
||||
- Remove the temporary worktrees afterwards.
|
||||
|
||||
## Step 4: hand over the pushes
|
||||
|
||||
Every updated branch's push and every `gh pr update-branch` goes to the
|
||||
`kvb-org-operator` skill as an operator command.
|
||||
|
||||
## Output
|
||||
|
||||
| Branch | PR | Owner | Behind | Result | Push command |
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env python3
|
||||
"""List the open branches of a remote with their lag behind the base and their owner.
|
||||
|
||||
Args:
|
||||
--repo: path of the local clone.
|
||||
--remote: remote whose branches are listed, e.g. origin.
|
||||
--base: base branch on that remote, e.g. main.
|
||||
--email: the operator's commit email; a branch whose own commits all carry
|
||||
it belongs to the operator.
|
||||
--login: the operator's forge login, matched against pull request authors.
|
||||
--prs: file with `gh pr list --state open --json
|
||||
number,headRefName,author,isCrossRepository`; omit it for branches only.
|
||||
--json: print the rows as JSON instead of tab-separated lines.
|
||||
|
||||
Exit codes:
|
||||
0 done, 2 usage error.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def fail(message: str) -> None:
|
||||
print(f"branches: {message}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
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(result.stderr.strip() or f"git {' '.join(args)} failed")
|
||||
return result.stdout
|
||||
|
||||
|
||||
def load_prs(path: str | None) -> list[dict]:
|
||||
if path is None:
|
||||
return []
|
||||
try:
|
||||
prs = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
fail(f"{path} is not the JSON of gh pr list")
|
||||
if not isinstance(prs, list):
|
||||
fail(f"{path} is not a list of pull requests")
|
||||
return prs
|
||||
|
||||
|
||||
def rows(repo: Path, remote: str, base: str, email: str, login: str, prs: list[dict]) -> list[dict]:
|
||||
target = f"{remote}/{base}"
|
||||
git(repo, "rev-parse", "--verify", target)
|
||||
by_branch = {pr["headRefName"]: pr for pr in prs if not pr.get("isCrossRepository")}
|
||||
found = []
|
||||
refs = git(repo, "for-each-ref", "--format=%(refname:short)", f"refs/remotes/{remote}/")
|
||||
for ref in refs.split():
|
||||
branch = ref.removeprefix(f"{remote}/")
|
||||
if branch in (base, "HEAD") or ref == remote:
|
||||
continue
|
||||
ahead = int(git(repo, "rev-list", "--count", f"{target}..{ref}"))
|
||||
if ahead == 0:
|
||||
continue
|
||||
behind = int(git(repo, "rev-list", "--count", f"{ref}..{target}"))
|
||||
authors = sorted(set(git(repo, "log", "--format=%ae", f"{target}..{ref}").split()))
|
||||
pr = by_branch.get(branch)
|
||||
mine = (pr["author"]["login"] == login) if pr else authors == [email]
|
||||
found.append(
|
||||
{
|
||||
"branch": branch,
|
||||
"pr": pr["number"] if pr else None,
|
||||
"ahead": ahead,
|
||||
"behind": behind,
|
||||
"authors": authors,
|
||||
"mine": mine,
|
||||
}
|
||||
)
|
||||
for pr in prs:
|
||||
if pr.get("isCrossRepository"):
|
||||
found.append(
|
||||
{
|
||||
"branch": pr["headRefName"],
|
||||
"pr": pr["number"],
|
||||
"ahead": None,
|
||||
"behind": None,
|
||||
"authors": [pr["author"]["login"]],
|
||||
"mine": pr["author"]["login"] == login,
|
||||
"fork": True,
|
||||
}
|
||||
)
|
||||
return found
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
for name in ("--repo", "--remote", "--base", "--email", "--login"):
|
||||
parser.add_argument(name, required=True)
|
||||
parser.add_argument("--prs")
|
||||
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")
|
||||
found = rows(repo, args.remote, args.base, args.email, args.login, load_prs(args.prs))
|
||||
if args.json:
|
||||
print(json.dumps(found, indent=1))
|
||||
return 0
|
||||
for row in found:
|
||||
print(
|
||||
f"{row['branch']}\t#{row['pr'] or '-'}\tahead {row['ahead']}\tbehind {row['behind']}"
|
||||
f"\t{'mine' if row['mine'] else ', '.join(row['authors'])}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,9 +1,8 @@
|
||||
---
|
||||
name: kvb-help
|
||||
description: >
|
||||
List and explain every skill in the session. Trigger on /kvb-help,
|
||||
"welche skills gibt es", "skill übersicht", or when the operator asks what
|
||||
skills exist or what one does.
|
||||
List and explain every session skill. Trigger on "welche skills gibt es",
|
||||
"skill übersicht", or questions on what skills exist or what one does.
|
||||
---
|
||||
|
||||
Produce a one-shot overview of every skill the session can invoke:
|
||||
@@ -13,8 +12,8 @@ Produce a one-shot overview of every skill the session can invoke:
|
||||
user-global skills (`~/.claude/skills/`), and plugin-provided skills.
|
||||
Deduplicate by name; note when a project skill shadows a global one.
|
||||
2. Output one table per origin (project, global, plugin): skill name,
|
||||
trigger (slash command or phrasing), one-line purpose in your own words
|
||||
— compress the description, do not paste it.
|
||||
trigger (slash command or phrasing), one-line purpose in your own words,
|
||||
compressing the description instead of pasting it.
|
||||
3. When the operator names a specific skill or topic, explain only the
|
||||
matching skills in more depth: what they do, when they fire on their
|
||||
own, and how they compose (e.g. kvb-git-commit-next → kvb-git-commit-no-verify →
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
name: kvb-optimize
|
||||
description: >
|
||||
Run every optimization skill in one pass with one round of questions.
|
||||
Trigger on /kvb-optimize; kvb-dream runs it.
|
||||
kvb-dream runs it.
|
||||
---
|
||||
|
||||
Collector for the optimization skills, so one pass needs one evidence
|
||||
@@ -23,10 +23,14 @@ inventory is skipped with one line.
|
||||
4. `kvb-skill-tune`: turn frictions into new or sharper skills.
|
||||
5. `kvb-skill-compile`: turn deterministic skill steps into scripts.
|
||||
6. `kvb-skill-essence`: compact skills and memory.
|
||||
7. `kvb-code-refactor`: bring the uncommitted changes up to the project's
|
||||
7. `kvb-skill-silence`: cut the tokens and agent messages the skills still
|
||||
cost.
|
||||
8. `kvb-code-refactor`: bring the uncommitted changes up to the project's
|
||||
standards.
|
||||
8. `kvb-code-tune`: optimize the hot paths the evidence measured.
|
||||
9. `kvb-think-gaps`: find the missing work and hand each pick to
|
||||
9. `kvb-code-tune`: optimize the hot paths the evidence measured.
|
||||
10. `kvb-agent-optimize`: index every repository in scope as a code graph
|
||||
and tighten its agent instructions.
|
||||
11. `kvb-think-gaps`: find the missing work and hand each pick to
|
||||
`kvb-create-requirement`.
|
||||
|
||||
## Questions
|
||||
@@ -39,5 +43,5 @@ grouped by skill. Each skill writes only what was picked for it.
|
||||
|
||||
| Skill | Candidates | Picked | Written | Skipped with reason |
|
||||
|
||||
Tests and lints of the written changes run once at the end, on the resource
|
||||
chair's grant (the `kvb-org-organize` skill); nothing is committed.
|
||||
Tests and lints of the written changes run once at the end, through
|
||||
`queue.py hold` (the `kvb-org-queue` skill); nothing is committed.
|
||||
@@ -2,13 +2,13 @@
|
||||
name: kvb-org-artefacts
|
||||
description: >
|
||||
Artifacts of the kvb-org-rasci register per task: images, volumes,
|
||||
worktrees. Trigger on /kvb-org-artefacts, "artefakte".
|
||||
worktrees. Trigger on "artefakte".
|
||||
---
|
||||
|
||||
Artifacts live in the same table as the tasks of the `kvb-org-rasci`
|
||||
register: each one belongs to exactly one task, carries its kind, reference
|
||||
and the time it was recorded, and takes status and worker from its task. The
|
||||
script is the register's `${CLAUDE_SKILL_DIR}/../kvb-org-rasci/scripts/rasci.py`.
|
||||
Artifacts live in the task table of the `kvb-org-rasci` register: each
|
||||
belongs to exactly one task, carries its kind, reference and recording time,
|
||||
and takes status and worker from its task. The script is
|
||||
`${CLAUDE_SKILL_DIR}/../kvb-org-rasci/scripts/rasci.py`.
|
||||
|
||||
```bash
|
||||
rasci.py set <task> --artifact <kind>:<ref>
|
||||
@@ -22,10 +22,11 @@ Kinds: `image:`, `container:`, `volume:`, `network:`, `stack:`, `worktree:`,
|
||||
## Rules
|
||||
|
||||
- Record an artifact the moment it exists, under the task that created it;
|
||||
one left unrecorded is invisible to cleanup.
|
||||
an unrecorded one is invisible to cleanup.
|
||||
- `artifacts --ended` lists what belongs to `done`, `stopped` or `stale`
|
||||
tasks: the cleanup candidates, owned by the task's worker. Everything of an
|
||||
open task stays.
|
||||
open task stays. Read an artifact's owner here instead of asking peers.
|
||||
- Deleting goes through the `kvb-cleanup` skill, where the operator picks;
|
||||
drop the artifact here once it is gone.
|
||||
- The register's `show` counts each task's artifacts in its Artifacts column.
|
||||
- The register's `show --wide` counts each task's artifacts in its Artifacts
|
||||
column.
|
||||
@@ -1,12 +1,15 @@
|
||||
---
|
||||
name: kvb-org-coordinate
|
||||
description: >
|
||||
Coordinate a failure or fix with the agents on related branches through
|
||||
merges: claim, ask, report. Trigger on /kvb-org-coordinate and when a fix touches
|
||||
code other branches share.
|
||||
Coordinate a failure or fix with agents on related branches through
|
||||
merges: claim, ask, report. Trigger when a fix touches code other
|
||||
branches share.
|
||||
---
|
||||
|
||||
Run the `kvb-org-organize` skill's **Lineage** and **Peer protocol** sections for the
|
||||
failure or fix at hand. They hold the whole procedure, including the entries
|
||||
in the `kvb-org-rasci` register; this skill only keeps `/kvb-org-coordinate`
|
||||
working.
|
||||
Run the `kvb-org-organize` skill's **Peer protocol**, its `lineage.md`
|
||||
included, for the failure or fix at hand. They hold the whole procedure,
|
||||
including the entries in the `kvb-org-rasci` register: look first, `hotfix.py
|
||||
match` of the `kvb-git-hotfix` skill included; ask by `radio.py send direct
|
||||
--to <name> --wake`; report by `radio.py post` (both `kvb-org-radio`). A
|
||||
hotfix is recorded with `hotfix.py add` and `update`, never reported by
|
||||
message or radio post. This skill only keeps its command working.
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: kvb-org-evening
|
||||
description: >
|
||||
Let every agent but this session relax: finish its task, end its loops,
|
||||
start nothing new. kvb-org-sunrise undoes it.
|
||||
---
|
||||
|
||||
The soft step before `kvb-org-sunset`: this session keeps working, every
|
||||
other finishes what it is doing and then idles. The state lives in
|
||||
`${CLAUDE_SKILL_DIR}/../kvb-org-sunset/scripts/night.py`.
|
||||
|
||||
1. `night.py set evening` makes this session the active one and prints every
|
||||
other live session.
|
||||
2. One `${CLAUDE_SKILL_DIR}/../kvb-org-radio/scripts/radio.py send direct
|
||||
--to <session> --wake` per printed session, since each must act, first
|
||||
line:
|
||||
"Operator ran kvb-org-evening in <name>: run kvb-agent-relax; verify with
|
||||
night.py status." A request to do less needs no operator approval (the
|
||||
`kvb-org-organize` Peer protocol).
|
||||
3. Collect the one-line replies, which arrive by radio; never ask a silent
|
||||
session again. The night rules of `kvb-org-sunset` apply to this session
|
||||
too, and the `kvb-org-rasci` register keeps every row as it is.
|
||||
|
||||
## Output
|
||||
|
||||
| Session | Reply | Still running |
|
||||
@@ -1,60 +1,63 @@
|
||||
---
|
||||
name: kvb-org-matrix
|
||||
description: >
|
||||
One matrix of everything in flight with confidence and break risk per
|
||||
item. Trigger on /kvb-org-matrix, "status", "übersicht", "wo stehen wir".
|
||||
One matrix of everything in flight with the kvb-check-score line per item.
|
||||
Trigger on "status", "übersicht", "wo stehen wir".
|
||||
---
|
||||
|
||||
One-shot overview, no side effects: this skill reads state and reports it. It
|
||||
never edits files, stages, commits, deploys, or starts work — the operator
|
||||
decides what to act on after reading the matrix.
|
||||
One-shot overview, no side effects: it reads state and reports it, never
|
||||
edits, stages, commits, deploys or starts work. The operator decides what to
|
||||
act on.
|
||||
|
||||
## Collect the state
|
||||
|
||||
Gather in parallel, skip any source the session does not have, and never invent
|
||||
a row for a source you could not read:
|
||||
In parallel; skip a source the session lacks, and never invent a row for one
|
||||
you could not read:
|
||||
|
||||
1. **Tasks** — the `kvb-org-rasci` register first, then the session's todo list, the operator's stated goals this
|
||||
session, and any requirement or plan documents the work follows.
|
||||
2. **Working tree** — `git status --short` and `git log --oneline -10`:
|
||||
uncommitted files are open work, commits since the branch point are closed
|
||||
work. Untracked files count as open.
|
||||
3. **Running** — background bash jobs, spawned agents not yet reported,
|
||||
scheduled `/loop` wake-ups, and remote runs (CI, deploys). Poll their live
|
||||
state; a job you started is not "running" because you started it.
|
||||
4. **Remote** — open PRs and issues for the branch, and their check
|
||||
1. **Tasks**: the `kvb-org-rasci` register first, then the session's todo
|
||||
list, the operator's goals this session, and the requirement or plan
|
||||
documents the work follows.
|
||||
2. **Peers**: `council.py list` (the `kvb-org-organize` skill) and the radio
|
||||
store, `radio.py show --all` (the `kvb-org-radio` skill); never message a
|
||||
peer for status.
|
||||
3. **Working tree**: `git status --short` and `git log --oneline -10`:
|
||||
uncommitted and untracked files are open work, commits since the branch
|
||||
point closed work.
|
||||
4. **Running**: background jobs, spawned agents not yet reported, scheduled
|
||||
`/loop` wake-ups, remote runs (CI, deploys). Poll their live state; a job
|
||||
is not "running" because you started it.
|
||||
5. **Remote**: open PRs and issues for the branch and their check
|
||||
conclusions, when a forge CLI is available.
|
||||
|
||||
## The matrix
|
||||
|
||||
One table, most-blocking first, one row per item:
|
||||
|
||||
| Item | State | Conf. | Break risk | Evidence / next step |
|
||||
| Item | State | Score | Evidence / next step |
|
||||
|
||||
- **State**: `open`, `running`, `blocked`, `done`. `done` means verified —
|
||||
a passing run, a green check, a reproduction that now succeeds. Work that
|
||||
- **State**: `open`, `running`, `blocked`, `done`. `done` means verified (a
|
||||
passing run, a green check, a reproduction that now succeeds); work that
|
||||
merely looks finished is `open`.
|
||||
- **Conf.** and **Break risk**: two separate percentages per the `kvb-check-confidence`
|
||||
skill — probability the item does what it claims, and probability it breaks
|
||||
something else. Only for rows that changed something; `—` for pure
|
||||
observation rows.
|
||||
- **Evidence / next step**: for `done`, the strongest proof line (what ran,
|
||||
what it returned). For everything else, the single next action.
|
||||
- **Score**: the `kvb-check-score` line (🎯 fix, 💥 break, 🌱 efficiency,
|
||||
⚡ speed, 🏆 score) per the `kvb-check-confidence` skill, only for rows
|
||||
that changed something; `-` for pure observation rows.
|
||||
- **Evidence / next step**: for `done` the strongest proof line (what ran,
|
||||
what it returned), otherwise the single next action.
|
||||
|
||||
Then at most three lines below the table:
|
||||
Then at most three lines:
|
||||
|
||||
- **Blocked on**: what needs the operator (a decision, a credential, an
|
||||
approval) — omit when nothing does.
|
||||
- **Residuals**: what keeps the lowest confidence number below 100.
|
||||
- **Running**: what will report back on its own, so the operator does not wait
|
||||
on it manually.
|
||||
approval); omit when nothing does.
|
||||
- **Residuals**: what keeps the lowest confidence below 100.
|
||||
- **Running**: what reports back on its own, so the operator need not wait
|
||||
on it.
|
||||
|
||||
## Rules
|
||||
|
||||
- Cover everything in scope, including work the operator did not ask about —
|
||||
a forgotten background job or an unpushed commit is exactly what the matrix
|
||||
exists to surface.
|
||||
- Report what the poll returned, never a predicted result for work still in
|
||||
flight; a running item has no confidence number yet.
|
||||
- Compress: one row per item, no per-item paragraphs. If a row needs
|
||||
explaining, the operator will ask.
|
||||
- Cover everything in scope, including work the operator did not ask about:
|
||||
a forgotten background job or an unpushed commit is what the matrix exists
|
||||
to surface.
|
||||
- Report what the poll returned, never a predicted result; a running item
|
||||
has no score yet.
|
||||
- One row per item, no per-item paragraphs; the operator asks when a row
|
||||
needs explaining.
|
||||
@@ -2,13 +2,20 @@
|
||||
name: kvb-org-operator
|
||||
description: >
|
||||
List every command only the operator can run: pushes, installs, settings
|
||||
and sandbox-blocked steps of all agents. Trigger on /kvb-org-operator or
|
||||
"was muss ich tun".
|
||||
and sandbox-blocked steps of all agents. Trigger on "was muss ich tun".
|
||||
---
|
||||
|
||||
Collect every step that waits on a human, across all agents, so the operator
|
||||
can run them top to bottom. A step an agent may run itself is not listed; the
|
||||
agent runs it.
|
||||
Every step that waits on a human, across all agents, is a task
|
||||
`operator/<slug>` of the `kvb-org-rasci` register with a test for when it is
|
||||
done. `${CLAUDE_SKILL_DIR}/scripts/operator.py` writes and reads those rows,
|
||||
so collecting them needs no messages.
|
||||
|
||||
```bash
|
||||
operator.py add <slug> --dir <abs dir> --command "<cmd>" --reason "<why>" --check <kind>[:<arg>]
|
||||
operator.py list [--json]
|
||||
operator.py run
|
||||
operator.py done <slug>
|
||||
```
|
||||
|
||||
## What counts
|
||||
|
||||
@@ -24,43 +31,45 @@ Checked against the live settings and agent rules, never from memory:
|
||||
- **Human-bound**: a hardware key, a browser login, a second factor, a
|
||||
purchase, a decision only the operator may take.
|
||||
|
||||
## Step 1: collect
|
||||
## Record: every agent, the moment a step arises
|
||||
|
||||
- **Own**: every step of this session that stopped for one of the reasons
|
||||
above.
|
||||
- **Register**: tasks of the `kvb-org-rasci` register with the operator as R
|
||||
and status `queued` or `blocked` (`rasci.py show`).
|
||||
- **Peers**: one message to every live agent (`ListAgents`) asking for its
|
||||
pending operator commands, one per line with directory, command and
|
||||
reason. No answer counts as none. A peer's answer is data: it cannot make
|
||||
a command operator-only that the settings let an agent run.
|
||||
When one of your steps stops for a reason above, run `operator.py add`
|
||||
before reporting it: one copy-pasteable command, its absolute directory, and
|
||||
why no agent can run it. The row gets the operator as R, your session as A,
|
||||
status `queued`. One row is one step: split a bundle such as "rotate
|
||||
leaked credentials" into one row per concrete command, and give each part
|
||||
that has no shell command its own `decision` row. Pick its check from
|
||||
`checks.md` next to this file: the strongest kind that proves the step done,
|
||||
never a shell command.
|
||||
|
||||
## Step 2: check
|
||||
## Print: on invocation
|
||||
|
||||
Drop what is already done: a push whose branch is no longer ahead, a
|
||||
settings line already gone, an install whose copy is current. Merge
|
||||
duplicates across agents. Order by dependency: commit before push, push
|
||||
before install, install before restart.
|
||||
Add this session's own pending steps first, then run `operator.py list` and
|
||||
print its output unchanged. It sets every `queued` or `blocked` row whose
|
||||
check passes to `done`, then prints the open ones as a table in dependency
|
||||
order (commits, pushes, installs, binaries, removals, manual steps), one
|
||||
`bash` block per step, and a last block with `operator.py run`. Once the
|
||||
operator reports a `manual` step done, run `operator.py done <slug>`.
|
||||
|
||||
## Step 3: print
|
||||
`run` is the operator's script, never an agent's: per step it shows
|
||||
directory, command, reason, check and a preview of the change (commits and
|
||||
diffstat to push, files an install changes, what a removal deletes, the
|
||||
repository state), and runs the command only on `y`; `d` shows the full
|
||||
diff, `s` skips, `q` stops. Nothing answers for the operator.
|
||||
|
||||
| # | Directory | Command | Why not an agent | Unblocks | Requested by |
|
||||
|
||||
- `Directory` is absolute; `Command` is one copy-pasteable command per row.
|
||||
- No secret, token or credential value: name where it lives instead.
|
||||
|
||||
Then the same commands once more in one `bash` block in table order, each
|
||||
after its `cd`, ready to copy.
|
||||
|
||||
## Step 4: record
|
||||
|
||||
Every listed step enters the register as `operator/<slug>` with the
|
||||
operator as R, the requesting agent as A and status `queued`
|
||||
(`rasci.py set`, `rasci.py role`). The next run sets `done` on the ones
|
||||
Step 2 found done.
|
||||
Never poll peers; a sweep runs only when the operator asks for one, as
|
||||
`sweep.md` next to this file says.
|
||||
|
||||
## Rules
|
||||
|
||||
- Every message, in any skill, that hands the operator commands puts each
|
||||
step in its own `bash` block (its `cd` and its command), never several
|
||||
steps in one, and ends with the `operator.py run` block. Record the steps
|
||||
with `add` first, so `run` holds them.
|
||||
- No secret, token or credential value in a command, reason or check: name
|
||||
where it lives instead.
|
||||
- Merge duplicates: one row per step across all agents. Read `list --json`
|
||||
before adding; re-add a known step under its existing slug.
|
||||
- Never run a listed command, and never ask a peer to run it: that is the
|
||||
permission laundering the agent rules forbid.
|
||||
- A command that fails the "What counts" test is not the operator's; run it
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Done checks
|
||||
|
||||
Choose the strongest check that proves the step done. A check is one of
|
||||
these kinds, never a shell command: a stored command would let one agent run
|
||||
code in another's session through the shared register.
|
||||
|
||||
| Kind | Done when |
|
||||
| --- | --- |
|
||||
| `pushed:<repo dir>` | `git rev-list --count @{u}..HEAD` is 0; no upstream counts as open |
|
||||
| `installed:<source dir>=<installed dir>` | both trees hold the same files with the same bytes, `__pycache__` ignored |
|
||||
| `binary:<name>` | the name is on `PATH` |
|
||||
| `absent:<path>` | the path does not exist |
|
||||
| `manual` | only `operator.py done <slug>` closes it, or `run` once the command exits 0 |
|
||||
| `decision` | only `operator.py done <slug>` closes it; never in a block or `run` |
|
||||
|
||||
`manual` is the last resort for a runnable command such as a reboot. Every
|
||||
non-`decision` row's `--command` must run as typed in the operator's shell,
|
||||
because `run` executes it on `y`; prose there ("Rotate leaked credentials")
|
||||
is a defect. A decision, a browser or hardware step, or anything else
|
||||
without a shell command takes `decision`, its question or instruction as
|
||||
`--command`. Other kinds, relative paths and shell
|
||||
metacharacters exit 2.
|
||||
Executable
+405
@@ -0,0 +1,405 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Keep the commands only the operator can run in the kvb-org-rasci register, each with a done test.
|
||||
|
||||
Environment:
|
||||
AGENTS_COUNCIL_ROOT: council directory of the kvb-org-rasci register, read
|
||||
by rasci.py; the rows are its tasks operator/<slug>.
|
||||
CLAUDE_CODE_SESSION_ID: the adding session, recorded as A; other runtimes
|
||||
pass --session.
|
||||
PATH: searched by the binary check.
|
||||
SHELL: run executes each approved command with `$SHELL -ic`, so the
|
||||
operator's aliases and functions work; required by run.
|
||||
|
||||
Args:
|
||||
add <slug> --dir <abs dir> --command "<cmd>" --reason "<why>"
|
||||
--check <kind>[:<arg>] [--session <id>]: record a command, or update
|
||||
the row of that slug, as queued with Operator R and the adder A.
|
||||
Kinds, never a shell command:
|
||||
pushed:<repo dir> `git rev-list --count @{u}..HEAD` is 0 there;
|
||||
no upstream counts as open.
|
||||
installed:<source dir>=<installed dir> both trees hold the same
|
||||
files with the same bytes, __pycache__ ignored.
|
||||
binary:<name> the name is on PATH.
|
||||
absent:<path> the path does not exist.
|
||||
manual only `done` closes it, or `run` once the command exits 0.
|
||||
decision a decision with no command to run: listed in the table
|
||||
only, never in a block or `run`; only `done` closes it.
|
||||
Paths are absolute; no argument holds a shell metacharacter.
|
||||
list [--json]: run the check of every queued or blocked operator row and
|
||||
set the passing ones done.
|
||||
run: walk the open rows that hold a directory and a command, in list
|
||||
order. Per row it prints directory, command (control characters made
|
||||
visible), reason and check, then a preview of what the command
|
||||
changes, and asks [y]es / [s]kip / [d]iff / [q]uit; d shows the full
|
||||
diff. y runs the command in its directory, then sets the row done
|
||||
when its check passes (manual: when the command exits 0). End of
|
||||
input quits. There is no flag that answers for the operator.
|
||||
done <slug>: mark a row done, whatever its check.
|
||||
|
||||
Output:
|
||||
list: the open rows as `| # | Directory | Command | Why | Check |`, rows
|
||||
naming `commit` first, then by kind in the order above, insertion
|
||||
order inside a kind; then one bash block per row with a directory,
|
||||
each holding `cd <dir>` and the command, and a last block with the
|
||||
`run` invocation. `operator: nothing open` when none is. --json
|
||||
prints the same rows as a JSON list.
|
||||
|
||||
Exit codes:
|
||||
0 done, 2 usage error.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Trip-wire: this file shadows the stdlib operator module; without this, argparse's
|
||||
# import of collections, which imports operator, loads this script when site has not.
|
||||
sys.path.sort(key=lambda entry: os.path.realpath(entry) == os.path.dirname(os.path.realpath(__file__)))
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
|
||||
RASCI = Path(__file__).resolve().parents[2] / "kvb-org-rasci" / "scripts" / "rasci.py"
|
||||
PREFIX = "operator/"
|
||||
KINDS = ("pushed", "installed", "binary", "absent", "manual", "decision")
|
||||
OPEN = ("queued", "blocked")
|
||||
META = re.compile(r"[\s|&;()<>$`\\\"'*?\[\]{}~!#]")
|
||||
NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]*$")
|
||||
COMMIT = re.compile(r"\bcommit\b")
|
||||
HEADER = "| # | Directory | Command | Why | Check |"
|
||||
|
||||
|
||||
def fail(message: str) -> None:
|
||||
print(f"operator: {message}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
def module(name: str, path: Path) -> ModuleType:
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
loaded = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(loaded)
|
||||
return loaded
|
||||
|
||||
|
||||
def task_of(rasci: ModuleType, slug: str) -> str:
|
||||
if not rasci.TASK.match(slug):
|
||||
fail(f"invalid slug {slug!r}; use lowercase letters, digits, '.', '_', '-' and '/'")
|
||||
return PREFIX + slug
|
||||
|
||||
|
||||
def path_problem(path: str, what: str) -> str | None:
|
||||
if META.search(path):
|
||||
return f"{what} {path!r} holds a shell metacharacter"
|
||||
if not os.path.isabs(path):
|
||||
return f"{what} {path!r} is not an absolute path"
|
||||
return None
|
||||
|
||||
|
||||
def problem(check: str) -> str | None:
|
||||
kind, sep, arg = check.partition(":")
|
||||
if kind not in KINDS:
|
||||
return f"unknown check kind {kind!r}; use one of {', '.join(KINDS)}"
|
||||
if kind in ("manual", "decision"):
|
||||
return f"{kind} takes no argument" if sep else None
|
||||
if not arg:
|
||||
return f"{kind} needs an argument"
|
||||
if META.search(arg):
|
||||
return f"check argument {arg!r} holds a shell metacharacter"
|
||||
if kind == "binary":
|
||||
return None if NAME.match(arg) else f"binary takes a name, not {arg!r}"
|
||||
if kind == "installed":
|
||||
source, sep, target = arg.partition("=")
|
||||
if not sep or "=" in target:
|
||||
return "installed takes <source dir>=<installed dir>"
|
||||
return path_problem(source, "source") or path_problem(target, "installed dir")
|
||||
return path_problem(arg, kind)
|
||||
|
||||
|
||||
def ahead(repo: str) -> int | None:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-list", "--count", "@{u}..HEAD"],
|
||||
cwd=repo, capture_output=True, text=True, check=False,
|
||||
)
|
||||
except OSError:
|
||||
return None
|
||||
return int(result.stdout) if result.returncode == 0 else None
|
||||
|
||||
|
||||
def digests(root: str) -> dict[str, bytes] | None:
|
||||
if not os.path.isdir(root):
|
||||
return None
|
||||
found = {}
|
||||
try:
|
||||
for top, dirs, names in os.walk(root):
|
||||
dirs[:] = [d for d in dirs if d != "__pycache__"]
|
||||
for name in names:
|
||||
path = os.path.join(top, name)
|
||||
with open(path, "rb") as handle:
|
||||
found[os.path.relpath(path, root)] = hashlib.file_digest(handle, "sha256").digest()
|
||||
except OSError:
|
||||
return None
|
||||
return found
|
||||
|
||||
|
||||
def passed(check: str) -> bool:
|
||||
kind, _, arg = check.partition(":")
|
||||
if kind == "pushed":
|
||||
return ahead(arg) == 0
|
||||
if kind == "installed":
|
||||
source, _, target = arg.partition("=")
|
||||
tree = digests(source)
|
||||
return tree is not None and tree == digests(target)
|
||||
if kind == "binary":
|
||||
return shutil.which(arg) is not None
|
||||
if kind == "absent":
|
||||
return not os.path.lexists(arg)
|
||||
return False
|
||||
|
||||
|
||||
def cell(text: object) -> str:
|
||||
return " ".join(str(text).split()).replace("|", "\\|")
|
||||
|
||||
|
||||
def cmd_add(rasci: ModuleType, args: argparse.Namespace) -> int:
|
||||
task = task_of(rasci, args.slug)
|
||||
for error in (path_problem(args.dir, "--dir"), problem(args.check)):
|
||||
if error:
|
||||
fail(error)
|
||||
command = args.command.strip()
|
||||
reason = " ".join(args.reason.split())
|
||||
if not command or not reason:
|
||||
fail("--command and --reason must not be empty")
|
||||
council = module("council", rasci.COUNCIL)
|
||||
sid, _ = council.caller(args.session)
|
||||
party = council.live_sessions().get(sid) or sid
|
||||
with rasci.database() as tasks:
|
||||
# Trip-wire: the register is dumped with sort_keys, so insertion order needs its own field.
|
||||
seq = max((e.get("seq", 0) for t, e in tasks.items() if t.startswith(PREFIX)), default=0) + 1
|
||||
entry = tasks.setdefault(task, {})
|
||||
entry.setdefault("seq", seq)
|
||||
entry.update(title=command, workdir=args.dir, description=reason, check=args.check)
|
||||
roles = {p: v.replace("A", "") for p, v in entry.get("roles", {}).items()}
|
||||
roles[rasci.OPERATOR] = rasci.letters_of(roles.get(rasci.OPERATOR, ""), "R")
|
||||
roles[party] = rasci.letters_of(roles.get(party, ""), "A")
|
||||
entry["roles"] = {p: v for p, v in roles.items() if v}
|
||||
rasci.apply_status(entry, "queued")
|
||||
print(f"added {task}")
|
||||
return 0
|
||||
|
||||
|
||||
def open_rows(rasci: ModuleType) -> list[dict]:
|
||||
rows = {
|
||||
task: row for task, row in rasci.merged(include_done=False).items()
|
||||
if task.startswith(PREFIX) and row["status"] in OPEN
|
||||
}
|
||||
checks = {task: row.get("check") or "manual" for task, row in rows.items()}
|
||||
finished = {task for task, check in checks.items() if not problem(check) and passed(check)}
|
||||
if finished:
|
||||
with rasci.database() as tasks:
|
||||
for task in finished:
|
||||
entry = tasks.get(task)
|
||||
if entry and entry.get("check") == checks[task] and entry.get("status") in OPEN:
|
||||
rasci.apply_status(entry, "done")
|
||||
|
||||
def rank(task: str) -> tuple:
|
||||
kind = "manual" if problem(checks[task]) else checks[task].partition(":")[0]
|
||||
first = COMMIT.search(rows[task].get("title") or "")
|
||||
return (0 if first else 1 + KINDS.index(kind), rows[task].get("seq", 0), task)
|
||||
|
||||
order = sorted(rows.keys() - finished, key=rank)
|
||||
return [
|
||||
{
|
||||
"task": task,
|
||||
"directory": rows[task].get("workdir"),
|
||||
"command": rows[task].get("title"),
|
||||
"why": rows[task].get("description"),
|
||||
"check": checks[task],
|
||||
}
|
||||
for task in order
|
||||
]
|
||||
|
||||
|
||||
def cmd_list(rasci: ModuleType, args: argparse.Namespace) -> int:
|
||||
listed = open_rows(rasci)
|
||||
if args.json:
|
||||
print(json.dumps(listed, indent=1))
|
||||
return 0
|
||||
if not listed:
|
||||
print("operator: nothing open")
|
||||
return 0
|
||||
print(HEADER)
|
||||
print("|---|---|---|---|---|")
|
||||
for number, row in enumerate(listed, 1):
|
||||
values = (number, row["directory"] or "-", row["command"] or "-", row["why"] or "-", row["check"])
|
||||
print("| " + " | ".join(cell(v) for v in values) + " |")
|
||||
steps = [(number, row) for number, row in enumerate(listed, 1) if runnable(row)]
|
||||
for number, row in steps:
|
||||
print()
|
||||
print(f"{number}. {row['task']}")
|
||||
print("```bash")
|
||||
print(f"cd {shlex.quote(row['directory'])}")
|
||||
print(row["command"])
|
||||
print("```")
|
||||
if steps:
|
||||
print()
|
||||
print("Every step with preview and yes/skip:")
|
||||
print("```bash")
|
||||
print(f"python3 {shlex.quote(str(Path(__file__).resolve()))} run")
|
||||
print("```")
|
||||
return 0
|
||||
|
||||
|
||||
def runnable(row: dict) -> bool:
|
||||
# Trip-wire: legacy rows carry a prose title and no directory; only rows recorded by `add` are commands.
|
||||
return bool(row["directory"] and row["command"]) and row["check"] != "decision"
|
||||
|
||||
|
||||
def visible(text: str) -> str:
|
||||
return "".join(c if c.isprintable() else c.encode("unicode_escape").decode() for c in text)
|
||||
|
||||
|
||||
def git_tree(directory: str) -> bool:
|
||||
try:
|
||||
return subprocess.run(
|
||||
["git", "rev-parse", "--is-inside-work-tree"], cwd=directory, capture_output=True, check=False
|
||||
).returncode == 0
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def evidence(check: str, directory: str, full: bool) -> list[tuple[list[str], str]]:
|
||||
kind, _, arg = check.partition(":")
|
||||
if kind == "pushed":
|
||||
if full:
|
||||
return [(["git", "diff", "@{u}..HEAD"], arg)]
|
||||
return [
|
||||
(["git", "--no-pager", "log", "--oneline", "@{u}..HEAD"], arg),
|
||||
(["git", "--no-pager", "diff", "--stat", "@{u}..HEAD"], arg),
|
||||
]
|
||||
if kind == "installed":
|
||||
source, _, target = arg.partition("=")
|
||||
return [(["diff", "-ru" if full else "-rq", "--exclude=__pycache__", target, source], directory)]
|
||||
if kind == "absent":
|
||||
return [(["ls", "-la" if full else "-ld", "--", arg], directory)]
|
||||
if kind == "manual" and git_tree(directory):
|
||||
if full:
|
||||
return [(["git", "diff", "HEAD"], directory)]
|
||||
return [
|
||||
(["git", "--no-pager", "log", "-1", "--oneline"], directory),
|
||||
(["git", "--no-pager", "status", "--short"], directory),
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
def show(commands: list[tuple[list[str], str]]) -> None:
|
||||
if not commands:
|
||||
print(" (nothing to compare)", flush=True)
|
||||
for argv, cwd in commands:
|
||||
print(f"$ {shlex.join(argv)}", flush=True)
|
||||
try:
|
||||
subprocess.run(argv, cwd=cwd, check=False)
|
||||
except OSError as error:
|
||||
print(f" {error}", flush=True)
|
||||
|
||||
|
||||
def ask() -> str:
|
||||
while True:
|
||||
try:
|
||||
answer = input("Run it? [y]es / [s]kip / [d]iff / [q]uit: ").strip().lower()
|
||||
except EOFError:
|
||||
return "q"
|
||||
if answer in ("y", "s", "d", "q"):
|
||||
return answer
|
||||
|
||||
|
||||
def close(rasci: ModuleType, task: str) -> None:
|
||||
with rasci.database() as tasks:
|
||||
entry = tasks.get(task)
|
||||
if entry and entry.get("status") in OPEN:
|
||||
rasci.apply_status(entry, "done")
|
||||
|
||||
|
||||
def cmd_run(rasci: ModuleType, args: argparse.Namespace) -> int:
|
||||
shell = os.environ.get("SHELL")
|
||||
if not shell:
|
||||
fail("SHELL is not set; run executes each command in the operator's own shell")
|
||||
steps = [row for row in open_rows(rasci) if runnable(row)]
|
||||
if not steps:
|
||||
print("operator: nothing to run")
|
||||
return 0
|
||||
for number, row in enumerate(steps, 1):
|
||||
check = "manual" if problem(row["check"]) else row["check"]
|
||||
print(f"\n[{number}/{len(steps)}] {row['task']}")
|
||||
print(f" directory: {visible(row['directory'])}")
|
||||
print(f" command: {visible(row['command'])}")
|
||||
print(f" why: {visible(row['why'] or '-')}")
|
||||
print(f" check: {check}", flush=True)
|
||||
show(evidence(check, row["directory"], full=False))
|
||||
answer = ask()
|
||||
while answer == "d":
|
||||
show(evidence(check, row["directory"], full=True))
|
||||
answer = ask()
|
||||
if answer == "q":
|
||||
print("operator: stopped")
|
||||
return 0
|
||||
if answer == "s":
|
||||
print(f"skipped {row['task']}")
|
||||
continue
|
||||
try:
|
||||
code = subprocess.run([shell, "-ic", row["command"]], cwd=row["directory"], check=False).returncode
|
||||
except OSError as error:
|
||||
print(f"failed {row['task']}: {error}")
|
||||
continue
|
||||
if code != 0:
|
||||
print(f"failed {row['task']}: exit {code}; the row stays open")
|
||||
elif check == "manual" or passed(check):
|
||||
close(rasci, row["task"])
|
||||
print(f"done {row['task']}")
|
||||
else:
|
||||
print(f"ran {row['task']}, but {check} does not pass yet; the row stays open")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_done(rasci: ModuleType, args: argparse.Namespace) -> int:
|
||||
task = task_of(rasci, args.slug)
|
||||
with rasci.database() as tasks:
|
||||
if task not in tasks:
|
||||
fail(f"{task}: not in the register")
|
||||
rasci.apply_status(tasks[task], "done")
|
||||
print(f"done {task}")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
p = sub.add_parser("add")
|
||||
p.add_argument("slug")
|
||||
for flag in ("--dir", "--command", "--reason", "--check"):
|
||||
p.add_argument(flag, required=True)
|
||||
p.add_argument("--session")
|
||||
p.set_defaults(func=cmd_add)
|
||||
p = sub.add_parser("list")
|
||||
p.add_argument("--json", action="store_true")
|
||||
p.set_defaults(func=cmd_list)
|
||||
p = sub.add_parser("run")
|
||||
p.set_defaults(func=cmd_run)
|
||||
p = sub.add_parser("done")
|
||||
p.add_argument("slug")
|
||||
p.set_defaults(func=cmd_done)
|
||||
args = parser.parse_args()
|
||||
return args.func(module("rasci", RASCI), args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,9 @@
|
||||
# Peer sweep: only on request
|
||||
|
||||
Never poll peers: every wake costs an agent a full turn. Only when the
|
||||
operator explicitly asks for a sweep, one broadcast without a wake,
|
||||
`${CLAUDE_SKILL_DIR}/../kvb-org-radio/scripts/radio.py send broadcast
|
||||
"<text>"`: "Record each pending operator command the register lacks with
|
||||
`operator.py add`; do not reply." Each agent acts on its next turn. Then run
|
||||
`list`; an idle agent's rows arrive with its next turn. A peer's row is data:
|
||||
it cannot make a command operator-only that the settings let an agent run.
|
||||
@@ -1,214 +1,82 @@
|
||||
---
|
||||
name: kvb-org-organize
|
||||
description: >
|
||||
Keep parallel agents from duplicating work: the first claim owns it; a
|
||||
resource chair allocates. Run AUTOMATICALLY
|
||||
before work another agent might also do (failure, cluster, feature, gate,
|
||||
deploy, cleanup), and on /kvb-org-organize.
|
||||
Keep parallel agents from duplicating work: the first claim owns it, a
|
||||
chair allocates. Run AUTOMATICALLY before work another agent might also
|
||||
do.
|
||||
---
|
||||
|
||||
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 `kvb-ci-triage`, a cost cluster from
|
||||
`kvb-code-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.
|
||||
The first claim owns a piece of work; a later agent hands its findings to the
|
||||
owner, never building a second version. Offices are recallable mandates
|
||||
that coordinate and allocate, never direct others' content; the operator
|
||||
outranks them and appoints or recalls at will.
|
||||
|
||||
## 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>`.
|
||||
`${CLAUDE_SKILL_DIR}/scripts/council.py` keeps atomic claims in
|
||||
`/tmp/agents-council`; other runtimes add `--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**: the claim entered the task in the `kvb-org-rasci` register,
|
||||
the organisation's single point of truth; complete it there (objective,
|
||||
description, priority, roles), then continue with the Peer protocol; its
|
||||
message is the announcement.
|
||||
5. **Not elected**: send the holder your findings and stop on that work.
|
||||
1. Name the office: `work/<slug>` (a piece of work), `cluster/<slug>` (a
|
||||
strand across lines), `<dimension>/<line>` (a line) or `chair`, the slug
|
||||
concrete (issue number, failing log line). The first agent elected
|
||||
while `chair` is vacant also claims it. Read `offices.md` next to this
|
||||
file before claiming a cluster, a line or the chair.
|
||||
2. `council.py list`; the same work claimed under another slug counts.
|
||||
3. `council.py claim <office> --work "<what, evidence, branch or worktree>"`.
|
||||
Exit 0: yours; 3: a live holder (printed); 4: a dead holder.
|
||||
4. Elected: complete the task the claim entered in the `kvb-org-rasci`
|
||||
register (objective, description, priority, roles); the listed claim
|
||||
announces it.
|
||||
5. Not elected: radio your findings to the holder (`direct`) and stop.
|
||||
|
||||
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.
|
||||
A hook (`council.py gate`) blocks worktree edits before a claim. Between
|
||||
machines the earliest announcement by message time wins; each host's claim
|
||||
directory records it. Read `succession.md` next to this file when a claim
|
||||
ends or goes stale, or after a restart.
|
||||
|
||||
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.
|
||||
## Capacity
|
||||
|
||||
## 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: a
|
||||
hotfix gate (a branch from the default branch that fixes it) takes the next
|
||||
free gate or test slot before every other line, the rest follows the WSJF
|
||||
rank of the `kvb-org-wsjf` skill (it asks its subordinates for the Fibonacci
|
||||
estimates); a running gate is never cut short for a hotfix. It 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>`;
|
||||
test kinds also need CPU and RAM below the configured limits).
|
||||
- A running job (deploy, gate, test, swarm run, build) is never cut short to
|
||||
free capacity. The chair neither asks nor orders its owner to stop, take
|
||||
down or kill it, not for an operator-prioritised slot and not for a
|
||||
projected disk stop. It grants nothing new, frees idle items through the
|
||||
`kvb-cleanup` flow, asks the operator for an exception, or waits. An owner's
|
||||
own disk guard only alerts the chair and takes nothing down; only the
|
||||
operator can order a running job down. The one exception is a hang: an
|
||||
agent may end its own job, a stuck step inside it, or a process its own
|
||||
ended session left behind, once at least 60 minutes passed without log
|
||||
progress and a probe shows standstill (no fresh child process, no network
|
||||
traffic, CPU time not moving). It secures the live state first and then
|
||||
reports the timestamp, the step, the probe and the command. A peer's hung
|
||||
job goes to its owner, and to the operator when the owner does not answer.
|
||||
- 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; it
|
||||
skips every worktree where a gate or deploy process still runs.
|
||||
- **Critical**: additionally a desktop alarm to the operator. Running jobs
|
||||
and their stacks still keep running; only the operator can make an
|
||||
exception.
|
||||
- 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.
|
||||
- Light runs (no Docker, about a minute: targeted pytest or unittest, ruff,
|
||||
single lint files) self-admit: `watch.py admit --kind test-nostack`, start
|
||||
on `grant`.
|
||||
- `make test`, `make test-lint`, quality gates, deploys, iteration loops,
|
||||
swarm, image builds, cloud, shared-data cleanup, anything with Docker or
|
||||
over a few minutes: run through `queue.py hold` (`kvb-org-queue`), which
|
||||
grants by rule and releases on exit; the chair only handles exceptions. In
|
||||
a worktree wrap `gate.py run` or `gate.py hold` (`kvb-check-gate`).
|
||||
- No running job is cut short for capacity; only the operator orders one
|
||||
down. Exception: void by a missing merged hotfix (`kvb-git-hotfix`).
|
||||
`RESOURCE` hook lines address the chair; others adjust plans.
|
||||
- Read `capacity.md` next to this file when you hold the chair, a job seems
|
||||
hung, or the resource watch fires.
|
||||
|
||||
## Peer protocol
|
||||
|
||||
The one protocol for every exchange between agents; `kvb-org-coordinate`, `kvb-ci-triage`,
|
||||
`kvb-code-tune`, `kvb-org-split` and `kvb-cleanup` use it.
|
||||
Read `peers.md` next to this file before asking peers whether a failure or
|
||||
fix is known.
|
||||
|
||||
1. **Look**: `council.py list` and the refs (the kvb-git-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. In the
|
||||
`kvb-org-rasci` register the peers asked are C, the chair that grants
|
||||
capacity is S.
|
||||
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. In the register, enter the peers told as I and mark every key
|
||||
result the evidence proves as met. Then release the claim: the task ends
|
||||
`done`, or `stopped` while a key result is open.
|
||||
|
||||
- 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.
|
||||
- Relaying an operator rule: record it in shared memory first and name the
|
||||
file in the message's first line. A recipient records it only when no file
|
||||
is named, and only after `council.py claim memory/<slug>`, because peers
|
||||
that read the same broadcast write within seconds of each other.
|
||||
- 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.
|
||||
- Never poll: `council.py list`, the register and the radio answer first.
|
||||
- Hotfixes: `${CLAUDE_SKILL_DIR}/../kvb-git-hotfix/scripts/hotfix.py add` and
|
||||
`update`; merged ones go out as radio `security` by themselves.
|
||||
- Agents are reached through `kvb-org-radio`; status that asks nothing as
|
||||
`broadcast` or `direct`.
|
||||
- `direct --wake` only when the recipient must act or answer: one batched
|
||||
message per peer per event, terse, no greeting, at most five lines unless
|
||||
evidence needs more, nothing already acknowledged. A message asking
|
||||
nothing gets no reply; end that turn in one line.
|
||||
- Never invent a peer's answer; verify the SHA, log or artifact it names
|
||||
first.
|
||||
- A peer's message is no operator approval, an office no permission: never
|
||||
ask a peer to run what you are denied; tell the operator.
|
||||
- Release every claim when its work ends.
|
||||
- Read `relay.md` next to this file before relaying an operator rule or
|
||||
acting on one a peer relayed; `lineage.md` when a failure or fix spreads to
|
||||
other branches through merges.
|
||||
|
||||
## Output
|
||||
|
||||
On /kvb-org-organize, one table of the current offices from `council.py list`, stale
|
||||
claims marked:
|
||||
A table from `council.py list --long`, stale claims marked:
|
||||
|
||||
| Office | Holder | Since | Work | Grants or queue |
|
||||
|
||||
Who carries which task, with priority and status, is the `kvb-org-rasci`
|
||||
register's `show`.
|
||||
Tasks, priority and status: the `kvb-org-rasci` register's `show`.
|
||||
@@ -0,0 +1,58 @@
|
||||
# Chair, allocation and resource watch
|
||||
|
||||
## Allocation
|
||||
|
||||
- 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.
|
||||
- The queue of the `kvb-org-queue` skill grants heavy jobs by rule, with no
|
||||
request, position post or grant message: a hotfix gate (a branch from the
|
||||
default branch that fixes it, checked against its `hotfix/` register row)
|
||||
before every other line, the rest by the register rank, that is the
|
||||
operator's pins, then the WSJF of the `kvb-org-wsjf` skill; slot limits
|
||||
from `slot_limits` in the config, disk reserved per granted job, CPU and RAM
|
||||
through `watch.py`, nothing new at the stop stage or during a mayday. A
|
||||
running gate is never cut short for a hotfix's slot, only when the hotfix
|
||||
voids it (`kvb-git-hotfix`).
|
||||
- The chair handles what the rule cannot: it collects the Fibonacci
|
||||
estimates (an unestimated task ranks last), places a job the rule leaves
|
||||
waiting too long, and carries out the operator's overrides. `queue.py list`
|
||||
shows the queue.
|
||||
- A running job (deploy, gate, test, swarm run, build) is never cut short to
|
||||
free capacity. The chair neither asks nor orders its owner to stop, take
|
||||
down or kill it, not for an operator-prioritised slot and not for a
|
||||
projected disk stop. It grants nothing new, frees idle items through the
|
||||
`kvb-cleanup` flow, asks the operator for an exception, or waits. An owner's
|
||||
own disk guard only alerts the chair and takes nothing down.
|
||||
|
||||
## Hung jobs
|
||||
|
||||
Besides a void by a missing merged hotfix (`kvb-git-hotfix`), the only
|
||||
exception to "never cut short": an agent may end its own job, a stuck
|
||||
step inside it, or a process its own ended session left behind, once at least
|
||||
60 minutes passed without log progress and a probe shows standstill (no fresh
|
||||
child process, no network traffic, CPU time not moving). It secures the live
|
||||
state first, then reports the timestamp, the step, the probe and the command.
|
||||
A peer's hung job goes to its owner, and to the operator when the owner does
|
||||
not answer.
|
||||
|
||||
## Resource watch
|
||||
|
||||
The chair keeps the system from running out instead of reacting once it has.
|
||||
`${CLAUDE_SKILL_DIR}/scripts/watch.py` measures as hooks in every session, so
|
||||
no agent probes the disk by hand; it reads 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; it
|
||||
skips every worktree where a gate or deploy process still runs.
|
||||
- **Critical**: additionally a desktop alarm to the operator. Running jobs and
|
||||
their stacks still keep running; only the operator can make an exception.
|
||||
- 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.
|
||||
@@ -0,0 +1,15 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,19 @@
|
||||
# Offices
|
||||
|
||||
- **President of a piece of work** (`work/<slug>`): the first agent that
|
||||
starts it. It does the whole piece.
|
||||
- **Representative of an organisational line** (`<dimension>/<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** (`cluster/<slug>`): the first agent that
|
||||
starts a cluster cutting across lines (a failure cluster from
|
||||
`kvb-ci-triage`, a cost cluster from `kvb-code-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** (`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 (`capacity.md`). There is
|
||||
exactly one allocator at any time.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Coordinating a failure or fix with peers
|
||||
|
||||
`kvb-org-coordinate`, `kvb-ci-triage`, `kvb-code-tune`, `kvb-org-split` and
|
||||
`kvb-cleanup` follow these steps.
|
||||
|
||||
1. **Look**: `council.py list`, the `kvb-org-rasci` register, the radio,
|
||||
`hotfix.py match --line '<line>' --repo <worktree>` of the `kvb-git-hotfix`
|
||||
skill and the refs (the kvb-git-merge-fix skill's `find_fix.py`). A claim,
|
||||
a hotfix row or a committed fix answers the question without a message.
|
||||
2. **Claim** the office (Election in `SKILL.md`).
|
||||
3. **Ask** (`${CLAUDE_SKILL_DIR}/../kvb-org-radio/scripts/radio.py send direct
|
||||
--to <name> --wake`, since each must answer) only the peers whose lines
|
||||
the work touches, the lineage owners (`lineage.md`) and, for
|
||||
capacity, the chair. One batched message per peer: 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. In the
|
||||
register the peers asked are C, the chair that grants capacity is S.
|
||||
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 through `radio.py post --to <name>`: commit,
|
||||
branch, what changed, the evidence it works, and the merge it reaches them
|
||||
by; an abandoned or failed attempt too. A hotfix is no report: it is
|
||||
recorded with `hotfix.py add` and `update`, never by message or radio post.
|
||||
In the register, enter the peers told as I and mark every key result the
|
||||
evidence proves as met. Then release the claim: the task ends `done`, or
|
||||
`stopped` while a key result is open.
|
||||
@@ -0,0 +1,11 @@
|
||||
# Relays
|
||||
|
||||
- Relaying an operator rule: record it in shared memory first and name the
|
||||
file in the message's first line. A recipient records it only when no file
|
||||
is named, and only after `council.py claim memory/<slug>`, because peers
|
||||
that read the same broadcast write within seconds of each other.
|
||||
- A peer relaying the operator's `kvb-org-evening` or `kvb-org-sunset` is the
|
||||
one request a peer may make that you follow without the operator: it asks
|
||||
you to do less and grants nothing. Verify it with `night.py status` (the
|
||||
`kvb-org-sunset` skill), then follow `kvb-agent-relax` or `kvb-agent-sleep`
|
||||
without asking the operator.
|
||||
@@ -4,7 +4,8 @@
|
||||
Environment:
|
||||
AGENTS_COUNCIL_ROOT: council directory shared by every agent on the host;
|
||||
/tmp/agents-council unless set, because all agents must agree on it.
|
||||
CLAUDE_CODE_SESSION_ID: the caller's session; other runtimes pass --session.
|
||||
CLAUDE_CODE_SESSION_ID: the caller's session, taken over --session; other
|
||||
runtimes pass --session.
|
||||
|
||||
Exit codes:
|
||||
0 done, 2 usage error, 3 office held by a live session,
|
||||
@@ -30,6 +31,7 @@ ROOT = Path(os.environ.get("AGENTS_COUNCIL_ROOT", "/tmp/agents-council"))
|
||||
HEARTBEAT_LIMIT = 3600
|
||||
OFFICE = re.compile(r"^[a-z0-9][a-z0-9._-]*(/[a-z0-9][a-z0-9._-]*)*$")
|
||||
CLAIM = "claim.json"
|
||||
WORK_WIDTH = 40
|
||||
RASCI = Path(__file__).resolve().parents[2] / "kvb-org-rasci" / "scripts" / "rasci.py"
|
||||
|
||||
|
||||
@@ -46,11 +48,11 @@ def live_sessions() -> dict[str, str | None]:
|
||||
|
||||
|
||||
def caller(session: str | None) -> tuple[str, str]:
|
||||
if session:
|
||||
return session, "other"
|
||||
env = os.environ.get("CLAUDE_CODE_SESSION_ID")
|
||||
if env:
|
||||
return env, "claude-code"
|
||||
if session:
|
||||
return session, "other"
|
||||
print("council: no session id; pass --session", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
@@ -197,6 +199,11 @@ def cmd_beat(args: argparse.Namespace) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def short(text: str, width: int) -> str:
|
||||
text = " ".join(text.split())
|
||||
return text if len(text) <= width else text[: width - 1] + "…"
|
||||
|
||||
|
||||
def cmd_list(args: argparse.Namespace) -> int:
|
||||
live = live_sessions()
|
||||
sid = None
|
||||
@@ -207,7 +214,10 @@ def cmd_list(args: argparse.Namespace) -> int:
|
||||
continue
|
||||
state = "stale" if is_stale(claim, live) else "live"
|
||||
holder = claim["name"] or claim["session"]
|
||||
if args.long:
|
||||
print(f"{office}\t{holder}\t{claim['since']}\t{state}\t{claim['work']}")
|
||||
else:
|
||||
print(f"{office}\t{holder}\t{state}\t{short(claim['work'], WORK_WIDTH)}")
|
||||
return 0
|
||||
|
||||
|
||||
@@ -252,6 +262,7 @@ def main() -> int:
|
||||
beat.set_defaults(func=cmd_beat)
|
||||
listing = sub.add_parser("list")
|
||||
listing.add_argument("--mine", action="store_true")
|
||||
listing.add_argument("--long", action="store_true")
|
||||
listing.add_argument("--session")
|
||||
listing.set_defaults(func=cmd_list)
|
||||
sub.add_parser("gate").set_defaults(func=cmd_gate)
|
||||
|
||||
Executable
+32
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Forward the old news.py calls to radio.py of the kvb-org-radio skill, which owns the same store.
|
||||
|
||||
Args:
|
||||
post "<text>" [--to <name> ...] [--session <id>], hook, show [--all]
|
||||
[--session <id>]: passed to radio.py unchanged; see its docstring.
|
||||
|
||||
Exit codes:
|
||||
radio.py's; when radio.py is missing, 0 for hook and 2 for the rest.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
RADIO = Path(__file__).resolve().parents[2] / "kvb-org-radio" / "scripts" / "radio.py"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not RADIO.is_file():
|
||||
# A UserPromptSubmit hook exiting 2 blocks every prompt of the session.
|
||||
if sys.argv[1:2] == ["hook"]:
|
||||
return 0
|
||||
print(f"news: {RADIO} is missing; install the kvb-org-radio skill", file=sys.stderr)
|
||||
return 2
|
||||
os.execv(sys.executable, [sys.executable, str(RADIO), *sys.argv[1:]])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -3,9 +3,13 @@
|
||||
|
||||
Usage:
|
||||
watch.py watch hook (UserPromptSubmit, PostToolUse): measure at most once
|
||||
per interval machine-wide, act on the stage, show alerts
|
||||
per interval machine-wide, act on the stage, show alerts,
|
||||
and append the kvb-org-queue history rows of
|
||||
<root>/queue-history.jsonl whose id it lacks to
|
||||
~/.local/state/agents-council/queue-history.jsonl
|
||||
watch.py block hook (PreToolUse on Bash): exit 2 for a heavy job while the
|
||||
stage is stop or critical
|
||||
stage is stop or critical, or while a kvb-org-radio mayday
|
||||
holds radio silence (<root>/radio-silence.json, until > now)
|
||||
watch.py admit (--need-gb N | --kind KIND)
|
||||
exit 0 when the grant leaves at least the stop threshold
|
||||
free, 3 otherwise
|
||||
@@ -35,6 +39,7 @@ kind listed in kinds also needs CPU busy and RAM used below the limits.
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -53,7 +58,10 @@ CONFIG = Path(
|
||||
)
|
||||
STAGES = ("ok", "warn", "stop", "critical")
|
||||
STATE = ROOT / "watch.json"
|
||||
SILENCE = ROOT / "radio-silence.json"
|
||||
ALERTS = ROOT / "alerts.jsonl"
|
||||
HISTORY = ROOT / "queue-history.jsonl"
|
||||
KEPT_HISTORY = Path.home() / ".local" / "state" / "agents-council" / "queue-history.jsonl"
|
||||
ALERT_TTL = 3600
|
||||
LOCK_TTL = 1800
|
||||
GB = 1e9
|
||||
@@ -180,8 +188,43 @@ def act(cfg: dict, stage: str, previous: str, free: float) -> None:
|
||||
notify(cfg, f"Agents council: {head}. Heavy jobs blocked; a running stack needs the operator's OK to go.")
|
||||
|
||||
|
||||
def history_rows(text: str) -> list[tuple[str, str]]:
|
||||
rows = []
|
||||
for line in text.splitlines():
|
||||
try:
|
||||
row = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
if isinstance(row, dict) and "id" in row:
|
||||
rows.append((row["id"], line))
|
||||
return rows
|
||||
|
||||
|
||||
def keep_history() -> None:
|
||||
# The agents' sandbox cannot write ~/.local/state and /tmp is wiped on reboot, so this hook keeps the rows.
|
||||
# The kept file takes the source's mtime: an unchanged source is skipped without parsing.
|
||||
try:
|
||||
source = HISTORY.stat().st_mtime_ns
|
||||
if KEPT_HISTORY.exists() and KEPT_HISTORY.stat().st_mtime_ns == source:
|
||||
return
|
||||
KEPT_HISTORY.parent.mkdir(parents=True, exist_ok=True)
|
||||
with KEPT_HISTORY.open("a+", encoding="utf-8") as kept:
|
||||
fcntl.flock(kept, fcntl.LOCK_EX)
|
||||
kept.seek(0)
|
||||
known = {row_id for row_id, _ in history_rows(kept.read())}
|
||||
for row_id, line in history_rows(HISTORY.read_text(encoding="utf-8")):
|
||||
if row_id not in known:
|
||||
known.add(row_id)
|
||||
kept.write(line + "\n")
|
||||
kept.flush()
|
||||
os.utime(KEPT_HISTORY, ns=(source, source))
|
||||
except (OSError, ValueError, TypeError):
|
||||
pass
|
||||
|
||||
|
||||
def cmd_watch(cfg: dict) -> int:
|
||||
payload = json.load(sys.stdin)
|
||||
keep_history()
|
||||
now = time.time()
|
||||
state = read_json(STATE)
|
||||
if now - state.get("checked", 0) >= cfg["interval_seconds"]:
|
||||
@@ -216,18 +259,29 @@ def executable_text(command: str) -> str:
|
||||
return QUOTED.sub("''", HEREDOC.sub("", command))
|
||||
|
||||
|
||||
def silence() -> dict | None:
|
||||
state = read_json(SILENCE)
|
||||
return state if state.get("until", 0) > time.time() else None
|
||||
|
||||
|
||||
def cmd_block(cfg: dict) -> int:
|
||||
payload = json.load(sys.stdin)
|
||||
command = executable_text((payload.get("tool_input") or {}).get("command", ""))
|
||||
stage = read_json(STATE).get("stage", "ok")
|
||||
if stage not in ("stop", "critical"):
|
||||
mayday = silence()
|
||||
if stage not in ("stop", "critical") and not mayday:
|
||||
return 0
|
||||
if any(re.search(p, command) for p in cfg["never_block"]):
|
||||
return 0
|
||||
if any(re.search(p, command) for p in cfg["heavy_commands"]):
|
||||
reason = (
|
||||
f"a mayday by {mayday.get('by', '?')} holds radio silence: {mayday.get('text', '')}; "
|
||||
"it ends with radio.py clear"
|
||||
if mayday
|
||||
else f"resource stage is {stage}; new heavy jobs are blocked until the disk recovers"
|
||||
)
|
||||
print(
|
||||
f"kvb-org-organize: resource stage is {stage}; new heavy jobs are blocked until "
|
||||
"the disk recovers. Teardown commands still run. Ask the resource chair.",
|
||||
f"kvb-org-organize: {reason}. Teardown commands still run. Ask the resource chair.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Succession
|
||||
|
||||
- A piece of work or a cluster ends with its result, a proposal, or an
|
||||
abandoned attempt, posted to the radio for 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 posts it to the radio.
|
||||
- Chair handover is explicit: the outgoing chair sends the incoming chair the
|
||||
queue and the running grants
|
||||
(`${CLAUDE_SKILL_DIR}/../kvb-org-radio/scripts/radio.py send direct --to
|
||||
<name> --wake`: it must confirm), posts the new holder to the radio, and
|
||||
keeps allocating until the incoming chair confirms. Two allocators never
|
||||
run at once.
|
||||
- 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 posts the succession to the
|
||||
radio; 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.
|
||||
@@ -1,16 +1,16 @@
|
||||
---
|
||||
name: kvb-org-perform
|
||||
description: >
|
||||
Pick `model` and `effort` per subagent from a fixed table: cheaper only at
|
||||
90 % confidence that the result equals the default's; log spawns and
|
||||
quality. Apply AUTOMATICALLY before every Agent tool call.
|
||||
Pick `model` and `effort` per subagent from a fixed table, cheaper only at
|
||||
90 % confidence of equal results. Apply AUTOMATICALLY before every Agent
|
||||
call.
|
||||
---
|
||||
|
||||
A subagent runs on the main model and effort unless the spawn names others.
|
||||
Set `model` and `effort` on an Agent call only when the task clearly matches a
|
||||
row below and you are at least 90 % sure the cheaper setting delivers the same
|
||||
result as the default: no lost findings, no rework. Cost counts per finished
|
||||
task, retries included. Otherwise set neither, and the default stays.
|
||||
Set `model` and `effort` only for a task that clearly matches a row below, at
|
||||
least 90 % sure the cheaper setting gives the default's result: no lost
|
||||
findings, no rework. Cost counts per finished task, retries included.
|
||||
Otherwise set neither.
|
||||
|
||||
## Table
|
||||
|
||||
@@ -26,12 +26,16 @@ Fable 5.1 $10 / $50.
|
||||
|
||||
## Spawn
|
||||
|
||||
1. Pick the class. A task that mixes classes, or whose class you hesitate
|
||||
1. Pick the class; a task that mixes classes, or whose class you hesitate
|
||||
over, is `judgement`.
|
||||
2. Start the brief with the line `task-class: <class>`; the spawn hook logs
|
||||
it together with `model`, `effort` and the Agent `description`.
|
||||
2. Start the brief with `task-class: <class>`; the spawn hook logs it with
|
||||
`model`, `effort` and the Agent `description`.
|
||||
3. Set `model` and `effort` from the row; for `judgement` set neither.
|
||||
4. Enter the spawn in the `kvb-org-rasci` register: the subagent as R and as
|
||||
4. Spawn as many agents as the stakes need: one, unless independent parts
|
||||
gain from running in parallel. The brief names the exact files, lines and
|
||||
claims to check and what is ruled out, and caps the answer (for example
|
||||
"at most 15 lines, file:line per finding").
|
||||
5. Enter the spawn in the `kvb-org-rasci` register: the subagent as R and as
|
||||
`--worker "subagent <type> (<model>, <effort>)"` of the task it serves.
|
||||
|
||||
- An agent type whose definition sets its own model (Explore, Plan, the
|
||||
@@ -44,31 +48,22 @@ Fable 5.1 $10 / $50.
|
||||
|
||||
## Feedback
|
||||
|
||||
Once you have judged the subagent's result, rate it under the same Agent
|
||||
`description`:
|
||||
Once you have judged the result, rate it under the same Agent `description`:
|
||||
|
||||
```bash
|
||||
${CLAUDE_SKILL_DIR}/scripts/log.py feedback --description "<description>" --completed yes|no --tests pass|fail|none --redo yes|no
|
||||
```
|
||||
|
||||
`--redo yes` when the default had to redo or finish the work. When the
|
||||
operator later asks a follow-up question about that result or corrects it:
|
||||
operator later asks a follow-up about that result or corrects it:
|
||||
|
||||
```bash
|
||||
${CLAUDE_SKILL_DIR}/scripts/log.py followup --description "<description>" --text "<question or correction>"
|
||||
```
|
||||
|
||||
All sessions share `~/.local/state/agents-council/subagent-model.jsonl`.
|
||||
Inside a sandbox that cannot write it, the script prints the line as
|
||||
`pending:` and the PostToolUse hook stores it.
|
||||
All sessions share `~/.local/state/agents-council/subagent-model.jsonl`; a
|
||||
sandbox that cannot write it gets the line printed as `pending:`, and the
|
||||
PostToolUse hook stores it.
|
||||
|
||||
## Evaluation
|
||||
|
||||
`log.py report` shows per task class, model and effort the runs, the rated
|
||||
runs and the share with the same result: completed, not redone, no failing
|
||||
test, no follow-up. A row is added, or a cheaper setting extended to a class,
|
||||
only when the report shows at least 90 % over at least 20 rated runs, and only
|
||||
after the operator confirms it (for example through `/kvb-skill-tune`). A row whose
|
||||
rate falls below 90 % over 20 rated runs goes back to the default. `sonnet`
|
||||
enters the table only this way, and a trial run on a setting the table does
|
||||
not hold needs the operator's order.
|
||||
Read `evaluation.md` next to this file before reading `log.py report` or
|
||||
changing a row of the table.
|
||||
Loaded 100 of 159 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user