Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
2abb6a3
fix(plugin): build the candidate commit from the assembled tree only
knihit Oct 16, 2026
845e6ad
docs: add changelog entry for the plugin candidate tree fix
knihit Oct 16, 2026
3b2f4bd
fix(plugin): resolve mux-dispatch/cmux-dispatch script paths for plug…
knihit Oct 16, 2026
640ed1d
fix(plugin): resolve bundled script paths for plugin installs
knihit Oct 16, 2026
9dd5697
fix(plugin): use shipped agent names in k-architect's Agent() grant
knihit Oct 16, 2026
4d3fdae
test: cover skill script-path resolution and agent delegation names
knihit Oct 16, 2026
698c080
docs: changelog entry for plugin skill-path and delegation fixes
knihit Oct 16, 2026
d3486fe
test: trim candidate regression test docstring
knihit Oct 16, 2026
95302a3
fix(plugin): fail when the plugin tree has an unexpected top-level entry
knihit Oct 16, 2026
1896f75
fix(plugin): qualify Agent() grants with the plugin name at assembly
knihit Oct 16, 2026
7eddc4c
fix(skills): resolve plugin agents in mux and cmux dispatch
knihit Oct 16, 2026
d1aa133
docs: plugin agent names in dispatch and delegation guidance
knihit Oct 16, 2026
4599a5d
docs: drop dashes from edited dispatch and delegation lines
knihit Oct 16, 2026
42af9e8
docs: tighten unreleased changelog entries for the plugin fixes
knihit Oct 16, 2026
8a8ed98
fix(skills): use the substitutable CLAUDE_SKILL_DIR and a trusted fal…
knihit Oct 16, 2026
5615b98
fix(skills): restrict plugin-qualified dispatch and block cmux prefix…
knihit Oct 16, 2026
9f2e79f
fix(plugin): check candidate tree content and ignored files before th…
knihit Oct 16, 2026
06e1fa8
fix(plugin): reject dangling symlinks at the plugin tree's top level
knihit Oct 16, 2026
b93d78c
fix(build): fail claude-plugin-check when plugin assembly fails
knihit Oct 16, 2026
c0f979b
docs(skills): drop a dash from the persistent-memory fail-closed line
knihit Oct 16, 2026
171c11c
docs: drop dashes from new dispatch guidance and test headers
knihit Oct 16, 2026
3fe3672
test(skills): cover local- and registry prefixed agent installs in di…
knihit Oct 16, 2026
ef18511
fix(plugin): keep the newline after the frontmatter fence when rewrit…
knihit Oct 17, 2026
5c0dca8
fix(skills): use the printed dispatch path, not a shell variable
knihit Oct 20, 2026
9170f7f
fix(plugin): fail the grant rewrite when an agent file has no frontma…
knihit Oct 20, 2026
a8c6fdc
docs(skills): say which repos the script lookup trusts
knihit Oct 20, 2026
257d3f2
Merge branch 'main' into fix/plugin-candidate-clean-tree
knihit Oct 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,24 @@ not per individual commit.

### Fixed

- The Claude Code plugin release candidate could include untracked build output (`cli/`,
`build/`, `dist/`, `generated/`) from the release job's checkout. The candidate is now built
only from the assembled plugin tree, its file list is checked against that tree, and any
top-level entry outside the plugin's own files fails the build.
- Plugin installs could not delegate to specialists. Claude Code names plugin agents
`konductor:<agent>`, so bare `Agent(k-developer, ...)` grants matched nothing. Plugin assembly
now qualifies each grant with the plugin name and fails on a grant for an agent the plugin
doesn't ship. `k-architect` also lost a stale package prefix on its grants.
- `mux-dispatch` and `cmux-dispatch` refused to dispatch in plugin installs: they only looked
in `~/.claude/agents/` and rejected `:` in `--agent`. They now fall back to the plugin's own
agents and accept `konductor:<agent>` for an agent the plugin ships. `cmux-dispatch` also
refuses an agent file name that could inject a command into the pane.
- `mux-dispatch`, `cmux-dispatch`, `persistent-memory` and `legacy-to-agentic-estimate` could
not find their bundled scripts in plugin installs. They now look in `${CLAUDE_SKILL_DIR}`
first, then the global install, then a project install with a `.konductor/manifest`, then a
konductor source checkout. A repo is searched only if it has `.konductor/manifest` or a
`.claude-plugin/marketplace.json` that names `konductor`. Each skill stops with a clear error
when its script is missing.
- The skill-lookup MCP server's `mcp_tool_call` events now include `parentAgentName` (as
`null`), which `docs/telemetry-schema.json` requires on every event.
- `konductor update --cli` retries its smoke test of the downloaded binary when exec fails with
Expand Down
11 changes: 7 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -200,18 +200,21 @@ claude-plugin-check:
validated=0; \
fi; \
scratch=$$(mktemp -d)/claude-plugin-branch; \
KONDUCTOR_BIN=$(KONDUCTOR_BIN) scripts/assemble-claude-plugin-branch.sh --out "$$scratch"; \
status=0; \
if command -v claude >/dev/null 2>&1; then \
KONDUCTOR_BIN=$(KONDUCTOR_BIN) scripts/assemble-claude-plugin-branch.sh --out "$$scratch" || status=1; \
if [ "$$status" -eq 0 ]; then \
scripts/check-claude-plugin-tree.sh "$$scratch" || status=1; \
fi; \
if [ "$$status" -eq 0 ] && command -v claude >/dev/null 2>&1; then \
echo "=== [claude-plugin-check] Validating flat branch plugin shape ==="; \
claude plugin validate "$$scratch" --strict || status=1; \
else \
elif [ "$$status" -eq 0 ]; then \
echo "notice: [claude-plugin-check] 'claude' CLI not found on PATH -- skipping validation" >&2; \
validated=0; \
fi; \
rm -rf "$$(dirname "$$scratch")"; \
if [ "$$status" -ne 0 ]; then \
echo "=== [claude-plugin-check] FAILED: the flat branch plugin tree did not validate ===" >&2; \
echo "=== [claude-plugin-check] FAILED: the flat branch plugin tree did not assemble or validate ===" >&2; \
exit 1; \
fi; \
if [ "$$validated" -eq 1 ]; then \
Expand Down
2 changes: 1 addition & 1 deletion agents/k-architect.agent-spec.json
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@
"TodoWrite",
"Skill",
"mcp__aws-mcp__*",
"Agent(ASDLCCoreAICapabilities-k-developer, ASDLCCoreAICapabilities-k-quality-assurance)"
"Agent(k-developer, k-quality-assurance)"
],
"skills": [
"constraints",
Expand Down
2 changes: 1 addition & 1 deletion agents/konductor-cmux-orchestrator.agent-spec.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion agents/konductor-mux-orchestrator.agent-spec.json

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions scripts/assemble-claude-plugin-branch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,9 @@ python3 "$REPO_ROOT/scripts/rewrite-claude-plugin-mcp-tool-names.py" \
--plugin-json "$OUT/.claude-plugin/plugin.json" \
--bundled-mcp-servers "$BUNDLED_MCP_SERVERS"

# Same reason for Agent(...) grants: plugin agents are named <plugin>:<agent>.
python3 "$REPO_ROOT/scripts/rewrite-claude-plugin-agent-grants.py" \
--agents-dir "$OUT/agents" \
--plugin-json "$OUT/.claude-plugin/plugin.json"

echo "=== [assemble-claude-plugin] Done: flat plugin tree at $OUT ==="
113 changes: 77 additions & 36 deletions scripts/build-plugin-candidate-commit.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,33 @@
#
# Builds the candidate-branch commit for release.yml's publish-claude-plugin
# job: a new branch, based on the base branch's (release/plugins) current
# remote tip, whose tree is replaced wholesale with an already-assembled
# flat plugin tree.
# remote tip, whose tree is exactly an already-assembled flat plugin tree.
#
# A prior revision built this commit with `git init` in a throwaway
# directory, so the candidate shared no history with release/plugins and
# `gh pr create` had no common ancestor to diff against. This script fixes
# that: the candidate is checked out directly on top of
# <remote>/<base-branch>, so the resulting commit's parent is release/
# plugins' own tip -- a real merge base, not a disconnected root.
# `gh pr create` had no common ancestor to diff against. A later revision
# fixed that by checking the candidate branch out directly on top of
# <remote>/<base-branch>, then running `git rm -rq .` + `cp -a
# "$ASSEMBLE_DIR"/. .` + `git add -A` in the job's own checkout. That
# checkout is the same one `make -C cli build` and
# scripts/generate-claude-plugin.sh already ran in, so it carries
# untracked build output (cli/konductor-rs/target/, build/cli/konductor,
# dist/, generated/claude-plugin/). `git rm -rq .` also deletes the
# checked-out .gitignore, so `git add -A` picks that untracked output up
# alongside the assembled tree.
#
# Every previously tracked path is removed before the assembled tree is
# copied in, so the resulting commit's tree is exactly the assembled
# tree's contents regardless of what release/plugins currently contains --
# a merge of this candidate always leaves release/plugins an artifact-only
# tree, never retaining source-tree leftovers from its own history.
# This script builds the tree from --assemble-dir alone, through a scratch
# index the job's checkout never touches: `git add -A -f` (so no ignore rule
# can drop a file), then `git write-tree` and `git commit-tree`. Untracked
# output in the checkout can never reach the candidate. Before the no-op
# check, the tree must match --assemble-dir exactly: same paths, modes, and
# blob IDs from `git hash-object --no-filters`. A file dropped by an ignore
# rule or rewritten by git attributes fails the build.
#
# This script never pushes anything and never writes to the base branch:
# it only builds a commit on a new local branch. The caller pushes that
# branch, by its own name only, after this script succeeds.
# it only builds a commit on a new local branch ref in --repo-root's
# repository. The caller pushes that branch, by its own name only, after
# this script succeeds.
#
# If the assembled tree already matches the base branch's tip exactly,
# there's nothing to commit -- that's a legitimate outcome (e.g. a
Expand Down Expand Up @@ -112,6 +120,7 @@ fi
# would otherwise point at the wrong place once the working directory
# changes.
ASSEMBLE_DIR="$(cd "$ASSEMBLE_DIR" && pwd)"
"$(dirname "${BASH_SOURCE[0]}")/check-claude-plugin-tree.sh" "$ASSEMBLE_DIR"

if [ "$CANDIDATE_BRANCH" = "$BASE_BRANCH" ]; then
echo "FATAL: --candidate-branch must not equal --base-branch ('$BASE_BRANCH'). Failing closed." >&2
Expand All @@ -128,11 +137,11 @@ fi

cd "$REPO_ROOT"

# Discard any uncommitted tracked-file changes earlier workflow steps left
# behind (e.g. release.yml writes a release-tag-derived VERSION into the
# checkout before this script runs) -- without this, switching to the base
# branch's tip below can conflict with those local modifications.
git reset -q --hard
# Resolved once, up front: every plumbing command below targets this
# repository's object store and refs explicitly via GIT_DIR, regardless of
# which directory it's invoked from (including the --assemble-dir subshell
# below).
GIT_DIR_ABS="$(git rev-parse --absolute-git-dir)"

# Explicit refspec, not a bare `git fetch <remote> <branch>` (which lands
# in FETCH_HEAD under some remote configs, not necessarily
Expand All @@ -141,30 +150,56 @@ git reset -q --hard
# origin's own fetch refspec.
git fetch -q "$REMOTE" "+refs/heads/$BASE_BRANCH:refs/remotes/$REMOTE/$BASE_BRANCH"
BASE_SHA="$(git rev-parse "$REMOTE/$BASE_BRANCH")"
BASE_TREE="$(git rev-parse "$BASE_SHA^{tree}")"

if git show-ref --verify --quiet "refs/heads/$CANDIDATE_BRANCH"; then
echo "FATAL: local branch '$CANDIDATE_BRANCH' already exists. Refusing to overwrite it. Failing closed." >&2
exit 1
fi

# Base the candidate directly on the base branch's current tip, not a
# disconnected root -- this is what gives the branch a real merge base
# with release/plugins, so GitHub can diff and open a pull request
# against it.
git checkout -q -b "$CANDIDATE_BRANCH" "$REMOTE/$BASE_BRANCH"

# Clear every tracked path from the new branch before overlaying the
# assembled artifact, so the resulting tree is exactly the artifact's
# contents -- never a mix of the artifact and whatever release/plugins
# happened to contain already.
if [ -n "$(git ls-files)" ]; then
git rm -rq .
fi
# The scratch index is never associated with $REPO_ROOT's own index or work
# tree, so nothing untracked in the checkout can be picked up.
SCRATCH_DIR="$(mktemp -d)"
trap 'rm -rf "$SCRATCH_DIR"' EXIT
SCRATCH_INDEX="$SCRATCH_DIR/index"
(
cd "$ASSEMBLE_DIR"
GIT_INDEX_FILE="$SCRATCH_INDEX" GIT_DIR="$GIT_DIR_ABS" GIT_WORK_TREE="$ASSEMBLE_DIR" \
git -c core.autocrlf=false -c core.fileMode=true -c core.symlinks=true add -A -f
)
ASSEMBLED_TREE="$(GIT_INDEX_FILE="$SCRATCH_INDEX" GIT_DIR="$GIT_DIR_ABS" git write-tree)"

cp -a "$ASSEMBLE_DIR"/. .
git add -A
# Prints "<mode> <blob>\t<path>" for each file and symlink under $1, like
# `git ls-tree -r` without the object type.
dir_listing() (
cd "$1"
hash_files() {
local mode="$1" paths
shift
paths="$(find . -type f "$@" | sed 's|^\./||')"
[ -n "$paths" ] || return 0
printf '%s\n' "$paths" | GIT_DIR="$GIT_DIR_ABS" git hash-object --no-filters --stdin-paths \
| sed "s/^/$mode /" | paste - <(printf '%s\n' "$paths")
}
hash_files 100755 -perm -u+x
hash_files 100644 ! -perm -u+x
find . -type l | sed 's|^\./||' | while IFS= read -r link; do
printf '120000 %s\t%s\n' "$(printf '%s' "$(readlink "$link")" | GIT_DIR="$GIT_DIR_ABS" git hash-object --stdin)" "$link"
done
)

if git diff --cached --quiet; then
TREE_LISTING="$(git -c core.quotePath=false ls-tree -r "$ASSEMBLED_TREE" | sed 's/^\([0-7]*\) [a-z]* /\1 /' | LC_ALL=C sort)"
ASSEMBLE_DIR_LISTING="$(dir_listing "$ASSEMBLE_DIR" | LC_ALL=C sort)"
if [ "$TREE_LISTING" != "$ASSEMBLE_DIR_LISTING" ]; then
echo "FATAL: the candidate tree does not match --assemble-dir's paths, modes, and contents exactly. Failing closed." >&2
echo "--- only in candidate tree ---" >&2
comm -23 <(printf '%s\n' "$TREE_LISTING") <(printf '%s\n' "$ASSEMBLE_DIR_LISTING") >&2
echo "--- only in --assemble-dir ---" >&2
comm -13 <(printf '%s\n' "$TREE_LISTING") <(printf '%s\n' "$ASSEMBLE_DIR_LISTING") >&2
exit 1
fi

if [ "$ASSEMBLED_TREE" = "$BASE_TREE" ]; then
echo "Candidate branch '$CANDIDATE_BRANCH' has no changes relative to $REMOTE/$BASE_BRANCH ($BASE_SHA); nothing to commit."
echo "committed=false"
if [ -n "${GITHUB_OUTPUT:-}" ]; then
Expand All @@ -173,8 +208,14 @@ if git diff --cached --quiet; then
exit 0
fi

git -c user.name="$AUTHOR_NAME" -c user.email="$AUTHOR_EMAIL" commit -q -m "$COMMIT_MESSAGE"
echo "Built candidate commit $(git rev-parse HEAD) on branch '$CANDIDATE_BRANCH', based on $REMOTE/$BASE_BRANCH ($BASE_SHA)."
# Based directly on the base branch's current tip, not a disconnected
# root -- this is what gives the branch a real merge base with
# release/plugins, so GitHub can diff and open a pull request against it.
NEW_COMMIT="$(git -c user.name="$AUTHOR_NAME" -c user.email="$AUTHOR_EMAIL" commit-tree "$ASSEMBLED_TREE" -p "$BASE_SHA" -m "$COMMIT_MESSAGE")"

# The empty old value makes update-ref fail if the branch appeared meanwhile.
git update-ref "refs/heads/$CANDIDATE_BRANCH" "$NEW_COMMIT" ""
echo "Built candidate commit $NEW_COMMIT on branch '$CANDIDATE_BRANCH', based on $REMOTE/$BASE_BRANCH ($BASE_SHA)."
echo "committed=true"
if [ -n "${GITHUB_OUTPUT:-}" ]; then
echo "committed=true" >> "$GITHUB_OUTPUT"
Expand Down
30 changes: 30 additions & 0 deletions scripts/check-claude-plugin-tree.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: Apache-2.0
#
# Fails if an assembled Claude Code plugin tree has a top-level entry outside
# the set the marketplace plugin ships, so build output such as cli/ or dist/
# can never be published.
#
# Usage: check-claude-plugin-tree.sh <plugin-dir>
set -euo pipefail

PLUGIN_DIR="${1:?usage: check-claude-plugin-tree.sh <plugin-dir>}"
[ -d "$PLUGIN_DIR" ] || { echo "check-claude-plugin-tree.sh: '$PLUGIN_DIR' is not a directory" >&2; exit 1; }

ALLOWED=".claude-plugin .mcp.json LICENSE.txt README.md agents docs hooks skills"

unexpected=()
for entry in "$PLUGIN_DIR"/* "$PLUGIN_DIR"/.[!.]* "$PLUGIN_DIR"/..?*; do
[ -e "$entry" ] || [ -L "$entry" ] || continue
name="$(basename "$entry")"
case " $ALLOWED " in
*" $name "*) ;;
*) unexpected+=("$name") ;;
esac
done

if [ "${#unexpected[@]}" -gt 0 ]; then
echo "FATAL: plugin tree '$PLUGIN_DIR' has unexpected top-level entries: ${unexpected[*]}" >&2
echo "Allowed: $ALLOWED" >&2
exit 1
fi
125 changes: 125 additions & 0 deletions scripts/rewrite-claude-plugin-agent-grants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: Apache-2.0
"""Rewrite Agent(...) tool grants for Claude Code plugin agent files.

Claude Code names plugin agents "<plugin>:<agent>", and a bare grant such as
Agent(k-developer) does not match them. This script prefixes every name in an
Agent(...) frontmatter grant with the plugin name. It fails, writing nothing,
if a grant names an agent the plugin does not ship or an agent file has no
frontmatter. Prompt bodies are left unchanged.
"""

import argparse
import json
import re
import sys
from pathlib import Path

FRONTMATTER_FENCE = "---"
AGENT_GRANT_RE = re.compile(r"\bAgent\(([^)]*)\)")


def _load_plugin(plugin_json_path: Path) -> tuple[str, set[str]]:
plugin = json.loads(plugin_json_path.read_text(encoding="utf-8"))
name = plugin.get("name")
if not name or not isinstance(name, str):
raise ValueError(f'{plugin_json_path}: missing or non-string "name" field')
agents = plugin.get("agents") or []
if not isinstance(agents, list):
raise ValueError(f'{plugin_json_path}: "agents" must be a list of paths')
return name, {Path(path).stem for path in agents}


def _split_frontmatter(text: str) -> tuple[str, str, bool]:
"""Return the frontmatter, body, and whether a complete frontmatter block exists."""
lines = text.split("\n")
if not lines or lines[0].strip() != FRONTMATTER_FENCE:
return "", text, False
for index in range(1, len(lines)):
if lines[index].rstrip() == FRONTMATTER_FENCE:
return "\n".join(lines[: index + 1]), "\n".join(lines[index + 1 :]), True
return "", text, False


def rewrite_frontmatter(frontmatter: str, plugin_name: str, shipped: set[str]) -> tuple[str, int, list[str]]:
"""Return the rewritten frontmatter, the number of names prefixed, and unknown names."""
prefix = f"{plugin_name}:"
prefixed = 0
unknown: list[str] = []

def _qualify(name: str) -> str:
nonlocal prefixed
bare = name.removeprefix(prefix)
if bare not in shipped:
unknown.append(name)
return name
if name == bare:
prefixed += 1
return prefix + bare

def _rewrite_grant(match: re.Match) -> str:
names = [part.strip() for part in match.group(1).split(",") if part.strip()]
return "Agent(" + ", ".join(_qualify(name) for name in names) + ")"

return AGENT_GRANT_RE.sub(_rewrite_grant, frontmatter), prefixed, unknown


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--agents-dir",
required=True,
type=Path,
help="Directory of generated Claude agent Markdown files",
)
parser.add_argument(
"--plugin-json",
required=True,
type=Path,
help='Rendered plugin.json used to read the plugin "name" and "agents"',
)
args = parser.parse_args()

if not args.agents_dir.is_dir():
print(f"error: agents directory not found: {args.agents_dir}", file=sys.stderr)
return 1
if not args.plugin_json.is_file():
print(f"error: plugin.json not found: {args.plugin_json}", file=sys.stderr)
return 1

try:
plugin_name, shipped = _load_plugin(args.plugin_json)
except ValueError as error:
print(f"error: {error}", file=sys.stderr)
return 1

rewrites: dict[Path, str] = {}
prefixed_total = 0
failures: list[str] = []
for md_path in sorted(args.agents_dir.glob("*.md")):
frontmatter, body, has_frontmatter = _split_frontmatter(md_path.read_text(encoding="utf-8"))
if not has_frontmatter:
failures.append(f"{md_path.name}: no --- frontmatter block, so its grants cannot be checked")
continue
rewritten, prefixed, unknown = rewrite_frontmatter(frontmatter, plugin_name, shipped)
failures.extend(f"{md_path.name}: Agent(...) names {name!r}" for name in unknown)
if rewritten != frontmatter:
rewrites[md_path] = rewritten + "\n" + body
prefixed_total += prefixed

if failures:
print(
f"error: Agent(...) grants cannot be prefixed for the {plugin_name} plugin:\n "
+ "\n ".join(failures),
file=sys.stderr,
)
return 1

for md_path, text in rewrites.items():
md_path.write_text(text, encoding="utf-8")
print(f"prefixed {prefixed_total} Agent() grant name(s) across {len(rewrites)} file(s)")
return 0


if __name__ == "__main__":
sys.exit(main())
Loading
Loading