Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 9 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# The two files that decide what every user of this plugin runs. .mcp.json pins the idfkit-mcp
# release an editor acquires, and .lsp.json the language server. Changing either changes what
# reaches a person's editor, so feature 004 of the unification (FR-016) requires the change to be
# reviewed, not only detected by the consumer register's self-check.
#
# Enforcement needs branch protection with "Require review from Code Owners" on main.

/.mcp.json @samuelduchesne
/.lsp.json @samuelduchesne
169 changes: 169 additions & 0 deletions .github/workflows/bump-idfkit.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
name: bump-idfkit

# Adopt a published idfkit level, as a pull request (feature 004 of the unification, US4).
#
# idfkit's notify-downstream.yml dispatches this in wave 2, after the consumers that resolve idfkit
# directly, because the plugin reaches idfkit only through the two servers it hands to an editor.
# It never pins idfkit. It pins idfkit-mcp in .mcp.json, and idfkit follows from that release.
#
# ORDER IS ENFORCED HERE, NOT ASSUMED (FR-020). idfkit-conformance's tools/adoption.py check-order
# rejects the adoption until each server the plugin pins has a release on the new level, naming the
# server it waits on. For idfkit-mcp that is a hard stop: this run fails red, and the adoption
# follows once idfkit-mcp releases.
#
# THE LANGUAGE SERVER IS THE ONE HONEST EXCEPTION. idfkit-lsp has never been published to PyPI
# (idfkit/idfkit-plugin#17), so check-order will always name it, and .lsp.json has no version to
# move to. Refusing the whole adoption for that would freeze the MCP server behind a gap this run
# cannot close, so the MCP half proceeds and the pull request says in plain words that the LSP half
# is unchanged and why. It does not pretend the LSP was adopted, and it does not close the
# register's lag, which is about exactly that.
#
# DECLINING. Give `decline_reason` and nothing is bumped: the run records a deliberate lag in the
# consumer register instead (FR-024). The reason states a policy, never a version.

on:
workflow_dispatch:
inputs:
idfkit_version:
description: The released idfkit version to adopt, with or without a leading v.
required: true
type: string
decline_reason:
description: Decline the level instead, recording this policy as a deliberate lag.
required: false
type: string
default: ""

permissions:
contents: write
pull-requests: write

concurrency:
group: bump-idfkit
cancel-in-progress: false

env:
CONSUMER: idfkit-plugin
GOVERNANCE_LEVEL: governance-2026.18

jobs:
bump:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Check out
uses: actions/checkout@v4

- name: Check out the consumer register
uses: actions/checkout@v4
with:
repository: idfkit/idfkit-conformance
ref: refs/tags/${{ env.GOVERNANCE_LEVEL }}
path: governance
persist-credentials: false

- uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Normalize version
id: ver
env:
RAW_VERSION: ${{ inputs.idfkit_version }}
run: |
set -euo pipefail
version="${RAW_VERSION#v}"
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "previous=$(python3 -c 'import json;print(json.load(open(".mcp.json"))["mcpServers"]["idfkit"]["args"][0])')" >> "$GITHUB_OUTPUT"

- name: Decline, and record why
if: inputs.decline_reason != ''
env:
GH_TOKEN: ${{ secrets.CONFORMANCE_DISPATCH_TOKEN }}
REASON: ${{ inputs.decline_reason }}
run: |
set -euo pipefail
command=(gh workflow run record-adoption.yml -R idfkit/idfkit-conformance
-f consumer="$CONSUMER" -f library=python -f outcome=decline -f reason="$REASON")
if [ -z "$GH_TOKEN" ]; then
echo "::error title=Decline not recorded::No CONFORMANCE_DISPATCH_TOKEN, so the deliberate lag cannot be recorded from here. Run: ${command[*]}"
exit 1
fi
"${command[@]}"

# check-order names every provider that has not released on this level. idfkit-mcp is a
# hard stop; idfkit-lsp is reported and recorded, for the reason in the header.
- name: Refuse to adopt before idfkit-mcp has
id: order
if: inputs.decline_reason == ''
run: |
set +e
python governance/tools/adoption.py check-order --consumer "$CONSUMER" --library python \
--level "${{ steps.ver.outputs.version }}" 2> "$RUNNER_TEMP/order.txt"
cat "$RUNNER_TEMP/order.txt"
if grep -q "provider idfkit-mcp" "$RUNNER_TEMP/order.txt"; then
echo "::error title=Out of order::idfkit-mcp has published no release on idfkit ${{ steps.ver.outputs.version }}. The plugin adopts after it (FR-020)."
exit 1
fi
if grep -q "provider idfkit-lsp" "$RUNNER_TEMP/order.txt"; then
echo "lsp_waiting=true" >> "$GITHUB_OUTPUT"
echo "::warning title=LSP unchanged::idfkit-lsp has no release on this level, so .lsp.json does not move (idfkit/idfkit-plugin#17)."
fi

- name: Find the idfkit-mcp release on this level
id: release
if: inputs.decline_reason == ''
env:
LEVEL: ${{ steps.ver.outputs.version }}
run: |
python - <<'PY' >> "$GITHUB_OUTPUT"
import os, sys
sys.path.insert(0, "governance/tools")
from adoption import _fetch_json, pypi_released_on
release = pypi_released_on(_fetch_json)("idfkit-mcp", os.environ["LEVEL"])
if release is None:
sys.exit("check-order allowed the adoption, and no idfkit-mcp release pins this level. Stop.")
print(f"release={release}")
PY

# Text substitution rather than a JSON round trip, so the file keeps its formatting and the
# diff is the one version string that changed.
- name: Pin the MCP server to that release
if: inputs.decline_reason == ''
env:
RELEASE: ${{ steps.release.outputs.release }}
run: |
python - <<'PY'
import os, re
from pathlib import Path
path = Path(".mcp.json")
text, count = re.subn(r'"idfkit-mcp(@[^"]*)?"', f'"idfkit-mcp@{os.environ["RELEASE"]}"', path.read_text())
if count != 1:
raise SystemExit(f"expected one idfkit-mcp entry in .mcp.json, found {count}")
path.write_text(text)
PY

- name: Run the tests
id: tests
if: inputs.decline_reason == ''
continue-on-error: true
run: python -m unittest tests.discovery.test_discover -v

- name: Open the pull request
if: inputs.decline_reason == ''
uses: peter-evans/create-pull-request@v7
with:
branch: bot/bump-idfkit-${{ steps.ver.outputs.version }}
title: "Deliver idfkit-mcp ${{ steps.release.outputs.release }} (idfkit ${{ steps.ver.outputs.version }})"
commit-message: "Deliver idfkit-mcp ${{ steps.release.outputs.release }} (idfkit ${{ steps.ver.outputs.version }})"
reviewers: samuelduchesne
body: |
idfkit ${{ steps.ver.outputs.version }} was released, and idfkit-mcp ${{ steps.release.outputs.release }}
pins it, so `.mcp.json` moves from `${{ steps.ver.outputs.previous }}` to
`idfkit-mcp@${{ steps.release.outputs.release }}`. Every user who installs or updates the
plugin after this merges runs that server.

- Discovery tests: `${{ steps.tests.outcome }}`
- Language server: ${{ steps.order.outputs.lsp_waiting == 'true' && 'unchanged. idfkit-lsp has no release on this level, and has never been published to PyPI, so .lsp.json has no version to move to (idfkit/idfkit-plugin#17). The register lag for that stays open.' || 'idfkit-lsp has a release on this level; move .lsp.json to it in this pull request before merging.' }}

Opened by idfkit's notify-downstream.yml; it never merges itself.
37 changes: 37 additions & 0 deletions .github/workflows/rehearse-candidate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Rehearse the plugin against an unpublished idfkit build (feature 004, User Story 2).
#
# The plugin holds no code that calls idfkit. A candidate reaches a user only inside an idfkit-mcp
# or idfkit-lsp release, so its register entry rehearses `through-provider`: the called workflow
# builds the candidate and names the two servers whose rehearsals this is. It exists so idfkit's
# rehearse-all.yml can address every consumer the same way.

name: rehearse-candidate

on:
workflow_dispatch:
inputs:
# Declared because both libraries' rehearse-all.yml dispatch every consumer with the same two
# inputs, and GitHub rejects a dispatch carrying an input the workflow does not declare. This
# consumer resolves one library, so the choice has one option.
library:
description: The library being rehearsed. This consumer resolves python only.
required: true
type: choice
options: [python]
default: python
candidate_ref:
description: A commit, branch or tag in idfkit/idfkit to rehearse against. Required.
required: true
type: string

permissions:
contents: read

jobs:
rehearse:
uses: idfkit/idfkit-conformance/.github/workflows/rehearse.yml@governance-2026.18
with:
governance_level: governance-2026.18
consumer: idfkit-plugin
library: ${{ inputs.library }}
candidate_ref: ${{ inputs.candidate_ref }}
10 changes: 10 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,13 @@ jobs:
python-version: ${{ matrix.python-version }}
- name: Run discovery tests
run: python -m unittest tests.discovery.test_discover -v

consumer-register:
# This repository is `idfkit-plugin` in the consumer register, governance/consumers.toml in
# idfkit-conformance (feature 004). The called workflow asserts that the register still
# describes it: .mcp.json pins idfkit-mcp exactly, and .lsp.json is the recorded, unpinned
# exception while idfkit-lsp is unpublished. The tag is written twice because a called workflow
# cannot see its own ref; the called workflow refuses the run unless they match.
uses: idfkit/idfkit-conformance/.github/workflows/check-consumer.yml@governance-2026.18
with:
governance_level: governance-2026.18
2 changes: 1 addition & 1 deletion .mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"mcpServers": {
"idfkit": {
"command": "uvx",
"args": ["idfkit-mcp"]
"args": ["idfkit-mcp@0.9.3"]
}
}
}
28 changes: 27 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ idfkit package, not here.
| Sub-agents | `agents/*.md` | Yes |
| Slash commands | `commands/*.md` | Yes |
| Context hooks | `hooks/hooks.json`, `scripts/*.sh` | Yes |
| MCP server wiring | `.mcp.json` (`uvx idfkit-mcp`) | Yes |
| MCP server wiring | `.mcp.json` (`uvx idfkit-mcp@<version>`, pinned) | Yes, by review |
| LSP wiring | `.lsp.json` (`uvx --from idfkit-lsp`, unpinned until idfkit-lsp publishes) | Yes, by review |
| Claude plugin / marketplace | `.claude-plugin/plugin.json`, `.claude-plugin/marketplace.json` | Yes |

## Discovery contract
Expand Down Expand Up @@ -58,3 +59,28 @@ Keep the two surfaces separate:
tools. Do not duplicate library reference content into the workflow skills, and do
not route library references through the MCP — that coupling is intentionally
avoided so library authoring works without spinning up the server.

## Delivery paths and the consumer register

This plugin hands idfkit to a person's editor, so what it pins is what they run.
It is `idfkit-plugin` in the consumer register, `governance/consumers.toml` in
idfkit-conformance (feature 004 of the unification), with role `delivers`,
depending on `idfkit-mcp` and `idfkit-lsp`, and adopting in wave 2 after them.

- **`.mcp.json` pins idfkit-mcp exactly** (`idfkit-mcp@X.Y.Z`). Never remove the
version: an unpinned `uvx idfkit-mcp` runs whatever is newest on the day, so a
release would reach users unreviewed. The user still types no version; the
level is committed here. The idfkit level follows from that release's own pin.
- **`.lsp.json` cannot be pinned yet.** idfkit-lsp has never been published to
PyPI, so today that entry resolves nothing on a clean machine. The register
records it as a `not-yet` lag on idfkit/idfkit-plugin#17. Pin it as
`uvx --from idfkit-lsp==X.Y.Z` in the change that follows the first release.
- **Both files are CODEOWNERS-reviewed**, because changing either changes what
every user runs.
- **The self-check.** The `consumer-register` job in `tests.yml` calls
`check-consumer.yml` at a pinned governance tag and fails if the register no
longer describes this repository.
- **Adoption.** `bump-idfkit.yml` refuses to adopt an idfkit level until
idfkit-mcp has published a release pinning it, naming it, then moves the pin
in `.mcp.json` and opens a pull request. `rehearse-candidate.yml` rehearses
through the two servers, since this repository holds no code that calls idfkit.
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Claude Code, Cursor, Copilot, Gemini, Codex, and any MCP-capable client.

| Layer | What it does |
|---|---|
| **MCP server** (`uvx idfkit-mcp`) | Tools and `idfkit://…` resources to load, query, edit, validate, and simulate IDF / epJSON models. |
| **MCP server** (`uvx idfkit-mcp@<version>`, pinned in `.mcp.json`) | Tools and `idfkit://…` resources to load, query, edit, validate, and simulate IDF / epJSON models. |
| **`developing-with-idfkit` skill** | Discovers the idfkit installed in your project and loads **version-matched** reference docs for writing Python against the library. |
| **Workflow skills** | Task playbooks that orchestrate the MCP tools: `simulate`, `validate`, `new-model`, `load-model`, `energy-audit`, `weather`, `upgrade-version`, `idf-conventions`, `docs`. |
| **Agents** | Focused sub-agents: `energy-modeler`, `envelope-analyst`, `hvac-designer`. |
Expand Down Expand Up @@ -91,16 +91,21 @@ From inside a Codex session, run `$skill-installer` and point it at

### MCP server — any MCP-capable client

For Claude Desktop, ChatGPT, or other MCP clients, add the server directly:
For Claude Desktop, ChatGPT, or other MCP clients, add the server directly, pinned
to the version the plugin's own [`.mcp.json`](.mcp.json) names:

```json
{
"mcpServers": {
"idfkit": { "command": "uvx", "args": ["idfkit-mcp"] }
"idfkit": { "command": "uvx", "args": ["idfkit-mcp@<version>"] }
}
}
```

Pin a version. Without one, `uvx` runs whatever is newest on the day, so a release
reaches your client the moment it publishes, reviewed by nobody in between. The
plugin itself always pins, and you type no version when you install it.

## Requirements

- **Python 3.10+** with **idfkit >= 0.13** installed in your project — required for
Expand Down
Loading