diff --git a/docs/architecture.md b/docs/architecture.md index a4a22c7..b84f682 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -167,7 +167,8 @@ Plugin `bin/` PATH injection is Bash-tool only, not hook commands. All hook `com As of [spec 016-scaffold-mode](specs/016-scaffold-mode/spec.md) (slices 016-01 + 016-02 + 016-03 all DONE; 016-04 deferred), `scaffold-init` copies the -runtime machinery (`skills/`, `agents/`, `hooks/scripts/`) into the +runtime machinery (`skills/`, `agents/`, `hooks/scripts/`, and — since +[slice 095-01](specs/095-scaffold-template-copy/spec.md) — `templates/`) into the user's `.claude/` directory under `jig-` prefixed names (`.claude/skills/jig-/`, `.claude/agents/jig-.md`, `.claude/hooks/scripts/jig-*.sh`), and generate/merge @@ -287,12 +288,20 @@ registration, and hook protocol translation. host-neutral `HostRenderer` interface plus concrete renderers for Claude (`ClaudeScaffoldRenderer`) and Codex (`CodexScaffoldRenderer`). Claude scaffold mode writes `AGENTS.md`, `CLAUDE.md`, `.claude/skills/`, -`.claude/agents/`, `.claude/hooks/scripts/`, and `.claude/settings.json`. -Codex scaffold mode writes `AGENTS.md`, `.codex/skills/`, -`.codex/agents/*.toml`, `.codex/hooks/scripts/`, `.codex/templates/`, and -`.codex/hooks.json` without producing Claude-only files. The templates copy -is runtime support for scaffolded helpers whose source fallback resolves -template paths relative to the materialized jig runtime. Codex also installs +`.claude/agents/`, `.claude/hooks/scripts/`, `.claude/templates/`, and +`.claude/settings.json`. Codex scaffold mode writes `AGENTS.md`, +`.codex/skills/`, `.codex/agents/*.toml`, `.codex/hooks/scripts/`, +`.codex/templates/`, and `.codex/hooks.json` without producing Claude-only +files. **Both** scaffold hosts copy `templates/`: it is runtime support for +scaffolded helpers whose source fallback resolves template paths relative to +the materialized jig runtime (`parents[2]/templates/` — `decisions.py`, +`adr.py`, `migrate.py seed-decisions`, `workflow.py`'s slice-template render, +and `memory.py`'s people.md bootstrap all read it there). Codex +has copied templates since its adapter shipped, because its rewrite table +redirects `${CLAUDE_PLUGIN_ROOT}/templates/` into `.codex/templates/`; the +Claude side gained the copy in [slice 095-01](specs/095-scaffold-template-copy/spec.md) +([ADR-0038](decisions/adr-0038-claude-scaffold-template-copy.md)), which is +what makes record-seeding work in a scaffolded project with no plugin root. Codex also installs non-discoverable unprefixed helper aliases under `.codex/skills//` without `SKILL.md`; these preserve existing peer-helper imports such as `skills/scaffold-init/scaffold.py` without registering duplicate skills. diff --git a/docs/bugs/012-decisions-no-template-backfill.md b/docs/bugs/012-decisions-no-template-backfill.md index 72d24c4..05c6888 100644 --- a/docs/bugs/012-decisions-no-template-backfill.md +++ b/docs/bugs/012-decisions-no-template-backfill.md @@ -312,6 +312,49 @@ Run: `python3 skills/memory-sync/test_decisions.py` (30 OK) · Recorded in [docs/memory/learnings.md](../memory/learnings.md) — "Bug 012: an init-time-only seed is a permanent gap for every existing project". +## Amendments + +Per [ADR-0010](../decisions/adr-0010-amendment-scope-records-vs-live-prose.md): +this record is closed (`status: DONE`), so its prose above is preserved as written +and corrections are appended here, dated. + +### 2026-07-16 — `## Remaining risk` is closed + +**Closed by [slice 095-01](../specs/095-scaffold-template-copy/slice-01-claude-scaffold-templates.md) +/ [ADR-0038](../decisions/adr-0038-claude-scaffold-template-copy.md).** The design +call this record deferred to the maintainer — should `copy-machinery` ship +`templates/`, or should record helpers embed their templates? — came back as +**option (a)**: `copy_machinery` now copies `templates/` into `.claude/templates/`, +mirroring `_copy_codex_templates`. The copied helpers' `parents[2]/templates/` +fallback therefore resolves, with no change to any helper's template +*resolution* (several helpers' comments and error messages — including this +bug's — were re-premised; that is prose, not behaviour). + +What that changes in the record above: + +- **`## Remaining risk` no longer describes reality.** The captured probe + (`env -u CLAUDE_PLUGIN_ROOT … decisions.py add-lightweight` → + `error: lightweight-decisions template not found`) now exits 0 and seeds the + record home. Read that section as the state at the time of this fix. +- **`## Fix class`'s "Honest scope" qualifier is spent.** `fix_class: + structural_fix` was qualified as structural for **plugin-mode** installs + because the scaffold-mode variant stayed open. With 095-01 landed it holds for + all four install modes; the frontmatter field is unchanged and now unqualified. +- **The "inherited, not invented" bullet is discharged.** `adr.py`'s identical + gap closed with the same copy — it needed no mitigation of its own, which was + the argument for fixing the family rather than this helper. +- **The mitigation survives, re-premised.** The remedy-naming failure this bug + shipped is now the **broken-install** guard (a copy predating 095-01, a partial + tree, a `CLAUDE_PLUGIN_ROOT` aimed at a non-jig root). `UnreachableTemplateTests` + was re-premised rather than deleted, and the message gained + `migrate copy-machinery` as the remedy that repairs the install instead of + working around it. + +Still open, deliberately: whether a `migrate copy-machinery` re-run is the +sanctioned backfill for projects scaffolded *before* 095-01, and whether `migrate +report` should flag the gap. Spec 095 declared it out of scope; asked on +[#109](https://github.com/ramboz/jig/issues/109). + ## Main recheck - 2026-07-16 - `origin/main@91427b4` -> reproduces: Detached worktree at origin/main@91427b4. Mode 1 (no LD file): 'add-lightweight --title probe --decision probe' -> exit 1, 'error: no docs/decisions/lightweight-decisions.md — scaffold the lightweight-decisions home first'. Mode 2 (hand-rolled LD table): same command -> exit 1, 'error: docs/decisions/lightweight-decisions.md is missing its `## Entries` heading — cannot place the entry', file left untouched. migrate.py on main offers only {report,rename-decisions,split-slices,copy-machinery,adopt-layout} — no seed op. All three confirmed. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index faf5111..0d773b3 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -42,6 +42,7 @@ - [ADR-0035: Active Plan-mode driving for the plan phase (extends ADR-0027)](adr-0035-active-plan-mode-driving.md) — [ADR-0027](adr-0027-host-native-phase-modes.md) adopted **mode-aware, not mode-dependent**: host planning/implementation modes are *advisory* affordances, never lifecycle state or gate inputs. (2026-07-06, Proposed) - [ADR-0036: Immutable Release Identity](adr-0036-immutable-release-identity.md) — Jig publishes semantic versions and release tags, but its root Claude marketplace currently fetches `hosts/claude` from the repository default branch without a `ref` or `sha`. (2026-07-14, Accepted) - [ADR-0037: Bug-fix repository closure evidence](adr-0037-bug-fix-repository-closure-evidence.md) — The bug lifecycle proves diagnose-before-fix, fresh-main reproduction, and a red-to-green regression test. (2026-07-15, Proposed) +- [ADR-0038: Claude scaffold mode copies templates/](adr-0038-claude-scaffold-template-copy.md) — jig's record helpers seed their files from the shipped `templates/` tree: `decisions.py` creates `docs/decisions/lightweight-decisions.md` (bug 012), and `adr.py new` renders `adr-0000-template.md`. (2026-07-16, Accepted) ## Lightweight decisions diff --git a/docs/decisions/adr-0038-claude-scaffold-template-copy.md b/docs/decisions/adr-0038-claude-scaffold-template-copy.md new file mode 100644 index 0000000..d9f312b --- /dev/null +++ b/docs/decisions/adr-0038-claude-scaffold-template-copy.md @@ -0,0 +1,306 @@ +--- +status: Accepted +dependencies: [adr-0012] +last_verified: 2026-07-16 +frame_review: true +--- + +# ADR-0038: Claude scaffold mode copies templates/ + +## Status + +Accepted (2026-07-16) + +**Provenance — this is the maintainer's call, not this ADR's proposal.** The +question was posted to him on +[#109 (comment)](https://github.com/ramboz/jig/issues/109#issuecomment-4996295388) +with the three options below, and parked in +[refinement-todo.md](../refinement-todo.md). He picked **option (a)**; the pick +was relayed verbally via Marie-Rose, who implemented it in +[spec 095](../specs/095-scaffold-template-copy/spec.md). This record exists so the +choice and its rejected alternatives outlive the branch — a future agent seeing a +`templates/` tree inside `.claude/` and thinking "that looks like duplication, +let's drop it" should land here first (start with the live-file table below). + +**On the status field.** The *decision* is the maintainer's and is not in +question; `status:` is not a vote on it. In jig, `status:` is the **record's** +lifecycle state, and the Proposed→Accepted flip is gated: `adr.py accept` refuses +it for a `frame_review: true` ADR without a passing frame-critique verdict +(slice 064-05 / ADR-0020 OQ2). This record starts `Proposed` and is flipped by +`adr.py accept` once that gate clears — which is the whole point of the gate. + +An earlier draft hand-wrote `Accepted` on the reasoning that the maintainer's +pick *is* the acceptance act. That reasoning is not wrong about authority, but it +was wrong about mechanism: hand-stamping the field bypassed the one gate jig +applies to exactly this kind of record, on an ADR that was at that moment failing +its frame critique. The gate has a sanctioned bypass +(`JIG_REVIEW_EVIDENCE_GATE=0`) that leaves a trace; a hand-written status leaves +none. Recording the correction here because the same temptation will recur: +`adr.py resolve-todo` refuses a non-Accepted ADR, which creates pressure to +hand-set the field. The order is frame-critique → `accept` → `resolve-todo`. + +## Context + +jig's record helpers seed their files from the shipped `templates/` tree: +`decisions.py` creates `docs/decisions/lightweight-decisions.md` (bug 012), and +`adr.py new` renders `adr-0000-template.md`. Both resolve the tree the same way +(`_plugin_root`, byte-identical in both helpers): `CLAUDE_PLUGIN_ROOT` if set, +else `Path(__file__).resolve().parents[2] / "templates"`. + +That reaches a template in three of jig's four install modes. It does not reach +one in **Claude scaffold mode** — where the machinery is copied into the project +and there is no plugin root: + +| Install mode | Helper lives at | `parents[2]` | Template reachable? | +|---|---|---|---| +| Claude plugin | `/skills/memory-sync/` | `` | ✅ | +| Codex plugin | `/skills/memory-sync/` | `` | ✅ | +| Codex scaffold | `/.codex/skills/jig-memory-sync/` | `/.codex` | ✅ — `_copy_codex_templates` copies `templates/` | +| **Claude scaffold** | `/.claude/skills/jig-memory-sync/` | `/.claude` | ❌ | + +`copy_machinery` copied `skills/`, `agents/` and `hooks/` into `.claude/`, but not +`templates/`. Only the Codex path copied templates. **Both hosts need the copy for +the same primary reason** — a copied helper resolves `parents[2]/templates/`, +which is `/.codex` or `/.claude`, so the tree has to be there or +the fallback is a lie. Codex has a **second, host-specific** reason on top: +`CodexScaffoldRenderer.rewrite_skill_md_paths` redirects +`${CLAUDE_PLUGIN_ROOT}/templates/` → `${CODEX_PROJECT_DIR:-$PWD}/.codex/templates/`, +so its copy is also what makes its own rewritten prose true. The Claude renderer +(`_PLUGIN_SKILL_PATH_RE`) rewrites only `/skills//` and never touches +`/templates/`, which is plausibly why the Claude gap went unnoticed: nothing on +the Claude side ever *pointed* at a copied templates tree, so nothing broke +loudly until a helper tried to read one. + +So on the function this ADR is about — the helper fallback — the hosts are +symmetric, and Claude is the odd one out. What load-bears is not parity either +way: it is that the copy is the only thing that makes the helpers' **existing** +fallback true in a mode with no plugin root. + +### Which of the 25 copied files are actually live + +The copy ships the whole tree, but only **4 of 25 files** are read at runtime by +a copied helper. A future agent auditing `.claude/templates/` needs this list, +because the rest looks — correctly — inert: + +| Live file | Read by | +|---|---| +| `docs/decisions/lightweight-decisions.md.template` | `decisions.py` (seed the record home); `migrate.py seed-decisions` | +| `docs/decisions/adr-0000-template.md` | `adr.py new` | +| `docs/specs/slice-template.md` | `workflow.py` `_render_stub_slice` (else a degraded inline fallback) | +| `docs/memory/people.md.template` | `memory.py` (people.md bootstrap; spec 050 solo→team) | + +The other 21 are `scaffold-init` seed templates (`CLAUDE.md.template`, +`docs/workflow.md.template`, the `docs/specs/seed/…` tree, …) whose only consumer +is `scaffold()`, which **refuses to run in an already-scaffolded project**. They +are inert weight in a scaffolded tree, and that is the honest cost of (a). + +**The hazard this creates is authority confusion, not bulk.** A scaffolded +project now has `.claude/templates/CLAUDE.md.template` sitting beside its own +generated `CLAUDE.md`, and a seed `docs/specs/` tree beside its real one. jig's +own primer carries "templates/CLAUDE.md.template is the scaffold source — not +this file" precisely because that confusion is real. **The copied tree is not +your project's content: it is jig's source, materialized so helpers can read it. +Edit your project's files, never `.claude/templates/`** (a `copy-machinery` +refresh overwrites them). + +**This list is the argument against a scoped copy.** The templates the reported +bug is *about* are 2 of the 4; the other two belong to helpers nobody had +connected to the problem. An allowlist built from the bug in evidence would have +shipped, looked right, and left `workflow.py` and `memory.py` broken. Copy the +tree, not a curated subset of it. + +Two forces made this worth deciding rather than mitigating again: + +1. **It is a family problem, not one helper's.** `adr.py` has the identical gap + and no mitigation — and it is not a family of two: `migrate.py`, `memory.py` + and `workflow.py` resolve templates the same way, each with its own resolver + and its own template path (see the live-file table). A per-helper fix lands + five times, then again for the next one. +2. **Bug 012 already mitigated once.** Its fix made the failure name two working + remedies (set `CLAUDE_PLUGIN_ROOT`; run `migrate.py seed-decisions`) and left + the mode broken, under `## Remaining risk`. A second mitigation stacked on the + first is how a gap becomes permanent. + +## Decision Options Considered + +### Option A: Claude-side template copy — mirror `_copy_codex_templates` +`copy_machinery(host="claude")` copies `templates/` into `.claude/templates/`, +so the helpers' existing `parents[2]` fallback resolves unchanged. + +- **Pros:** Smallest conceptual change — the mechanism already exists and is + proven on the Codex side. No helper changes at all, so it fixes the whole + family (and every future helper) at once. Templates keep exactly one source of + truth. +- **Cons:** Every scaffolded Claude project's `.claude/` grows a `templates/` + tree (25 files, ~120 KB) — a change to scaffold output for **every** install, + including projects that never record a decision. **Only 4 of those 25 files + are live** (see the live-file table in Context), so the tree is mostly inert + weight. And the drift story + is weaker than it looks: the copy can go stale relative to the plugin, and the + refresh path (`copy-machinery` **from a jig install**) requires exactly the + plugin root this mode is defined by not having — the same remedy-class this + ADR faults bug 012's mitigation for. The difference is one of default, not + kind: after (a) the mode works out of the box and only *staleness* needs the + plugin root, where before, *every* first record did. + +### Option B: Embed each template in its helper +A module constant per helper, plus a drift test asserting it equals the shipped +template file. + +- **Pros:** Works in every install mode with no scaffold change. Makes + `decisions.py` genuinely self-contained — which its own module docstring + already claims ("Self-contained by design … so the host-packaging step can + copy the skill tree whole without a cross-tree dependency"). **On size it + wins outright:** ~4 KB embedded once in the plugin source, against ~120 KB + copied into every scaffolded project. This option is cheaper on bytes, and + the record should not pretend otherwise. +- **Cons:** Duplicates template bodies into helpers (`adr.py` too, then + `workflow.py`, then the next one), so every template edit has two homes and a + drift test standing between them — a synthetic single source of truth + defended by a test, in place of a real one. That is the whole case against it, + and it does not need a bytes argument. + +### Option C: Leave it +Scaffold mode stays unable to seed a record home; bug 012's mitigation stands. + +- **Pros:** Zero change; the reported case (#109) was plugin mode, and scaffold + mode is a minority path. +- **Cons:** A documented, permanently-broken path in the mode jig offers for + in-repo adoption — and it fails at the worst moment: when someone is trying to + record a decision, which is exactly when they are least interested in + debugging their install. Leaves `adr.py` with no mitigation at all. + +## Recommended Decision + +**Option (a)**, and not because of parity — parity is a consequence, not the +argument. The reason is that (a) makes the fallback the helpers **already have** +true, in the one mode where it is false, without touching any of the five helpers +that depend on it. Options (b) and (c) both lack that: (b) fixes the family by +editing every member of it (and the next one), (c) fixes nothing. + +The record should be explicit that (a) is not free, and that it declines a stated +principle: `decisions.py`'s docstring claims to be "self-contained by design", +which is exactly what option (b) would deliver and (a) does not — the helper +stays dependent on a sibling tree being copied next to it. (a) accepts that +dependency and moves the tree instead. The other accepted costs: scaffold output +grows a 25-file `templates/` tree for every Claude install, of which 21 files are +inert there; and projects scaffolded *before* this cannot repair themselves from +inside (Open questions). + +## Consequences + +**Becomes easier:** +- Recording a decision or opening an ADR in a scaffold-mode project — the + reported #109 workflow, in the mode with no plugin root to fall back on. +- Adding a future helper that seeds from a template: it inherits reachability in + all four modes with no per-helper work. +- Reasoning about hosts: both scaffold hosts now copy the same four trees + (skills, agents, hooks, templates). + +**Becomes harder:** +- `.claude/` is bulkier and less obviously hand-auditable; `templates/` is one + more copied tree that can drift from the plugin between `copy-machinery` + refreshes. +- Any future change to the copy set has to keep two hosts in step, not one. + +## Assumptions + +Every claim below was probed. Citations are by symbol, not line number — +this slice's own insertion shifted `scaffold.py` by ~47 lines and stale-dated an +earlier draft's line citations within the same change. + +- **Five *record* helpers** read `templates/` through a + plugin-root-then-`parents[2]` fallback: `decisions.py` (`_plugin_root`), + `adr.py` (`_plugin_root`), + `migrate.py` (`_resolve_plugin_root`), `memory.py` (`plugin_root`), and + `workflow.py` (`_render_stub_slice`). They share the *shape*, not the code — + and `_render_stub_slice` is the odd one: it goes straight to `parents[2]` + without consulting `CLAUDE_PLUGIN_ROOT` at all. Four distinct template files + are read (see the live-file table; `migrate.py seed-decisions` shares + `decisions.py`'s). `scaffold.py` resolves the tree the same way but is not in + this family: it is the *writer* of the copy, and the consumer of the other 21 + seed templates named in Context. +- **The gap is real and reproduces** — probed against a real `--with-machinery` + install with `CLAUDE_PLUGIN_ROOT` unset: + `error: lightweight-decisions template not found: /.claude/templates/…` + and `template not found: /.claude/templates/docs/decisions/adr-0000-template.md` + (captured in the slice 095-01 deviation log; now pinned by + `ClaudeScaffoldTemplatesTests`). +- **Codex's rewrite table** redirects `${CLAUDE_PLUGIN_ROOT}/templates/` into + `.codex/templates/` — `CodexScaffoldRenderer.rewrite_skill_md_paths`; the + Claude rewrite (`_PLUGIN_SKILL_PATH_RE`) matches only `/skills//`. +- **A scaffolded project's own copied `migrate.py` cannot retrofit templates** — + probed (Open questions). + +Two claims in an earlier draft were asserted as fact, unprobed, and false — the +retrofit claim and Codex's copy being an unexplained asymmetry. Both are +corrected above; the drafting history is in the slice's deviation log (§4), which +is its home. Noted here only because this section is where a reader decides how +much to trust the rest. + +Remaining assumption, **not** probed: that 21 inert seed templates in every +scaffolded `.claude/` cause no harm beyond weight and the authority confusion +named in Context. The mitigation is documentation (that warning), not a +mechanism. + +## Kill criteria + +**Neither criterion has an automatic detector today, and saying so is part of the +record** — the copied tree carries no version stamp, nothing compares it to the +plugin, and `migrate report` flagging the gap is explicitly deferred (Open +questions). Both fire on a human noticing. That is a real weakness of this +decision, not a formality: + +- **The copied tree drifts and misleads.** If a stale `.claude/templates/` + produces a *wrong* record rather than a merely refreshable one, the copy is + worse than the plugin-root read it replaced, and option (b)'s single-artifact + property starts to look right. *Closest thing to a detector:* a project's + seeded record disagreeing with the shipped template's shape. +- **`templates/` outgrows what belongs in every project.** 25 files today, 4 of + them live. *Partial detector:* `test_ac3_every_plugin_template_is_copied` pins + the copied set against the plugin's tree, so growth changes a test — but it + asserts equality, not a budget, so it will happily follow the tree to 200 + files. If someone objects to the weight, the retreat is a scoped copy, and the + live-file table in Context is the list to scope to — re-derive it first rather + than trusting it, since it is exactly the kind of list that goes stale as + helpers grow. + +## Open questions + +- **Retrofitting existing scaffolded projects.** Projects scaffolded before this + ADR have no `.claude/templates/` until someone re-runs `migrate copy-machinery` + **from a jig install (or with `CLAUDE_PLUGIN_ROOT` set)**. The qualifier is + load-bearing, and an earlier draft of this record omitted it: re-running the + project's *own copied* `.claude/skills/jig-migrate/migrate.py` does **not** + retrofit. That helper resolves its plugin root to `/.claude`, so the + copy would read from a `templates/` tree that is precisely what is missing. + Probed on this branch — it fails before that, at + `cannot locate scaffold.py at /.claude/skills/scaffold-init/scaffold.py` + (copied skills carry the `jig-` prefix), so the self-refresh is a dead end + either way, not a silent no-op. **A scaffolded project cannot repair itself + from inside**; the remedy needs a jig install, which is the same + go-get-a-plugin-root remedy-class this ADR faults in bug 012's mitigation. + That is the honest limit of (a): it fixes every project scaffolded *after* it, + not the ones already out there. + + **Resolved (maintainer's call on [#119](https://github.com/ramboz/jig/pull/119)):** + the `migrate copy-machinery` re-run **is** the sanctioned backfill, and + `migrate report` now flags the gap so an affected project learns about it + before a record helper fails rather than after. Implemented in this slice: + the inventory records whether `.claude/templates/` is present, and + `render_operations` routes a project that has jig machinery but no templates + tree to `copy-machinery` (pinned by `TemplatesBackfillOperationsTests`). The + honest limit above is unchanged and stated in the nudge itself — the backfill + runs **from a jig install**, because a scaffolded project cannot repair itself + from its own copied files. + +- **Tier gating (discharging `dependencies: [adr-0012]`).** ADR-0012 makes tier + gating a promise-integrity decision: a project's on-disk skill set must match + its manifest. Templates ship **ungated** — a Tier-0-only project receives + `adr-0000-template.md` even though `adr-workflow` is Tier 1 and not installed. + This is deliberate: templates have no natural tier (`docs/workflow.md.template` + is owned by no skill), so gating would mean inventing and maintaining a second + template→tier map, and a partial tree is the same gap with a smaller blast + radius. Precedent: the `.gitignore` security floor is ungated infra for the + same reason (ADR-0013 / slice 052-04). Recording it here because a reader of + ADR-0012 alone would expect gating and find none. diff --git a/docs/decisions/reviews/adr-0038-frame-critique.md b/docs/decisions/reviews/adr-0038-frame-critique.md new file mode 100644 index 0000000..362f240 --- /dev/null +++ b/docs/decisions/reviews/adr-0038-frame-critique.md @@ -0,0 +1,32 @@ +--- +adr: 0038 +pass: frame-critique +verdict: pass +reviewer: jig:reviewer (fresh context, round 4 of 4) +reviewed_at: 2026-07-17T06:16:19Z +prompt_source: review.py frame-critique +--- + +Round 4. FRAME SOUND. Counts verified against the code rather than the prose: +five record helpers with distinct resolvers, `templates/` is exactly 25 files +with the 4 named live ones present, `_render_stub_slice` really does skip +`CLAUDE_PLUGIN_ROOT`, and `copy_machinery` really does copy four trees. No +section misleads about why `.claude/templates/` exists or licenses its deletion. + +This took four rounds, and the record is better for each. Round 1 (FRAME NEEDS +WORK) found the retrofit claim was false — the ADR told a future agent to run +`migrate copy-machinery`, which cannot work from inside a scaffolded project — +and that option (b) was rejected on a bytes argument backwards by ~30×. Round 2 +(needs-changes) found `status: Accepted` had been hand-stamped, bypassing the +very frame-critique gate this verdict file feeds; its lead objection was that +the machinery cited in defence of the hand-write actually orders the steps +against it. Round 3 (needs-changes) found the round-2 fix had corrected Context +while leaving Recommended Decision asserting the repudiated claim *and citing +Context as its source* — a wrong claim wearing a cross-reference to its own +refutation. Round 4 confirms those are closed. + +Non-blocking follow-through it named, actioned rather than filed: the `hosts/` +mirrors had drifted from the canonical ordinal fix (rebuilt), and +`docs/decisions/README.md` still advertised the ADR as Accepted (regenerated by +`adr.py index` after `accept` — the reviewer's point stands that the index does +not self-heal by mechanism, only by same-day coincidence). diff --git a/docs/inbox.md b/docs/inbox.md index a74a1e2..acd9813 100644 --- a/docs/inbox.md +++ b/docs/inbox.md @@ -51,3 +51,6 @@ - [2026-07-06] **`plan-mode-routing/broad-scope:`** Routing work through native Plan mode is a **universal** jig capability — and it is **already shipped and broadly scoped**, NOT parked and NOT limited to any lightweight variant. [ADR-0027](decisions/adr-0027-host-native-phase-modes.md) (mode-aware, Accepted) + [spec 074](specs/074-host-native-phase-modes/spec.md) (**all 3 slices DONE**) ship: the portable phase vocabulary (plan/implement/review/reconcile/land) in `docs/workflow.md` + `docs/prompts.md`; advisory host-mode hints in `workflow.py session-plan`; and host-native primer substitutions (`phase_mode_substitutions()` in scaffold.py) for BOTH Claude and Codex. Value = the [spec 057](specs/057-thin-orchestrator/spec.md) thin-orchestrator win (front-load planning, dispatch compactly), benefiting expert users equally. [ADR-0034](decisions/adr-0034-lower-interaction-altitude.md) (altitude, non-expert-only) lists it as *lever 3* and explicitly disclaims ownership. **Correction:** it was repeatedly mis-called 'parked' this session from stale spec.md overview prose — it is DONE; that overview line is a closed-spec-drift artifact ([ADR-0010](decisions/adr-0010-amendment-scope-records-vs-live-prose.md)) worth correcting inline. **Live remaining questions (NOT lightweight-scoped):** ADR-0027 OQ#3 (`review` a separate phase hint vs. bundled with the read-only passes) and OQ#4 (copy accepted chat plans into a durable `plan.md` vs. specs-only); PLUS the deliberate boundary — spec 074 stopped at *advisory* hints (ADR-0027 Option C), so any move toward *first-class/deeper* use of host plan/task primitives (actively entering Plan mode; mapping slices → host task/todo primitives) would be a NEW decision beyond ADR-0027. Surfaced 2026-07-06 — user flagged Plan-mode routing must apply to jig broadly; confirmed it already does. - [2026-07-11] **`bug.py/_section/heading-fragility:`** Bug 005 (issue 80) fixed the diagnose gate's list-item parser but deliberately deferred the sibling fragility in `_section` (`skills/bug-fix/bug.py`): its exact-match regex `^##\s+\s*\n` makes a section invisible when the heading is one level too deep (`### Hypotheses` — real record 010), carries trailing text (`## Hypotheses (for the fix session to confirm)` — real record 002), or the *first* `## ` on a line is a literal **inside a fenced code block** (this bit bug 005's own record — its real dash-bullet `## Hypotheses` was shadowed by a `## Hypotheses` example inside the Repro fence, so the gate parsed a numbered example and warned). `_section` is shared by the Evidence/Proof gates, so widening it has its own blast radius (heading-depth tolerance, trailing-text `\b`-anchored so `## Evidence-based` doesn't false-match "Evidence", and fenced-code-block awareness — nested/`~~~` fences make this non-trivial) and deserves its own change with dedicated tests. **Resolution trigger:** a real bug record misfires the diagnose/proof gate because its heading was `###`/trailing-text/fenced, OR the next edit that touches `_section`. Mitigation sketch: `^#{2,6}[ \t]+(?=[ \t]|\n)` + strip fenced code blocks before section extraction. From bug 005 / issue 80. - [2026-07-16] **`bug.py/next-number/parallel-branch-collision:`** `bug.py new` hands out a number already in use when the competing record lives on an unmerged branch. `_next_number()` (`skills/bug-fix/bug.py:143`) globs the local `docs/bugs/` and returns `highest + 1`; a record created on another branch is invisible to it. Hit for real in the bug 012 session (#109 finding 1): `origin/main`'s board stops at 010, bug **011** (`011-decision-dedup-suppresses-reversals`, ROOT_CAUSED) lives only on `claude/bug-011-decision-dedup-reversals`, so `new` allocated **011** a second time. `--push` does not help — it reserves against `origin/main`, which also stops at 010, so it would reserve 011 *onto main* and collide head-on with that branch; there is no `--number` flag. Worked around by renaming the record to **012** by hand and patching the `# Bug NNN:` header (a gap at 011 is harmless if that branch is abandoned; two records numbered 011 corrupts the board). Note the collision is **silent** — nothing warns, and it only surfaces when both branches land. Contrast the ADR-0015 reservation machinery (`--push`/`--pr`), which exists precisely to make a number visible to parallel sessions but cannot see an unpushed sibling either. **Resolution trigger:** a second parallel-bug-session collision, OR the next edit to `_next_number` / the reservation path — at which point consider fetching `origin` and scanning remote branches' `docs/bugs/` for allocated numbers, or a `--number` escape hatch, or making bug reservation push-by-default like the parked `IN_PROGRESS` claim item in [refinement-todo](refinement-todo.md). Surfaced 2026-07-16; flagged to the maintainer on [#109](https://github.com/ramboz/jig/issues/109#issuecomment-4996295388). +- [2026-07-16] **`templates/default-encoding-reads:`** Slice 095-01 read its copied templates with an explicit `encoding="utf-8"` (matching `atomic_write_text`'s utf-8 write) and fixed the one *newly-enabled* read that regressed under a C/POSIX locale (`workflow.py` `_render_stub_slice` — a default-encoding `read_text()` inside `except OSError`, where `UnicodeDecodeError` is a `ValueError` and would escape the fallback into a crash). The same shape survives, **pre-existing and unfixed**, in the rest of the template-reading family: `adr.py` (`_render_adr_content`'s `template.read_text()`, plus the reservation paths), `memory.py`'s people.md bootstrap, and `_copy_codex_templates` itself. Those fail identically in *plugin* mode on `main` today, so 095-01 did not introduce them and left them alone. Real templates carry non-ASCII (em-dashes, `✅`, `→`), so under `LANG=C` these raise `UnicodeDecodeError` where the caller expects `AdrError` / a graceful `(False, msg)` degrade. `decisions.py` is the one that gets it right (explicit `encoding="utf-8"` both ways). **Note no test would catch any of it** — no fixture forces a non-UTF-8 locale, so dropping the kwarg ships green; a fix should bring one. **Resolution trigger:** a real `LANG=C` / POSIX-locale report, OR the next edit touching template reads in any of those helpers — sweep the family at once. From the craft + arch re-review passes on slice 095-01. +- [2026-07-16] **`scaffold/two-template-copies-already-drifting:`** `_copy_claude_templates` (slice 095-01) and `_copy_codex_templates` are ~14-line near-duplicates differing in two tokens (destination subdir, rewrite fn). Extraction is **correctly declined at two callers** under the extract-at-third-caller rule ([ADR-0002](decisions/adr-0002-no-internal-contracts-scope.md) / [ADR-0023](decisions/adr-0023-lifecycle-family-spine.md)) — both reviewers independently confirmed leaving it. But [ADR-0038](decisions/adr-0038-claude-scaffold-template-copy.md) names "any future change to the copy set has to keep two hosts in step" as an accepted cost, and **that cost is already being paid badly at n=2**: the two functions diverged *within the slice that created the second one* (the Claude copy reads utf-8 explicitly; the Codex one still reads at the default encoding — see the `templates/default-encoding-reads` entry). A third host is not the only trigger worth watching. **Resolution trigger:** a third host, OR the next change to either copy function that should apply to both — at which point unify into one host-parameterised copy (dest + rewrite fn as args) rather than fixing the same bug twice. Surfaced by the arch re-review on slice 095-01. +- [2026-07-16] **`shipped-skills/bare-relative-template-paths:`** `skills/spec-workflow/SKILL.md` points agents at a bare relative `templates/docs/specs/slice-template.md`, which resolves in **neither** scaffold mode (CWD = the consuming project, not the jig repo root) — the copied tree lives at `.claude/templates/` / `.codex/templates/`, and in plugin mode it is under `${CLAUDE_PLUGIN_ROOT}/templates/`. Same bug class as the parked `shipped-skills/bare-path-invocations` entry [2026-06-19] (which covers bare `python3 skills/...` *invocations*); this is the **data-path** sibling — a bare path to a shipped *template*, not a helper. Note slice 095-01 changed the failure mode without fixing the prose: `.claude/templates/` now exists, so a reader following the bare path lands nowhere while an equivalent file sits one directory over. **Resolution trigger:** fold into whatever sweep fixes the 2026-06-19 entry — a contract test greping shipped surfaces for bare `templates/...` / `python3 (scripts|skills)/...` references should cover both classes at once. Surfaced by the frame re-critique on ADR-0038. diff --git a/docs/memory/glossary.md b/docs/memory/glossary.md index fbe4574..b3a423e 100644 --- a/docs/memory/glossary.md +++ b/docs/memory/glossary.md @@ -170,6 +170,10 @@ EngTip #21's five test *types*, distinguished by **scope** (how much of the syst [ADR-0013](../decisions/adr-0013-security-floor-policy.md) / [spec 052](../specs/052-security-scaffold/spec.md) — every scaffolded/migrated project gets a 5-part floor: `.gitignore` secret patterns + the agent-time `jig-secret-scan.sh` PreToolUse hook (`JIG_SECRET_SCAN_APPROVED=1` bypass, fails open) + conservative `permissions.deny` defaults + a `## Security (MUST)` CLAUDE.md block + the slim `jig:security-review` skill. Defense-in-depth, not a firewall (mirrors ADR-0011); flows to existing projects via `migrate copy-machinery`, asserted by `scripts/verify_install.py`. +## Copied templates tree + +[ADR-0038](../decisions/adr-0038-claude-scaffold-template-copy.md) / [spec 095](../specs/095-scaffold-template-copy/spec.md) — **both** scaffold hosts copy jig's `templates/` beside the copied machinery (`.claude/templates/`, `.codex/templates/`), because five record helpers resolve their template as `CLAUDE_PLUGIN_ROOT`, else `parents[2]/templates/` — which in scaffold mode lands on `/.claude` (or `.codex`), the one install mode with no plugin root to fall back on. Copied by `_copy_claude_templates` / `_copy_codex_templates` in `scaffold.py`, both behind `copy_machinery`, both rewriting `*.md.template` plugin-root skill paths to the copied runtime; ungated infra, never tier-scoped (templates have no natural tier — mirrors the `.gitignore` security floor). **The tree is jig's source materialized so helpers can read it — not the project's content: edit your project's files, never `.claude/templates/`** (a `copy-machinery` refresh overwrites it). Readers: `decisions.py` + `migrate.py seed-decisions` (lightweight-decisions), `adr.py new`, `workflow.py` (slice-template), `memory.py` (people.md) — 4 live files of 25; the other 21 are `scaffold-init` seed templates, inert in a scaffolded project. Retrofit for pre-095 projects needs `copy-machinery` **from a jig install** — a scaffolded project cannot repair itself from inside — and `migrate report` now flags the gap and routes there (maintainer's call on [#119](https://github.com/ramboz/jig/pull/119); ADR-0038 Open questions, resolved). + ## Review-evidence gate [ADR-0014](../decisions/adr-0014-review-evidence-model.md) (spec 045) — durable verdict artifacts at `docs/specs/NNN-slug/reviews/slice-NN-.md` (body = the VERDICT envelope); `review.py record-review` writes, `check-reviews` validates. `workflow.py transition` gates READY_FOR_REVIEW (frame-critique iff `frame_review`), REVIEWED (compliance+craft, +arch/+code-health/+design-review iff their flags), RECONCILED (reconciliation verdict + deviation log), DONE (re-validates the set + dep-check) — each clears iff `verdict: pass`. Bypass `JIG_REVIEW_EVIDENCE_GATE=0` (deliberateness, not human-only). Shared schema/validator `skills/_common/review_evidence.py` (`PASSES` = compliance/craft/arch/code-health/reconciliation/frame-critique/design-review; `validate_evidence` reads the gating flags itself so spawner+gate can't drift); flag-truthy set in `skills/_common/parsing.py`. arch/code-health/design (REVIEWED) + frame-critique (READY_FOR_REVIEW) gate on per-slice flags (default-off). Frame-critique = adversarial PRE-implementation hardening (spec 064 / [ADR-0020](../decisions/adr-0020-spec-frame-hardening.md)); also gates `adr.py accept` (064-05, ADR evidence at `docs/decisions/reviews/adr-NNNN-.md`); rung-3 cross-model critique deferred. Design-review = attest-only external-eval pass (spec 071 / [ADR-0022](../decisions/adr-0022-pluggable-oracle-boundary.md)). diff --git a/docs/refinement-todo.md b/docs/refinement-todo.md index 45f6d92..814d158 100644 --- a/docs/refinement-todo.md +++ b/docs/refinement-todo.md @@ -64,10 +64,12 @@ Resolved by [Slice 002-03](specs/002-memory-layer/spec.md) — `{ "continue": tr ### ~~Decision: AI-first onboarding doc separate from CLAUDE.md~~ — RESOLVED 2026-05-18 Resolved by [Spec 025-01](specs/025-claude-md-hygiene/spec.md) — onboarding content preserved in CLAUDE.md; no separate `docs/AI-ONBOARDING.md` needed. -### Decision: how should record helpers reach their templates in Claude scaffold mode? -**Deferred:** [Bug 012](bugs/012-decisions-no-template-backfill.md) made `decisions.py` seed `lightweight-decisions.md` from `templates/docs/decisions/…`, resolving it the way `adr.py` already does (`CLAUDE_PLUGIN_ROOT`, else `parents[2]/templates/`). That reaches the template in **three of four install modes** — plugin (Claude + Codex) and Codex scaffold mode, where `scaffold.py` `_copy_codex_templates` copies `templates/` into `.codex/`. It does **not** reach it in **Claude scaffold mode**: `copy_machinery` copies `skills/` and `hooks/` but not `templates/`, so a copied helper's `parents[2]` lands on `/.claude` (no `templates/`), with `CLAUDE_PLUGIN_ROOT` unset. Not a regression (that mode failed before bug 012 too) and not invented by it — **`adr.py:73-81` has the identical gap today**, so this is about the record-helper family, not one helper. Bug 012 mitigated rather than closed it: the failure now names two verified-working remedies (set `CLAUDE_PLUGIN_ROOT`; or `migrate.py seed-decisions` from a jig install), pinned by `UnreachableTemplateTests`, and the bug record carries it under `## Remaining risk` with `fix_class` qualified as "structural for plugin-mode installs". +### ~~Decision: how should record helpers reach their templates in Claude scaffold mode?~~ — RESOLVED 2026-07-16 +~~**Deferred:** [Bug 012](bugs/012-decisions-no-template-backfill.md) made `decisions.py` seed `lightweight-decisions.md` from `templates/docs/decisions/…`, resolving it the way `adr.py` already does (`CLAUDE_PLUGIN_ROOT`, else `parents[2]/templates/`). That reaches the template in **three of four install modes** — plugin (Claude + Codex) and Codex scaffold mode, where `scaffold.py` `_copy_codex_templates` copies `templates/` into `.codex/`. It does **not** reach it in **Claude scaffold mode**: `copy_machinery` copies `skills/` and `hooks/` but not `templates/`, so a copied helper's `parents[2]` lands on `/.claude` (no `templates/`), with `CLAUDE_PLUGIN_ROOT` unset. Not a regression (that mode failed before bug 012 too) and not invented by it — **`adr.py:73-81` has the identical gap today**, so this is about the record-helper family, not one helper. Bug 012 mitigated rather than closed it: the failure now names two verified-working remedies (set `CLAUDE_PLUGIN_ROOT`; or `migrate.py seed-decisions` from a jig install), pinned by `UnreachableTemplateTests`, and the bug record carries it under `## Remaining risk` with `fix_class` qualified as "structural for plugin-mode installs".~~ **Resolution trigger:** A Claude scaffold-mode project is observed unable to seed a record home (or `adr.py new` failing the same way), OR the next time `copy_machinery`'s copy set is revised for another reason — decide the family's answer then rather than growing a second mitigation. **Options (asked of the maintainer on [#109](https://github.com/ramboz/jig/issues/109#issuecomment-4996295388), his call):** (a) **Claude-side template copy** — mirror `_copy_codex_templates` in `copy_machinery`; smallest conceptual change, but adds a `templates/` tree to every scaffolded Claude project's `.claude/` and changes scaffold output for everyone. (b) **Embed the template** in each helper (constant + a drift test asserting it equals the shipped file); makes `decisions.py` genuinely self-contained — which its own module docstring already claims — and works in every mode, at the cost of duplicating template bodies into helpers, presumably for `adr.py` too. (c) **Leave it** — scaffold mode is a minority path and the reported case (#109) was plugin mode. +**Resolved by:** [ADR-0038: Claude scaffold mode copies templates/](decisions/adr-0038-claude-scaffold-template-copy.md). +**The maintainer picked option (a)** — implemented by [spec 095](specs/095-scaffold-template-copy/spec.md) / [slice 095-01](specs/095-scaffold-template-copy/slice-01-claude-scaffold-templates.md): `copy_machinery` copies `templates/` into `.claude/templates/`, mirroring `_copy_codex_templates`, so the copied helpers' existing `parents[2]/templates/` fallback resolves with **no change to any helper's template resolution** (four helpers' comments/messages are re-premised — prose, not behaviour). `ClaudeScaffoldTemplatesTests` pins it end-to-end (the *copied* `decisions.py` and `adr.py` run with `CLAUDE_PLUGIN_ROOT` unset). Both halves of the resolution trigger fired: `adr.py new` was confirmed failing the same way, and `copy_machinery`'s copy set was revised for exactly this reason. The entry framed this as a **two**-helper family; review found it is **five** — `workflow.py` (slice-template), `memory.py` (people.md) and `migrate.py` (`seed-decisions`) read the tree through the same shape, so an allowlist of the two templates in evidence would have left consumers broken. (The count needed two corrections before it settled — first to four, then to five — which is itself the argument against curating that list by hand.) Accepted cost: every scaffolded Claude project's `.claude/` now carries a 25-file `templates/` tree, of which 4 files are live there. **Not** decided here — whether a `migrate copy-machinery` re-run (which only works **from a jig install**; a project cannot repair itself from inside) should be the sanctioned backfill for projects scaffolded before 095-01 (asked on [#109](https://github.com/ramboz/jig/issues/109); ADR-0038 Open questions). ## Conventions diff --git a/docs/specs/095-scaffold-template-copy/reviews/slice-01-arch.md b/docs/specs/095-scaffold-template-copy/reviews/slice-01-arch.md new file mode 100644 index 0000000..0c5c09d --- /dev/null +++ b/docs/specs/095-scaffold-template-copy/reviews/slice-01-arch.md @@ -0,0 +1,29 @@ +--- +slice: 095-01 — claude-scaffold-templates +pass: arch +verdict: pass +reviewer: jig:reviewer (fresh context, round 2) +reviewed_at: 2026-07-17T06:07:32Z +prompt_source: review.py arch-review +--- + +Round 2 (after fixes). Both round-1 must-fixes — the two shipped doc contracts +that enumerate the copy set (`skills/migrate/SKILL.md`, `docs/architecture.md`) +— are fixed and verified against source rather than taken on trust; the new +Codex-rewrite-table claim checks out. + +Design stands. Verified independently: module placement is right; the two copy +functions should NOT be unified at two callers (extract-at-third-caller, +ADR-0002/ADR-0023); the ungated (non-tier-scoped) template copy is sound and +precedented by the ungated `.gitignore` security floor; spec 084's docs_root +interaction is correct (`.claude/templates/` is the template *source* tree, and +destinations honour docs_root via each helper's own `_docs_base`); release +packaging already ships `templates`; no new drift class. + +Round-2 must-fixes, both addressed: ADR-0038 contradicted its own live-file +table (said 3, table said 4 — now 5 helpers / 4 files); and §6's encoding fix +stopped at the write side while leaving the read paths this slice newly enabled +— `workflow.py`'s regressed from graceful degrade to unhandled crash under a C +locale, now fixed. Follow-ups recorded to the inbox rather than fixed here: +the rest of the family's default-encoding reads (pre-existing in plugin mode +too), and the two copy functions already having drifted at n=2. diff --git a/docs/specs/095-scaffold-template-copy/reviews/slice-01-compliance.md b/docs/specs/095-scaffold-template-copy/reviews/slice-01-compliance.md new file mode 100644 index 0000000..d4a6b9a --- /dev/null +++ b/docs/specs/095-scaffold-template-copy/reviews/slice-01-compliance.md @@ -0,0 +1,30 @@ +--- +slice: 095-01 — claude-scaffold-templates +pass: compliance +verdict: pass +reviewer: jig:reviewer (fresh context, round 2) +reviewed_at: 2026-07-17T06:07:06Z +prompt_source: review.py implementation +--- + +Round 2 (after fixes). Every AC1..AC6 met and pinned by a non-vacuous test; +implementation faithful to ADR-0038 option (a) — no helper's template +resolution touched. Round 1 was `needs-changes` and found real defects: a +vacuous AC4 edge-case test (asserted the rewrite round-trip against two files +that never take the rewrite branch), a `decisions.py` comment still asserting +the opposite of shipped behaviour, two stale shipped doc contracts, a missing +partial-state assert, and a locale-dependent `read_text()`. All fixed and +re-verified. + +Round 2 findings, all addressed: the Codex-*rendered* migrate contract still +omitted the templates step (`finalize_codex_migrate_skill` replaces the section +wholesale — the SKILL.md fix never reached Codex users); the phrase "no helper +changes" survived verbatim in two places the deviation log calls false; a +`scaffold.py:944-947` citation went stale from this slice's own +47-line +insertion; and the "four helpers" learning was itself an undercount — it is +five (`migrate.py` reads the same template through the same shape). + +Verified true and worth keeping: deviation §2's memory.py/workflow.py claims, +§7's `load_tests` Python-version claim, §9's honest "knowingly not done". +Residual, recorded not fixed: the `encoding="utf-8"` fix is not pinned by any +test (no fixture forces a non-UTF-8 locale), so dropping it would ship green. diff --git a/docs/specs/095-scaffold-template-copy/reviews/slice-01-craft.md b/docs/specs/095-scaffold-template-copy/reviews/slice-01-craft.md new file mode 100644 index 0000000..7ce5d91 --- /dev/null +++ b/docs/specs/095-scaffold-template-copy/reviews/slice-01-craft.md @@ -0,0 +1,31 @@ +--- +slice: 095-01 — claude-scaffold-templates +pass: craft +verdict: pass +reviewer: jig:reviewer (fresh context, round 2) +reviewed_at: 2026-07-17T06:07:32Z +prompt_source: review.py pr-review +--- + +Round 2 (after fixes). All five round-1 craft findings fixed: the AC2 git +fixture is isolated (`commit.gpgsign=false` + a `shutil.which("git")` guard + +a comment stating why `checkout -b` is load-bearing); `workflow.py`'s docstring +and fallback comment are true again; `docs/architecture.md` reconciled; the +`_rewrite_skill_md_paths` docstring precondition now describes reality for all +three callers; the byte-copy drift guard iterates instead of hand-listing. + +Round 2 raised and this addressed: `workflow.py`'s newly-reachable +`read_text()` had no `encoding=` inside an `except OSError`, so a C-locale +scaffold-mode project would crash where it used to degrade cleanly — a +regression this slice would have introduced. Fixed (utf-8 + widened except). +The weak `test_slice_template_is_reachable_...` (a strict subset of AC3, whose +name over-claimed and whose docstring said "three") was replaced with two real +end-to-end tests that run the copied `workflow.py` and `memory.py` and assert +the consumer actually uses the copied template. `migrate.py`'s own `--help` and +`report` Operations text now name templates (SKILL.md alone was not the whole +contract). Review-history narration was cut from the ADR and a test docstring +per the cut-list — the deviation log owns that history. + +Explicitly defended and kept: not unifying the two copy functions at n=2 +(extract-at-third-caller); the encoding comment naming the Codex sibling's +divergence; `memory.py`'s rewritten degrade message. diff --git a/docs/specs/095-scaffold-template-copy/reviews/slice-01-reconciliation.md b/docs/specs/095-scaffold-template-copy/reviews/slice-01-reconciliation.md new file mode 100644 index 0000000..bc92ca2 --- /dev/null +++ b/docs/specs/095-scaffold-template-copy/reviews/slice-01-reconciliation.md @@ -0,0 +1,42 @@ +--- +slice: 095-01 — claude-scaffold-templates +pass: reconciliation +verdict: pass +reviewer: jig:reviewer (fresh context) +reviewed_at: 2026-07-17T06:21:26Z +prompt_source: review.py reconciliation +--- + +PASS WITH FINDINGS on first read; all findings actioned, re-verified below. + +What it confirmed by spot-checking the claims easiest to overstate: all three +`copy-machinery` contract surfaces genuinely name templates; bug 012 is amended, +not rewritten (its `## Remaining risk` preserved verbatim, ADR-0010); §7's +load-bearing test-reach claim is true at source (`load_tests` returns an empty +suite below 3.11, and it is defined *after* `unittest.main()`, so the direct-run +verification really does bypass it); the three inbox entries describe real work; +the §9↔§7 self-indictment is accurate and against interest; the recorded verdicts +are evidence rather than self-congratulation. + +Findings, all fixed: +- **ADR/index/todo three-way disagreement.** Read mid-flight: `status: Proposed` + vs an index saying Accepted vs a struck-through refinement-todo entry that + `resolve-todo` refuses for a non-Accepted ADR. The reviewer's diagnosis was + exact — reverting the hand-stamped status did not undo what it had enabled, and + §7b narrated the intended order as if it had run. Now actually run: + frame-critique passed (round 4) → verdict recorded → `adr.py accept` → `adr.py + index`. Recorded as §7c, including that the index does not self-heal by + mechanism. +- **Stale "four helpers" on three surfaces** (`refinement-todo.md`, + `architecture.md`, and the *Codex render* of the migrate contract) after + compliance established five — the source→render drift class §8 exists to fix, + reintroduced one level down. Swept; recorded as §7d. +- **`test_workflow.py`'s `SelfDefiningReminderInRenderersTests`** documented "a + scaffolded project, where `slice-template.md` is NOT copied" as its rationale — + a premise this slice falsified, unaccounted for by any record. Re-premised; the + invariant it pins is unchanged and still earns its place. +- **`hosts/` mirrors** carried pre-fix ordinals (canonical edit landed after the + last rebuild). Rebuilt; drift guard green in isolation. +- **"[x] Reconciliation review passed" was ticked before any verdict existed.** + Fair catch, and the only finding about the records claiming something untrue + about themselves. This file is that verdict. diff --git a/docs/specs/095-scaffold-template-copy/slice-01-claude-scaffold-templates.md b/docs/specs/095-scaffold-template-copy/slice-01-claude-scaffold-templates.md new file mode 100644 index 0000000..213d3b8 --- /dev/null +++ b/docs/specs/095-scaffold-template-copy/slice-01-claude-scaffold-templates.md @@ -0,0 +1,373 @@ +--- +status: DONE +dependencies: [] +last_verified: 2026-07-16 +arch_review: true +# design_review: true # set true when this slice ships UI gated by an external +# # design-fidelity eval (attest-only; ADR-0014/0022). +--- + + + + + +## Slice 095-01 — claude-scaffold-templates + +**Goal:** A Claude-scaffolded project with `CLAUDE_PLUGIN_ROOT` unset can record +a lightweight decision and open an ADR — because `copy_machinery` brought +`templates/` with the machinery, so the copied helpers' existing `parents[2]` +fallback resolves. + +`arch_review: true` — this widens `copy_machinery`'s copy set, which is the +contract every scaffolded Claude project's `.claude/` tree is built from. The +option was the maintainer's call (spec Overview); the review pass is for the +*implementation* of it, not for re-litigating the choice. + +**DoR:** +- ✅ Bug 012 is DONE — `decisions.py` seeds from the template (this slice makes + that seed reachable in the fourth install mode). +- ✅ The maintainer picked option (a) — relayed verbally via Marie-Rose, + recorded in the spec Overview and [ADR-0038](../../decisions/adr-0038-claude-scaffold-template-copy.md). +- ✅ `_copy_codex_templates` exists as the mirror to follow (`scaffold.py:1386`). + +**Acceptance Criteria:** + +1. **A scaffold-mode project can seed its record home with no plugin root.** + After `scaffold.py --with-machinery `, with + `docs/decisions/lightweight-decisions.md` **absent** — the shape of every + project scaffolded before that template shipped, which is bug 012's mode 1 + and the case #109 reported — running the *copied* + `.claude/skills/jig-memory-sync/decisions.py add-lightweight` with + `CLAUDE_PLUGIN_ROOT` **unset** exits 0, seeds the record home from the + copied template, and appends the entry. + + (The absent-home precondition is load-bearing, not test convenience: a + *fresh* scaffold seeds the record home at install time, so `add-lightweight` + there takes the append path and never reads a template. The template is only + reached when the home has to be created — which is exactly when scaffold + mode fails today, with + `error: lightweight-decisions template not found: /.claude/templates/…`.) +2. **The same holds for the ADR helper.** Under the same conditions, + `.claude/skills/jig-adr-workflow/adr.py new ""` exits 0 and writes + `docs/decisions/adr-NNNN-<slug>.md` from the copied template — the family's + second member, which had the identical gap and no mitigation. +3. **The copy mirrors Codex.** `copy_machinery(host="claude")` copies every file + under the plugin's `templates/` into `<project>/.claude/templates/`, + preserving the relative tree, exactly as `_copy_codex_templates` does for + `.codex/templates/`. +4. **Copied `.md.template` bodies name the copied machinery.** Every + `${CLAUDE_PLUGIN_ROOT}/skills/<name>/` literal in a copied `.md.template` + is rewritten to `${CLAUDE_PROJECT_DIR}/.claude/skills/jig-<name>/` — the same + transform SKILL.md bodies and rendered docs already get in scaffold mode. A + record seeded from a copied template must not name a variable that is unset + in the project that seeded it. +5. **Idempotent re-run.** A second `copy_machinery` run over the same project + leaves `.claude/templates/` byte-identical (no duplication, no error), so + `migrate copy-machinery` can be re-run to refresh machinery. +6. **Plugin-only mode is unaffected.** `scaffold.py --plugin-only` writes no + `.claude/templates/` — the plugin root is the template home there, and the + copy would be dead weight. + +**DoD:** +- [x] All ACs pass; full test suite green (no regressions). _(3.12: 3508 → + 3518 OK; 3.9: 3361 OK, unchanged — see deviation §7 for why the floor + count doesn't move.)_ +- [x] Implementer test coverage exercises each AC with at least one + fixture. Edge cases listed in the slice are covered explicitly. + _(AC4's edge case was covered vacuously at first — see §5.)_ +- [x] Reviewed by `reviewer` subagent. Reviewer prompt built by + `review.py`. +- [x] Implementation review passed. +- [x] Deviation log produced under this slice heading. +- [x] Reconciliation sweep produced under this slice heading. +- [x] Reconciliation review passed. +- [x] `docs/refinement-todo.md` updated if any decisions were + deferred during implementation. _(This slice **resolves** the "how should + record helpers reach their templates in Claude scaffold mode?" entry.)_ + +### Close-out (post-DONE) + +- [x] `docs/specs/README.md` regenerated by `workflow.py status-board`; the + load-bearing invariants migrated to its Notes column (preserved across regen). +- [x] Primer hygiene per spec 025-01: this slice closes the spec, so **no** + Active-specs entry was added. A one-line Key-terms entry was drafted and + then dropped: it pushed `CLAUDE.md` to 71 lines against the 70-line budget + guard (spec 076-01), and the guard is right — the "don't delete this tree" + warning belongs at the code site (`_copy_claude_templates`, + `decisions.py`) and in the glossary, which is the on-demand home the + primer indexes into. `docs/memory/glossary.md` gains **Copied templates + tree**, reachable via `/jig:explain`. + +**Edge cases covered explicitly:** + +- Re-run over an existing `.claude/templates/` (AC5) — the refresh path + `migrate copy-machinery` depends on. +- `--plugin-only` (AC6) — the copy must not leak into the mode that does not + copy machinery. +- A template with no `${CLAUDE_PLUGIN_ROOT}` literal must survive the rewrite + byte-identically (the rewrite is a no-op, not a re-render). +- Non-`.md.template` files (e.g. `scaffold.json.template`) are byte-copied, not + rewritten — same split `_copy_codex_templates` makes. + +**Anti-horizontal-phasing check:** After this slice a scaffold-mode project can +actually record a decision end-to-end (AC1/AC2 run the copied helpers as +subprocesses against a real scaffolded tree) — not "the copy exists and a later +slice will make it usable". + +### Reconciliation sweep + +| Artifact | Disposition | Why | +|---|---|---| +| `docs/architecture.md` | **rewrite** (live prose, inline per ADR-0010) | Enumerated the Claude scaffold copy set (no `templates/`) and framed the templates copy as a Codex-only trait. Both now false; the paragraph teaches the host asymmetry this slice removed. | +| `skills/migrate/SKILL.md` | **rewrite** | `copy-machinery`'s shipped contract omitted the templates step. This is what an agent reads before running the repair `decisions.py` now recommends. | +| `scaffold.py` `finalize_codex_migrate_skill` | **rewrite** | The Codex *render* of that contract is a separate hardcoded list; fixing SKILL.md alone left Codex users with the old one. | +| `migrate.py` `--help` + `report` Operations text | **rewrite** | Two more copies of the same contract, at the CLI rather than in SKILL.md. | +| `decisions.py`, `migrate.py`, `memory.py`, `workflow.py` | **rewrite** (comments/messages) | Each asserted "scaffold mode has no templates" as a premise. Re-premised; no resolution logic touched. | +| `docs/bugs/012-…md` | **amend** (closed record → `## Amendments`) | `status: DONE`, so ADR-0010 forbids rewriting the prose. The `## Remaining risk` this slice closes is preserved as written, with a dated amendment. | +| `docs/refinement-todo.md` | **resolve** | Struck through by `adr.py resolve-todo` + the substance of the pick. | +| [ADR-0038](../../decisions/adr-0038-claude-scaffold-template-copy.md) | **new** | Records the maintainer's choice + rejected options. | +| `docs/inbox.md` | **new** (3 entries) | Follow-ups surfaced by review that are out of this slice's scope: the family's remaining default-encoding reads; the two copy functions already drifting at n=2; `spec-workflow/SKILL.md`'s bare relative template path. | +| `docs/conventions.md` | **no-op** | Untouched by design — needs explicit human approval, and nothing here is a convention change. | +| `scripts/scaffold_contract.py` | **no-op** (knowing) | See §9. | +| `CLAUDE.md` | **compress** | Spec closes in one slice; per spec 025-01 the Active-specs entry is not grown. One primer line earns its place (see close-out). | + +**Not swept, deliberately:** `_copy_codex_templates`'s default-encoding read and +`adr.py`/`memory.py`'s template reads. Pre-existing, failing identically in plugin +mode on `main` today, and inboxed rather than folded in — this slice fixes the one +read it *newly* broke (`workflow.py`, §6). + +### Deviation log (after reconciliation) + +The original spec is preserved above. Implementation notes: + +**§1 — Red witnessed, both helpers, against a real install.** Probed on a real +`scaffold.py --with-machinery` tree with `CLAUDE_PLUGIN_ROOT` unset, before any +implementation: + +``` +$ env -u CLAUDE_PLUGIN_ROOT python3 .claude/skills/jig-memory-sync/decisions.py \ + add-lightweight --title probe --decision probe --project-dir . +error: lightweight-decisions template not found: <project>/.claude/templates/docs/decisions/lightweight-decisions.md.template + +$ env -u CLAUDE_PLUGIN_ROOT python3 .claude/skills/jig-adr-workflow/adr.py \ + new probe-decision --no-push --project-dir . +template not found: <project>/.claude/templates/docs/decisions/adr-0000-template.md +``` + +Test-level red: 3 failures + 2 errors across the new `ClaudeScaffoldTemplatesTests` +/ `PluginOnlyTemplatesTests`; green after `_copy_claude_templates` landed, with no +test edited to make it pass. AC6 was green from the start — it is a guard against +the copy leaking into `--plugin-only`, not a behaviour this slice adds. + +**§2 — The family is five helpers, not two. The spec undercounted, and so did +the first correction.** The spec and the refinement-todo entry framed this as "the +record-helper family" (`decisions.py` + `adr.py`). The craft pass found +`workflow.py`'s `_render_stub_slice` reads +`parents[2]/templates/docs/specs/slice-template.md`; a follow-up grep found +`memory.py` (people.md bootstrap) — whose error message *already said* the failure +was "expected for a scaffold-mode target", documenting this gap in a helper nobody +had connected to it; and the compliance re-review found the fifth, `migrate.py` +(`seed-decisions`, sharing `decisions.py`'s template). Enumerated with a grep over +every non-test helper, rather than a third guess: + +| Helper | Template | +|---|---| +| `decisions.py`, `migrate.py` | `docs/decisions/lightweight-decisions.md.template` | +| `adr.py` | `docs/decisions/adr-0000-template.md` | +| `workflow.py` | `docs/specs/slice-template.md` | +| `memory.py` | `docs/memory/people.md.template` | + +Five helpers, four distinct files. Consequences: + +- Two helpers silently changed behaviour in scaffold mode: `workflow.py` stops + using its degraded inline slice template, and `memory.py`'s bootstrap starts + working. Neither is in an AC; both are improvements. Now pinned by + `test_workflow_renders_the_real_slice_template_not_the_fallback` and + `test_memory_bootstraps_people_md_from_the_copied_template` — an earlier + version asserted only that the *file* was copied, which AC3 already covered and + which proved nothing about the consumer. +- Their now-false comments/messages were re-premised here. +- **This is the argument for the whole-tree copy over a scoped allowlist**, and + it is stronger than the Codex-parity and duplication-cost arguments ADR-0038 + first leaned on: an allowlist built from the two templates in evidence would + have shipped, looked correct, and left two consumers broken. Found by review, + not by design — and the count needed two corrections before it settled, which + is itself the argument against curating the list by hand. +- Caveat on "identical shape": `workflow.py` goes straight to `parents[2]` and + never consults `CLAUDE_PLUGIN_ROOT`, unlike the other four. Same fallback, one + fewer door. + +**§3 — "No helper changes" was literally false as written.** The spec said a +helper edit "would mean the copy is not doing its job", and the refinement-todo +asserted "no helper changes". Four helper *files* do change — comments and error +messages carrying the old premise. No helper's `_plugin_root()` / +`_template_path()` logic is touched, which is what the claim meant. Both +documents now say "no change to any helper's template *resolution*". Caught by +the compliance pass. + +**§4 — Two ADR-0038 claims were asserted as fact and were false.** Recorded +because the record's own Assumptions section said "None unverified" while +carrying them: + +- *"A `migrate copy-machinery` re-run picks the tree up — same call."* True only + from a jig install. A project's own copied `migrate.py` cannot retrofit itself + — probed: it fails at `cannot locate scaffold.py at + <project>/.claude/skills/scaffold-init/scaffold.py`. So (a) fixes projects + scaffolded *after* it, not the ones already out there. Corrected in ADR-0038 + Open questions and the spec's Out-of-scope. +- *"Codex's template copy is an asymmetry with no recorded rationale."* It has + one: `CodexScaffoldRenderer.rewrite_skill_md_paths` redirects + `${CLAUDE_PLUGIN_ROOT}/templates/` into `.codex/templates/`, so the Codex copy + is also what makes its own rewritten prose true. + +Both found by the frame-critique pass. **The correction to the second one then +over-corrected** and had to be walked back by the re-critique: the first fix +claimed Codex's copy is a "mechanical necessity of the rewrite table" and that +(a) therefore *creates* parity rather than restoring it. That contradicts the +ADR's own Context table — Codex scaffold mode resolves via `parents[2]` precisely +*because* `_copy_codex_templates` copies, so on the helper-fallback function the +hosts are symmetric and Claude is the odd one out. The rewrite table is an +*additional*, host-specific reason. The record now says that; worth noting that +the over-correction was more flattering to the reviewer than the truth was. + +**A citation lesson, recorded because it bit twice.** An earlier version of this +section cited `scaffold.py:944-947` for the rewrite table. That was correct when +observed against `origin/main` and false by the time it was written down — this +slice inserts ~47 lines above it, moving it to ~991-994. Two reviewers +independently flagged it. Line citations in a deviation log describe a file the +same change is editing; cite by symbol. + +**§5 — A test asserted its edge case against the wrong branch.** +`test_ac4_templates_without_plugin_root_are_byte_identical` claimed to pin "the +rewrite is a substitution, not a re-render" using `adr-0000-template.md` and +`scaffold.json.template` — neither of which ends in `.md.template`, so both took +the byte-copy branch and the rewrite round-trip had **zero** coverage. Split into +`test_ac4_rewritten_templates_without_a_literal_are_byte_identical` (real +`.md.template` files) and `test_ac4_non_md_templates_are_byte_copied` (iterates +every non-`.md.template` file rather than hand-listing two — the hand-list had +already missed `slice-template.md`). Caught by the compliance pass. + +**§6 — Encoding, on the write side and the reads it newly enables.** +`_copy_claude_templates` reads with an explicit `encoding="utf-8"` to match +`atomic_write_text`'s utf-8 write. Mirroring `_copy_codex_templates` verbatim +would have inherited its default-encoding read, which under `LANG=C` mojibakes or +raises on the em-dashes in the templates — and that corruption would propagate +into every seeded record. + +The re-review pass then made the obvious point back: if `LANG=C` is a real enough +threat model to change the write side, it is real on the **read** side this slice +just enabled. `workflow.py`'s `_render_stub_slice` was the live case and is fixed +here — it read at the default encoding inside `except OSError`, and +`UnicodeDecodeError` is a `ValueError`, so post-095-01 a C-locale scaffold-mode +project would **crash** where the pre-095-01 behaviour (file absent → +`FileNotFoundError` → inline fallback) degraded cleanly. That is a regression this +slice would have introduced; it now reads utf-8 and catches both. + +Knowingly not fixed: `adr.py` and `memory.py` read their templates at the default +encoding too. Unlike `workflow.py`'s, those paths fail the same way in *plugin* +mode on `main` today — pre-existing, not introduced here, and fixing them is a +separate concern (inboxed). + +**Not pinned by a test:** no fixture forces a non-UTF-8 locale, so dropping any +of these `encoding=` kwargs would ship green. The reasoning is the only guard. + +**§7 — Test-suite reach.** `test_scaffold_mode.py` has a module-level +`load_tests` hook that returns an empty suite below Python 3.11 (a `tomllib` gate +for the Codex-packaging tests). On a 3.9 interpreter — the documented floor, and +the default macOS python3 — `run_tests.py` therefore skips this file **whole**, +including these new tests. They were verified two ways instead: directly on 3.9 +(`python3 skills/scaffold-init/test_scaffold_mode.py`, which bypasses the hook) +and via the full suite on 3.12, which is what CI's matrix runs. Not introduced +here and not fixed here; flagged in the PR as a question — an 88-test file +vanishing on the supported floor is a bigger call than this slice. + +**§7b — A gate was bypassed by hand, and the gate caught it.** ADR-0038 was +first written with `status: Accepted` hand-stamped into the frontmatter, on the +reasoning that the maintainer's pick *is* the acceptance act so "Proposed" would +misattribute his decision to this branch. The authority argument is fine; the +mechanism was not. `adr.py accept` gates the Proposed→Accepted flip on a passing +frame-critique verdict for a `frame_review: true` ADR (slice 064-05 / ADR-0020 +OQ2) — so hand-writing the field skipped the one gate jig applies to exactly this +kind of record, on an ADR that was at that moment **failing** its frame critique. +`adr.py resolve-todo` then accepted the refinement-todo resolution because it only +reads the field. + +Two things worth keeping from this: the pressure was structural (resolve-todo +*requires* Accepted, which is precisely what tempts a hand-write), and the gate's +sanctioned bypass (`JIG_REVIEW_EVIDENCE_GATE=0`) leaves a trace where a +hand-written status leaves none. The record is now `Proposed` and flipped by +`adr.py accept` once the frame critique passes; the order is frame-critique → +`accept` → `resolve-todo`. Found by the frame re-critique, which noticed that the +machinery cited in defence of the hand-write actually ordered the steps against +it. + +**§7c — The reverted hand-stamp left downstream state behind, and §7b initially +narrated the intended sequence as if it were the shipped one.** Reverting +`status:` to `Proposed` did not undo what the hand-written `Accepted` had already +enabled: `adr.py resolve-todo` had struck the refinement-todo entry through +(it refuses a non-Accepted ADR — that is the whole reason the field got +hand-written), and `docs/decisions/README.md` still advertised the ADR as +Accepted. For a window, three durable records disagreed about one ADR's state, +and §7b described the correct order without saying that the last step had not yet +run. The reconciliation pass caught it as "a redemption story whose ending never +happened" — the finding §7b would have caught about anyone else. + +Now actually run, in the order the ADR prescribes: frame-critique passed (round 4 +of 4) → verdict recorded → `adr.py accept 0038` (the gate cleared on evidence, +not on a hand-write) → `adr.py index` regenerated. **Note the index does not +self-heal**: `cmd_accept` rewrites only the ADR file, so the README entry was +correct afterwards only because `_today()` happened to be the same date the +hand-stamp used. On any later date the index would have been stale too. The order +worth carrying forward is frame-critique → `accept` → **`index`** → +`resolve-todo`, plus a host rebuild if any canonical helper prose moved (it had — +see below). + +**§7d — The four→five sweep needed three passes, and that is the lesson, not a +footnote.** The family count went two → four (craft) → five (compliance), and each +correction left surfaces behind: after the "five" fix landed in the ADR and the +deviation log, `docs/refinement-todo.md`, `docs/architecture.md`, and the *Codex +render* of the migrate contract still said four; `test_workflow.py`'s +`SelfDefiningReminderInRenderersTests` still documented "a scaffolded project, +where `slice-template.md` is NOT copied" as its rationale; and the committed +`hosts/` mirrors still carried the pre-fix ordinals ("a third member…") because +the canonical edit landed after the last rebuild. All swept, found by the +reconciliation pass. §2 argues that hand-curated lists of consumers go stale; +this slice's own records proved it three times while making that argument. (live prose, corrected inline per ADR-0010): +`docs/architecture.md` (twice — the copy-set enumeration and the paragraph that +framed the templates copy as a Codex-only trait) and the `copy-machinery` +contract, which omitted the templates step for **both** hosts and is what an +agent reads before running the repair `decisions.py` now recommends. + +That contract has **three** surfaces, and the first pass fixed one. The +compliance re-review caught that `skills/migrate/SKILL.md` is only the Claude +source: `finalize_codex_migrate_skill` replaces the whole "What it does" section +with a hardcoded list for the Codex render, so the fix never reached +`.codex/skills/jig-migrate/SKILL.md`. `migrate.py`'s own `--help` and its +`report` Operations suggestion were also still enumerating the old copy set. +All three now name templates. + +**§9 — Knowingly not done.** `scripts/scaffold_contract.py` pins a scaffolded +`.claude/` tree and was not extended to assert `.claude/templates/`; the copy is +asserted by `test_scaffold_mode.py` only. Raised by the arch pass as low-value; +recording the omission as a choice rather than an oversight. + +**§9 ↔ §7, stated plainly because separately they read better than they are:** +§9 declines the contract check on the grounds that `test_scaffold_mode.py` covers +it — and §7 establishes that file is skipped whole below Python 3.11. Together: +on the *documented floor*, this slice has zero discovered coverage, and the one +check that would have pinned it version-independently is the one declined. CI's +3.12 job makes this non-blocking, and the direct-run verification is real, but +§9's rationale is weaker than it sounds and should not be read as "covered". + +Also from the arch pass: a copied `scaffold-init` run in `--plugin-only` mode can +now read an already-rewritten template tree where it previously crashed. Narrow, +and precedented — `plugin_root(host="codex")` ignores the env var entirely, so +Codex-scaffolded projects have always done this. An earlier version of this +section said ADR-0038's first kill criterion covers it; it does not — that +criterion is about staleness producing a wrong record, which is a different +failure. Recorded here as an uncovered narrow case, not as a covered one. diff --git a/docs/specs/095-scaffold-template-copy/slice-01-tbd.md b/docs/specs/095-scaffold-template-copy/slice-01-tbd.md deleted file mode 100644 index ffefeb6..0000000 --- a/docs/specs/095-scaffold-template-copy/slice-01-tbd.md +++ /dev/null @@ -1,132 +0,0 @@ ---- -status: DRAFT -dependencies: [] -last_verified: -# arch_review: true # set to true when this slice changes module -# # boundaries, public contracts, or architecture- -# # shaped concerns (triggers arch-review pass). -# design_review: true # set true when this slice ships UI gated by an external -# # design-fidelity eval (attest-only; ADR-0014/0022). ---- - -<!-- jig self-defining vocabulary (soft, forward-only): expand each acronym on - first use and link the term to docs/memory/glossary.md (or jig's lexicon). - See docs/workflow.md "Self-defining vocabulary". --> - -<!-- jig grounding (spec 064-02 / ADR-0020): ground factual claims about - runnable surfaces by probe first (run it / read source) or a citation, - else mark them as assumptions in the spec's `## Assumptions` section — - never assert an unverified claim as fact. --> - -## Slice 095-01 — tbd - -**Goal:** _TODO: one-sentence statement of what this slice delivers. -End-to-end value in a single vertical slice (anti-horizontal-phasing -check below)._ - -**DoR:** -- ✅ _TODO: list the prerequisites that must hold before this slice - becomes `READY_FOR_IMPLEMENTATION` — upstream slices done, helpers - in place, fixtures/data available, etc._ - -**Acceptance Criteria:** - -1. **_TODO: first AC._** Sharp, testable, observable from outside the - helper (CLI output, file mutation, exit code, log line). Avoid - "code is clean" / "tests pass" framings — those belong in the DoD. -2. _TODO: second AC._ -3. _TODO: third AC._ - -**DoD:** -- [ ] All ACs pass; full test suite green (no regressions). -- [ ] Implementer test coverage exercises each AC with at least one - fixture. Edge cases listed in the slice are covered explicitly. -- [ ] Reviewed by `reviewer` subagent. Reviewer prompt built by - `review.py`. -- [ ] Implementation review passed. -- [ ] Deviation log produced under this slice heading. -- [ ] Reconciliation sweep produced under this slice heading. -- [ ] Reconciliation review passed. -- [ ] `docs/refinement-todo.md` updated if any decisions were - deferred during implementation. - -### For `kind: spike` slices - -When the slice's frontmatter has `kind: spike`, the body carries four -extra labelled blocks alongside the standard Goal / DoR / AC / DoD -scaffolding. Spike slices are timeboxed investigation, not feature -work — they reduce an unknown before committing to a design. - -```markdown -**Question:** _One sentence stating the open question. Set at DRAFT._ - -**Time-box:** _Explicit budget — e.g., "1 day", "4 hours". Set at DRAFT._ - -**Findings:** _Bullet evidence collected during the spike. Filled -during IN_PROGRESS._ - -**Outcome:** _One of: `ADR-NNNN created` / `spec NNN-NN unblocked` / -`abandoned (reason)`. Multiple outcomes separated by `;` -(e.g., `ADR-0007 created; spec 030-02 unblocked`). Set at DONE._ -``` - -`spec_lint.py` validates the `kind:` enum (allowed values: `spike`, -`feature`) and soft-warns when a `kind: spike` slice is missing any of -the four labels. Mid-flight spikes legitimately have empty Findings / -Outcome, so this is a warning, not a hard error. - -See `skills/spec-workflow/SKILL.md` (Spike slices subsection) and -`docs/spec-workflow/spidr-primer.md` for the always-nested rule (spike -slices live inside a real spec, never as standalone `docs/spikes/` -artifacts) and the abandoned-outcome manual-reshape failure mode. - -### Close-out (post-DONE) - -These items can only be ticked AFTER the final `RECONCILED → DONE` -transition. Slice-land's `check_dod` (slice 009-01) excludes them -from the count. - -- [ ] `docs/specs/README.md` regenerated by `workflow.py status-board`. - Notes column receives any load-bearing per-slice invariant - (it's preserved across regen). -- [ ] Primer hygiene per spec 025-01 rule: **if this slice closes the - spec** (all non-deferred slices DONE), check `CLAUDE.md`, - `AGENTS.md`, and scaffold templates when present, then **compress** - the spec's Active-specs entry — drop facts derivable from the - spec dir + status board, migrate load-bearing per-slice - invariants to the status board Notes column, keep at most a - one-liner only for cross-cutting facts. If the spec is still - in flight (other slices DRAFT / READY / IN_PROGRESS), leave - the entry. If this slice introduces a new skill, add or - update its row in the Skills table. - -**Anti-horizontal-phasing check:** _TODO: in one sentence, describe -the end-to-end observable value a user gets after this slice lands. -If the answer is "intermediate state for the next slice," the slice -is mis-shaped — re-split._ - -### Deviation log (after reconciliation) - -The original spec is preserved above. Implementation notes: - -_TODO: numbered sections covering deviations from the planned shape, -reviewer findings folded back in, doc updates, plan adherence._ - -### Reconciliation sweep - -Record the drift-prone surfaces checked during reconciliation. The transition -gate only requires this subsection to exist; the reconciliation reviewer judges -whether coverage and rationales are honest. - -| Artifact | Disposition | Rationale | -|----------|-------------|-----------| -| `README.md` | `no-op` | _TODO: why this slice did not affect the project front door, or summarize the update._ | -| `docs/specs/README.md` | `updated` | _TODO: regenerated by `workflow.py status-board`, or explain why deferred._ | -| `docs/product-vision.md` | `no-op` | _TODO: checked for behavior / scope drift._ | -| `docs/architecture.md` | `no-op` | _TODO: checked for module-boundary / public-contract drift._ | -| Primer surfaces: `CLAUDE.md` / `AGENTS.md` / scaffold templates | `no-op` | _TODO: primer hygiene checked; note compression or template updates if any._ | -| `docs/inbox.md` | `no-op` | _TODO: checked for items resolved by this slice._ | -| `docs/refinement-todo.md` | `no-op` | _TODO: checked for resolved items or new deferred decisions._ | -| `docs/memory/**` | `no-op` | _TODO: note memory-sync result or why nothing was worth capturing._ | -| `docs/decisions/README.md` / ADR index | `no-op` | _TODO: use `updated` when the slice touched ADRs; otherwise mark checked._ | -| Additional live prose / generated templates touched by this slice | `deferred` | _TODO: name owner or trigger when real cleanup remains; otherwise replace with `no-op`._ | diff --git a/docs/specs/095-scaffold-template-copy/spec.md b/docs/specs/095-scaffold-template-copy/spec.md index f37a013..c9b716c 100644 --- a/docs/specs/095-scaffold-template-copy/spec.md +++ b/docs/specs/095-scaffold-template-copy/spec.md @@ -1,6 +1,6 @@ --- -status: DRAFT -skill: +status: DONE +skill: scaffold-init use_cases: [] --- @@ -8,20 +8,127 @@ use_cases: [] # Spec 095: Scaffold-template copy -> Reserved on 2026-07-16 via `workflow.py new`. Body to be drafted in a feature branch. +> Reserved on 2026-07-16 via `workflow.py new`. ## Overview -_TBD_ +**Record helpers cannot reach their templates in Claude scaffold mode.** jig's +record helpers — `decisions.py` (the lightweight-decisions home) and `adr.py` +(new Architectural Decision Records) — seed their files from +`templates/docs/decisions/…`, resolved as `CLAUDE_PLUGIN_ROOT`, else +`Path(__file__).parents[2] / "templates"`. That fallback reaches the template in +three of jig's four install modes: + +| Install mode | Helper lives at | `parents[2]` | Template reachable? | +|---|---|---|---| +| Claude plugin | `<plugin>/skills/memory-sync/` | `<plugin>` | ✅ (env var, and the fallback) | +| Codex plugin | `<plugin>/skills/memory-sync/` | `<plugin>` | ✅ | +| Codex scaffold | `<project>/.codex/skills/jig-memory-sync/` | `<project>/.codex` | ✅ — `_copy_codex_templates` copies `templates/` | +| **Claude scaffold** | `<project>/.claude/skills/jig-memory-sync/` | `<project>/.claude` | ❌ — **no `templates/` tree** | + +`copy_machinery` copies `skills/` and `hooks/` into `.claude/`, but not +`templates/`. Only the Codex path copies templates. So a Claude-scaffolded +project — one that carries jig's machinery in-repo rather than as a plugin, with +`CLAUDE_PLUGIN_ROOT` unset — cannot record a lightweight decision or open an +ADR: both helpers fail with a template-not-found error. + +This is the gap [bug 012](../../bugs/012-decisions-no-template-backfill.md) +mitigated but could not close. Bug 012 made `decisions.py` seed its record home +from the template instead of failing on an absent file; in Claude scaffold mode +the seed has no template to read, so the fix stops at a loud error naming two +manual remedies. `adr.py` has the identical gap and no mitigation. The question +of how the *family* should reach its templates was parked in +[refinement-todo.md](../../refinement-todo.md) and +[asked of the maintainer on #109](https://github.com/ramboz/jig/issues/109#issuecomment-4996295388) +with three options; **he picked option (a)** — mirror `_copy_codex_templates` on +the Claude side. This spec implements that pick. + +## Decision (maintainer's, not this spec's) + +**Option (a) — Claude-side template copy.** `copy_machinery` in Claude scaffold +mode also copies `templates/` into `.claude/templates/`, mirroring what +`_copy_codex_templates` already does for `.codex/`. The copied helpers' existing +`parents[2]/templates/` fallback then resolves with **no change to any helper's +template resolution**: `.claude/skills/jig-memory-sync/decisions.py` → +`parents[2]` = `.claude` → +`.claude/templates/docs/decisions/lightweight-decisions.md.template`. + +Precisely: no helper's `_plugin_root()` / `_template_path()` logic is touched. +Four helper files do change — comments and error messages that asserted the old +"scaffold mode has no templates" premise are re-premised (`decisions.py`, +`migrate.py`, `workflow.py`, `memory.py`). Prose, not behaviour; but "no helper +changes" as an unqualified claim is false, and the distinction is the point of +the option. + +Rejected (recorded in [ADR-0038](../../decisions/adr-0038-claude-scaffold-template-copy.md)): + +- **(b) Embed each template in its helper** as a constant plus a drift test. + Works in every mode and makes `decisions.py` self-contained, but duplicates + template bodies into helpers (`adr.py` too), and every template edit then has + two homes. +- **(c) Leave it.** Scaffold mode stays permanently unable to record a decision; + the reported case (#109) was plugin mode, so the pain stays latent — until it + isn't. + +The cost accepted with (a) is stated plainly: **every scaffolded Claude project's +`.claude/` grows a `templates/` tree** (25 files, ~120 KB), and that is a change +to scaffold output for every install, not just projects that record decisions. ## Assumptions -_TBD — list load-bearing assumptions about runnable surfaces (library/API capability, version/perf behavior, behavior of existing code); probe-back (run it / cite source) or mark explicitly here. Risk-gated: omit (or write "None") when there are no unverified load-bearing assumptions — do not pad with boilerplate._ +None unverified. The three claims this spec rests on were each probed on +`main@af53265`: + +- Codex already copies templates and rewrites `.md.template` bodies — + `scaffold.py:1386` `_copy_codex_templates`. +- The Claude copy set today is skills + hooks only — `scaffold.py:1904` + `copy_machinery`. +- Both record helpers resolve templates via `parents[2]` — + `decisions.py:73-81`, `adr.py:73-81` (byte-identical `_plugin_root`). ## Decomposition -_TBD — SPIDR analysis. See SKILL.md for the five axes (Spike / Paths / Interfaces / Data / Rules)._ +SPIDR analysis. The change is one mechanical copy behind one existing seam, so +four of the five axes collapse to nothing worth splitting: + +- **Spike:** none. The mechanism is already shipped and proven on the Codex side; + there is no unknown to reduce. +- **Paths:** one — `copy_machinery(host="claude")`. The Codex path already has + its copy; the plugin path needs none (the plugin root *is* the template home). +- **Interfaces:** none new. No new flag, subcommand, or helper argument; the + copy rides the existing `--with-machinery` / `migrate copy-machinery` calls. +- **Data:** one tree — `templates/`, copied whole. Splitting it (e.g. "only + `docs/decisions/`") would make the copy set a second thing to keep in sync + with the template tree, and would still fail the next helper that grows a + template. +- **Rules:** one — `.md.template` bodies get the same `${CLAUDE_PLUGIN_ROOT}` + → copied-path rewrite that SKILL.md bodies and rendered docs already get in + scaffold mode. Without it, a seeded record would hand a scaffold-mode project a + command naming a variable that is unset there. + +→ **One slice.** A vertical slice here is "a scaffold-mode project can record a +decision", which needs the copy, the rewrite, and both helpers proven end-to-end +together; anything smaller is horizontal phasing. ## Slices -- [095-01 — tbd](slice-01-tbd.md) +- [095-01 — claude-scaffold-templates](slice-01-claude-scaffold-templates.md) + +## Out of scope + +- **Retrofitting existing scaffolded projects.** Whether a `migrate + copy-machinery` re-run should backfill `templates/` into projects scaffolded + before this spec is a separate question, posted on + [#109](https://github.com/ramboz/jig/issues/109) rather than decided here. + Mechanically, a re-run **from a jig install** picks the tree up + (`copy_machinery` is the same call); a re-run of the project's *own copied* + `migrate.py` does **not** — probed, see + [ADR-0038](../../decisions/adr-0038-claude-scaffold-template-copy.md) Open + questions. So this slice fixes every project scaffolded after it, and leaves + the existing ones to that question. +- **Helper resolution changes.** The point of option (a) is that the existing + `parents[2]` fallback resolves unchanged; an edit to `_plugin_root()` / + `_template_path()` in this slice would mean the copy is not doing its job. + (Helper *prose* does change — see the Decision section.) +- **Bug 011 / the dedup fix class**, and the capture-rewrite design work + ([#108](https://github.com/ramboz/jig/issues/108)) — unrelated. diff --git a/docs/specs/README.md b/docs/specs/README.md index 7d5f47e..4ad3526 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -294,6 +294,7 @@ | [094-capture-hygiene](094-capture-hygiene/spec.md) | 094-01 — machine text is never attributed to the owner | **DONE** | `who: "user"` is earned by the text, never assumed from the event: harness wrappers reach `UserPromptSubmit` too. One home — `decision_scan.strip_machine_text` (re-exported `typed_by_owner`); empty output *is* the machine-text verdict. Whether the Stop scan's `role == "user"` branch needs the same rule is asked on [#108](https://github.com/ramboz/jig/issues/108), not decided. | | [094-capture-hygiene](094-capture-hygiene/spec.md) | 094-02 — a dismissed dialog produces no stub | **DONE** | **Reverses 083-07's "a noisy stub is cheap" premise on measured evidence** ([#108](https://github.com/ramboz/jig/issues/108): 17 of 27 entries were the agent's own dialog). Owner-gating does not make a *wrong* stub free — it is durable and costs agent attention every Stop. Don't re-argue from the old premise; `extract_askuserquestion_answer` takes no `tool_input` by design. | | [094-capture-hygiene](094-capture-hygiene/spec.md) | 094-03 — decision vocabulary on the routing surface | **DONE** | `memory-sync` and `adr-workflow` both advertise "record this decision"; the split is lightweight/shipped vs load-bearing-with-alternatives. Guarded both ways in `evals/cases/`. Watch-item: `analyze`'s "decision records" positive sits at rank 3, one off the gate — the next skill to add decision vocabulary breaks it. | +| [095-scaffold-template-copy](095-scaffold-template-copy/spec.md) | 095-01 — claude-scaffold-templates | **DONE** | **Spec 095 closed.** Maintainer's pick (option (a) of 3 on [#109](https://github.com/ramboz/jig/issues/109), [ADR-0038](../decisions/adr-0038-claude-scaffold-template-copy.md)): `copy_machinery(host="claude")` copies `templates/` → `.claude/templates/` via `_copy_claude_templates`, mirroring `_copy_codex_templates`, so **both** scaffold hosts carry templates beside the copied machinery. **Invariant: no helper's template *resolution* changed** — the fix is that `parents[2]/templates/` now resolves. Ungated infra, never tier-scoped (templates have no natural tier; mirrors the `.gitignore` floor). **`.claude/templates/` is jig's source materialized for helpers, not project content — don't delete it, don't edit it** (a refresh overwrites). Family is **five** helpers / **4 live files of 25**: `decisions.py`+`migrate.py seed-decisions`, `adr.py new`, `workflow.py` slice-template, `memory.py` people.md — the last three found only by review, which is why the copy is whole-tree, not an allowlist. Retrofit for pre-095 projects needs `copy-machinery` **from a jig install**; a scaffolded project cannot repair itself from inside (ADR-0038 Open questions). Reviews: compliance/craft/arch/reconciliation PASS (round 2 after real defects), frame-critique PASS (round 4 of 4). | ## Deferred slices diff --git a/hosts/claude/skills/memory-sync/decisions.py b/hosts/claude/skills/memory-sync/decisions.py index 551f408..c4ed644 100644 --- a/hosts/claude/skills/memory-sync/decisions.py +++ b/hosts/claude/skills/memory-sync/decisions.py @@ -62,8 +62,10 @@ # the plugin-root paths inside `*.md.template` (build_codex_plugin.py # `_copy_templates`), so the text read here is already host-correct. A data # read, not an import — the module docstring's no-cross-tree-import rule holds. -# NOT reachable in Claude scaffold mode, which copies skills/ but not -# templates/ — see `seed_lightweight` and bug 012's Remaining risk. +# Reachable in all four install modes since slice 095-01 (ADR-0038): both +# scaffold hosts copy `templates/` beside the copied machinery, so `parents[2]` +# finds it there too. Do not "clean up" `.claude/templates/` — this read is what +# it is for. _TEMPLATE_RELATIVE = ( Path("templates") / "docs" / "decisions" / "lightweight-decisions.md.template" @@ -161,20 +163,26 @@ def seed_lightweight(project_dir: Path) -> bool: return False template = _template_path() if not template.is_file(): - # Reachable in Claude scaffold mode: `copy-machinery` copies skills/ - # and hooks/ but not templates/ (only Codex copies templates — see - # scaffold.py `_copy_codex_templates`), so a copied helper's - # parents[2] lands on `<project>/.claude`, which has no templates/. - # Not fixed here (see bug 012's Remaining risk); but it must name a - # remedy that works, or it is the original bug wearing a new costume. + # No install mode reaches here by design any more: plugin installs + # resolve the template under the plugin root, and both scaffold hosts + # copy templates/ beside the copied machinery (slice 095-01 for + # Claude, `_copy_codex_templates` for Codex — see ADR-0038). So this + # now means a BROKEN install: a copy that predates 095-01, a partial + # tree, or CLAUDE_PLUGIN_ROOT pointed at a non-jig root. It still must + # name a remedy that works, or it is the original bug (012) wearing a + # new costume. raise FileNotFoundError( "lightweight-decisions template not found: %s\n\n" - "jig ships it at templates/docs/decisions/. This usually means " - "the helper is running from copied machinery (scaffold mode), " - "which has no templates/ tree. Either:\n" - " 1. point CLAUDE_PLUGIN_ROOT at a jig plugin/checkout root and " + "jig ships it at templates/docs/decisions/. A scaffolded project " + "carries its own copy beside its copied machinery; a plugin " + "install reads it from the plugin root. Reaching this means " + "neither is in place. Either:\n" + " 1. refresh this project's copied machinery from a jig install " + "(`migrate.py copy-machinery <project-dir>`), which brings " + "templates/ with it; or\n" + " 2. point CLAUDE_PLUGIN_ROOT at a jig plugin/checkout root and " "re-run this command; or\n" - " 2. seed the file from a jig install with `/jig:migrate`'s " + " 3. seed just this file from a jig install with `/jig:migrate`'s " "seed-decisions op (`migrate.py seed-decisions <project-dir>`)." % template) path.parent.mkdir(parents=True, exist_ok=True) diff --git a/hosts/claude/skills/memory-sync/memory.py b/hosts/claude/skills/memory-sync/memory.py index 93a6c66..ffa0adf 100644 --- a/hosts/claude/skills/memory-sync/memory.py +++ b/hosts/claude/skills/memory-sync/memory.py @@ -65,10 +65,14 @@ def _bootstrap_people_md(target: Path) -> tuple: Locates the template the same way scaffold-init does — under `plugin_root()/templates/` — and renders its `{{PROJECT_NAME}}` - placeholder before an atomic write. If the template cannot be resolved - (e.g. a scaffold-mode target whose `templates/` dir was not copied in), + placeholder before an atomic write. If the template cannot be resolved, degrades gracefully: returns (False, <manual-create guidance>) and the - caller exits 0.""" + caller exits 0. + + Since slice 095-01 (ADR-0038) both scaffold hosts copy `templates/` beside + the copied machinery, so a scaffold-mode target resolves this too — this is + one of the template-reading family ADR-0038 enumerates. The degrade path now + means a broken install, not an expected mode.""" people = team_signal.people_md_path(target) if people.exists(): return (False, f"people.md already exists at {people} — leaving it untouched.") @@ -78,9 +82,10 @@ def _bootstrap_people_md(target: Path) -> tuple: return ( False, "could not locate the people.md template at " - f"{template} — create docs/memory/people.md manually " - "(this is expected for a scaffold-mode target without a " - "bundled templates/ dir).", + f"{template} — create docs/memory/people.md manually, or " + "refresh this project's copied machinery from a jig install " + "(`migrate.py copy-machinery <project-dir>`), which brings " + "templates/ with it.", ) # Inline `{{PROJECT_NAME}}`-only substitution is byte-identical to # scaffold-init's `copy_template` for the CURRENT single-placeholder diff --git a/hosts/claude/skills/migrate/SKILL.md b/hosts/claude/skills/migrate/SKILL.md index c338999..d3e0db5 100644 --- a/hosts/claude/skills/migrate/SKILL.md +++ b/hosts/claude/skills/migrate/SKILL.md @@ -317,7 +317,8 @@ python3 .../migrate.py report /path/to/existing-project `copy-machinery` brings a migrated project to scaffold-mode parity — the same host-local runtime shape `/jig:scaffold-init` produces by default for greenfield projects. After running it, the project owns its -own copy of jig's skills, agents, hook scripts, and hook registration. +own copy of jig's skills, agents, hook scripts, templates, and hook +registration. The dev can edit those files in their own repo, and they ride along under version control. @@ -361,7 +362,18 @@ What it does: Claude, `.codex/agents/jig-*.toml` for Codex). 3. Copies hook scripts into the host runtime, pinning each script's mode to `0o755`. -4. Generates or merges host hook registration. `--host claude` uses +4. Copies jig's `templates/` tree into the host runtime + (`.claude/templates/` or `.codex/templates/`), rewriting helper paths in + `*.md.template` bodies to that runtime. This is what lets the copied + helpers seed from a template with no plugin root — `decisions.py` + (lightweight-decisions), `adr.py new`, `migrate.py seed-decisions`, + `workflow.py`'s slice-template render, and `memory.py`'s people.md + bootstrap all resolve `parents[2]/templates/`. Claude gained this in + [slice 095-01](../../docs/specs/095-scaffold-template-copy/spec.md); + Codex has always had it. **Re-run `copy-machinery` from a jig install to + refresh a project scaffolded before 095-01** — it is the repair path + `decisions.py` names when a template is unreachable. +5. Generates or merges host hook registration. `--host claude` uses `.claude/settings.json`, with per-entry `metadata.managed_by_jig: true` markers. `--host codex` uses `.codex/hooks.json`, with a schema-clean top-level `hooks` object. diff --git a/hosts/claude/skills/migrate/migrate.py b/hosts/claude/skills/migrate/migrate.py index f7610c8..0978810 100644 --- a/hosts/claude/skills/migrate/migrate.py +++ b/hosts/claude/skills/migrate/migrate.py @@ -104,6 +104,13 @@ def __init__(self, docs_root: str = "docs"): # Slice 021-01 — used by report's operations section to suppress the # `copy-machinery` suggestion when the machinery is already in place. self.jig_skill_dirs: list[Path] = [] + # Slice 095-01 — whether `.claude/templates/` is present beside the + # copied machinery. False in every project scaffolded before 095-01 + # (copy-machinery only started carrying templates then), which strands + # the record helpers' `parents[2]/templates/` fallback. Only actionable + # when `jig_skill_dirs` is non-empty — report routes such a project to + # `copy-machinery` to backfill (ADR-0038 retrofit). + self.jig_templates_present: bool = False # Bug 012 — the lightweight-decisions home, when present. Absent in # every project that adopted jig before the feature landed, because # scaffold-init only seeds it at init; report's Operations section @@ -279,6 +286,12 @@ def scan(project_dir: Path, docs_root: str = "docs") -> Inventory: # decide whether to suggest `copy-machinery`. elif entry.is_dir() and entry.name.startswith("jig-"): inv.jig_skill_dirs.append(entry) + # Slice 095-01 — templates present iff `.claude/templates/` holds at least + # one file (an empty or absent tree is the pre-095 shape). + templates_dir = project_dir / ".claude" / "templates" + inv.jig_templates_present = templates_dir.is_dir() and any( + p.is_file() for p in templates_dir.rglob("*") + ) claude_agents_dir = project_dir / ".claude" / "agents" for entry in _safe_iterdir(claude_agents_dir): if _is_content_md(entry): @@ -658,11 +671,36 @@ def render_operations(inv: Inventory, verdict: str) -> str: and not inv.jig_skill_dirs): items.append( "**`migrate.py copy-machinery <project-dir>`** — copy " - "jig's hooks / agents / skill helpers into the target's " - "`.claude/` (scaffold-mode parity). Mirrors what " + "jig's hooks / agents / skill helpers / templates into the " + "target's `.claude/` (scaffold-mode parity). Mirrors what " "`scaffold-init` does by default for greenfield projects." ) + # Slice 095-01 — templates backfill (ADR-0038 retrofit). A project that + # already has jig machinery but no `.claude/templates/` was scaffolded + # before 095-01, when copy-machinery started carrying templates. Its + # record helpers (`decisions.py`, `adr.py`, `workflow.py`, `memory.py`) + # resolve `parents[2]/templates/`, which lands on `<project>/.claude` — + # so without the tree they cannot seed a record home or open an ADR. + # Distinct from the full-copy branch above (mutually exclusive on + # `jig_skill_dirs`): here the machinery is present and only templates are + # missing. + if (verdict in {"adoptable", "partial"} + and inv.jig_skill_dirs + and not inv.jig_templates_present): + items.append( + "**`migrate.py copy-machinery <project-dir>`** — this project has " + "jig machinery but no `.claude/templates/`, so it was scaffolded " + "before that tree shipped (ADR-0038). The record helpers " + "(`decisions.py`, `adr.py`, `workflow.py`, `memory.py`) resolve " + "their template beside the machinery, so in this state recording a " + "decision or opening an ADR fails. Re-run `copy-machinery` **from a " + "jig install** — a scaffolded project cannot repair itself from its " + "own copied files, since `templates/` is exactly what it is missing " + "to copy from — to backfill the tree. Idempotent; never overwrites " + "your project's own files." + ) + # Bug 012 — seed-decisions. Gated on a jig-shaped verdict because a # not-yet-spec-driven project should be routed to scaffold-init (which # seeds the home itself), not handed a backfill op for a tree it lacks. @@ -1164,16 +1202,19 @@ def seed_decisions(project_dir: Path, docs_root: str = "docs", template = _resolve_plugin_root() / LIGHTWEIGHT_TEMPLATE_RELATIVE if not template.is_file(): - # Symmetry with decisions.py's `seed_lightweight`: a copied - # `.claude/skills/jig-migrate/migrate.py` has no templates/ tree - # beside it, and a bare "not found" would strand exactly the user - # decisions.py just redirected here. + # Symmetry with decisions.py's `seed_lightweight` (slice 095-01): both + # scaffold hosts now copy templates/ beside the copied machinery, so + # this means a broken install rather than an expected mode — and a bare + # "not found" would strand exactly the user decisions.py redirected + # here. raise MigrateError( f"lightweight-decisions template not found: {template}\n" - "jig ships it at templates/docs/decisions/. This usually means " - "migrate.py is running from copied machinery (scaffold mode), " - "which has no templates/ tree. Point CLAUDE_PLUGIN_ROOT at a jig " - "plugin/checkout root and re-run.") + "jig ships it at templates/docs/decisions/. A scaffolded project " + "carries its own copy beside its copied machinery; a plugin " + "install reads it from the plugin root. Reaching this means " + "neither is in place: refresh the copied machinery from a jig " + "install (`migrate.py copy-machinery <project-dir>`), or point " + "CLAUDE_PLUGIN_ROOT at a jig plugin/checkout root and re-run.") if target.is_file(): if LIGHTWEIGHT_ENTRIES_HEADING in _safe_read_text(target): @@ -2036,8 +2077,9 @@ def _build_parser() -> argparse.ArgumentParser: cm = sub.add_parser( "copy-machinery", - help="copy jig's skills, agents, hooks, and hook configuration " - "into the target's host-local runtime (scaffold-mode parity)", + help="copy jig's skills, agents, hooks, templates, and hook " + "configuration into the target's host-local runtime " + "(scaffold-mode parity)", ) cm.add_argument("project_dir", help="path to the project to receive jig's machinery") diff --git a/hosts/claude/skills/scaffold-init/scaffold.py b/hosts/claude/skills/scaffold-init/scaffold.py index dafa5bb..25ac361 100644 --- a/hosts/claude/skills/scaffold-init/scaffold.py +++ b/hosts/claude/skills/scaffold-init/scaffold.py @@ -782,8 +782,11 @@ def _rewrite_skill_md_paths(body: str) -> str: """Replace every `${CLAUDE_PLUGIN_ROOT}/skills/<name>/` with `${CLAUDE_PROJECT_DIR}/.claude/skills/jig-<name>/`. - Operates on the SKILL.md body only — the frontmatter must be carved off - by the caller before calling here (AC #5). + For a SKILL.md the caller must carve the frontmatter off first (AC #5) — + the substitution must not touch it. Callers passing text that has no + frontmatter to protect (rendered docs at `scaffold()`; `.md.template` + bodies at `_copy_claude_templates`) pass the whole text, which is why this + is a precondition on SKILL.md callers rather than on the function. String substitution, not AST: SKILL.md is markdown + bash, no parsing needed. The Known-constraint #1 fallback (substitute absolute paths if @@ -796,6 +799,52 @@ def _rewrite_skill_md_paths(body: str) -> str: ) +def _copy_claude_templates(plugin: Path, target: Path) -> None: + """Copy `plugin/templates/` → `target/.claude/templates/` (slice 095-01). + + The Claude mirror of `_copy_codex_templates`. Record helpers resolve their + template as `CLAUDE_PLUGIN_ROOT`, else `Path(__file__).parents[2] / + "templates"` (`decisions.py`, `adr.py` — same `_plugin_root`). For a helper + copied to `.claude/skills/jig-<name>/<helper>.py` that fallback lands on + `<project>/.claude`, so without this copy scaffold mode — the one install + mode with no plugin root to fall back on — cannot seed the + lightweight-decisions home or open an ADR. Copying the tree makes the + existing fallback resolve, with no change to any helper's template + *resolution* (ADR-0038; the option the maintainer picked over embedding + templates in each helper). + + `.md.template` bodies get the same `${CLAUDE_PLUGIN_ROOT}/skills/<name>/` + rewrite SKILL.md bodies and rendered docs already get in scaffold mode — a + record seeded from a copied template must not teach a command naming a + variable that is unset in the project carrying it. Everything else is + byte-copied. Both splits mirror `_copy_codex_templates`. + + Idempotent (overwrite in place), so a `migrate copy-machinery` refresh + leaves the tree byte-identical.""" + templates_src = plugin / "templates" + templates_dst = target / ".claude" / "templates" + if not templates_src.is_dir(): + return + for entry in sorted(templates_src.rglob("*")): + if entry.is_dir(): + continue + rel = entry.relative_to(templates_src) + dst = templates_dst / rel + dst.parent.mkdir(parents=True, exist_ok=True) + if entry.name.endswith(".md.template"): + # Explicit utf-8 both ways: `atomic_write_text` writes utf-8, and + # the templates carry non-ASCII (em-dashes). Reading at the + # locale's default encoding would mojibake — or raise — under + # LANG=C, and the corruption would then propagate into every record + # seeded from the copy. (`_copy_codex_templates` reads at default + # encoding; `decisions.py` is the one that gets this right.) + atomic_write_text( + dst, _rewrite_skill_md_paths(entry.read_text(encoding="utf-8")), + ) + else: + dst.write_bytes(entry.read_bytes()) + + def _split_frontmatter(text: str) -> tuple[str, str]: """Split a SKILL.md into (frontmatter_with_fences, body). If the file has no YAML frontmatter, returns ('', text).""" @@ -1139,7 +1188,14 @@ def finalize_codex_migrate_skill(body: str) -> str: "3. Copies Codex agents into `.codex/agents/jig-*.toml`.\n" "4. Copies hook scripts into `.codex/hooks/scripts/`, pinning " "each script's mode to `0o755`.\n" - "5. Generates or merges Codex hook registration in " + "5. Copies jig's `templates/` tree into `.codex/templates/`, " + "rewriting `*.md.template` bodies to that runtime. This is what " + "lets the copied helpers seed from a template with no plugin " + "root — `decisions.py` (lightweight-decisions), `adr.py new`, " + "`migrate.py seed-decisions`, `workflow.py`'s slice-template " + "render, and `memory.py`'s people.md bootstrap all resolve " + "`parents[2]/templates/`.\n" + "6. Generates or merges Codex hook registration in " "`.codex/hooks.json` using Codex's native top-level `hooks` " "schema.\n\n" "Subsequent runs are idempotent: re-running `copy-machinery` " @@ -1916,9 +1972,12 @@ def copy_machinery(plugin: Path, target: Path, *, check (inbox 2026-05-15): ensures we refuse BEFORE any filesystem mutation when settings.json is unmanaged. Closes the partial- state-on-refuse gap noted in spec 016-03 deviation §7. - 2. `_copy_skills_and_agents(plugin, target, installed_tiers)` — + 2. `_copy_claude_templates(plugin, target)` — slice 095-01; the + Claude mirror of `_copy_codex_templates`, so the copied record + helpers' `parents[2]/templates/` fallback resolves. + 3. `_copy_skills_and_agents(plugin, target, installed_tiers)` — slice 016-01; tier-gated since slice 038-02. - 3. `_copy_hooks_and_register(plugin, target, force=force)` — slice + 4. `_copy_hooks_and_register(plugin, target, force=force)` — slice 016-02; the safety check inside this call is now redundant but kept so the function still works correctly when called directly. @@ -1959,6 +2018,13 @@ def copy_machinery(plugin: Path, target: Path, *, if host != "claude": raise ValueError(f"unsupported scaffold host: {host}") _check_hooks_safety(target, force=force) + # Slice 095-01: templates ride the machinery copy (Codex parity), so a + # scaffold-mode project can seed a record home / open an ADR with no + # CLAUDE_PLUGIN_ROOT. Ungated infra — never tier-scoped: the record + # helpers span tiers (memory-sync is Tier 0, adr-workflow Tier 1) and a + # partial template tree would just be the same gap with a smaller blast + # radius. Runs AFTER the safety check so a refusal still writes nothing. + _copy_claude_templates(plugin, target) _copy_skills_and_agents(plugin, target, installed_tiers) _copy_hooks_and_register(plugin, target, force=force) _write_gitignore_secret_block(target) diff --git a/hosts/claude/skills/spec-workflow/workflow.py b/hosts/claude/skills/spec-workflow/workflow.py index c85a7c4..34a3bfe 100644 --- a/hosts/claude/skills/spec-workflow/workflow.py +++ b/hosts/claude/skills/spec-workflow/workflow.py @@ -2942,18 +2942,30 @@ def _render_stub_slice(num_str: str, slice_num: str = "01", and `{{NAME}}` → `name`. Returns the rendered text. Falls back to an inline minimal template when the file template - isn't reachable (e.g. running the helper outside the jig tree).""" + isn't reachable (e.g. running the helper outside the jig tree). + + Since slice 095-01 both scaffold hosts copy `templates/` beside the copied + machinery, so `parents[2]` reaches the real template in a scaffolded + project too — this helper is one of the template-reading family ADR-0038 + enumerates, and it silently stopped needing the fallback there.""" template_path = (Path(__file__).resolve().parents[2] / "templates" / "docs" / "specs" / "slice-template.md") fragment = f"{num_str}-{slice_num}" try: - body = template_path.read_text() - except OSError: + # Explicit utf-8: slice-template.md carries em-dashes, ✅ and →. Slice + # 095-01 makes this path reach a real file in scaffold mode for the + # first time, so a default-encoding read would newly raise + # UnicodeDecodeError under LANG=C — a ValueError, which would escape + # the OSError fallback below and crash where the old behaviour cleanly + # degraded. + body = template_path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): # Inline fallback — keeps the helper functional even when the # template file isn't on disk (e.g. minimal scaffold smoke tests, or a - # scaffolded project where the template is not distributed). Carries the - # spec 065-04 self-defining-vocabulary reminder so the author meets it - # here too, in parity with the on-disk slice-template.md. + # project scaffolded before slice 095-01, which is when copied + # machinery started carrying `templates/`). Carries the spec 065-04 + # self-defining-vocabulary reminder so the author meets it here too, in + # parity with the on-disk slice-template.md. body = ( "---\nstatus: DRAFT\ndependencies: []\nlast_verified:\n---\n" "\n<!-- jig self-defining vocabulary (soft, forward-only): expand " diff --git a/hosts/codex/plugins/jig/skills/memory-sync/decisions.py b/hosts/codex/plugins/jig/skills/memory-sync/decisions.py index 551f408..c4ed644 100644 --- a/hosts/codex/plugins/jig/skills/memory-sync/decisions.py +++ b/hosts/codex/plugins/jig/skills/memory-sync/decisions.py @@ -62,8 +62,10 @@ # the plugin-root paths inside `*.md.template` (build_codex_plugin.py # `_copy_templates`), so the text read here is already host-correct. A data # read, not an import — the module docstring's no-cross-tree-import rule holds. -# NOT reachable in Claude scaffold mode, which copies skills/ but not -# templates/ — see `seed_lightweight` and bug 012's Remaining risk. +# Reachable in all four install modes since slice 095-01 (ADR-0038): both +# scaffold hosts copy `templates/` beside the copied machinery, so `parents[2]` +# finds it there too. Do not "clean up" `.claude/templates/` — this read is what +# it is for. _TEMPLATE_RELATIVE = ( Path("templates") / "docs" / "decisions" / "lightweight-decisions.md.template" @@ -161,20 +163,26 @@ def seed_lightweight(project_dir: Path) -> bool: return False template = _template_path() if not template.is_file(): - # Reachable in Claude scaffold mode: `copy-machinery` copies skills/ - # and hooks/ but not templates/ (only Codex copies templates — see - # scaffold.py `_copy_codex_templates`), so a copied helper's - # parents[2] lands on `<project>/.claude`, which has no templates/. - # Not fixed here (see bug 012's Remaining risk); but it must name a - # remedy that works, or it is the original bug wearing a new costume. + # No install mode reaches here by design any more: plugin installs + # resolve the template under the plugin root, and both scaffold hosts + # copy templates/ beside the copied machinery (slice 095-01 for + # Claude, `_copy_codex_templates` for Codex — see ADR-0038). So this + # now means a BROKEN install: a copy that predates 095-01, a partial + # tree, or CLAUDE_PLUGIN_ROOT pointed at a non-jig root. It still must + # name a remedy that works, or it is the original bug (012) wearing a + # new costume. raise FileNotFoundError( "lightweight-decisions template not found: %s\n\n" - "jig ships it at templates/docs/decisions/. This usually means " - "the helper is running from copied machinery (scaffold mode), " - "which has no templates/ tree. Either:\n" - " 1. point CLAUDE_PLUGIN_ROOT at a jig plugin/checkout root and " + "jig ships it at templates/docs/decisions/. A scaffolded project " + "carries its own copy beside its copied machinery; a plugin " + "install reads it from the plugin root. Reaching this means " + "neither is in place. Either:\n" + " 1. refresh this project's copied machinery from a jig install " + "(`migrate.py copy-machinery <project-dir>`), which brings " + "templates/ with it; or\n" + " 2. point CLAUDE_PLUGIN_ROOT at a jig plugin/checkout root and " "re-run this command; or\n" - " 2. seed the file from a jig install with `/jig:migrate`'s " + " 3. seed just this file from a jig install with `/jig:migrate`'s " "seed-decisions op (`migrate.py seed-decisions <project-dir>`)." % template) path.parent.mkdir(parents=True, exist_ok=True) diff --git a/hosts/codex/plugins/jig/skills/memory-sync/memory.py b/hosts/codex/plugins/jig/skills/memory-sync/memory.py index 93a6c66..ffa0adf 100644 --- a/hosts/codex/plugins/jig/skills/memory-sync/memory.py +++ b/hosts/codex/plugins/jig/skills/memory-sync/memory.py @@ -65,10 +65,14 @@ def _bootstrap_people_md(target: Path) -> tuple: Locates the template the same way scaffold-init does — under `plugin_root()/templates/` — and renders its `{{PROJECT_NAME}}` - placeholder before an atomic write. If the template cannot be resolved - (e.g. a scaffold-mode target whose `templates/` dir was not copied in), + placeholder before an atomic write. If the template cannot be resolved, degrades gracefully: returns (False, <manual-create guidance>) and the - caller exits 0.""" + caller exits 0. + + Since slice 095-01 (ADR-0038) both scaffold hosts copy `templates/` beside + the copied machinery, so a scaffold-mode target resolves this too — this is + one of the template-reading family ADR-0038 enumerates. The degrade path now + means a broken install, not an expected mode.""" people = team_signal.people_md_path(target) if people.exists(): return (False, f"people.md already exists at {people} — leaving it untouched.") @@ -78,9 +82,10 @@ def _bootstrap_people_md(target: Path) -> tuple: return ( False, "could not locate the people.md template at " - f"{template} — create docs/memory/people.md manually " - "(this is expected for a scaffold-mode target without a " - "bundled templates/ dir).", + f"{template} — create docs/memory/people.md manually, or " + "refresh this project's copied machinery from a jig install " + "(`migrate.py copy-machinery <project-dir>`), which brings " + "templates/ with it.", ) # Inline `{{PROJECT_NAME}}`-only substitution is byte-identical to # scaffold-init's `copy_template` for the CURRENT single-placeholder diff --git a/hosts/codex/plugins/jig/skills/migrate/SKILL.md b/hosts/codex/plugins/jig/skills/migrate/SKILL.md index f889e29..c6502e0 100644 --- a/hosts/codex/plugins/jig/skills/migrate/SKILL.md +++ b/hosts/codex/plugins/jig/skills/migrate/SKILL.md @@ -310,7 +310,8 @@ python3 .../migrate.py report /path/to/existing-project `copy-machinery` brings a migrated project to scaffold-mode parity — the same host-local runtime shape `/jig:scaffold-init` produces by default for greenfield projects. After running it, the project owns its -own copy of jig's skills, agents, hook scripts, and hook registration. +own copy of jig's skills, agents, hook scripts, templates, and hook +registration. The dev can edit those files in their own repo, and they ride along under version control. @@ -348,7 +349,8 @@ What it does: 2. Copies non-discoverable helper aliases under `.codex/skills/<name>/` so peer helper imports continue to resolve without duplicate discoverable skills. 3. Copies Codex agents into `.codex/agents/jig-*.toml`. 4. Copies hook scripts into `.codex/hooks/scripts/`, pinning each script's mode to `0o755`. -5. Generates or merges Codex hook registration in `.codex/hooks.json` using Codex's native top-level `hooks` schema. +5. Copies jig's `templates/` tree into `.codex/templates/`, rewriting `*.md.template` bodies to that runtime. This is what lets the copied helpers seed from a template with no plugin root — `decisions.py` (lightweight-decisions), `adr.py new`, `migrate.py seed-decisions`, `workflow.py`'s slice-template render, and `memory.py`'s people.md bootstrap all resolve `parents[2]/templates/`. +6. Generates or merges Codex hook registration in `.codex/hooks.json` using Codex's native top-level `hooks` schema. Subsequent runs are idempotent: re-running `copy-machinery` overwrites copied runtime files in place and regenerates jig-managed `.codex/hooks.json` as a whole. diff --git a/hosts/codex/plugins/jig/skills/migrate/migrate.py b/hosts/codex/plugins/jig/skills/migrate/migrate.py index f7610c8..0978810 100644 --- a/hosts/codex/plugins/jig/skills/migrate/migrate.py +++ b/hosts/codex/plugins/jig/skills/migrate/migrate.py @@ -104,6 +104,13 @@ def __init__(self, docs_root: str = "docs"): # Slice 021-01 — used by report's operations section to suppress the # `copy-machinery` suggestion when the machinery is already in place. self.jig_skill_dirs: list[Path] = [] + # Slice 095-01 — whether `.claude/templates/` is present beside the + # copied machinery. False in every project scaffolded before 095-01 + # (copy-machinery only started carrying templates then), which strands + # the record helpers' `parents[2]/templates/` fallback. Only actionable + # when `jig_skill_dirs` is non-empty — report routes such a project to + # `copy-machinery` to backfill (ADR-0038 retrofit). + self.jig_templates_present: bool = False # Bug 012 — the lightweight-decisions home, when present. Absent in # every project that adopted jig before the feature landed, because # scaffold-init only seeds it at init; report's Operations section @@ -279,6 +286,12 @@ def scan(project_dir: Path, docs_root: str = "docs") -> Inventory: # decide whether to suggest `copy-machinery`. elif entry.is_dir() and entry.name.startswith("jig-"): inv.jig_skill_dirs.append(entry) + # Slice 095-01 — templates present iff `.claude/templates/` holds at least + # one file (an empty or absent tree is the pre-095 shape). + templates_dir = project_dir / ".claude" / "templates" + inv.jig_templates_present = templates_dir.is_dir() and any( + p.is_file() for p in templates_dir.rglob("*") + ) claude_agents_dir = project_dir / ".claude" / "agents" for entry in _safe_iterdir(claude_agents_dir): if _is_content_md(entry): @@ -658,11 +671,36 @@ def render_operations(inv: Inventory, verdict: str) -> str: and not inv.jig_skill_dirs): items.append( "**`migrate.py copy-machinery <project-dir>`** — copy " - "jig's hooks / agents / skill helpers into the target's " - "`.claude/` (scaffold-mode parity). Mirrors what " + "jig's hooks / agents / skill helpers / templates into the " + "target's `.claude/` (scaffold-mode parity). Mirrors what " "`scaffold-init` does by default for greenfield projects." ) + # Slice 095-01 — templates backfill (ADR-0038 retrofit). A project that + # already has jig machinery but no `.claude/templates/` was scaffolded + # before 095-01, when copy-machinery started carrying templates. Its + # record helpers (`decisions.py`, `adr.py`, `workflow.py`, `memory.py`) + # resolve `parents[2]/templates/`, which lands on `<project>/.claude` — + # so without the tree they cannot seed a record home or open an ADR. + # Distinct from the full-copy branch above (mutually exclusive on + # `jig_skill_dirs`): here the machinery is present and only templates are + # missing. + if (verdict in {"adoptable", "partial"} + and inv.jig_skill_dirs + and not inv.jig_templates_present): + items.append( + "**`migrate.py copy-machinery <project-dir>`** — this project has " + "jig machinery but no `.claude/templates/`, so it was scaffolded " + "before that tree shipped (ADR-0038). The record helpers " + "(`decisions.py`, `adr.py`, `workflow.py`, `memory.py`) resolve " + "their template beside the machinery, so in this state recording a " + "decision or opening an ADR fails. Re-run `copy-machinery` **from a " + "jig install** — a scaffolded project cannot repair itself from its " + "own copied files, since `templates/` is exactly what it is missing " + "to copy from — to backfill the tree. Idempotent; never overwrites " + "your project's own files." + ) + # Bug 012 — seed-decisions. Gated on a jig-shaped verdict because a # not-yet-spec-driven project should be routed to scaffold-init (which # seeds the home itself), not handed a backfill op for a tree it lacks. @@ -1164,16 +1202,19 @@ def seed_decisions(project_dir: Path, docs_root: str = "docs", template = _resolve_plugin_root() / LIGHTWEIGHT_TEMPLATE_RELATIVE if not template.is_file(): - # Symmetry with decisions.py's `seed_lightweight`: a copied - # `.claude/skills/jig-migrate/migrate.py` has no templates/ tree - # beside it, and a bare "not found" would strand exactly the user - # decisions.py just redirected here. + # Symmetry with decisions.py's `seed_lightweight` (slice 095-01): both + # scaffold hosts now copy templates/ beside the copied machinery, so + # this means a broken install rather than an expected mode — and a bare + # "not found" would strand exactly the user decisions.py redirected + # here. raise MigrateError( f"lightweight-decisions template not found: {template}\n" - "jig ships it at templates/docs/decisions/. This usually means " - "migrate.py is running from copied machinery (scaffold mode), " - "which has no templates/ tree. Point CLAUDE_PLUGIN_ROOT at a jig " - "plugin/checkout root and re-run.") + "jig ships it at templates/docs/decisions/. A scaffolded project " + "carries its own copy beside its copied machinery; a plugin " + "install reads it from the plugin root. Reaching this means " + "neither is in place: refresh the copied machinery from a jig " + "install (`migrate.py copy-machinery <project-dir>`), or point " + "CLAUDE_PLUGIN_ROOT at a jig plugin/checkout root and re-run.") if target.is_file(): if LIGHTWEIGHT_ENTRIES_HEADING in _safe_read_text(target): @@ -2036,8 +2077,9 @@ def _build_parser() -> argparse.ArgumentParser: cm = sub.add_parser( "copy-machinery", - help="copy jig's skills, agents, hooks, and hook configuration " - "into the target's host-local runtime (scaffold-mode parity)", + help="copy jig's skills, agents, hooks, templates, and hook " + "configuration into the target's host-local runtime " + "(scaffold-mode parity)", ) cm.add_argument("project_dir", help="path to the project to receive jig's machinery") diff --git a/hosts/codex/plugins/jig/skills/scaffold-init/scaffold.py b/hosts/codex/plugins/jig/skills/scaffold-init/scaffold.py index dafa5bb..25ac361 100644 --- a/hosts/codex/plugins/jig/skills/scaffold-init/scaffold.py +++ b/hosts/codex/plugins/jig/skills/scaffold-init/scaffold.py @@ -782,8 +782,11 @@ def _rewrite_skill_md_paths(body: str) -> str: """Replace every `${CLAUDE_PLUGIN_ROOT}/skills/<name>/` with `${CLAUDE_PROJECT_DIR}/.claude/skills/jig-<name>/`. - Operates on the SKILL.md body only — the frontmatter must be carved off - by the caller before calling here (AC #5). + For a SKILL.md the caller must carve the frontmatter off first (AC #5) — + the substitution must not touch it. Callers passing text that has no + frontmatter to protect (rendered docs at `scaffold()`; `.md.template` + bodies at `_copy_claude_templates`) pass the whole text, which is why this + is a precondition on SKILL.md callers rather than on the function. String substitution, not AST: SKILL.md is markdown + bash, no parsing needed. The Known-constraint #1 fallback (substitute absolute paths if @@ -796,6 +799,52 @@ def _rewrite_skill_md_paths(body: str) -> str: ) +def _copy_claude_templates(plugin: Path, target: Path) -> None: + """Copy `plugin/templates/` → `target/.claude/templates/` (slice 095-01). + + The Claude mirror of `_copy_codex_templates`. Record helpers resolve their + template as `CLAUDE_PLUGIN_ROOT`, else `Path(__file__).parents[2] / + "templates"` (`decisions.py`, `adr.py` — same `_plugin_root`). For a helper + copied to `.claude/skills/jig-<name>/<helper>.py` that fallback lands on + `<project>/.claude`, so without this copy scaffold mode — the one install + mode with no plugin root to fall back on — cannot seed the + lightweight-decisions home or open an ADR. Copying the tree makes the + existing fallback resolve, with no change to any helper's template + *resolution* (ADR-0038; the option the maintainer picked over embedding + templates in each helper). + + `.md.template` bodies get the same `${CLAUDE_PLUGIN_ROOT}/skills/<name>/` + rewrite SKILL.md bodies and rendered docs already get in scaffold mode — a + record seeded from a copied template must not teach a command naming a + variable that is unset in the project carrying it. Everything else is + byte-copied. Both splits mirror `_copy_codex_templates`. + + Idempotent (overwrite in place), so a `migrate copy-machinery` refresh + leaves the tree byte-identical.""" + templates_src = plugin / "templates" + templates_dst = target / ".claude" / "templates" + if not templates_src.is_dir(): + return + for entry in sorted(templates_src.rglob("*")): + if entry.is_dir(): + continue + rel = entry.relative_to(templates_src) + dst = templates_dst / rel + dst.parent.mkdir(parents=True, exist_ok=True) + if entry.name.endswith(".md.template"): + # Explicit utf-8 both ways: `atomic_write_text` writes utf-8, and + # the templates carry non-ASCII (em-dashes). Reading at the + # locale's default encoding would mojibake — or raise — under + # LANG=C, and the corruption would then propagate into every record + # seeded from the copy. (`_copy_codex_templates` reads at default + # encoding; `decisions.py` is the one that gets this right.) + atomic_write_text( + dst, _rewrite_skill_md_paths(entry.read_text(encoding="utf-8")), + ) + else: + dst.write_bytes(entry.read_bytes()) + + def _split_frontmatter(text: str) -> tuple[str, str]: """Split a SKILL.md into (frontmatter_with_fences, body). If the file has no YAML frontmatter, returns ('', text).""" @@ -1139,7 +1188,14 @@ def finalize_codex_migrate_skill(body: str) -> str: "3. Copies Codex agents into `.codex/agents/jig-*.toml`.\n" "4. Copies hook scripts into `.codex/hooks/scripts/`, pinning " "each script's mode to `0o755`.\n" - "5. Generates or merges Codex hook registration in " + "5. Copies jig's `templates/` tree into `.codex/templates/`, " + "rewriting `*.md.template` bodies to that runtime. This is what " + "lets the copied helpers seed from a template with no plugin " + "root — `decisions.py` (lightweight-decisions), `adr.py new`, " + "`migrate.py seed-decisions`, `workflow.py`'s slice-template " + "render, and `memory.py`'s people.md bootstrap all resolve " + "`parents[2]/templates/`.\n" + "6. Generates or merges Codex hook registration in " "`.codex/hooks.json` using Codex's native top-level `hooks` " "schema.\n\n" "Subsequent runs are idempotent: re-running `copy-machinery` " @@ -1916,9 +1972,12 @@ def copy_machinery(plugin: Path, target: Path, *, check (inbox 2026-05-15): ensures we refuse BEFORE any filesystem mutation when settings.json is unmanaged. Closes the partial- state-on-refuse gap noted in spec 016-03 deviation §7. - 2. `_copy_skills_and_agents(plugin, target, installed_tiers)` — + 2. `_copy_claude_templates(plugin, target)` — slice 095-01; the + Claude mirror of `_copy_codex_templates`, so the copied record + helpers' `parents[2]/templates/` fallback resolves. + 3. `_copy_skills_and_agents(plugin, target, installed_tiers)` — slice 016-01; tier-gated since slice 038-02. - 3. `_copy_hooks_and_register(plugin, target, force=force)` — slice + 4. `_copy_hooks_and_register(plugin, target, force=force)` — slice 016-02; the safety check inside this call is now redundant but kept so the function still works correctly when called directly. @@ -1959,6 +2018,13 @@ def copy_machinery(plugin: Path, target: Path, *, if host != "claude": raise ValueError(f"unsupported scaffold host: {host}") _check_hooks_safety(target, force=force) + # Slice 095-01: templates ride the machinery copy (Codex parity), so a + # scaffold-mode project can seed a record home / open an ADR with no + # CLAUDE_PLUGIN_ROOT. Ungated infra — never tier-scoped: the record + # helpers span tiers (memory-sync is Tier 0, adr-workflow Tier 1) and a + # partial template tree would just be the same gap with a smaller blast + # radius. Runs AFTER the safety check so a refusal still writes nothing. + _copy_claude_templates(plugin, target) _copy_skills_and_agents(plugin, target, installed_tiers) _copy_hooks_and_register(plugin, target, force=force) _write_gitignore_secret_block(target) diff --git a/hosts/codex/plugins/jig/skills/spec-workflow/workflow.py b/hosts/codex/plugins/jig/skills/spec-workflow/workflow.py index c85a7c4..34a3bfe 100644 --- a/hosts/codex/plugins/jig/skills/spec-workflow/workflow.py +++ b/hosts/codex/plugins/jig/skills/spec-workflow/workflow.py @@ -2942,18 +2942,30 @@ def _render_stub_slice(num_str: str, slice_num: str = "01", and `{{NAME}}` → `name`. Returns the rendered text. Falls back to an inline minimal template when the file template - isn't reachable (e.g. running the helper outside the jig tree).""" + isn't reachable (e.g. running the helper outside the jig tree). + + Since slice 095-01 both scaffold hosts copy `templates/` beside the copied + machinery, so `parents[2]` reaches the real template in a scaffolded + project too — this helper is one of the template-reading family ADR-0038 + enumerates, and it silently stopped needing the fallback there.""" template_path = (Path(__file__).resolve().parents[2] / "templates" / "docs" / "specs" / "slice-template.md") fragment = f"{num_str}-{slice_num}" try: - body = template_path.read_text() - except OSError: + # Explicit utf-8: slice-template.md carries em-dashes, ✅ and →. Slice + # 095-01 makes this path reach a real file in scaffold mode for the + # first time, so a default-encoding read would newly raise + # UnicodeDecodeError under LANG=C — a ValueError, which would escape + # the OSError fallback below and crash where the old behaviour cleanly + # degraded. + body = template_path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): # Inline fallback — keeps the helper functional even when the # template file isn't on disk (e.g. minimal scaffold smoke tests, or a - # scaffolded project where the template is not distributed). Carries the - # spec 065-04 self-defining-vocabulary reminder so the author meets it - # here too, in parity with the on-disk slice-template.md. + # project scaffolded before slice 095-01, which is when copied + # machinery started carrying `templates/`). Carries the spec 065-04 + # self-defining-vocabulary reminder so the author meets it here too, in + # parity with the on-disk slice-template.md. body = ( "---\nstatus: DRAFT\ndependencies: []\nlast_verified:\n---\n" "\n<!-- jig self-defining vocabulary (soft, forward-only): expand " diff --git a/skills/memory-sync/decisions.py b/skills/memory-sync/decisions.py index 551f408..c4ed644 100644 --- a/skills/memory-sync/decisions.py +++ b/skills/memory-sync/decisions.py @@ -62,8 +62,10 @@ # the plugin-root paths inside `*.md.template` (build_codex_plugin.py # `_copy_templates`), so the text read here is already host-correct. A data # read, not an import — the module docstring's no-cross-tree-import rule holds. -# NOT reachable in Claude scaffold mode, which copies skills/ but not -# templates/ — see `seed_lightweight` and bug 012's Remaining risk. +# Reachable in all four install modes since slice 095-01 (ADR-0038): both +# scaffold hosts copy `templates/` beside the copied machinery, so `parents[2]` +# finds it there too. Do not "clean up" `.claude/templates/` — this read is what +# it is for. _TEMPLATE_RELATIVE = ( Path("templates") / "docs" / "decisions" / "lightweight-decisions.md.template" @@ -161,20 +163,26 @@ def seed_lightweight(project_dir: Path) -> bool: return False template = _template_path() if not template.is_file(): - # Reachable in Claude scaffold mode: `copy-machinery` copies skills/ - # and hooks/ but not templates/ (only Codex copies templates — see - # scaffold.py `_copy_codex_templates`), so a copied helper's - # parents[2] lands on `<project>/.claude`, which has no templates/. - # Not fixed here (see bug 012's Remaining risk); but it must name a - # remedy that works, or it is the original bug wearing a new costume. + # No install mode reaches here by design any more: plugin installs + # resolve the template under the plugin root, and both scaffold hosts + # copy templates/ beside the copied machinery (slice 095-01 for + # Claude, `_copy_codex_templates` for Codex — see ADR-0038). So this + # now means a BROKEN install: a copy that predates 095-01, a partial + # tree, or CLAUDE_PLUGIN_ROOT pointed at a non-jig root. It still must + # name a remedy that works, or it is the original bug (012) wearing a + # new costume. raise FileNotFoundError( "lightweight-decisions template not found: %s\n\n" - "jig ships it at templates/docs/decisions/. This usually means " - "the helper is running from copied machinery (scaffold mode), " - "which has no templates/ tree. Either:\n" - " 1. point CLAUDE_PLUGIN_ROOT at a jig plugin/checkout root and " + "jig ships it at templates/docs/decisions/. A scaffolded project " + "carries its own copy beside its copied machinery; a plugin " + "install reads it from the plugin root. Reaching this means " + "neither is in place. Either:\n" + " 1. refresh this project's copied machinery from a jig install " + "(`migrate.py copy-machinery <project-dir>`), which brings " + "templates/ with it; or\n" + " 2. point CLAUDE_PLUGIN_ROOT at a jig plugin/checkout root and " "re-run this command; or\n" - " 2. seed the file from a jig install with `/jig:migrate`'s " + " 3. seed just this file from a jig install with `/jig:migrate`'s " "seed-decisions op (`migrate.py seed-decisions <project-dir>`)." % template) path.parent.mkdir(parents=True, exist_ok=True) diff --git a/skills/memory-sync/memory.py b/skills/memory-sync/memory.py index 93a6c66..ffa0adf 100644 --- a/skills/memory-sync/memory.py +++ b/skills/memory-sync/memory.py @@ -65,10 +65,14 @@ def _bootstrap_people_md(target: Path) -> tuple: Locates the template the same way scaffold-init does — under `plugin_root()/templates/` — and renders its `{{PROJECT_NAME}}` - placeholder before an atomic write. If the template cannot be resolved - (e.g. a scaffold-mode target whose `templates/` dir was not copied in), + placeholder before an atomic write. If the template cannot be resolved, degrades gracefully: returns (False, <manual-create guidance>) and the - caller exits 0.""" + caller exits 0. + + Since slice 095-01 (ADR-0038) both scaffold hosts copy `templates/` beside + the copied machinery, so a scaffold-mode target resolves this too — this is + one of the template-reading family ADR-0038 enumerates. The degrade path now + means a broken install, not an expected mode.""" people = team_signal.people_md_path(target) if people.exists(): return (False, f"people.md already exists at {people} — leaving it untouched.") @@ -78,9 +82,10 @@ def _bootstrap_people_md(target: Path) -> tuple: return ( False, "could not locate the people.md template at " - f"{template} — create docs/memory/people.md manually " - "(this is expected for a scaffold-mode target without a " - "bundled templates/ dir).", + f"{template} — create docs/memory/people.md manually, or " + "refresh this project's copied machinery from a jig install " + "(`migrate.py copy-machinery <project-dir>`), which brings " + "templates/ with it.", ) # Inline `{{PROJECT_NAME}}`-only substitution is byte-identical to # scaffold-init's `copy_template` for the CURRENT single-placeholder diff --git a/skills/memory-sync/test_decisions.py b/skills/memory-sync/test_decisions.py index cba1026..d8fb858 100644 --- a/skills/memory-sync/test_decisions.py +++ b/skills/memory-sync/test_decisions.py @@ -297,20 +297,30 @@ def test_foreign_format_fails_loud_even_when_title_looks_recorded(self): class UnreachableTemplateTests(unittest.TestCase): - """Bug 012, review follow-up — Claude scaffold mode copies `skills/` but - NOT `templates/` (only Codex has `_copy_codex_templates`), so a copied - helper resolves `parents[2]` to `<project>/.claude`, where no template - exists. That mode is NOT fixed by this bug (see the record's Remaining - risk), but it must fail with a remedy that actually works rather than a - bare 'not found' — otherwise it is the original bug in a new costume. + """Bug 012, review follow-up — re-premised by slice 095-01. + + Originally this pinned the *expected* Claude scaffold-mode failure: that + mode copied `skills/` but not `templates/` (only Codex had + `_copy_codex_templates`), so a copied helper resolved `parents[2]` to + `<project>/.claude`, where no template existed. Bug 012 could not fix that + mode and settled for failing with remedies that work. + + Slice 095-01 closed it — `copy_machinery` now copies `templates/` too + (ADR-0038), and `ClaudeScaffoldTemplatesTests` in + `skills/scaffold-init/test_scaffold_mode.py` pins the copied helper + seeding for real. So no install mode is *expected* to land here any more, + and these tests now guard the **broken-install** path: a copy predating + 095-01, a partial tree, or `CLAUDE_PLUGIN_ROOT` aimed at a non-jig root. + The invariant is unchanged and still the point — a bare 'not found' would + be the original bug in a new costume. """ def setUp(self): self._tmp = tempfile.TemporaryDirectory() self.project = Path(self._tmp.name) self._saved = os.environ.get("CLAUDE_PLUGIN_ROOT") - # Point the plugin root at a dir with no templates/ tree — exactly - # what a copied `.claude/skills/jig-memory-sync/` helper sees. + # Point the plugin root at a dir with no templates/ tree — an install + # whose template home is missing, however it got that way. os.environ["CLAUDE_PLUGIN_ROOT"] = str(self.project) def tearDown(self): @@ -327,6 +337,10 @@ def test_unreachable_template_names_working_remedies(self): with self.assertRaises(FileNotFoundError) as ctx: decisions.seed_lightweight(self.project) msg = str(ctx.exception) + self.assertIn("copy-machinery", msg, + "must name the remedy that repairs the install rather " + "than working around it — slice 095-01 made " + "copy-machinery bring templates/ with it") self.assertIn("CLAUDE_PLUGIN_ROOT", msg, "must name the env-var remedy — it demonstrably works " "from copied machinery") diff --git a/skills/migrate/SKILL.md b/skills/migrate/SKILL.md index c338999..d3e0db5 100644 --- a/skills/migrate/SKILL.md +++ b/skills/migrate/SKILL.md @@ -317,7 +317,8 @@ python3 .../migrate.py report /path/to/existing-project `copy-machinery` brings a migrated project to scaffold-mode parity — the same host-local runtime shape `/jig:scaffold-init` produces by default for greenfield projects. After running it, the project owns its -own copy of jig's skills, agents, hook scripts, and hook registration. +own copy of jig's skills, agents, hook scripts, templates, and hook +registration. The dev can edit those files in their own repo, and they ride along under version control. @@ -361,7 +362,18 @@ What it does: Claude, `.codex/agents/jig-*.toml` for Codex). 3. Copies hook scripts into the host runtime, pinning each script's mode to `0o755`. -4. Generates or merges host hook registration. `--host claude` uses +4. Copies jig's `templates/` tree into the host runtime + (`.claude/templates/` or `.codex/templates/`), rewriting helper paths in + `*.md.template` bodies to that runtime. This is what lets the copied + helpers seed from a template with no plugin root — `decisions.py` + (lightweight-decisions), `adr.py new`, `migrate.py seed-decisions`, + `workflow.py`'s slice-template render, and `memory.py`'s people.md + bootstrap all resolve `parents[2]/templates/`. Claude gained this in + [slice 095-01](../../docs/specs/095-scaffold-template-copy/spec.md); + Codex has always had it. **Re-run `copy-machinery` from a jig install to + refresh a project scaffolded before 095-01** — it is the repair path + `decisions.py` names when a template is unreachable. +5. Generates or merges host hook registration. `--host claude` uses `.claude/settings.json`, with per-entry `metadata.managed_by_jig: true` markers. `--host codex` uses `.codex/hooks.json`, with a schema-clean top-level `hooks` object. diff --git a/skills/migrate/migrate.py b/skills/migrate/migrate.py index f7610c8..0978810 100644 --- a/skills/migrate/migrate.py +++ b/skills/migrate/migrate.py @@ -104,6 +104,13 @@ def __init__(self, docs_root: str = "docs"): # Slice 021-01 — used by report's operations section to suppress the # `copy-machinery` suggestion when the machinery is already in place. self.jig_skill_dirs: list[Path] = [] + # Slice 095-01 — whether `.claude/templates/` is present beside the + # copied machinery. False in every project scaffolded before 095-01 + # (copy-machinery only started carrying templates then), which strands + # the record helpers' `parents[2]/templates/` fallback. Only actionable + # when `jig_skill_dirs` is non-empty — report routes such a project to + # `copy-machinery` to backfill (ADR-0038 retrofit). + self.jig_templates_present: bool = False # Bug 012 — the lightweight-decisions home, when present. Absent in # every project that adopted jig before the feature landed, because # scaffold-init only seeds it at init; report's Operations section @@ -279,6 +286,12 @@ def scan(project_dir: Path, docs_root: str = "docs") -> Inventory: # decide whether to suggest `copy-machinery`. elif entry.is_dir() and entry.name.startswith("jig-"): inv.jig_skill_dirs.append(entry) + # Slice 095-01 — templates present iff `.claude/templates/` holds at least + # one file (an empty or absent tree is the pre-095 shape). + templates_dir = project_dir / ".claude" / "templates" + inv.jig_templates_present = templates_dir.is_dir() and any( + p.is_file() for p in templates_dir.rglob("*") + ) claude_agents_dir = project_dir / ".claude" / "agents" for entry in _safe_iterdir(claude_agents_dir): if _is_content_md(entry): @@ -658,11 +671,36 @@ def render_operations(inv: Inventory, verdict: str) -> str: and not inv.jig_skill_dirs): items.append( "**`migrate.py copy-machinery <project-dir>`** — copy " - "jig's hooks / agents / skill helpers into the target's " - "`.claude/` (scaffold-mode parity). Mirrors what " + "jig's hooks / agents / skill helpers / templates into the " + "target's `.claude/` (scaffold-mode parity). Mirrors what " "`scaffold-init` does by default for greenfield projects." ) + # Slice 095-01 — templates backfill (ADR-0038 retrofit). A project that + # already has jig machinery but no `.claude/templates/` was scaffolded + # before 095-01, when copy-machinery started carrying templates. Its + # record helpers (`decisions.py`, `adr.py`, `workflow.py`, `memory.py`) + # resolve `parents[2]/templates/`, which lands on `<project>/.claude` — + # so without the tree they cannot seed a record home or open an ADR. + # Distinct from the full-copy branch above (mutually exclusive on + # `jig_skill_dirs`): here the machinery is present and only templates are + # missing. + if (verdict in {"adoptable", "partial"} + and inv.jig_skill_dirs + and not inv.jig_templates_present): + items.append( + "**`migrate.py copy-machinery <project-dir>`** — this project has " + "jig machinery but no `.claude/templates/`, so it was scaffolded " + "before that tree shipped (ADR-0038). The record helpers " + "(`decisions.py`, `adr.py`, `workflow.py`, `memory.py`) resolve " + "their template beside the machinery, so in this state recording a " + "decision or opening an ADR fails. Re-run `copy-machinery` **from a " + "jig install** — a scaffolded project cannot repair itself from its " + "own copied files, since `templates/` is exactly what it is missing " + "to copy from — to backfill the tree. Idempotent; never overwrites " + "your project's own files." + ) + # Bug 012 — seed-decisions. Gated on a jig-shaped verdict because a # not-yet-spec-driven project should be routed to scaffold-init (which # seeds the home itself), not handed a backfill op for a tree it lacks. @@ -1164,16 +1202,19 @@ def seed_decisions(project_dir: Path, docs_root: str = "docs", template = _resolve_plugin_root() / LIGHTWEIGHT_TEMPLATE_RELATIVE if not template.is_file(): - # Symmetry with decisions.py's `seed_lightweight`: a copied - # `.claude/skills/jig-migrate/migrate.py` has no templates/ tree - # beside it, and a bare "not found" would strand exactly the user - # decisions.py just redirected here. + # Symmetry with decisions.py's `seed_lightweight` (slice 095-01): both + # scaffold hosts now copy templates/ beside the copied machinery, so + # this means a broken install rather than an expected mode — and a bare + # "not found" would strand exactly the user decisions.py redirected + # here. raise MigrateError( f"lightweight-decisions template not found: {template}\n" - "jig ships it at templates/docs/decisions/. This usually means " - "migrate.py is running from copied machinery (scaffold mode), " - "which has no templates/ tree. Point CLAUDE_PLUGIN_ROOT at a jig " - "plugin/checkout root and re-run.") + "jig ships it at templates/docs/decisions/. A scaffolded project " + "carries its own copy beside its copied machinery; a plugin " + "install reads it from the plugin root. Reaching this means " + "neither is in place: refresh the copied machinery from a jig " + "install (`migrate.py copy-machinery <project-dir>`), or point " + "CLAUDE_PLUGIN_ROOT at a jig plugin/checkout root and re-run.") if target.is_file(): if LIGHTWEIGHT_ENTRIES_HEADING in _safe_read_text(target): @@ -2036,8 +2077,9 @@ def _build_parser() -> argparse.ArgumentParser: cm = sub.add_parser( "copy-machinery", - help="copy jig's skills, agents, hooks, and hook configuration " - "into the target's host-local runtime (scaffold-mode parity)", + help="copy jig's skills, agents, hooks, templates, and hook " + "configuration into the target's host-local runtime " + "(scaffold-mode parity)", ) cm.add_argument("project_dir", help="path to the project to receive jig's machinery") diff --git a/skills/migrate/test_migrate.py b/skills/migrate/test_migrate.py index 61fbc44..a96e60c 100644 --- a/skills/migrate/test_migrate.py +++ b/skills/migrate/test_migrate.py @@ -2193,15 +2193,22 @@ def test_operations_section_suggests_copy_machinery_when_absent(self): # ----- AC #9 — suppressed when jig-* skill dir already present -------- def test_operations_section_suppresses_when_jig_skills_present(self): - """AC #9 — same project, but with at least one - `.claude/skills/jig-*/` dir present, must NOT mention + """AC #9 — same project, but with a COMPLETE jig install present + (`.claude/skills/jig-*/` AND `.claude/templates/`), must NOT mention `copy-machinery` in Operations. The Inventory row may still reference `copy-machinery` as the refresh path; the suppression rule is scoped to the Operations section. + + Slice 095-01: templates are part of a complete install, so the + fixture seeds them too — without a templates tree this is the + pre-095 shape the backfill nudge (below) deliberately flags. """ jig_dir = self.tmpdir / ".claude" / "skills" / "jig-migrate" jig_dir.mkdir(parents=True) (jig_dir / "SKILL.md").write_text("seed\n") + templates = self.tmpdir / ".claude" / "templates" / "docs" / "decisions" + templates.mkdir(parents=True) + (templates / "lightweight-decisions.md.template").write_text("seed\n") r = run_migrate("report", str(self.tmpdir)) self.assertEqual(r.returncode, 0, f"stderr: {r.stderr}") @@ -2276,6 +2283,77 @@ def test_operations_section_suggests_copy_machinery_on_partial_verdict(self): shutil.rmtree(partial_root, ignore_errors=True) +class TemplatesBackfillOperationsTests(unittest.TestCase): + """Slice 095-01 — `migrate.py report` flags a project that has jig + machinery copied but no `.claude/templates/` (the shape of a project + scaffolded before 095-01) and routes it to `copy-machinery` to backfill + the tree. The retrofit itself already works — `copy-machinery` carries + `templates/` since 095-01; this is the discoverability half, resolving + ADR-0038's retrofit open question so an affected project learns about the + gap before a record helper fails on it.""" + + def setUp(self): + self.tmpdir = Path(tempfile.mkdtemp(prefix="jig-095-backfill-")) + _seed_spec_driven_project(self.tmpdir) + # A project scaffolded before 095-01: machinery copied, but no + # `.claude/templates/` tree beside it. + jig_dir = self.tmpdir / ".claude" / "skills" / "jig-memory-sync" + jig_dir.mkdir(parents=True) + (jig_dir / "SKILL.md").write_text("seed\n") + + def tearDown(self): + shutil.rmtree(self.tmpdir, ignore_errors=True) + + def _ops_section(self, stdout: str) -> str: + self.assertIn("## Operations", stdout) + return stdout[stdout.index("## Operations"):] + + def test_report_flags_missing_templates_and_routes_to_copy_machinery(self): + """Machinery present + `.claude/templates/` absent → Operations names + `copy-machinery` and the missing templates tree.""" + r = run_migrate("report", str(self.tmpdir)) + self.assertEqual(r.returncode, 0, f"stderr: {r.stderr}") + ops = self._ops_section(r.stdout) + self.assertIn("copy-machinery", ops) + self.assertIn(".claude/templates/", ops) + + def test_backfill_nudge_names_the_jig_install_caveat(self): + """The nudge must say the retrofit runs from a jig install — a + scaffolded project cannot repair itself from its own copied files + (ADR-0038 Open questions).""" + r = run_migrate("report", str(self.tmpdir)) + ops = self._ops_section(r.stdout) + self.assertIn("jig install", ops) + + def test_report_stops_flagging_once_templates_present(self): + """Backfill the templates tree → the nudge disappears.""" + templates = self.tmpdir / ".claude" / "templates" / "docs" / "decisions" + templates.mkdir(parents=True) + (templates / "lightweight-decisions.md.template").write_text("seed\n") + r = run_migrate("report", str(self.tmpdir)) + ops = self._ops_section(r.stdout) + self.assertNotIn( + ".claude/templates/", ops, + "templates present → the backfill nudge must be suppressed", + ) + + def test_no_backfill_nudge_without_jig_machinery(self): + """A project with no `.claude/skills/jig-*` gets the full copy-machinery + suggestion, not the templates-backfill variant — the backfill is + scoped to projects that already have machinery.""" + plain = Path(tempfile.mkdtemp(prefix="jig-095-nomach-")) + try: + _seed_spec_driven_project(plain) + r = run_migrate("report", str(plain)) + ops = self._ops_section(r.stdout) + self.assertNotIn( + "predates the templates copy", ops, + "no machinery → no templates-backfill nudge (full copy instead)", + ) + finally: + shutil.rmtree(plain, ignore_errors=True) + + class CopyMachinerySkillSurfaceTests(unittest.TestCase): """AC #7 — SKILL.md documents the new `copy-machinery` subcommand.""" diff --git a/skills/scaffold-init/scaffold.py b/skills/scaffold-init/scaffold.py index dafa5bb..25ac361 100644 --- a/skills/scaffold-init/scaffold.py +++ b/skills/scaffold-init/scaffold.py @@ -782,8 +782,11 @@ def _rewrite_skill_md_paths(body: str) -> str: """Replace every `${CLAUDE_PLUGIN_ROOT}/skills/<name>/` with `${CLAUDE_PROJECT_DIR}/.claude/skills/jig-<name>/`. - Operates on the SKILL.md body only — the frontmatter must be carved off - by the caller before calling here (AC #5). + For a SKILL.md the caller must carve the frontmatter off first (AC #5) — + the substitution must not touch it. Callers passing text that has no + frontmatter to protect (rendered docs at `scaffold()`; `.md.template` + bodies at `_copy_claude_templates`) pass the whole text, which is why this + is a precondition on SKILL.md callers rather than on the function. String substitution, not AST: SKILL.md is markdown + bash, no parsing needed. The Known-constraint #1 fallback (substitute absolute paths if @@ -796,6 +799,52 @@ def _rewrite_skill_md_paths(body: str) -> str: ) +def _copy_claude_templates(plugin: Path, target: Path) -> None: + """Copy `plugin/templates/` → `target/.claude/templates/` (slice 095-01). + + The Claude mirror of `_copy_codex_templates`. Record helpers resolve their + template as `CLAUDE_PLUGIN_ROOT`, else `Path(__file__).parents[2] / + "templates"` (`decisions.py`, `adr.py` — same `_plugin_root`). For a helper + copied to `.claude/skills/jig-<name>/<helper>.py` that fallback lands on + `<project>/.claude`, so without this copy scaffold mode — the one install + mode with no plugin root to fall back on — cannot seed the + lightweight-decisions home or open an ADR. Copying the tree makes the + existing fallback resolve, with no change to any helper's template + *resolution* (ADR-0038; the option the maintainer picked over embedding + templates in each helper). + + `.md.template` bodies get the same `${CLAUDE_PLUGIN_ROOT}/skills/<name>/` + rewrite SKILL.md bodies and rendered docs already get in scaffold mode — a + record seeded from a copied template must not teach a command naming a + variable that is unset in the project carrying it. Everything else is + byte-copied. Both splits mirror `_copy_codex_templates`. + + Idempotent (overwrite in place), so a `migrate copy-machinery` refresh + leaves the tree byte-identical.""" + templates_src = plugin / "templates" + templates_dst = target / ".claude" / "templates" + if not templates_src.is_dir(): + return + for entry in sorted(templates_src.rglob("*")): + if entry.is_dir(): + continue + rel = entry.relative_to(templates_src) + dst = templates_dst / rel + dst.parent.mkdir(parents=True, exist_ok=True) + if entry.name.endswith(".md.template"): + # Explicit utf-8 both ways: `atomic_write_text` writes utf-8, and + # the templates carry non-ASCII (em-dashes). Reading at the + # locale's default encoding would mojibake — or raise — under + # LANG=C, and the corruption would then propagate into every record + # seeded from the copy. (`_copy_codex_templates` reads at default + # encoding; `decisions.py` is the one that gets this right.) + atomic_write_text( + dst, _rewrite_skill_md_paths(entry.read_text(encoding="utf-8")), + ) + else: + dst.write_bytes(entry.read_bytes()) + + def _split_frontmatter(text: str) -> tuple[str, str]: """Split a SKILL.md into (frontmatter_with_fences, body). If the file has no YAML frontmatter, returns ('', text).""" @@ -1139,7 +1188,14 @@ def finalize_codex_migrate_skill(body: str) -> str: "3. Copies Codex agents into `.codex/agents/jig-*.toml`.\n" "4. Copies hook scripts into `.codex/hooks/scripts/`, pinning " "each script's mode to `0o755`.\n" - "5. Generates or merges Codex hook registration in " + "5. Copies jig's `templates/` tree into `.codex/templates/`, " + "rewriting `*.md.template` bodies to that runtime. This is what " + "lets the copied helpers seed from a template with no plugin " + "root — `decisions.py` (lightweight-decisions), `adr.py new`, " + "`migrate.py seed-decisions`, `workflow.py`'s slice-template " + "render, and `memory.py`'s people.md bootstrap all resolve " + "`parents[2]/templates/`.\n" + "6. Generates or merges Codex hook registration in " "`.codex/hooks.json` using Codex's native top-level `hooks` " "schema.\n\n" "Subsequent runs are idempotent: re-running `copy-machinery` " @@ -1916,9 +1972,12 @@ def copy_machinery(plugin: Path, target: Path, *, check (inbox 2026-05-15): ensures we refuse BEFORE any filesystem mutation when settings.json is unmanaged. Closes the partial- state-on-refuse gap noted in spec 016-03 deviation §7. - 2. `_copy_skills_and_agents(plugin, target, installed_tiers)` — + 2. `_copy_claude_templates(plugin, target)` — slice 095-01; the + Claude mirror of `_copy_codex_templates`, so the copied record + helpers' `parents[2]/templates/` fallback resolves. + 3. `_copy_skills_and_agents(plugin, target, installed_tiers)` — slice 016-01; tier-gated since slice 038-02. - 3. `_copy_hooks_and_register(plugin, target, force=force)` — slice + 4. `_copy_hooks_and_register(plugin, target, force=force)` — slice 016-02; the safety check inside this call is now redundant but kept so the function still works correctly when called directly. @@ -1959,6 +2018,13 @@ def copy_machinery(plugin: Path, target: Path, *, if host != "claude": raise ValueError(f"unsupported scaffold host: {host}") _check_hooks_safety(target, force=force) + # Slice 095-01: templates ride the machinery copy (Codex parity), so a + # scaffold-mode project can seed a record home / open an ADR with no + # CLAUDE_PLUGIN_ROOT. Ungated infra — never tier-scoped: the record + # helpers span tiers (memory-sync is Tier 0, adr-workflow Tier 1) and a + # partial template tree would just be the same gap with a smaller blast + # radius. Runs AFTER the safety check so a refusal still writes nothing. + _copy_claude_templates(plugin, target) _copy_skills_and_agents(plugin, target, installed_tiers) _copy_hooks_and_register(plugin, target, force=force) _write_gitignore_secret_block(target) diff --git a/skills/scaffold-init/test_scaffold_mode.py b/skills/scaffold-init/test_scaffold_mode.py index 2078e40..672049c 100644 --- a/skills/scaffold-init/test_scaffold_mode.py +++ b/skills/scaffold-init/test_scaffold_mode.py @@ -14,6 +14,7 @@ import json import os import re +import shutil import subprocess import sys import tempfile @@ -23,12 +24,63 @@ try: import tomllib except ModuleNotFoundError: # Python < 3.11 (e.g. default macOS 3.9) - tomllib = None # codex-packaging paths require 3.11+; gated below + tomllib = None # no tomli fallback (jig is zero-dependency); see _load_agent_toml REPO_ROOT = Path(__file__).resolve().parents[2] SCAFFOLD = REPO_ROOT / "skills" / "scaffold-init" / "scaffold.py" +def _load_agent_toml(text: str) -> dict: + """Parse a jig-rendered Codex agent TOML into a dict. + + Uses `tomllib` when available (Python 3.11+). Below that — jig's documented + floor is 3.9, the default macOS `python3` — there is no `tomli` fallback + because jig ships zero dependencies, so this parses the exact shape + `CodexScaffoldRenderer.render_codex_agent_toml` emits: a `#`-comment managed + marker, scalar keys rendered as JSON strings (`json.dumps`), and a trailing + `developer_instructions` as a `'''…'''` multiline literal (or a JSON string + when the body itself contains `'''`). Writer and reader are both jig's and + kept in step — this is not a general TOML parser. It exists so these tests + RUN and PASS on 3.9 rather than being skipped there (the whole file used to + skip below 3.11 for these two assertions, silently dropping ~90 tests that + have nothing to do with TOML).""" + if tomllib is not None: + return tomllib.loads(text) + data: dict = {} + lines = text.splitlines(keepends=True) + i = 0 + while i < len(lines): + stripped = lines[i].strip() + if not stripped or stripped.startswith("#"): + i += 1 + continue + key, sep, rest = lines[i].partition(" = ") + if not sep: + i += 1 + continue + key = key.strip() + value = rest.lstrip() + if value.startswith("'''"): + # Multiline literal: everything from the opening `'''` to the next + # `'''` is opaque (may contain `=`, `#`, blank lines). + after = value[3:] + if "'''" in after: + data[key] = after[: after.index("'''")] + else: + buf = after + i += 1 + while i < len(lines) and "'''" not in lines[i]: + buf += lines[i] + i += 1 + if i < len(lines): + buf += lines[i][: lines[i].index("'''")] + data[key] = buf + else: + data[key] = json.loads(rest.strip()) + i += 1 + return data + + def run_scaffold_with_args(target: Path, *args: str) -> subprocess.CompletedProcess: """Invoke scaffold.py with extra CLI flags before the target path.""" env = os.environ.copy() @@ -462,6 +514,314 @@ def test_scaffolded_helper_imports_common_at_runtime(self): self.assertNotIn("ModuleNotFoundError", r.stderr) +# -------------------------------------------------------------------------- +# Spec 095-01 — Claude scaffold mode copies `templates/` into `.claude/`, +# mirroring the Codex side's `_copy_codex_templates`, so the copied helpers' +# existing `parents[2]/templates/` fallback resolves with no change to any +# helper's template resolution. Before this slice, `copy_machinery` copied +# `skills/` and `hooks/` only, so no helper could reach a template in the one +# install mode that has no plugin root to fall back on. +# -------------------------------------------------------------------------- + + +class ClaudeScaffoldTemplatesTests(unittest.TestCase): + """Spec 095-01 — the record-helper family can seed records in Claude + scaffold mode. + + `--has-tests` forces tier-1 into `installed_tiers` so `adr-workflow` is + copied too (AC2). Both helpers run as subprocesses out of the project's + OWN copied machinery with `CLAUDE_PLUGIN_ROOT` unset — the exact runtime + a scaffolded project has, and the one where `parents[2]` lands on + `<project>/.claude`. + """ + + def setUp(self): + self.tmpdir = tempfile.mkdtemp(prefix="jig-095-templates-") + self.target = Path(self.tmpdir) / "demo-project" + self.target.mkdir() + r = run_scaffold_with_args(self.target, "--with-machinery", "--has-tests") + self.assertEqual( + r.returncode, 0, + f"scaffold --with-machinery failed: stderr={r.stderr}\n" + f"stdout={r.stdout}", + ) + + def tearDown(self): + import shutil + shutil.rmtree(self.tmpdir, ignore_errors=True) + + # ----- fixtures --------------------------------------------------------- + def _scaffold_mode_env(self) -> dict: + """The env a scaffolded project's helper actually runs in: no + CLAUDE_PLUGIN_ROOT. Git identity is supplied because `adr.py new` + commits its reservation stub (AC2); it is inert for AC1.""" + env = {k: v for k, v in os.environ.items() if k != "CLAUDE_PLUGIN_ROOT"} + env.update({ + "GIT_AUTHOR_NAME": "jig test", + "GIT_AUTHOR_EMAIL": "test@example.invalid", + "GIT_COMMITTER_NAME": "jig test", + "GIT_COMMITTER_EMAIL": "test@example.invalid", + }) + return env + + def _copied_helper(self, skill: str, helper: str) -> Path: + path = self.target / ".claude" / "skills" / skill / helper + self.assertTrue(path.is_file(), f"copied helper missing: {path}") + return path + + def _templates_snapshot(self) -> dict: + root = self.target / ".claude" / "templates" + return { + str(p.relative_to(root)): p.read_bytes() + for p in sorted(root.rglob("*")) if p.is_file() + } + + # ----- AC1: decisions.py seeds its record home -------------------------- + def test_ac1_scaffold_mode_seeds_lightweight_decisions_without_plugin_root(self): + """AC1 — with the record home absent (bug 012 mode 1: the shape of a + project scaffolded before the lightweight-decisions template shipped), + the copied helper seeds it from the copied template and appends.""" + record = self.target / "docs" / "decisions" / "lightweight-decisions.md" + record.unlink() # scaffold-init seeds it; the pre-feature shape has none + + helper = self._copied_helper("jig-memory-sync", "decisions.py") + r = subprocess.run( + [sys.executable, str(helper), "add-lightweight", + "--title", "Probe entry", + "--decision", "recorded from copied machinery", + "--context", "scaffold mode has no CLAUDE_PLUGIN_ROOT", + "--scope", "test fixture", + "--project-dir", str(self.target)], + capture_output=True, text=True, env=self._scaffold_mode_env(), + ) + self.assertEqual( + r.returncode, 0, + f"copied decisions.py could not seed its record home:\n" + f"stderr={r.stderr}\nstdout={r.stdout}", + ) + self.assertTrue(record.is_file(), f"record home not seeded: {record}") + self.assertIn("Probe entry", record.read_text(), + "seeded file must carry the appended entry") + + # ----- AC2: adr.py seeds a new ADR -------------------------------------- + def test_ac2_scaffold_mode_opens_an_adr_without_plugin_root(self): + """AC2 — the family's second member. `adr.py` had the identical gap + and no mitigation; unlike the record home, its template is never + rendered into the project at install time, so this fails on a + *fresh* scaffold today.""" + if shutil.which("git") is None: + self.skipTest("git not on PATH") + env = self._scaffold_mode_env() + # `checkout -b` is load-bearing, not cosmetic: off `main`, `adr.py new + # --no-push` routes to `_reserve_local_on_current_branch`, which skips + # the clean-tree gate. On `main` that gate refuses outright — a freshly + # scaffolded tree is entirely untracked. `commit.gpgsign=false` isolates + # the fixture from a maintainer's global signing config, which would + # otherwise fail this test with "copied adr.py could not scaffold an + # ADR" and blame the feature for the environment. + for args in (["init", "-q"], + ["config", "commit.gpgsign", "false"], + ["checkout", "-q", "-b", "probe-branch"]): + g = subprocess.run(["git", *args], cwd=str(self.target), + capture_output=True, text=True, env=env) + self.assertEqual(g.returncode, 0, f"git {args}: {g.stderr}") + + helper = self._copied_helper("jig-adr-workflow", "adr.py") + r = subprocess.run( + [sys.executable, str(helper), "new", "probe-decision", + "--no-push", "--project-dir", str(self.target)], + capture_output=True, text=True, env=env, + ) + self.assertEqual( + r.returncode, 0, + f"copied adr.py could not scaffold an ADR:\n" + f"stderr={r.stderr}\nstdout={r.stdout}", + ) + written = sorted( + (self.target / "docs" / "decisions").glob("adr-*-probe-decision.md") + ) + self.assertEqual(len(written), 1, + f"expected one new ADR, found {written}") + self.assertIn("Proposed", written[0].read_text(), + "ADR must be rendered from the copied template") + + # ----- AC3: the copy mirrors Codex -------------------------------------- + def test_ac3_every_plugin_template_is_copied(self): + """AC3 — every file under the plugin's `templates/` lands under + `.claude/templates/` at the same relative path.""" + src_root = REPO_ROOT / "templates" + expected = { + str(p.relative_to(src_root)) + for p in src_root.rglob("*") if p.is_file() + } + self.assertTrue(expected, "fixture check: plugin has no templates/") + self.assertEqual( + set(self._templates_snapshot()), expected, + "copied .claude/templates/ must mirror the plugin's templates/ " + "tree file-for-file (parity with _copy_codex_templates)", + ) + + # ----- AC4: copied templates name the copied machinery ------------------ + def test_ac4_copied_md_templates_name_the_copied_helpers(self): + """AC4 — a record seeded from a copied template must not name + `${CLAUDE_PLUGIN_ROOT}`, which is unset in the project that seeded + it. Same transform SKILL.md bodies and rendered docs already get.""" + template = (self.target / ".claude" / "templates" / "docs" + / "decisions" / "lightweight-decisions.md.template") + body = template.read_text() + self.assertNotIn( + "${CLAUDE_PLUGIN_ROOT}/skills/", body, + "copied template still names the plugin root — the command it " + "teaches would not run in the project that carries it", + ) + self.assertIn( + "${CLAUDE_PROJECT_DIR}/.claude/skills/jig-memory-sync/", body, + "copied template must name the copied helper", + ) + + def test_ac4_rewritten_templates_without_a_literal_are_byte_identical(self): + """AC4 edge case — the rewrite is a substitution, not a re-render. + These go through `_rewrite_skill_md_paths` (they end `.md.template`) + but carry no plugin-root literal, so the round trip + read_text→sub→atomic_write_text must return the source bytes. + + An earlier version of this test asserted the edge case against + `adr-0000-template.md` / `scaffold.json.template` — neither of which + ends in `.md.template`, so both took the byte-copy branch and the + rewrite path was never exercised at all. Caught in review. + """ + for rel in ("docs/conventions.md.template", + "docs/memory/glossary.md.template"): + src = (REPO_ROOT / "templates" / rel).read_bytes() + self.assertTrue(rel.endswith(".md.template"), + f"fixture check: {rel} must take the rewrite branch") + self.assertNotIn(b"${CLAUDE_PLUGIN_ROOT}/skills/", src, + f"fixture check: {rel} has a plugin-root literal") + copied = (self.target / ".claude" / "templates" / rel).read_bytes() + self.assertEqual( + copied, src, + f"{rel} has nothing to rewrite — it must survive byte-for-byte") + + def test_ac4_non_md_templates_are_byte_copied(self): + """AC4 edge case — files that are not `.md.template` skip the rewrite + entirely and are byte-copied. Iterated rather than hand-listed: a + hardcoded pair silently stops covering whatever gets added to + `templates/` next (it had already missed `docs/specs/slice-template.md`, + which `workflow.py` reads at runtime).""" + src_root = REPO_ROOT / "templates" + checked = 0 + for src in sorted(src_root.rglob("*")): + if not src.is_file() or src.name.endswith(".md.template"): + continue + rel = src.relative_to(src_root) + copied = (self.target / ".claude" / "templates" / rel) + self.assertEqual(copied.read_bytes(), src.read_bytes(), + f"{rel} must be copied verbatim") + checked += 1 + self.assertTrue(checked, "fixture check: no byte-copied templates found") + + # ----- The other two consumers (deviation log §2) ------------------------ + # The spec framed this as a two-helper family; it is five, and two of the + # others silently change behaviour here. AC3 pins that their template files + # are copied; these pin that the helpers actually USE them — which is the + # claim, and the evidence that copying the whole tree beat an allowlist of + # the two templates the ACs happened to name. + + def test_workflow_renders_the_real_slice_template_not_the_fallback(self): + """`workflow.py` `_render_stub_slice` reads + `parents[2]/templates/docs/specs/slice-template.md`, falling back to a + degraded inline template when unreachable. In scaffold mode it now + finds the real one. Distinguished by a marker only the on-disk + template carries — asserting `is_file()` on the copy would prove + nothing AC3 doesn't.""" + if shutil.which("git") is None: + self.skipTest("git not on PATH") + env = self._scaffold_mode_env() + for args in (["init", "-q"], + ["config", "commit.gpgsign", "false"], + ["checkout", "-q", "-b", "probe-branch"]): + subprocess.run(["git", *args], cwd=str(self.target), + capture_output=True, text=True, env=env, check=True) + + helper = self._copied_helper("jig-spec-workflow", "workflow.py") + r = subprocess.run( + [sys.executable, str(helper), "new", "probe-spec", "--no-push", + "--project-dir", str(self.target)], + capture_output=True, text=True, env=env, + ) + self.assertEqual(r.returncode, 0, + f"copied workflow.py new failed:\nstderr={r.stderr}") + slices = sorted( + (self.target / "docs" / "specs").glob("*-probe-spec/slice-01-*.md") + ) + self.assertEqual(len(slices), 1, f"expected one slice, got {slices}") + body = slices[0].read_text() + marker = "kind: spike" + self.assertIn( + marker, body, + "rendered from the degraded inline fallback, not the copied " + "template — the fallback carries no spike-slice section", + ) + + def test_memory_bootstraps_people_md_from_the_copied_template(self): + """`memory.py`'s people.md bootstrap reads + `plugin_root()/templates/docs/memory/people.md.template`. Before this + slice its degrade path fired in scaffold mode — its own message said + the failure was "expected for a scaffold-mode target".""" + people = self.target / "docs" / "memory" / "people.md" + if people.exists(): + people.unlink() + helper = self._copied_helper("jig-memory-sync", "memory.py") + r = subprocess.run( + [sys.executable, "-c", + "import sys; from pathlib import Path;" + f"sys.path.insert(0, {str(helper.parent)!r});" + f"sys.path.insert(0, {str(helper.parent.parent)!r});" + "import memory;" + f"w, m = memory._bootstrap_people_md(Path({str(self.target)!r}));" + "print(w); print(m)"], + capture_output=True, text=True, env=self._scaffold_mode_env(), + ) + self.assertEqual(r.returncode, 0, + f"copied memory.py failed:\nstderr={r.stderr}") + self.assertTrue(r.stdout.startswith("True"), + f"bootstrap did not write people.md: {r.stdout}") + self.assertTrue(people.is_file(), "people.md was not created") + # ----- AC5: idempotent re-run ------------------------------------------- + def test_ac5_rerun_leaves_templates_byte_identical(self): + """AC5 — `migrate copy-machinery` re-runs over an existing project to + refresh machinery; the template copy must be idempotent.""" + before = self._templates_snapshot() + r = run_scaffold_with_args( + self.target, "--with-machinery", "--has-tests", "--force") + self.assertEqual(r.returncode, 0, f"re-run failed: {r.stderr}") + self.assertEqual(self._templates_snapshot(), before, + "re-run must leave .claude/templates/ unchanged") + + +class PluginOnlyTemplatesTests(unittest.TestCase): + """Spec 095-01 AC6 — the template copy rides the machinery copy. In + `--plugin-only` mode the plugin root IS the template home, so a copied + tree would be dead weight.""" + + def setUp(self): + self.tmpdir = tempfile.mkdtemp(prefix="jig-095-plugin-only-") + self.target = Path(self.tmpdir) / "demo-project" + self.target.mkdir() + + def tearDown(self): + import shutil + shutil.rmtree(self.tmpdir, ignore_errors=True) + + def test_ac6_plugin_only_writes_no_templates(self): + r = run_scaffold_with_args(self.target, "--plugin-only") + self.assertEqual(r.returncode, 0, f"stderr: {r.stderr}") + self.assertFalse( + (self.target / ".claude" / "templates").exists(), + "--plugin-only must not create .claude/templates/", + ) + + # -------------------------------------------------------------------------- # AC #9: regression guard — calling scaffold --with-machinery must NOT # break any existing scaffold behavior. The existing test_scaffold.py @@ -937,6 +1297,15 @@ def test_refused_scaffold_leaves_no_partial_hook_scripts(self): "The safety check must run BEFORE the hook-script copy " "loop so a refused scaffold leaves no trace.", ) + # Slice 095-01 — same invariant for the template tree. Without this, + # swapping `_check_hooks_safety` and `_copy_claude_templates` in + # `copy_machinery` would strand a 25-file `.claude/templates/` tree on + # a refused run while the suite stayed green. + self.assertFalse( + (self.target / ".claude" / "templates").exists(), + "refused scaffold left a partial .claude/templates/ tree — the " + "safety check must run BEFORE the template copy.", + ) def test_force_overrides_unmanaged_hooks_refusal(self): """AC #4 — --force is the documented escape hatch. With it, the @@ -1369,7 +1738,7 @@ def test_codex_agents_materialize_as_toml(self): for role, sandbox in expected_sandbox.items(): agent = self.target / ".codex" / "agents" / f"jig-{role}.toml" self.assertTrue(agent.is_file(), f"missing Codex agent: {agent}") - data = tomllib.loads(agent.read_text()) + data = _load_agent_toml(agent.read_text()) self.assertEqual(data["name"], f"jig-{role}") self.assertEqual(data["sandbox_mode"], sandbox) self.assertIn("Codex adapter note", data["developer_instructions"]) @@ -1399,7 +1768,7 @@ def test_codex_agent_installer_writes_toml_to_requested_dir(self): self.assertEqual(r.returncode, 0, f"stderr: {r.stderr}") self.assertIn("installed 3 Codex agent(s)", r.stdout) - data = tomllib.loads((agents_dir / "jig-reviewer.toml").read_text()) + data = _load_agent_toml((agents_dir / "jig-reviewer.toml").read_text()) self.assertEqual(data["name"], "jig-reviewer") self.assertEqual(data["sandbox_mode"], "read-only") @@ -2319,14 +2688,3 @@ def test_plugin_only_scaffold_preserves_plugin_root_path(self): if __name__ == "__main__": unittest.main() - - -def load_tests(loader, tests, pattern): # unittest discover hook - # Codex packaging validation needs tomllib (Python 3.11+). jig is - # zero-dependency, so there is no tomli fallback — skip the whole module - # below the 3.9 floor rather than error on a missing stdlib import. - import sys as _sys - import unittest as _unittest - if _sys.version_info < (3, 11): - return _unittest.TestSuite() - return tests diff --git a/skills/spec-workflow/test_workflow.py b/skills/spec-workflow/test_workflow.py index 9f45c54..d45d44e 100644 --- a/skills/spec-workflow/test_workflow.py +++ b/skills/spec-workflow/test_workflow.py @@ -7557,9 +7557,17 @@ def test_reviewer_return_envelope_present(self): class SelfDefiningReminderInRenderersTests(unittest.TestCase): """Spec 065-04 AC2(b): the self-defining-vocabulary reminder is emitted by the DISTRIBUTED workflow.py renderers (`_render_stub_spec` and the inline - fallback inside `_render_stub_slice`), so an author in a scaffolded project - — where `templates/docs/specs/slice-template.md` is NOT copied and the - inline fallback is used — still meets the reminder where they write.""" + fallback inside `_render_stub_slice`), so an author still meets the reminder + where they write, whichever renderer runs. + + The original rationale — "a scaffolded project, where + `templates/docs/specs/slice-template.md` is NOT copied and the inline + fallback is used" — expired with slice 095-01, which copies `templates/` + beside the copied machinery, so scaffold mode now renders the real + template. The class still earns its place: the fallback remains live for + projects scaffolded before 095-01 and for any unreachable-template case, + and `_render_stub_spec` has no on-disk template at all. Only the premise + changed, not the invariant.""" @classmethod def setUpClass(cls): diff --git a/skills/spec-workflow/workflow.py b/skills/spec-workflow/workflow.py index c85a7c4..34a3bfe 100644 --- a/skills/spec-workflow/workflow.py +++ b/skills/spec-workflow/workflow.py @@ -2942,18 +2942,30 @@ def _render_stub_slice(num_str: str, slice_num: str = "01", and `{{NAME}}` → `name`. Returns the rendered text. Falls back to an inline minimal template when the file template - isn't reachable (e.g. running the helper outside the jig tree).""" + isn't reachable (e.g. running the helper outside the jig tree). + + Since slice 095-01 both scaffold hosts copy `templates/` beside the copied + machinery, so `parents[2]` reaches the real template in a scaffolded + project too — this helper is one of the template-reading family ADR-0038 + enumerates, and it silently stopped needing the fallback there.""" template_path = (Path(__file__).resolve().parents[2] / "templates" / "docs" / "specs" / "slice-template.md") fragment = f"{num_str}-{slice_num}" try: - body = template_path.read_text() - except OSError: + # Explicit utf-8: slice-template.md carries em-dashes, ✅ and →. Slice + # 095-01 makes this path reach a real file in scaffold mode for the + # first time, so a default-encoding read would newly raise + # UnicodeDecodeError under LANG=C — a ValueError, which would escape + # the OSError fallback below and crash where the old behaviour cleanly + # degraded. + body = template_path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): # Inline fallback — keeps the helper functional even when the # template file isn't on disk (e.g. minimal scaffold smoke tests, or a - # scaffolded project where the template is not distributed). Carries the - # spec 065-04 self-defining-vocabulary reminder so the author meets it - # here too, in parity with the on-disk slice-template.md. + # project scaffolded before slice 095-01, which is when copied + # machinery started carrying `templates/`). Carries the spec 065-04 + # self-defining-vocabulary reminder so the author meets it here too, in + # parity with the on-disk slice-template.md. body = ( "---\nstatus: DRAFT\ndependencies: []\nlast_verified:\n---\n" "\n<!-- jig self-defining vocabulary (soft, forward-only): expand "