From 9644f9de2c8f5f2b5949e0b1e2827893160fe05d Mon Sep 17 00:00:00 2001 From: Connor England Date: Sun, 25 Oct 2026 15:12:41 -0500 Subject: [PATCH 1/7] Per-repo structural budgets for three oversized modules Connor's structural-sensor hook blocks any edit to a file over its budget. src/kitctl/model.py (889 lines), resolve.py (1233) and cli.py (551) were already over the 400-line standard tier, so no fix could touch them. Each exception is the measured size plus a small margin, with the measurement in its reason; splitting the modules is a separate refactor. --- .claude/structural-budgets.json | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .claude/structural-budgets.json diff --git a/.claude/structural-budgets.json b/.claude/structural-budgets.json new file mode 100644 index 0000000..6469913 --- /dev/null +++ b/.claude/structural-budgets.json @@ -0,0 +1,29 @@ +{ + "_comment": "Per-repo exceptions for Connor's structural-sensor hook (~/.claude/hooks/structural-sensor.ps1); they win over the global budgets. These three modules were already over the 'standard' tier (400 lines) when the hook first met them. Each limit is the size measured on 2026-10-25 at 641142d plus a small margin for in-flight fixes, so the file cannot keep growing unnoticed. Splitting them is a separate refactor; lower a limit when a module shrinks.", + "exceptions": [ + { + "pattern": "src/kitctl/model.py", + "max_file_loc": 940, + "max_methods": 39, + "max_imports": 13, + "max_classes": 5, + "reason": "Measured 889 lines, 37 functions, 11 imports, 5 classes: the whole warehouse schema and validation in one module. Pre-existing; split tracked as a follow-up refactor." + }, + { + "pattern": "src/kitctl/resolve.py", + "max_file_loc": 1290, + "max_methods": 34, + "max_imports": 9, + "max_classes": 2, + "reason": "Measured 1233 lines, 32 functions, 7 imports, 2 classes: the per-harness compiler. Pre-existing; split tracked as a follow-up refactor." + }, + { + "pattern": "src/kitctl/cli.py", + "max_file_loc": 560, + "max_methods": 17, + "max_imports": 18, + "max_classes": 1, + "reason": "Measured 551 lines, 15 functions, 16 imports, 0 classes: argparse wiring for every command. Pre-existing; split tracked as a follow-up refactor." + } + ] +} From 0f7aade9581d689faad024b59f073ef25d520a7c Mon Sep 17 00:00:00 2001 From: Connor England Date: Sun, 25 Oct 2026 15:14:29 -0500 Subject: [PATCH 2/7] validate: a marketplace with no git source is a problem, not a crash resolve.marketplace() passed source.git = None to _github_slug on the Codex path, so validate died with an AttributeError instead of naming the file. The model now requires source.git on every marketplace primitive, external or not; a test covers both, through parse_primitive and validate. --- src/kitctl/model.py | 4 ++++ tests/test_resolve.py | 26 ++++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/kitctl/model.py b/src/kitctl/model.py index ed06863..2029795 100644 --- a/src/kitctl/model.py +++ b/src/kitctl/model.py @@ -652,6 +652,10 @@ def parse_primitive(root: Path, ref: str) -> Primitive: _str_list(data["tags"], f"{where}.tags", problems) if "source" in data: _check_source(data["source"], f"{where}.source", problems) + # A marketplace is registered by its git URL, external or not. + source = data.get("source") + if kind == "marketplace" and not (isinstance(source, dict) and source.get("git")): + problems.append(Problem(f"{where}.source.git", "required", "a marketplace needs a git URL")) for slot in _str_list(data.get("provides", []), f"{where}.provides", problems): if not SLOT_RE.match(slot): problems.append(Problem(f"{where}.provides", "slot", f"bad slot name {slot!r}")) diff --git a/tests/test_resolve.py b/tests/test_resolve.py index a7807fc..228ba63 100644 --- a/tests/test_resolve.py +++ b/tests/test_resolve.py @@ -225,6 +225,32 @@ def test_credential_literal_is_rejected(cases: Path) -> None: assert "not-a-reference" not in str(error.value.problems) +# A marketplace is registered by its git URL; without one, `validate` used to die in resolve with +# a traceback instead of naming the file, so a warehouse author never learned what to fix. +@pytest.mark.parametrize("external", ["", "external: true\n"]) +def test_marketplace_without_a_git_source_is_a_problem( + cases: Path, tmp_path: Path, external: str +) -> None: + body = f"schema_version: 2\nkind: marketplace\nname: bare\ndescription: no source\n{external}" + (cases / "primitives/marketplaces").mkdir(exist_ok=True) + (cases / "primitives/marketplaces/bare.yaml").write_text(body) + with pytest.raises(ModelError) as error: + parse_primitive(cases, "marketplace:bare") + assert "required" in codes(error) + root = tmp_path / "wh" + for rel, text in { + "primitives/marketplaces/bare.yaml": body, + "primitives/plugins/p.yaml": "schema_version: 2\nkind: plugin\nname: p\n" + "description: p\nmarketplace: marketplace:bare\n", + "loadouts/l.yaml": "schema_version: 2\nname: l\ndescription: l\n" + "marketplaces: [marketplace:bare]\nplugins: [plugin:p]\n", + }.items(): + (root / rel).parent.mkdir(parents=True, exist_ok=True) + (root / rel).write_text(text) + report = validate(root) + assert any(p["path"].endswith("bare.yaml.source.git") for p in report["problems"]) + + def test_url_with_embedded_password_is_rejected(cases: Path) -> None: (cases / "primitives/mcp/remote.yaml").write_text( "schema_version: 2\nkind: mcp\nname: remote\ntransport: http\n" From debd53e66ae3cb0b3f1f6accd16df79e164dd09d Mon Sep 17 00:00:00 2001 From: Connor England Date: Sun, 25 Oct 2026 15:21:42 -0500 Subject: [PATCH 3/7] Codex marketplace remedy names the route for older Codex The fact behind the marketplace_fetch gap was verified on Codex 0.163.0. On codex-cli 0.144.1 (Connor's machine) a trusted project's [marketplaces.*] is not read at all, so 'codex plugin marketplace upgrade ' fails with 'not configured as a Git marketplace'. The remedy now also gives the route that worked there: marketplace add --ref , then plugin add. The three Codex goldens carry the new remedy. docs/harness-reference.md records the 0.144.1 and Claude Code 2.1.304 CLI observations. --- docs/harness-reference.md | 17 +++++++++++++++++ src/kitctl/resolve.py | 4 +++- .../expected/brand-studio.audit.codex.json | 2 +- tests/fixtures/expected/brand-studio.codex.json | 2 +- .../expected/brand-studio.voice.codex.json | 2 +- 5 files changed, 23 insertions(+), 4 deletions(-) diff --git a/docs/harness-reference.md b/docs/harness-reference.md index d5f5ed3..c6725eb 100644 --- a/docs/harness-reference.md +++ b/docs/harness-reference.md @@ -220,6 +220,23 @@ throwaway config. These facts supersede the "documented" entries above. - **verified**: `[mcp_servers.]` in a trusted project's config is listed and enabled by `codex mcp list`. +### Older CLIs on Connor's Windows machine (2026-10-25) + +First real apply (cairn, Gestalt's loadouts), with the project trusted in +`~/.codex/config.toml`. + +- **verified, codex-cli 0.144.1**: the trusted project's `[marketplaces.]` is + not read. `codex plugin marketplace upgrade ` fails with "marketplace `` + is not configured as a Git marketplace", and `codex plugin marketplace list` shows + only user marketplaces. What worked: `codex plugin marketplace add + --ref `, then `codex plugin add @` (an enabled plugin was + not installed by itself). Plans name this route after the 0.163.0 one. +- **verified, Claude Code 2.1.304 CLI**: `claude plugin marketplace list` and + `claude plugin list`, run inside the project, ignore its `extraKnownMarketplaces`. + `claude plugin install --marketplace --scope local` installed + the plugin for that project only (`.claude/settings.local.json`). Whether the + interactive session offers the project's marketplaces was not exercised. + ### Codex CLI 0.163.0, corrections (2026-10-20) Two earlier statements in this file were wrong for 0.163.0. Both are **verified** diff --git a/src/kitctl/resolve.py b/src/kitctl/resolve.py index 6a97175..276d763 100644 --- a/src/kitctl/resolve.py +++ b/src/kitctl/resolve.py @@ -445,7 +445,9 @@ def marketplace(self, primitive: Primitive, layer: str) -> None: "marketplace_fetch", "manual-step", self.fact("marketplace_file").note, - remedy=f"after trusting the project: codex plugin marketplace upgrade {name}", + remedy=f"after trusting the project: codex plugin marketplace upgrade {name}; on " + f"an older Codex: codex plugin marketplace add {slug} --ref {ref}, then " + f"codex plugin add @{name}", ) if not tag and not self.fact("marketplace_sha_pin").supported: self.gap( diff --git a/tests/fixtures/expected/brand-studio.audit.codex.json b/tests/fixtures/expected/brand-studio.audit.codex.json index b5d9ffa..8f18da6 100644 --- a/tests/fixtures/expected/brand-studio.audit.codex.json +++ b/tests/fixtures/expected/brand-studio.audit.codex.json @@ -276,7 +276,7 @@ "capability": "marketplace_fetch", "severity": "manual-step", "reason": "[marketplaces.] source_type/source/ref in a trusted project config is read, but Codex fetches it only on `codex plugin marketplace upgrade `", - "remedy": "after trusting the project: codex plugin marketplace upgrade brand-builder", + "remedy": "after trusting the project: codex plugin marketplace upgrade brand-builder; on an older Codex: codex plugin marketplace add ConnorBritain/brand-builder --ref aa4d814acb353934359f95526961babe07d2e0e6, then codex plugin add @brand-builder", "fallback": null }, { diff --git a/tests/fixtures/expected/brand-studio.codex.json b/tests/fixtures/expected/brand-studio.codex.json index 414a594..1327ac3 100644 --- a/tests/fixtures/expected/brand-studio.codex.json +++ b/tests/fixtures/expected/brand-studio.codex.json @@ -304,7 +304,7 @@ "capability": "marketplace_fetch", "severity": "manual-step", "reason": "[marketplaces.] source_type/source/ref in a trusted project config is read, but Codex fetches it only on `codex plugin marketplace upgrade `", - "remedy": "after trusting the project: codex plugin marketplace upgrade brand-builder", + "remedy": "after trusting the project: codex plugin marketplace upgrade brand-builder; on an older Codex: codex plugin marketplace add ConnorBritain/brand-builder --ref aa4d814acb353934359f95526961babe07d2e0e6, then codex plugin add @brand-builder", "fallback": null }, { diff --git a/tests/fixtures/expected/brand-studio.voice.codex.json b/tests/fixtures/expected/brand-studio.voice.codex.json index 71b2656..7e92bfb 100644 --- a/tests/fixtures/expected/brand-studio.voice.codex.json +++ b/tests/fixtures/expected/brand-studio.voice.codex.json @@ -223,7 +223,7 @@ "capability": "marketplace_fetch", "severity": "manual-step", "reason": "[marketplaces.] source_type/source/ref in a trusted project config is read, but Codex fetches it only on `codex plugin marketplace upgrade `", - "remedy": "after trusting the project: codex plugin marketplace upgrade brand-builder", + "remedy": "after trusting the project: codex plugin marketplace upgrade brand-builder; on an older Codex: codex plugin marketplace add ConnorBritain/brand-builder --ref aa4d814acb353934359f95526961babe07d2e0e6, then codex plugin add @brand-builder", "fallback": null }, { From cfc3267dd5918bc8c6975a7c521db9fd021073ed Mon Sep 17 00:00:00 2001 From: Connor England Date: Sun, 25 Oct 2026 15:21:42 -0500 Subject: [PATCH 4/7] publishing: the npm example uses , not 0.0.1 CodeRabbit on #12: the 0.1.1 steps point to #npm, whose commands still named kitctl-0.0.1.tgz and kitctl@0.0.1. --- docs/publishing.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/publishing.md b/docs/publishing.md index de800d5..269a677 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -102,7 +102,7 @@ uvx --from kitctl==0.0.1 kitctl --version ## npm -Build and verify the exact tarball: +Build and verify the exact tarball. `` is the release, for example `0.1.1`: ```sh uv sync --locked @@ -113,8 +113,8 @@ npm --prefix npm test cd npm npm pack --dry-run npm pack -npm publish kitctl-0.0.1.tgz --access public -npm view kitctl@0.0.1 version maintainers dist.integrity +npm publish kitctl-.tgz --access public +npm view kitctl@ version maintainers dist.integrity ``` Publishing requires [2FA or an eligible granular token](https://docs.npmjs.com/requiring-2fa-for-package-publishing-and-settings-modification/). From 240b10034ba8f2bc635b463fef1de74edb23d0fe Mon Sep 17 00:00:00 2001 From: Connor England Date: Sun, 25 Oct 2026 15:32:07 -0500 Subject: [PATCH 5/7] Budget exceptions: about 3% headroom, honest wording Review follow-up. Limits are now the measured size plus about 3% of lines and one function or import; the comment no longer claims a tracked refactor or names a personal hook path. --- .claude/structural-budgets.json | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/.claude/structural-budgets.json b/.claude/structural-budgets.json index 6469913..e811b21 100644 --- a/.claude/structural-budgets.json +++ b/.claude/structural-budgets.json @@ -1,29 +1,29 @@ { - "_comment": "Per-repo exceptions for Connor's structural-sensor hook (~/.claude/hooks/structural-sensor.ps1); they win over the global budgets. These three modules were already over the 'standard' tier (400 lines) when the hook first met them. Each limit is the size measured on 2026-10-25 at 641142d plus a small margin for in-flight fixes, so the file cannot keep growing unnoticed. Splitting them is a separate refactor; lower a limit when a module shrinks.", + "_comment": "Per-repo exceptions for a structural-budget hook (structural-sensor) that some contributors run in Claude Code; they win over that hook's global budgets. These three modules were already over its 'standard' tier (400 lines) on 2026-10-25 at 641142d, so no fix could touch them. Each limit is the size measured then plus about 3% on lines and one more function or import. Lower a limit when a module shrinks; splitting them is a separate refactor that is not scheduled yet.", "exceptions": [ { "pattern": "src/kitctl/model.py", - "max_file_loc": 940, - "max_methods": 39, - "max_imports": 13, + "max_file_loc": 916, + "max_methods": 38, + "max_imports": 12, "max_classes": 5, - "reason": "Measured 889 lines, 37 functions, 11 imports, 5 classes: the whole warehouse schema and validation in one module. Pre-existing; split tracked as a follow-up refactor." + "reason": "Measured 889 lines, 37 functions, 11 imports, 5 classes: the warehouse schema and validation in one module." }, { "pattern": "src/kitctl/resolve.py", - "max_file_loc": 1290, - "max_methods": 34, - "max_imports": 9, + "max_file_loc": 1270, + "max_methods": 33, + "max_imports": 8, "max_classes": 2, - "reason": "Measured 1233 lines, 32 functions, 7 imports, 2 classes: the per-harness compiler. Pre-existing; split tracked as a follow-up refactor." + "reason": "Measured 1233 lines, 32 functions, 7 imports, 2 classes: the per-harness compiler." }, { "pattern": "src/kitctl/cli.py", - "max_file_loc": 560, - "max_methods": 17, - "max_imports": 18, + "max_file_loc": 568, + "max_methods": 16, + "max_imports": 17, "max_classes": 1, - "reason": "Measured 551 lines, 15 functions, 16 imports, 0 classes: argparse wiring for every command. Pre-existing; split tracked as a follow-up refactor." + "reason": "Measured 551 lines, 15 functions, 16 imports, 0 classes: argparse wiring for every command." } ] } From 83269c28660a8084ac6f59a58b3f746439db5a1a Mon Sep 17 00:00:00 2001 From: Connor England Date: Sun, 25 Oct 2026 15:32:07 -0500 Subject: [PATCH 6/7] Review fixes: one Codex add command, no double source problem, docs - resolve.py builds the 'codex plugin marketplace add' command once for both remedies, with a note that the upgrade route was verified on 0.163.0. - model.py: a non-mapping source is already a type problem, so the marketplace rule no longer adds a second one. - owner-checklist, pilot-brand-studio and harness-reference say which Codex version the upgrade-only route holds for. --- docs/harness-reference.md | 3 ++- docs/owner-checklist.md | 4 +++- docs/pilot-brand-studio.md | 4 +++- src/kitctl/model.py | 7 ++++--- src/kitctl/resolve.py | 7 ++++--- 5 files changed, 16 insertions(+), 9 deletions(-) diff --git a/docs/harness-reference.md b/docs/harness-reference.md index c6725eb..58f18db 100644 --- a/docs/harness-reference.md +++ b/docs/harness-reference.md @@ -212,7 +212,8 @@ throwaway config. These facts supersede the "documented" entries above. therefore writes the table and reports the upgrade as a one-time manual step. - **verified**: `[plugins."brand-builder@brand-builder"] enabled = true` in a trusted project's config, after the marketplace upgrade, lists the plugin as - "installed, enabled 0.3.1". No separate `codex plugin add` is needed. + "installed, enabled 0.3.1". No separate `codex plugin add` is needed on 0.163.0 + (0.144.1 needs one; see 2026-10-25 below). - **verified**: `[[skills.config]] name = "…" enabled = false` in a trusted project's config is **ignored**: `codex debug prompt-input` still lists the skill. The same entry in user config removes it. Single-skill disables need user scope, which diff --git a/docs/owner-checklist.md b/docs/owner-checklist.md index 5043708..92850a1 100644 --- a/docs/owner-checklist.md +++ b/docs/owner-checklist.md @@ -67,7 +67,9 @@ that ships inside a plugin. - [ ] Codex: trust the project when Codex prompts. Until then the project's `.codex/config.toml` is ignored. -- [ ] Codex: `codex plugin marketplace upgrade brand-builder`, run once. +- [ ] Codex: `codex plugin marketplace upgrade brand-builder`, run once. On a Codex + older than 0.163.0 (0.144.1 was seen) use the plan's other route: `codex plugin + marketplace add`, then `codex plugin add brand-builder@brand-builder`. - [ ] Codex: review and trust the brand-builder plugin hooks. - [ ] Claude: approve the project's MCP servers and plugin when prompted. - [ ] `kitctl status --repo `: every owned entry should read `ok`. diff --git a/docs/pilot-brand-studio.md b/docs/pilot-brand-studio.md index 6dd721f..39e7cef 100644 --- a/docs/pilot-brand-studio.md +++ b/docs/pilot-brand-studio.md @@ -44,7 +44,9 @@ harnesses. The `voice-compile` setup step runs after `kitctl use` when you confi tagged. - Codex: Kit writes `[marketplaces.brand-builder]` pinned to the commit. That pin is verified with Codex 0.163.0. You then run - `codex plugin marketplace upgrade brand-builder` once to fetch it. + `codex plugin marketplace upgrade brand-builder` once to fetch it. Codex + 0.144.1 ignores the project table; there, follow the plan's `marketplace add` + route (see harness-reference.md). - **Trust.** Codex loads the project's `.codex/` folder only for trusted projects, and runs plugin hooks only after the user reviews them. Kit never writes trust. diff --git a/src/kitctl/model.py b/src/kitctl/model.py index 2029795..b517fde 100644 --- a/src/kitctl/model.py +++ b/src/kitctl/model.py @@ -652,9 +652,10 @@ def parse_primitive(root: Path, ref: str) -> Primitive: _str_list(data["tags"], f"{where}.tags", problems) if "source" in data: _check_source(data["source"], f"{where}.source", problems) - # A marketplace is registered by its git URL, external or not. - source = data.get("source") - if kind == "marketplace" and not (isinstance(source, dict) and source.get("git")): + # A marketplace is registered by its git URL, external or not. A non-mapping source is + # already a "type" problem from _check_source. + source = data.get("source", {}) + if kind == "marketplace" and isinstance(source, dict) and not source.get("git"): problems.append(Problem(f"{where}.source.git", "required", "a marketplace needs a git URL")) for slot in _str_list(data.get("provides", []), f"{where}.provides", problems): if not SLOT_RE.match(slot): diff --git a/src/kitctl/resolve.py b/src/kitctl/resolve.py index 276d763..f24a466 100644 --- a/src/kitctl/resolve.py +++ b/src/kitctl/resolve.py @@ -422,13 +422,14 @@ def marketplace(self, primitive: Primitive, layer: str) -> None: return slug = _github_slug(url) or url ref = tag or sha + add_market = f"codex plugin marketplace add {slug} --ref {ref}" if not self.fact("marketplace_file").supported: self.gap( primitive.identity, "marketplace_file", "manual-step", self.fact("marketplace_file").note, - remedy=f"codex plugin marketplace add {slug} --ref {ref}", + remedy=add_market, ) return self.add( @@ -445,9 +446,9 @@ def marketplace(self, primitive: Primitive, layer: str) -> None: "marketplace_fetch", "manual-step", self.fact("marketplace_file").note, + # Verified on Codex 0.163.0; 0.144.1 ignores the project table (harness-reference.md). remedy=f"after trusting the project: codex plugin marketplace upgrade {name}; on " - f"an older Codex: codex plugin marketplace add {slug} --ref {ref}, then " - f"codex plugin add @{name}", + f"an older Codex: {add_market}, then codex plugin add @{name}", ) if not tag and not self.fact("marketplace_sha_pin").supported: self.gap( From 0cbae4098fab2a78dcfcaa0799cfef690bc7feb0 Mon Sep 17 00:00:00 2001 From: Connor England Date: Sun, 25 Oct 2026 16:48:26 -0500 Subject: [PATCH 7/7] A marketplace's source.git must be a non-empty string CodeRabbit on #13: 'git: true' with a valid sha passed the new check, and _github_slug would still crash on .startswith(). The rule now requires a string; the test gains that case (it fails on the previous commit). --- src/kitctl/model.py | 3 ++- tests/test_resolve.py | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/kitctl/model.py b/src/kitctl/model.py index b517fde..89355ab 100644 --- a/src/kitctl/model.py +++ b/src/kitctl/model.py @@ -655,7 +655,8 @@ def parse_primitive(root: Path, ref: str) -> Primitive: # A marketplace is registered by its git URL, external or not. A non-mapping source is # already a "type" problem from _check_source. source = data.get("source", {}) - if kind == "marketplace" and isinstance(source, dict) and not source.get("git"): + git = source.get("git") if isinstance(source, dict) else "" + if kind == "marketplace" and isinstance(source, dict) and not (isinstance(git, str) and git): problems.append(Problem(f"{where}.source.git", "required", "a marketplace needs a git URL")) for slot in _str_list(data.get("provides", []), f"{where}.provides", problems): if not SLOT_RE.match(slot): diff --git a/tests/test_resolve.py b/tests/test_resolve.py index 228ba63..122513a 100644 --- a/tests/test_resolve.py +++ b/tests/test_resolve.py @@ -227,7 +227,10 @@ def test_credential_literal_is_rejected(cases: Path) -> None: # A marketplace is registered by its git URL; without one, `validate` used to die in resolve with # a traceback instead of naming the file, so a warehouse author never learned what to fix. -@pytest.mark.parametrize("external", ["", "external: true\n"]) +@pytest.mark.parametrize( + "external", + ["", "external: true\n", f"source: {{git: true, sha: '{'0' * 40}'}}\n"], +) def test_marketplace_without_a_git_source_is_a_problem( cases: Path, tmp_path: Path, external: str ) -> None: