From 8db0e20a3dcebbe952369145f0f27667bdccc299 Mon Sep 17 00:00:00 2001 From: Thomas Peoples Date: Mon, 9 Nov 2026 07:10:32 +0000 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=91=BB=20fix/WSK-00108:=20watch=20mat?= =?UTF-8?q?tpocock=20as=20a=20companion;=20unchanged=20derived=20files=20f?= =?UTF-8?q?ollow=20their=20pin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/wraith_tools/sync_upstream.py | 56 +++++++++++++++++++++++++------ src/wraith_tools/vet_companion.py | 1 + tests/test_sync_upstream.py | 38 +++++++++++++++++++-- 3 files changed, 81 insertions(+), 14 deletions(-) diff --git a/src/wraith_tools/sync_upstream.py b/src/wraith_tools/sync_upstream.py index 7db8a52..a33427c 100644 --- a/src/wraith_tools/sync_upstream.py +++ b/src/wraith_tools/sync_upstream.py @@ -12,7 +12,7 @@ from dataclasses import dataclass, field from pathlib import Path -COMPANIONS = ("DBT", "GCP", "TF", "SP") +COMPANIONS = ("DBT", "GCP", "TF", "SP", "MP") # The companion skills wraith names, from the Decision sections of # docs/adr/0002 to 0004 and 0006. Drift anywhere else in a vendor repo is # out of scope: wraith never vetted those skills and never points to them @@ -85,6 +85,19 @@ "writing-skills", } ), + "MP": frozenset( + { + "tdd", + "diagnosing-bugs", + "grilling", + "handoff", + "domain-modeling", + "code-review", + "setup-matt-pocock-skills", + "pr", + "writing-for-agents", + } + ), } SKIP_DIRS = {".git", ".upstream", ".venv", "node_modules"} @@ -250,21 +263,31 @@ class Change: def _sync_file( root: Path, rel: str, entries: list[Entry], old: Path, new: Path -) -> tuple[Row | None, Change | None]: +) -> tuple[Row | None, Change | None, list[tuple[str, str, str]]]: changed = [] - for key, _sha7, upath in entries: + # Source lines of files upstream left alone; main() moves them with + # the pin, or they drift from upstream-pins.txt. + moved = [] + for key, sha7, upath in entries: before = _read(old / key / upath) after = _read(new / key / upath) - if before is None or before == after: + if before is None: + continue + if before == after: + new7 = _sha(new / key)[:7] + if new7 and new7 != sha7: + moved.append( + (key, f"{key}@{sha7}:{upath}", f"{key}@{new7}:{upath}") + ) continue changed.append((key, upath, before, after)) if not changed: - return None, None + return None, None, moved key, upath, before, after = changed[0] if len(entries) > 1: - return Row(rel, "composite"), None + return Row(rel, "composite"), None, [] if after is None: - return Row(rel, "removed"), None + return Row(rel, "removed"), None, [] diff = _diff(before, after, upath) hits = risk_scan(diff) change = Change(key, upath, diff) @@ -276,12 +299,12 @@ def _sync_file( theirs_file.write_text(after, encoding="utf-8") merged, clean = merge_file(ours, base_file, theirs_file) if not clean: - return Row(rel, "conflict", hits), change + return Row(rel, "conflict", hits), change, [] old7 = entries[0][1] new7 = _sha(new / key)[:7] merged = merged.replace(f"{key}@{old7}:{upath}", f"{key}@{new7}:{upath}") ours.write_text(merged, encoding="utf-8") - return Row(rel, "clean", hits), change + return Row(rel, "clean", hits), change, [] def _named_dirs(root: Path, key: str) -> set[str]: @@ -423,14 +446,25 @@ def main(argv: list[str] | None = None) -> int: rows: list[Row] = [] changes: dict[str, Change] = {} blocked: set[str] = set() + derived: set[str] = set() + moves: list[tuple[str, str, str, str]] = [] for rel, entries in provenance(args.root).items(): - row, change = _sync_file(args.root, rel, entries, args.old, args.new) + row, change, moved = _sync_file( + args.root, rel, entries, args.old, args.new + ) + derived.update(key for key, _, _ in entries) + moves += [(rel, key, was, now) for key, was, now in moved] if row: rows.append(row) if row.status != "clean": blocked.update(key for key, _, _ in entries) if change: changes[rel] = change + for rel, key, was, now in moves: + if key not in blocked: + path = args.root / rel + text = path.read_text(encoding="utf-8") + path.write_text(text.replace(was, now), encoding="utf-8") drift = _companion_drift(args.old, args.new) pins = [] @@ -447,7 +481,7 @@ def main(argv: list[str] | None = None) -> int: " ".join( key for key, _, _, sha in pins - if key not in COMPANIONS + if (key not in COMPANIONS or key in derived) and key not in blocked and _sha(args.old / key) != sha ) diff --git a/src/wraith_tools/vet_companion.py b/src/wraith_tools/vet_companion.py index c921605..d429f8e 100644 --- a/src/wraith_tools/vet_companion.py +++ b/src/wraith_tools/vet_companion.py @@ -30,6 +30,7 @@ "GCP": "0003-companion-google.md", "TF": "0004-companion-hashicorp.md", "SP": "0006-process-skills-to-companions.md", + "MP": "0006-process-skills-to-companions.md", } DIFF_LIMIT = 150_000 BODY_DIFF_LIMIT = 45_000 diff --git a/tests/test_sync_upstream.py b/tests/test_sync_upstream.py index c665474..8e1d890 100644 --- a/tests/test_sync_upstream.py +++ b/tests/test_sync_upstream.py @@ -150,11 +150,11 @@ def test_clean_merge_applied_and_sha_rewritten(tmp_path): def test_prints_only_fully_clean_moved_keys(tmp_path, capsys): ka = upstream(tmp_path / "old", "KA", {"f.md": BASE}) gs = upstream(tmp_path / "old", "GS", {"g.md": BASE}) - upstream(tmp_path / "old", "MP", {"m.md": BASE}) + upstream(tmp_path / "old", "AA", {"m.md": BASE}) upstream(tmp_path / "old", "TF", {"t.md": BASE}) upstream(tmp_path / "new", "KA", {"f.md": THEIRS}) upstream(tmp_path / "new", "GS", {"g.md": THEIRS}) - upstream(tmp_path / "new", "MP", {"m.md": THEIRS}) + upstream(tmp_path / "new", "AA", {"m.md": THEIRS}) upstream(tmp_path / "new", "TF", {"t.md": THEIRS}) local(tmp_path / "root", "f.md", OURS, f"KA@{ka[:7]}:f.md") local( @@ -164,7 +164,7 @@ def test_prints_only_fully_clean_moved_keys(tmp_path, capsys): f"GS@{gs[:7]}:g.md", ) run(tmp_path) - assert capsys.readouterr().out.split() == ["KA", "MP"] + assert capsys.readouterr().out.split() == ["AA", "KA"] def test_conflict_left_untouched(tmp_path): @@ -367,3 +367,35 @@ def test_provenance_reads_source_in_first_fenced_block_of_md(tmp_path): "t.md": [("MP", "d1478fc", "a/t.sh")], "u.md": [("MP", "d1478fc", "a/u.sh")], } + + +def test_unchanged_file_follows_a_moved_pin(tmp_path): + old_sha = upstream(tmp_path / "old", "AA", {"f.md": BASE}) + new_sha = upstream( + tmp_path / "new", "AA", {"f.md": BASE, "other.md": "x\n"} + ) + path = local(tmp_path / "root", "f.md", OURS, f"AA@{old_sha[:7]}:f.md") + run(tmp_path) + assert f"AA@{new_sha[:7]}:f.md" in path.read_text(encoding="utf-8") + + +def test_blocked_key_keeps_its_unchanged_source_lines(tmp_path): + old_sha = upstream(tmp_path / "old", "AA", {"f.md": BASE, "g.md": BASE}) + upstream(tmp_path / "new", "AA", {"f.md": BASE, "g.md": THEIRS}) + keep = local(tmp_path / "root", "f.md", OURS, f"AA@{old_sha[:7]}:f.md") + local( + tmp_path / "root", + "g.md", + BASE.replace("line6", "line6 ours"), + f"AA@{old_sha[:7]}:g.md", + ) + run(tmp_path) + assert f"AA@{old_sha[:7]}:f.md" in keep.read_text(encoding="utf-8") + + +def test_companion_that_is_also_derived_still_bumps(tmp_path, capsys): + old_sha = upstream(tmp_path / "old", "MP", {"m.md": BASE}) + upstream(tmp_path / "new", "MP", {"m.md": THEIRS}) + local(tmp_path / "root", "m.md", OURS, f"MP@{old_sha[:7]}:m.md") + run(tmp_path) + assert "MP" in capsys.readouterr().out.split() From b7970e18a585cb746ca5499a5ed32386740b11c0 Mon Sep 17 00:00:00 2001 From: Thomas Peoples Date: Mon, 9 Nov 2026 07:13:38 +0000 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=91=BB=20fix/WSK-00108:=20a=20drifted?= =?UTF-8?q?=20companion=20keeps=20its=20pin=20until=20re-vetted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/wraith_tools/sync_upstream.py | 5 ++++- tests/test_sync_upstream.py | 10 ++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/wraith_tools/sync_upstream.py b/src/wraith_tools/sync_upstream.py index a33427c..69563a9 100644 --- a/src/wraith_tools/sync_upstream.py +++ b/src/wraith_tools/sync_upstream.py @@ -460,12 +460,15 @@ def main(argv: list[str] | None = None) -> int: blocked.update(key for key, _, _ in entries) if change: changes[rel] = change + drift = _companion_drift(args.old, args.new) + # A companion whose named skills drifted waits for its re-vet, even + # when its derived files merged cleanly. + blocked.update(drift) for rel, key, was, now in moves: if key not in blocked: path = args.root / rel text = path.read_text(encoding="utf-8") path.write_text(text.replace(was, now), encoding="utf-8") - drift = _companion_drift(args.old, args.new) pins = [] for path in sorted(p for p in args.new.iterdir() if p.is_dir()): diff --git a/tests/test_sync_upstream.py b/tests/test_sync_upstream.py index 8e1d890..034a66f 100644 --- a/tests/test_sync_upstream.py +++ b/tests/test_sync_upstream.py @@ -399,3 +399,13 @@ def test_companion_that_is_also_derived_still_bumps(tmp_path, capsys): local(tmp_path / "root", "m.md", OURS, f"MP@{old_sha[:7]}:m.md") run(tmp_path) assert "MP" in capsys.readouterr().out.split() + + +def test_drifted_companion_is_not_bumped(tmp_path, capsys): + old_sha = upstream( + tmp_path / "old", "MP", {"m.md": BASE, "tdd/SKILL.md": "a\n"} + ) + upstream(tmp_path / "new", "MP", {"m.md": THEIRS, "tdd/SKILL.md": "b\n"}) + local(tmp_path / "root", "m.md", OURS, f"MP@{old_sha[:7]}:m.md") + run(tmp_path) + assert "MP" not in capsys.readouterr().out.split()