diff --git a/skills/kvb-git-commit-next/SKILL.md b/skills/kvb-git-commit-next/SKILL.md index 4759a18..ad23a30 100644 --- a/skills/kvb-git-commit-next/SKILL.md +++ b/skills/kvb-git-commit-next/SKILL.md @@ -32,11 +32,10 @@ description: > 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 + else: never stage merely to run a lint or a test. Checks enumerating + `git ls-files` see tracked edits; only an untracked new file needs + `git add` to be seen. Path-scope every `git add`: `-A` sweeps up the + operator's edits and local config. A file that carries two bundles is staged by line: `${CLAUDE_SKILL_DIR}/scripts/stage_hunks.py --repo --file --match `, first with `--dry-run`. 5. For the newly staged bundle run the kvb-code-clean pass, then the @@ -46,10 +45,11 @@ description: > kvb-check-security, evidence, residuals). 6. Close with the staged bundle's commit table (`score.py ... --commit-table `: 📁 worktree, 🌿 branch, 📊 diff stat, scores, - gate), what remains unstaged, and wait for the operator's commit + gate), what remains unstaged, the `push` targets of `pkgmgr worktree + flow --json` the branch flows into, and wait for the operator's commit decision; never auto-commit the newly staged bundle. Ask through the kvb-git-diff skill: a hub approval is the operator's commit decision for that one bundle only once its `approval.py verify` step exits 0 on the - exact staged tree; otherwise the chat question stays the fallback. A gate exit 4 says + exact staged tree; else ask in chat. A gate exit 4 says up front that the bundle cannot be committed until its critical finding is fixed. diff --git a/skills/kvb-git-hotfix/SKILL.md b/skills/kvb-git-hotfix/SKILL.md index 7507d99..7f717d8 100644 --- a/skills/kvb-git-hotfix/SKILL.md +++ b/skills/kvb-git-hotfix/SKILL.md @@ -35,20 +35,21 @@ hotfix.py void --repo --target [--target ...] 1. `sync`: fetches, merges `origin/`, then every branch up the funnel chain the `kvb-org-swarm` 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. + repository), then the flow's `pull` list (`pkgmgr worktree flow --json`), + each with `--no-verify --no-edit`. It needs the task's branch checked out, + a clean tracked tree, a valid flow 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. + merged` is reported, never merged. `not on origin/` 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. +and run again. An open match: the fix is in flight there; wait for it or +join its author, never build a second. ## Stopping a running job @@ -75,5 +76,4 @@ capacity rules). - 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. +- Never edit `hotfix/` rows with `rasci.py set`; use `add` and `update`. diff --git a/skills/kvb-git-hotfix/scripts/hotfix.py b/skills/kvb-git-hotfix/scripts/hotfix.py index 4643ee2..0273232 100755 --- a/skills/kvb-git-hotfix/scripts/hotfix.py +++ b/skills/kvb-git-hotfix/scripts/hotfix.py @@ -140,6 +140,9 @@ ORIGIN_HEAD = "refs/remotes/origin/HEAD" GIT = ("git", "-c", "maintenance.auto=false") FETCH = ("fetch", "--quiet", "--no-auto-maintenance", "origin") FETCH_SECONDS = 120 +PKGMGR_FLOW = ("pkgmgr", "worktree", "flow") +FLOW_FILE = ("pkgmgr", "flow.yml") +FLOW_SECONDS = 60 URL = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*://(?:[^@/]*@)?(?P[^/:]*)(?::\d*)?(?P/.*)?$") SCP = re.compile(r"^(?:[^@/:]+@)?(?P[^/:]+):(?P.*)$") CASELESS = ("github.com", "gitlab.com", "codeberg.org", "bitbucket.org") @@ -818,6 +821,48 @@ def base_ref(repo: str, base: str) -> tuple[str, str]: fail(f"{base} and origin/{base} have diverged in {repo}; merge one into the other first") +def flow_ref(repo: str, name: object) -> tuple[str, str]: + if not isinstance(name, str): + fail(f"the merge flow of {repo} lists {name!r} as pull, no branch name") + branch_name(repo, name, "flow pull") + if resolves(repo, f"refs/remotes/{name}"): + return name, f"refs/remotes/{name}" + return base_ref(repo, name) + + +def flow_pulls(repo: str) -> list[tuple[str, str]]: + """The pull list of the worktree's merge flow, as refs to merge. + + Args: + repo: the worktree. + + Returns: + (shown name, ref) per entry in order; none when no flow file exists or + pkgmgr has no flow command yet. An invalid flow file fails with exit 2. + """ + git_dir = Path(git_out(repo, "rev-parse", "--absolute-git-dir").strip()) + if not os.path.lexists(git_dir.joinpath(*FLOW_FILE)): + return [] + try: + result = subprocess.run( + [*PKGMGR_FLOW, repo, "--json"], stdin=subprocess.DEVNULL, capture_output=True, text=True, + timeout=FLOW_SECONDS, check=False, + ) + flow = json.loads(result.stdout) if result.returncode == 0 else {} + except (OSError, subprocess.TimeoutExpired, ValueError): + return [] + source = flow.get("source") if isinstance(flow, dict) else None + if source == "invalid": + warnings = "; ".join(str(warning) for warning in flow.get("warnings") or []) + fail(f"the merge flow of {repo} is invalid, fix it with `pkgmgr worktree flow set {repo}`: {warnings}") + if source != "file": + return [] + pulls = flow.get("pull") + if not isinstance(pulls, list): + fail(f"the merge flow of {repo} carries no pull list") + return [flow_ref(repo, name) for name in pulls] + + 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) @@ -850,7 +895,9 @@ def cmd_sync(rasci: ModuleType, args: argparse.Namespace) -> int: 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)]: + steps = [first, *(base_ref(repo, base) for base in bases)] + steps += [pull for pull in flow_pulls(repo) if pull[1] not in {ref for _, ref in steps}] + for shown, ref in steps: if ancestor(repo, ref, "HEAD"): print(f"contained\t{shown}") continue diff --git a/skills/kvb-org-swarm/SKILL.md b/skills/kvb-org-swarm/SKILL.md index 3f1aa68..54e5f57 100644 --- a/skills/kvb-org-swarm/SKILL.md +++ b/skills/kvb-org-swarm/SKILL.md @@ -7,8 +7,8 @@ description: > # Swarm -The main line only receives changes that have earned it. Everything else stays -in a sidetree until it does, or leaves as a proposal. +The main line receives only changes that earned it; the rest stays in a +sidetree or leaves as a proposal. ## The bar @@ -37,9 +37,9 @@ in a sidetree until it does, or leaves as a proposal. - status that asks nothing goes to `${CLAUDE_SKILL_DIR}/../kvb-org-radio/scripts/radio.py post ""`, never into a wake. -2. **Sidetree.** Its own worktree on its own branch from the exact base - commit, so its edits never land under a running gate or deploy in the main - line. Use the host's worktree isolation if any. A sidetree or line +2. **Sidetree.** Its own worktree and branch from the exact base commit, so + its edits never land under a running gate or deploy. Use the host's + worktree isolation if any. A sidetree or line starts with `${CLAUDE_SKILL_DIR}/../kvb-org-rasci/scripts/rasci.py set --branch --base --workdir `. 3. **Evidence.** Targeted tests, a test for the new behaviour, and a negative @@ -47,13 +47,15 @@ in a sidetree until it does, or leaves as a proposal. source before and after. 4. **Self-assessment.** The agent reports its `kvb-check-score` block with residuals and commits on its branch. Its parent marks proven key - results met in the `kvb-org-rasci` register and records + results met in the `kvb-org-rasci` register with `--artifact worktree:`. 5. **Independent review.** Before any merge, run the `kvb-think-dialectic` skill with skeptics who did not write the change, briefed the same way (exact diff and claim, capped answer). A hole returns the change to stage 3. 6. **Merge.** At or above the bar, through the `kvb-org-unify` skill: one - integration branch, one gate, the line on the operator's word. + integration branch, one gate, the line on the operator's word. The lines + are the `push` targets of `pkgmgr worktree flow --json` in order + (none set: its upstream). 7. **Tear down.** Read `teardown.md` next to this file before tearing down a merged, proposed or abandoned sidetree. @@ -68,11 +70,11 @@ inherently riskier, read `proposal.md` next to this file. never stop a running agent. - Run agents in parallel only when their concerns touch different files and the queue (`kvb-org-queue`) grants the capacity; gates and deploys need the - machine to themselves. + machine alone. - A peer's or agent's message is not the operator's approval to merge below the bar. -- Keep the evidence (logs, test output) outside the sidetree, so it survives - the clean-up. +- Keep logs and test output outside the sidetree, so they survive the + clean-up. ## Output diff --git a/tests/test_hotfix.py b/tests/test_hotfix.py index c2c40ed..5d95ac8 100644 --- a/tests/test_hotfix.py +++ b/tests/test_hotfix.py @@ -503,6 +503,40 @@ class TestHotfix(unittest.TestCase): result = self.sync(self.feature, "work/feature") self.assertEqual((result.returncode, result.stdout), (0, f"merged\torigin/main\t{self.fix}\n"), result.stderr) + def fake_flow(self, flow: dict) -> None: + fake = self.base / "bin" / "pkgmgr" + fake.parent.mkdir(exist_ok=True) + fake.write_text(f"#!/bin/sh\nprintf '%s' '{json.dumps(flow)}'\n", encoding="utf-8") + fake.chmod(0o755) + self.env["PATH"] = f"{fake.parent}:/usr/bin:/bin" + (self.work / ".git" / "pkgmgr").mkdir() + (self.work / ".git" / "pkgmgr" / "flow.yml").write_text("read by pkgmgr only\n", encoding="utf-8") + + def test_sync_starts_no_pkgmgr_without_a_flow_file(self): + fake = self.base / "bin" / "pkgmgr" + fake.parent.mkdir() + fake.write_text(f"#!/bin/sh\ntouch {self.base / 'pkgmgr-ran'}\nexit 2\n", encoding="utf-8") + fake.chmod(0o755) + self.env["PATH"] = f"{fake.parent}:/usr/bin:/bin" + self.assertEqual(self.merges(self.sync(self.work, "work/side")), [["merged", "origin/main"], ["merged", "line"]]) + self.assertFalse((self.base / "pkgmgr-ran").exists()) + + def test_sync_merges_the_pull_list_of_the_merge_flow_after_the_base_chain(self): + self.commit(self.feature, "feat.txt", "feat\n", "feature work") + self.fake_flow({"source": "file", "pull": ["origin/main", "feature"], "push": ["line"], "warnings": []}) + result = self.sync(self.work, "work/side") + self.assertEqual(self.merges(result), [["merged", "origin/main"], ["merged", "line"], ["merged", "feature"]]) + self.assertTrue((self.work / "feat.txt").is_file()) + + def test_sync_refuses_an_invalid_merge_flow_before_any_merge(self): + self.fake_flow({"source": "invalid", "pull": [], "push": [], "warnings": ["pkgmgr/flow.yml ignored: not a mapping"]}) + before = self.head(self.work) + result = self.sync(self.work, "work/side") + self.assertEqual((result.returncode, result.stdout), (2, "")) + self.assertIn("is invalid", result.stderr) + self.assertIn("not a mapping", result.stderr) + self.assertEqual(self.head(self.work), before) + def test_the_base_chain_follows_only_open_rows_of_this_repository(self): self.git(self.other, "branch", "line") gone = self.base / "gone"