Skip to content

Commit a7890bb

Browse files
Bordaclaude[bot]
andcommitted
feat(plugins): per-skill compaction contracts + advisorModel sync
Two capabilities plus bundled in-flight tree changes, committed together at the user's request. Every touched plugin takes a minor (new-capability) bump: foundry 0.31.6→0.32.0, oss 0.17.8→0.18.0, develop 0.11.7→0.12.0, research 0.9.5→0.10.0. Per-skill compaction contracts — survive auto-compaction losslessly: - Context auto-compacts at 85% and previously preserved only modified-file paths, so a compaction landing mid-workflow (parallel fan-out, iteration loop, large gather) could drop the distilled hand-off the next phase needs. There is no skill-invokable /compact, so the design rides auto-compact: skills keep a contract file fresh at expanding-phase boundaries and the hook stages it verbatim. - The PreCompact hook (plugins/foundry/hooks/task-log.js) now reads .claude/state/skill-contract.md and appends it verbatim under a "## Skill Compaction Contract" section of session-context.md, which CLAUDE.md re-reads after compaction and now treats as an authoritative skill hand-off. Same change also fixes a pre-existing bug where the hook's full rewrite clobbered foundry:session's "## Parked items" — those are now carried over idempotently. - New shared convention: plugins/foundry/rules/compaction.md (the verbatim-file principle, placement, clear-on-completion) and plugins/foundry/skills/_shared/compaction-contract.md (the block template + acceptance criteria). - Adopted in 19 phased skills, each with a custom checkpoint tuned to its flow — fan-out→consolidate (oss:review, develop:review, foundry:audit, foundry:calibrate), iteration loops (research:run/sweep/fortify/kaggle, oss:resolve), discovery→edit→review (develop:feature/fix/refactor/debug), and gather→synthesize (oss:analyse, research:topic/judge, foundry:brainstorm/investigate/distill). Each gains a "# loads:" ref, a <compaction> block, boundary contract-writes, an optional --keep "<items>" arg to pin extra preserve items per invocation, and clear-on-completion. Single-shot/mechanical skills (codemap:*, foundry:session/setup/profile, research:plan/verify/retro) are excluded. advisorModel sync via /foundry:setup: - /advisor writes advisorModel only to the global ~/.claude/settings.json; sync.sh never propagated it. foundry:setup gains a Step 8 that reads a project-pinned advisorModel from ./.claude/settings.json and merges it into global, so the preference now travels with the repo. Pinned in this project's .claude/settings.json ("fable"). Bundled maintenance (in-flight tree work, committed together — not individually reviewed as part of the compaction change): - chore: pre-commit toolchain upgrade — ruff v0.11.4→v0.15.20, eslint v10.2.0→v10.6.0, check-added-large-files maxkb 125→250 — plus the resulting ruff auto-formatting/fixes across plugin bin/ and tests/ files. - fix: hardened shared flag parsing (develop dev_parse_args.py and peers) — full flag-token match via negative lookbehind so e.g. `--planets` no longer satisfies `--plan` and a flag is not consumed as another flag's value. - test: additional benchmark tests under benchmarks/tests/ (~380 lines). Verification (compaction): 16 hook tests pass; all 19 skills pass marker + tag-balance + orphan-safety checks; cross-plugin propagation in sync; --keep frontmatter valid. Contract survival is deterministic once written; timing rides auto-compact (a compaction before a skill's first boundary keeps only the generic files breadcrumb — stated limitation). --- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
1 parent 648ddea commit a7890bb

103 files changed

Lines changed: 3554 additions & 409 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/README.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ claude plugin install codemap@borda-ai-rig
6767
/foundry:setup
6868
```
6969

70-
This merges `statusLine`, `permissions.allow`, and `enabledPlugins` (codex plugin) into `~/.claude/settings.json`; symlinks `rules/*.md` and `TEAM_PROTOCOL.md` into `~/.claude/`. Agents, skills, and hooks are exposed natively by the Claude Code plugin system — no symlinks needed.
70+
This merges `statusLine`, `permissions.allow`, `enabledPlugins` (codex plugin), and `advisorModel` into `~/.claude/settings.json`; symlinks `rules/*.md` and `TEAM_PROTOCOL.md` into `~/.claude/`. Agents, skills, and hooks are exposed natively by the Claude Code plugin system — no symlinks needed.
7171

7272
**What is restored:** `~/.claude/rules/*.md` and `~/.claude/TEAM_PROTOCOL.md` become symlinks into the installed foundry plugin. `~/.claude/settings.json` is updated in-place. All other plugin files (agents, skills, hooks, CLAUDE.md) are served directly by the plugin system. The only local-machine files are `settings.local.json` and `settings.json` (project prefs + permissions).
7373

@@ -92,7 +92,7 @@ plugins/foundry/ ← source of truth
9292

9393
```text
9494
/foundry:setup # symlink rules/*.md + TEAM_PROTOCOL.md → ~/.claude/;
95-
# merge statusLine, permissions.allow, enabledPlugins → ~/.claude/settings.json
95+
# merge statusLine, permissions.allow, enabledPlugins, advisorModel → ~/.claude/settings.json
9696
# (re-run after plugin upgrade to refresh stale rule symlinks)
9797
```
9898

@@ -495,7 +495,6 @@ A native research harness for questions that need real sourcing rather than a si
495495
- **Don't use it** for a single-fact lookup (a normal web search or an inline answer is faster) or for anything answerable from the codebase (use `/codemap:query-code`, grep, or a direct read).
496496
- **Scope first** — if the question is underspecified (e.g. "what car should I buy" with no budget / use-case / region), it will ask 2-3 clarifying questions before spending the search budget. Giving those constraints up front produces a sharper report.
497497

498-
499498
## 🗺️ Plugin dependency matrix
500499

501500
<details>

.claude/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
{
2+
"advisorModel": "fable",
23
"autoUpdatesChannel": "stable",
34
"effortLevel": "high",
45
"env": {

.pre-commit-config.yaml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ repos:
3434
name: 🔤 check-case-conflict
3535
- id: check-added-large-files
3636
name: 📦 check-added-large-files
37-
args: ["--maxkb=125", "--enforce-all"]
37+
args: ["--maxkb=250", "--enforce-all"]
3838
- id: detect-private-key
3939
name: 🔐 detect-private-key
4040
- id: check-merge-conflict
@@ -56,7 +56,7 @@ repos:
5656
args: ["--print-width=120"]
5757

5858
- repo: https://github.com/pre-commit/mirrors-eslint
59-
rev: v10.2.0
59+
rev: v10.6.0
6060
hooks:
6161
- id: eslint
6262
name: 🛡️ eslint
@@ -66,10 +66,10 @@ repos:
6666
- "@eslint/js@9.21.0"
6767

6868
- repo: https://github.com/astral-sh/ruff-pre-commit
69-
rev: v0.11.4
69+
rev: v0.15.20
7070
hooks:
71-
- id: ruff
72-
name: 🦀 ruff
71+
- id: ruff-check
72+
name: 🦀 ruff-check
7373
args: ["--fix"]
7474
- id: ruff-format
7575
name: 🦀 ruff-format

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -461,7 +461,7 @@ claude plugin install research@borda-ai-rig
461461
claude plugin install codemap@borda-ai-rig
462462
```
463463
464-
Re-run `/foundry:setup` only if permissions or `enabledPlugins` changed. Re-run `/foundry:setup` if you previously used the link mode — symlinks point to the old plugin cache after an upgrade.
464+
Re-run `/foundry:setup` only if permissions, `enabledPlugins`, or `advisorModel` changed. Re-run `/foundry:setup` if you previously used the link mode — symlinks point to the old plugin cache after an upgrade.
465465
466466
### Session-only (no install, for development)
467467

benchmarks/generate-tasks-bench.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1124,8 +1124,16 @@ def _validate_oss(task: dict, sq: Path, index: Path, repo: Path) -> tuple[bool,
11241124
data = run_scan_query(sq, ["undocumented"] + q.get("args", []), index, repo)
11251125
if data is None:
11261126
return False, None, "scan-query undocumented returned None"
1127+
if not isinstance(data.get("total"), int):
1128+
return False, None, "undocumented total is missing or not an int"
1129+
if not isinstance(data.get("undocumented"), list):
1130+
return False, None, "undocumented result is missing or not a list"
1131+
if any(not isinstance(e, dict) for e in data["undocumented"]):
1132+
return False, None, "undocumented result contains non-object entries"
11271133
scan_count = data.get("total", 0)
11281134
scan_syms = [e.get("qualified_name", "") for e in data.get("undocumented", [])]
1135+
if scan_count != len(scan_syms):
1136+
return False, None, "undocumented total conflicts with symbol count"
11291137
if check == "undocumented":
11301138
# AST oracle is authoritative (review C-2) — scan-query is the tool under test.
11311139
module = next((a for a in q.get("args", []) if not str(a).startswith("-")), None)
@@ -1149,8 +1157,16 @@ def _validate_oss(task: dict, sq: Path, index: Path, repo: Path) -> tuple[bool,
11491157
data = run_scan_query(sq, ["uncovered"] + q.get("args", []), index, repo)
11501158
if data is None:
11511159
return False, None, "scan-query uncovered returned None"
1160+
if not isinstance(data.get("total"), int):
1161+
return False, None, "uncovered total is missing or not an int"
1162+
if not isinstance(data.get("uncovered"), list):
1163+
return False, None, "uncovered result is missing or not a list"
1164+
if any(not isinstance(e, dict) for e in data["uncovered"]):
1165+
return False, None, "uncovered result contains non-object entries"
11521166
scan_count = data.get("total", 0)
11531167
scan_syms = [e.get("qualified_name", "") for e in data.get("uncovered", [])]
1168+
if scan_count != len(scan_syms):
1169+
return False, None, "uncovered total conflicts with symbol count"
11541170
if check == "uncovered":
11551171
# AST oracle is authoritative (review C-2 remainder) — scan-query is the tool under test.
11561172
module = next((a for a in q.get("args", []) if not str(a).startswith("-")), None)
@@ -1189,9 +1205,13 @@ def _validate_oss(task: dict, sq: Path, index: Path, repo: Path) -> tuple[bool,
11891205
if data is None:
11901206
return False, None, "scan-query coupled returned None"
11911207
coupled = data.get("coupled", [])
1208+
if not isinstance(coupled, list):
1209+
return False, None, "coupled result is not a list"
11921210
if not coupled:
11931211
return False, None, "coupled result is empty"
11941212
top = coupled[0]
1213+
if not isinstance(top, dict):
1214+
return False, None, "coupled top result is not an object"
11951215
live_gt["top_module"] = top.get("name", "")
11961216
live_gt["top_dep_count"] = top.get("dep_count", 0)
11971217
live_gt["top_internal_dep_count"] = top.get("internal_dep_count", 0)
@@ -1222,7 +1242,15 @@ def _validate_oss(task: dict, sq: Path, index: Path, repo: Path) -> tuple[bool,
12221242
if data is None:
12231243
return False, None, "scan-query xrefs returned None"
12241244
broken = data.get("broken", [])
1245+
if not isinstance(broken, list):
1246+
return False, None, "xrefs broken result is not a list"
1247+
if any(not isinstance(b, dict) for b in broken):
1248+
return False, None, "xrefs broken result contains non-object entries"
12251249
live_count = data.get("count", len(broken))
1250+
if not isinstance(live_count, int):
1251+
return False, None, "xrefs broken count is not an int"
1252+
if live_count != len(broken):
1253+
return False, None, "xrefs broken count conflicts with target count"
12261254
live_targets = [{"target": b.get("target", ""), "line": b.get("line", 0)} for b in broken]
12271255
live_gt["broken_count"] = live_count
12281256
live_gt["broken_targets"] = live_targets

benchmarks/run-codemap-bench.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -549,10 +549,13 @@ def _parse_scan_query_subcommand(command: str) -> Optional[str]:
549549
tokens = shlex.split(command)
550550
except ValueError:
551551
tokens = command.split()
552-
# Locate the scan-query token (may be a path like .../bin/scan-query).
552+
# Locate the scan-query executable token (may be a path like .../bin/scan-query).
553553
start = None
554+
allowed_prefix = {"env", "command", "time"}
554555
for i, tok in enumerate(tokens):
555556
if tok == "scan-query" or tok.endswith("/scan-query"):
557+
if any(prev not in allowed_prefix and "=" not in prev for prev in tokens[:i]):
558+
continue
556559
start = i + 1
557560
break
558561
if start is None:
@@ -561,11 +564,12 @@ def _parse_scan_query_subcommand(command: str) -> Optional[str]:
561564
while i < len(tokens):
562565
tok = tokens[i]
563566
if tok.startswith("-"):
564-
# Skip a flag; if its value is a separate token (not another flag), skip it too.
565-
if "=" not in tok and i + 1 < len(tokens) and not tokens[i + 1].startswith("-"):
567+
if tok == "--index" and i + 1 < len(tokens) and not tokens[i + 1].startswith("-"):
566568
i += 2
567-
else:
569+
elif tok.startswith("--index="):
568570
i += 1
571+
else:
572+
return None
569573
continue
570574
return tok if tok in _SCAN_QUERY_SUBCOMMANDS else None
571575
return None
@@ -736,7 +740,7 @@ def _ri_file_matches(file_path: str, region: str) -> bool:
736740
if len(parts) >= 2:
737741
candidates.add(f"{parts[-2]}/{parts[-1]}")
738742
candidates.add(f"{parts[-2]}/{stem}")
739-
return any(cand in region for cand in candidates)
743+
return any(re.search(r"(?<![\w/.-])" + re.escape(cand) + r"(?![\w/.-])", region) for cand in candidates)
740744

741745

742746
def _extract_int(text: str, patterns: list[str]) -> Optional[int]:
@@ -802,7 +806,7 @@ def _int_close(got: Optional[int], expected: int, tolerance: float = 0.10) -> bo
802806
>>> _int_close(None, 40)
803807
False
804808
"""
805-
if got is None:
809+
if got is None or not isinstance(expected, (int, float)):
806810
return False
807811
return abs(got - expected) / max(expected, 1) <= tolerance
808812

benchmarks/run-codemap-cli.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1055,14 +1055,20 @@ def validate_central_json(data: dict) -> ValidationResult:
10551055
:class:`ValidationResult` with ``ok=True`` on success, or ``ok=False``
10561056
and a reason string describing the first structural violation found.
10571057
"""
1058+
if not isinstance(data, dict):
1059+
return ValidationResult(ok=False, reason="response is not an object")
10581060
if "central" not in data:
10591061
return ValidationResult(ok=False, reason="missing 'central' key")
10601062
central = data["central"]
10611063
if not isinstance(central, list) or len(central) == 0:
10621064
return ValidationResult(ok=False, reason="'central' is empty or not a list")
10631065
for item in central:
1066+
if not isinstance(item, dict):
1067+
return ValidationResult(ok=False, reason="central item is not an object")
10641068
if "rdep_count" not in item:
10651069
return ValidationResult(ok=False, reason="central item missing 'rdep_count'")
1070+
if not isinstance(item["rdep_count"], int):
1071+
return ValidationResult(ok=False, reason="central item 'rdep_count' is not an int")
10661072
return ValidationResult(ok=True, reason="")
10671073

10681074

@@ -1078,10 +1084,16 @@ def validate_rdeps_json(data: dict) -> ValidationResult:
10781084
:class:`ValidationResult` with ``ok=True`` on success, or ``ok=False``
10791085
and a reason string describing the missing key.
10801086
"""
1087+
if not isinstance(data, dict):
1088+
return ValidationResult(ok=False, reason="response is not an object")
10811089
if "imported_by" not in data:
10821090
return ValidationResult(ok=False, reason="missing 'imported_by' key")
10831091
if "module" not in data:
10841092
return ValidationResult(ok=False, reason="missing 'module' key")
1093+
if not isinstance(data["imported_by"], list):
1094+
return ValidationResult(ok=False, reason="'imported_by' is not a list")
1095+
if not isinstance(data["module"], str):
1096+
return ValidationResult(ok=False, reason="'module' is not a string")
10851097
return ValidationResult(ok=True, reason="")
10861098

10871099

@@ -1097,10 +1109,16 @@ def validate_deps_json(data: dict) -> ValidationResult:
10971109
:class:`ValidationResult` with ``ok=True`` on success, or ``ok=False``
10981110
and a reason string describing the missing key.
10991111
"""
1112+
if not isinstance(data, dict):
1113+
return ValidationResult(ok=False, reason="response is not an object")
11001114
if "direct_imports" not in data:
11011115
return ValidationResult(ok=False, reason="missing 'direct_imports' key")
11021116
if "module" not in data:
11031117
return ValidationResult(ok=False, reason="missing 'module' key")
1118+
if not isinstance(data["direct_imports"], list):
1119+
return ValidationResult(ok=False, reason="'direct_imports' is not a list")
1120+
if not isinstance(data["module"], str):
1121+
return ValidationResult(ok=False, reason="'module' is not a string")
11041122
return ValidationResult(ok=True, reason="")
11051123

11061124

benchmarks/tests/test_generate_tasks_bench.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1050,6 +1050,57 @@ def test_scan_query_failure_returns_none(self, script_gen_bench: Any, tmp_path:
10501050
assert ok is False
10511051
assert live_gt is None
10521052

1053+
@pytest.mark.parametrize(
1054+
"task,payload,reason_fragment",
1055+
[
1056+
(
1057+
_task_undocumented(None, 0, []),
1058+
{"undocumented": []},
1059+
"total",
1060+
),
1061+
(
1062+
_task_uncovered(None, 0, []),
1063+
{"total": 0, "uncovered": "not a list"},
1064+
"list",
1065+
),
1066+
(
1067+
_task_undocumented(None, 2, ["A", "B"]),
1068+
{"total": 2, "undocumented": [{"qualified_name": "A"}]},
1069+
"conflicts",
1070+
),
1071+
(
1072+
_task_coupled(None, "mod", 1, 0),
1073+
{"coupled": "not a list"},
1074+
"not a list",
1075+
),
1076+
(
1077+
_task_coupled(None, "mod", 1, 0),
1078+
{"coupled": [1]},
1079+
"not an object",
1080+
),
1081+
(
1082+
_task_xrefs(None, 2, [{"target": "mod::Fn", "line": 1}, {"target": "mod::Other", "line": 2}]),
1083+
{"count": 2, "broken": [{"target": "mod::Fn", "line": 1}]},
1084+
"conflicts",
1085+
),
1086+
],
1087+
)
1088+
def test_malformed_scan_query_payloads_fail_cleanly(
1089+
self,
1090+
script_gen_bench: Any,
1091+
tmp_path: Path,
1092+
task: dict,
1093+
payload: dict,
1094+
reason_fragment: str,
1095+
) -> None:
1096+
"""Missing keys, wrong types, and count/list conflicts return failures, not crashes."""
1097+
with patch.object(script_gen_bench, "run_scan_query", return_value=payload):
1098+
ok, live_gt, reason = script_gen_bench._validate_oss(task, MagicMock(), tmp_path / "idx.json", tmp_path)
1099+
1100+
assert ok is False
1101+
assert live_gt is None
1102+
assert reason_fragment in reason
1103+
10531104
def test_combined_health_validates_both_counts(self, script_gen_bench: Any, tmp_path: Path) -> None:
10541105
"""Validates both undocumented and uncovered fields for combined_health check.
10551106

benchmarks/tests/test_generate_tasks_real_issues.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -726,3 +726,72 @@ def test_no_warning_when_closing_pr_available(
726726
result = script_gen_real_issues.resolve_merged_pr([101], 1, 5, issue_number=42)
727727
assert result.closes_issue is True
728728
assert "weak provenance" not in capsys.readouterr().err
729+
730+
731+
# ===========================================================================
732+
# class TestInspectPrSourceFiltering
733+
# ===========================================================================
734+
735+
736+
class TestInspectPrSourceFiltering:
737+
"""Tests for _inspect_pr source-file filtering using mocked GitHub data."""
738+
739+
def test_filters_test_files_and_records_source_change_sizes(
740+
self, script_gen_real_issues: Any, monkeypatch: pytest.MonkeyPatch
741+
) -> None:
742+
"""Only non-test Python files become source ground truth; change sizes are retained."""
743+
744+
def _fake_gh_json(args: list[str], timeout: int = 60) -> dict:
745+
return {
746+
"state": "MERGED",
747+
"mergedAt": "2026-01-01T00:00:00Z",
748+
"body": "Fixes #42",
749+
"files": [
750+
{"path": "src/pkg/core.py", "additions": 3, "deletions": 2},
751+
{"path": "tests/test_core.py", "additions": 100, "deletions": 0},
752+
{"path": "docs/guide.md", "additions": 9, "deletions": 1},
753+
],
754+
}
755+
756+
monkeypatch.setattr(script_gen_real_issues, "_gh_json", _fake_gh_json)
757+
758+
result = script_gen_real_issues._inspect_pr(123, min_py=1, max_py=5, issue_number=42)
759+
760+
assert result is not None
761+
assert result.source_files == ["src/pkg/core.py"]
762+
assert result.py_file_count == 2
763+
assert result.source_changes == {"src/pkg/core.py": 5}
764+
assert result.closes_issue is True
765+
766+
def test_all_test_files_returns_none(self, script_gen_real_issues: Any, monkeypatch: pytest.MonkeyPatch) -> None:
767+
"""A PR with Python changes only in tests is not a source-backed task."""
768+
769+
def _fake_gh_json(args: list[str], timeout: int = 60) -> dict:
770+
return {
771+
"state": "MERGED",
772+
"mergedAt": "2026-01-01T00:00:00Z",
773+
"body": "Fixes #42",
774+
"files": [
775+
{"path": "tests/test_core.py", "additions": 10, "deletions": 0},
776+
{"path": "src/pkg/core_test.py", "additions": 1, "deletions": 1},
777+
],
778+
}
779+
780+
monkeypatch.setattr(script_gen_real_issues, "_gh_json", _fake_gh_json)
781+
782+
assert script_gen_real_issues._inspect_pr(123, min_py=1, max_py=5, issue_number=42) is None
783+
784+
def test_no_python_files_returns_none(self, script_gen_real_issues: Any, monkeypatch: pytest.MonkeyPatch) -> None:
785+
"""A PR without Python files cannot produce Python source ground truth."""
786+
787+
def _fake_gh_json(args: list[str], timeout: int = 60) -> dict:
788+
return {
789+
"state": "MERGED",
790+
"mergedAt": "2026-01-01T00:00:00Z",
791+
"body": "Fixes #42",
792+
"files": [{"path": "README.md", "additions": 10, "deletions": 0}],
793+
}
794+
795+
monkeypatch.setattr(script_gen_real_issues, "_gh_json", _fake_gh_json)
796+
797+
assert script_gen_real_issues._inspect_pr(123, min_py=1, max_py=5, issue_number=42) is None

0 commit comments

Comments
 (0)