feat(kvb-git): follow the pkgmgr merge flow of each worktree
pkgmgr worktree flow (pkgmgr 7fb2296, live after its release) records per worktree the refs merged in before a gate (pull) and the branches the finished branch merges into (push). The skills now act on it: - hotfix.py sync merges, after origin/<default> and the register's base chain, every entry of the flow's pull list that is not merged already; an invalid flow file stops sync with exit 2 before any merge. pkgmgr starts only when <git-dir>/pkgmgr/flow.yml exists, so a sync without a flow, and the tests, never run the system pkgmgr wrapper. - kvb-org-split merges a finished sidetree into each push target of the flow in order, its upstream when none is set. - kvb-git-commit-next names the push targets the branch flows into when it closes. Word budgets kept (kvb-git-hotfix and kvb-org-split trimmed back under 600), skill text 2807 -> 2809 tokens. Tests: 38 hotfix tests OK, 3 new (pull list merged after the chain, invalid flow refused before any merge, no pkgmgr start without a flow file). 🎯 Fix: 93 % 💥 Break: 3 % 💸 Cost: 100 % ⏱️ Time: 99 % 🛡️ Security: 100 % 🏆 Score: 91 🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 % Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> (cherry picked from commit 1e1fd0e79821ad9a8d4fea99b38ac59ecb02d7b2)
This commit is contained in:
1 parent
7199e6bfba
commit
7618492d66
5 files changed
+110
-27
No files matched your search
@@ -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
|
||||
<worktree> --file <path> --match <regex>`, 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>`: 📁 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.
|
||||
@@ -35,20 +35,21 @@ hotfix.py void --repo <worktree> --target <t> [--target ...]
|
||||
|
||||
1. `sync`: fetches, merges `origin/<default>`, 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/<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.
|
||||
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`.
|
||||
@@ -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<host>[^/:]*)(?::\d*)?(?P<path>/.*)?$")
|
||||
SCP = re.compile(r"^(?:[^@/:]+@)?(?P<host>[^/:]+):(?P<path>.*)$")
|
||||
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
|
||||
|
||||
@@ -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 "<text>"`,
|
||||
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
|
||||
<task> --branch <own> --base <upstream> --workdir <dir>`.
|
||||
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:<path>`.
|
||||
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 <dir> --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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user