From ad8e179c9eff2714df2ee151bf3b4fe5ff7880be Mon Sep 17 00:00:00 2001 From: Topi Jarvinen Date: Sat, 25 Jul 2026 08:58:47 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20kit=5Fdoctor=20+=20/upgrade=20?= =?UTF-8?q?=E2=80=94=20make=20an=20already-adopted=20install=20diagnosable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2. Re-running ./init.sh (landed in #8) already handles the config half of an upgrade. This is the engine half, which was the part nothing could see. The problem, measured rather than assumed. Surveying the real installs turned up four distinct shapes, none of them versioned at the engine level: · an ancestor with no config surface at all · a v1-schema repo with 2 of 6 engines and a paths.engines value pointing at a directory containing no engine — so every workflow's /… reference resolved to nothing, silently, because nothing validates that value · a correctly namespaced v2 repo whose pr_watch.py was 3 features behind · the template itself Nothing could tell an older engine from a hand-patched one, which is what makes "just copy the new files in" unsafe. scripts/kit_doctor.py — read-only drift report against a released kit-manifest.json (path -> sha256). Per kit-owned file: unchanged / differs / missing / unknown-version. Plus the four installation-level checks nothing else performs: schema generation, whether paths.engines actually holds engines, whether the pre-push hook is installed rather than merely shipped, and whether the narrative docs were rendered. Two deliberate design calls: · `differs` does NOT claim a cause. A hash mismatch cannot distinguish "older version" from "hand-edited", and labelling an old file "locally modified" sends someone hunting for edits they never made — this was caught by running the tool against a real adopter, which reported 4 files as locally-modified when they were almost certainly just old. It now narrows by schema version and leaves the call to the operator. · paths.engines indirection is handled by remapping the manifest's kit-layout prefix, so a repo with engines under scripts/devkit/ compares correctly against an unmodified manifest. Adopter-owned paths are never compared — they are supposed to differ, and reporting them would bury the signal. That list is explicit (not inferred) so an installer can consume the same boundary. /upgrade (.claude/commands/upgrade.md) — drives the sequence: detect which of the four shapes this repo is, migrate config via init.sh, then refresh per file keyed on kit_doctor's states. Routes a config-less repo to /adopt instead of guessing a config into existence. Names the case worth routing UPSTREAM: a local edit that is genuinely ahead of the kit should become a kit PR, not be overwritten. Also closes #5: check_doc_budget.py and archive_plan_sessions.py now read config through the stdlib kitconfig instead of PyYAML, so every shipped engine is dependency-free — including the SessionStart hook path added in #13. This is the same invariant kit_doctor asserts: an engine an adopter must edit (or install a dependency for) can never be safely replaced. CI gates the manifest against the tree, since a stale manifest silently degrades every adopter's report to unknown-version. 184 tests pass. Verified against both real adopter repos read-only: brain's missing-engine breakage and OpenKitchen's 4 drifted files are both correctly diagnosed, with the engines-dir failure called out explicitly. Closes #5. --- .claude/commands/upgrade.md | 146 +++++++++++ .github/workflows/test.yml | 5 + README.md | 34 +++ kit-manifest.json | 106 ++++++++ scripts/archive_plan_sessions.py | 7 +- scripts/check_doc_budget.py | 8 +- scripts/kit_doctor.py | 399 +++++++++++++++++++++++++++++++ scripts/lib/kitconfig.py | 13 + scripts/tests/test_kit_doctor.py | 187 +++++++++++++++ 9 files changed, 897 insertions(+), 8 deletions(-) create mode 100644 .claude/commands/upgrade.md create mode 100644 kit-manifest.json create mode 100755 scripts/kit_doctor.py create mode 100644 scripts/tests/test_kit_doctor.py diff --git a/.claude/commands/upgrade.md b/.claude/commands/upgrade.md new file mode 100644 index 0000000..7bcad13 --- /dev/null +++ b/.claude/commands/upgrade.md @@ -0,0 +1,146 @@ +--- +description: Upgrade an already-adopted agentic-dev-kit installation to the current kit — migrate the config schema, refresh kit-owned engines, and diff anything that drifted. The counterpart to /adopt (first install) and to re-running ./init.sh (config only). Use when pulling a kit update into a repo that already has the kit. +argument-hint: "[--dry-run]" +--- + +Upgrade this repo's agentic-dev-kit installation. Non-destructive: runs on a branch, and +never replaces a file without knowing it is safe to replace. + +> **The invariant this rests on.** Engines are **kit-owned**; config is **adopter-owned**. +> Everything project-specific — paths, tracker, review-bot markers, CI policy, model +> tiers — lives in `config/dev-model.yaml`, so an engine should never need editing to +> adopt it. That is what makes an upgrade a file copy instead of a manual merge. If this +> run finds engines you had to edit, that is a **kit bug**: report it rather than +> carrying the patch forward. + +## Step 0 — Establish what shape this repo is in + +Four shapes exist in the wild and they need different handling. Determine which: + +```bash +ls config/dev-model.yaml 2>/dev/null && echo "has config" || echo "NO CONFIG" +``` + +- **No `config/dev-model.yaml`** → this repo predates the config surface entirely (a kit + *ancestor*, or a partial hand-install). **Stop and run `/adopt` instead** — there is no + schema to migrate from. Say so plainly rather than guessing a config into existence. +- **Config present** → continue. `kit.version` tells you the schema generation; its + absence means v1 (pre-`runtime:`, pre-`models.tiers`). + +Also fetch the kit you are upgrading *to*, if it isn't already local: + +```bash +git clone --depth 1 https://github.com/topij/agentic-dev-kit /tmp/agentic-dev-kit +``` + +Everything below copies **from** that checkout **into** this repo. + +## Step 1 — Diff the installation (read-only) + +```bash +uv run /kit_doctor.py --manifest /tmp/agentic-dev-kit/kit-manifest.json +``` + +Read `` from `paths.engines`. If `kit_doctor.py` isn't installed yet, run the +kit's copy against this repo: `uv run /tmp/agentic-dev-kit/scripts/kit_doctor.py --root . +--manifest /tmp/agentic-dev-kit/kit-manifest.json`. + +The report gives you, per kit-owned file: `unchanged` / `differs` / `missing` / +`unknown-version`, plus four installation-level checks. **Read all four** — each is a +silent failure mode: + +- **config schema version** — unversioned or behind means migrations are pending. +- **`paths.engines` resolves to a directory that actually holds engines.** A `✗` here is + the live breakage where every workflow's `/…` reference points at nothing. + Nothing else validates this value, so nothing else would have told you. +- **pre-push hook installed** — a shipped-but-uninstalled hook binds nothing. +- **narrative docs rendered** — a doc still carrying `devkit-template: unrendered` means + the adoption never completed its seeding step. + +`differs` deliberately does **not** claim a cause: a hash mismatch cannot distinguish +"older kit version" from "hand-edited". The report narrows it by schema version; you +confirm with an actual diff in Step 3. + +## Step 2 — Migrate the config (idempotent) + +```bash +./init.sh +``` + +This is the supported config upgrade path, and it is safe to re-run any number of times. +It only ever **adds** missing keys, never guesses over an existing value; it probes +`paths.engines` from where engines actually are rather than defaulting; it stamps +`kit.version`; it installs the pre-push hook as a shim (honoring `core.hooksPath`); and +it leaves a narrative doc that is genuinely in use byte-identical, re-rendering only one +that still carries the unrendered marker. + +Press Enter through every prompt to keep current values. Then re-read the diff of +`config/dev-model.yaml` and confirm nothing you rely on changed. + +## Step 3 — Refresh engines, by state + +Work through `kit_doctor`'s file list. **Branch first:** + +```bash +git checkout -b chore/kit-upgrade +``` + +- **`unchanged`** → copy the new version straight in. It is provably untouched, so there + is nothing to lose. +- **`missing`** → decide, don't assume. A sized-down adoption omits engines deliberately + (one surveyed repo installs 2 of 6 on purpose). Ask the operator whether each missing + piece is wanted before installing it. If a piece stays out, note it in the PR body so + the next upgrade doesn't re-litigate it. +- **`differs`** → `diff` the local file against the kit's, and read the diff: + - Only kit-authored changes (the local copy is simply older) → replace it. + - Local edits present → for each, find where that value now lives in + `config/dev-model.yaml` and move it there, then take the kit's engine. If there is no + config key for it, **stop**: that is the kit bug the invariant above describes. File + it upstream and keep the local patch, clearly flagged, until it lands. + - **Local edits that are genuinely ahead of the kit** — a fix made here first — are the + one case to route *upstream* instead: open a PR against the kit rather than + overwriting your better version. +- **`unknown-version`** → the manifest has no entry, so drift is unjudgeable. Treat as + `differs` and diff by hand. + +Never batch-replace the whole list because most of it was `unchanged`. The `differs` +entries are exactly where the risk is. + +## Step 4 — Refresh the non-engine pieces + +- **Shared workflows** (`docs/agentic-dev-kit/workflows/`) — same state logic as engines. + These are prompts an agent reads verbatim; a stale one silently teaches old behavior. +- **Runtime adapters** (`.claude/commands/`, `.agents/skills/`) — install any the kit has + that this repo lacks; keep the adopter's version where one already exists. +- **Templates** (`docs/templates/`) — refresh freely; the *rendered* docs are yours and + are never touched. +- **`.claude/settings.json`** — if this repo has its own, **merge** the kit's hooks and + permissions into it rather than replacing; it likely carries project-specific entries. + +## Step 5 — Verify + +```bash +uv run /kit_doctor.py --manifest /tmp/agentic-dev-kit/kit-manifest.json +python -m pytest /lib/state_paths/tests /tests -q +uv run /check_doc_budget.py +``` + +`kit_doctor` should now report zero `differs` and zero `unknown-version`, with `missing` +containing only deliberately-omitted pieces. Anything else means Step 3 left something. + +> **Known gotcha:** the `state_paths` tests fail when run from inside a worktree carrying +> a `.devkit_state_root` marker — the fixture neutralizes the environment but not marker +> discovery. Run the gate from the main checkout, and see kit issue #10. + +## Step 6 — Record the friction, then hand off + +Append anything this upgrade surfaced to the friction log (`paths.friction_log`) — an +engine you had to edit, a config key that didn't exist, a `missing` piece the report +couldn't classify. Tag `[kit]` on anything that is a kit-side fix and open an issue +upstream. That is Principle #2 applied to the kit itself, and it is how the four shapes +this skill handles were discovered in the first place. + +Open a **draft PR** summarizing: schema version before → after, which engines were +refreshed / diffed / deliberately skipped, and any local-edit-vs-config resolution you +made. Leave the merge to the operator — an upgrade touches the machinery every other +workflow runs on. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bb6c830..14ff6f1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,6 +24,11 @@ jobs: run: test -x init.sh - name: Run state and portability tests run: python -m pytest scripts/lib/state_paths/tests scripts/tests -q + - name: kit-manifest.json must be in sync + # A stale manifest silently degrades every adopter's drift report to + # "unknown-version", so the self-check is a gate, not a convenience. + # Stdlib-only by design, so no uv needed here. + run: python scripts/kit_doctor.py - name: Engines must work without PyYAML # The stdlib config reader exists so engines carry no third-party # dependency. Prove it in an env where PyYAML genuinely is not installed. diff --git a/README.md b/README.md index 7fb666e..9d4228b 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,40 @@ path, and it is safe to run any number of times: lives in `config/dev-model.yaml`, so you should never need to edit an engine to adopt it. If you have, that's a bug — please report it. +### Which files actually drifted — `kit_doctor` + +Re-running `init.sh` handles the config. For the **engines**, the problem is that a +copy-in has no version marker and no record of whether it was edited, so nothing can +tell an older engine from a locally-patched one: + +```sh +uv run scripts/kit_doctor.py # or: python scripts/kit_doctor.py — stdlib only +``` + +Per kit-owned file it reports `unchanged` (safe to replace outright), `differs` (diff +before replacing), `missing`, or `unknown-version`. `differs` deliberately does **not** +claim a cause — a hash mismatch can't distinguish "older version" from "hand-edited", so +it narrows by schema version and leaves the call to you. Adopter-owned paths (your +config, your narrative docs) are never compared; they're *supposed* to differ. + +It also checks four things nothing else validates: + +- your config's schema generation vs. the kit's +- that **`paths.engines` points at a directory that actually holds engines** — a `✗` here + is the silent breakage where every workflow's `/…` reference resolves to + nothing +- that the pre-push hook is installed, not merely shipped +- that the narrative docs were rendered, not left as unrendered templates + +`kit-manifest.json` is the hash set it compares against, regenerated at release +(`--generate-manifest`) and gated in CI so it can't go stale. + +**[`/upgrade`](.claude/commands/upgrade.md)** drives the whole sequence — shape detection, +config migration, then per-file refresh keyed on those states — non-destructively, on a +branch. It's the counterpart to [`/adopt`](.claude/commands/adopt.md) (first install). A +repo with no `config/dev-model.yaml` at all predates the config surface and routes to +`/adopt` instead. + ### Agent runtime adapters The workflow definitions under `docs/agentic-dev-kit/workflows/` are shared. The diff --git a/kit-manifest.json b/kit-manifest.json new file mode 100644 index 0000000..e30143b --- /dev/null +++ b/kit-manifest.json @@ -0,0 +1,106 @@ +{ + "adopter_owned": [ + "config/dev-model.yaml", + "docs/handoff.md", + "docs/handoff-history.md", + "docs/friction-log.md", + "docs/friction-log-archive.md", + "README.md", + ".gitignore" + ], + "files": { + "docs/agentic-dev-kit/safety-critical-changes.md": { + "role": "doctrine", + "sha256": "cc3f3a76a22ba0a8552539bd2b85f07e10e59485eae2c3749f06036bf7db2e91" + }, + "docs/agentic-dev-kit/workflows/parallel.md": { + "role": "workflow", + "sha256": "519fa1c623b75ba8cc0c6a3377f49a9681f47ada49f141aaf130d4007fffd460" + }, + "docs/agentic-dev-kit/workflows/pr-watch.md": { + "role": "workflow", + "sha256": "169d3e27e149a49f736931b838d117a2fe6ad3ac6ddcaefccb99a51b97b505c8" + }, + "docs/agentic-dev-kit/workflows/session-start.md": { + "role": "workflow", + "sha256": "f7a0a3ba8b65bc82f19764de6430bf82d0ee11d08447936b03de0056ac152c3a" + }, + "docs/agentic-dev-kit/workflows/wrap-up.md": { + "role": "workflow", + "sha256": "63c26e6337c44653a4b710af68a5bad03e61e4fe0a2231479df4ab105053e071" + }, + "docs/templates/friction-log-archive.md.tmpl": { + "role": "template", + "sha256": "af4a138e4a5ebe5634c55e7e417cd6b9f362bf3a35182b033f72876cd88180cf" + }, + "docs/templates/friction-log.md.tmpl": { + "role": "template", + "sha256": "33e5bf0fe285ebbc248e062124d52f86bb78c47767f185d3d04df192db09c66d" + }, + "docs/templates/handoff-history.md.tmpl": { + "role": "template", + "sha256": "111cf71a4c9b2a296f4fcf5ea02c6516907710e5b4fe0a7b3074f444f862ae69" + }, + "docs/templates/handoff.md.tmpl": { + "role": "template", + "sha256": "1654465836a9e70a6e81d76208783e9a12d65343d1e9c3091f1d30c25a437ce7" + }, + "scripts/archive_plan_sessions.py": { + "role": "engine", + "sha256": "fb15bd2dbe03257e6304f6e40f5af14bdffa66288fe0cc7ef2cdc38d0b4f9c76" + }, + "scripts/check_doc_budget.py": { + "role": "engine", + "sha256": "a402424a12278e6a2ba55f90692aef9e44e9f030932529b5e85208590fc4e6dd" + }, + "scripts/dev_session.sh": { + "role": "engine", + "sha256": "80311d61d9210c633560cd493402724e64aba9ff95b0ab02a40c97fb130d22eb" + }, + "scripts/hooks/pre-push": { + "role": "hook", + "sha256": "c469700ff61a1506fa5951b4d5ab3ac95f3213227d8838efa95eef0492516fba" + }, + "scripts/kit_doctor.py": { + "role": "engine", + "sha256": "7dfd0f45e68b28602dd6c8fac07571e2ccf893163244915b38400a83518ab595" + }, + "scripts/lib/devmodel_config.py": { + "role": "engine", + "sha256": "53458a5d0c72b1fe5e5e5f6e8e5881c08c8c03c255493ae841391c5a8d6c1a29" + }, + "scripts/lib/kitconfig.py": { + "role": "engine", + "sha256": "41c01a2956185d898f6cb75865d713845630a2d8335976f1460de1ed5f875317" + }, + "scripts/lib/repo_root.sh": { + "role": "engine", + "sha256": "980cbf5596cea67033a5dd02d53630f2a92c24afd693ba7727d5fc50303ff555" + }, + "scripts/lib/state_paths/__init__.py": { + "role": "engine", + "sha256": "691a7df06a3bf9019b5faca8b8ebdb9d54463d231d492a632fd54c16cd51a2c6" + }, + "scripts/lib/state_paths/paths.py": { + "role": "engine", + "sha256": "f54fd7d970402f8581f6c5080aa9614ce41cb2dafa2010c08406129731799397" + }, + "scripts/lib/state_paths/repo_root.py": { + "role": "engine", + "sha256": "d9412f1fe395b944a86126bcc39f85d6838382b0fc29d68c8b29b965cd530b5a" + }, + "scripts/lib/state_paths/resolver.py": { + "role": "engine", + "sha256": "e39fca241d6fbf28596ed3954f28a55e65831181cedb02c7c15e372895bb5bd2" + }, + "scripts/pr_watch.py": { + "role": "engine", + "sha256": "8e1fa0e8e14cc4e26172a91ea675194f7f985592a139ac5a09dd9def97deecd1" + }, + "scripts/reconcile_sessions.sh": { + "role": "engine", + "sha256": "825796c961162b4b69ecacd4c6a228a3395213fc6323df957b0a123896e59a53" + } + }, + "kit_version": 2 +} diff --git a/scripts/archive_plan_sessions.py b/scripts/archive_plan_sessions.py index b56b195..0fa14d3 100755 --- a/scripts/archive_plan_sessions.py +++ b/scripts/archive_plan_sessions.py @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.12" -# dependencies = ["pyyaml"] +# dependencies = [] # /// """Sweep old session blocks out of the live handoff into its history document. @@ -42,12 +42,11 @@ import sys from pathlib import Path -import yaml sys.path.insert(0, str(Path(__file__).resolve().parent / "lib")) -from devmodel_config import _repo_root, load_config, resolve_path # noqa: E402 +from kitconfig import load_config, repo_root, resolve_path # noqa: E402 -REPO_ROOT = _repo_root() +REPO_ROOT = repo_root() SEP = "______________________________________________________________________\n" SESSION_PREFIXES = ("## Latest session", "## Earlier session", "## Session — ") # Recent sessions may write *dated* headings (`## June 5 Fri (cont.) — …`) or a diff --git a/scripts/check_doc_budget.py b/scripts/check_doc_budget.py index 74f83bb..dfd4c77 100755 --- a/scripts/check_doc_budget.py +++ b/scripts/check_doc_budget.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # /// script # requires-python = ">=3.12" -# dependencies = ["pyyaml"] +# dependencies = [] # /// """Warn-only tripwire: nudge an archive sweep when a handoff doc grows too large. @@ -18,7 +18,7 @@ The tracked docs + their budgets come from ``config/dev-model.yaml``'s ``doc_budgets`` list (``{path, budget, archive, remedy}``) — see -``scripts/lib/devmodel_config.py``. Adjust the budgets there, not here. +``scripts/lib/kitconfig.py``. Adjust the budgets there, not here. Usage: @@ -43,12 +43,12 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent / "lib")) -from devmodel_config import _repo_root, get, load_config # noqa: E402 +from kitconfig import get, load_config, repo_root # noqa: E402 # Discover the repo root by walking up for a `.git` marker (via devmodel_config) # rather than assuming a fixed `scripts/