From e16c66bb720f5e857cd7c7c38ef5e3db62c08467 Mon Sep 17 00:00:00 2001 From: Marie-Rose <174208342+Kyarha@users.noreply.github.com> Date: Thu, 16 Jul 2026 22:16:21 -0700 Subject: [PATCH 1/6] docs(specs): reserve 095-scaffold-template-copy --- .../slice-01-tbd.md | 132 ++++++++++++++++++ docs/specs/095-scaffold-template-copy/spec.md | 27 ++++ 2 files changed, 159 insertions(+) create mode 100644 docs/specs/095-scaffold-template-copy/slice-01-tbd.md create mode 100644 docs/specs/095-scaffold-template-copy/spec.md diff --git a/docs/specs/095-scaffold-template-copy/slice-01-tbd.md b/docs/specs/095-scaffold-template-copy/slice-01-tbd.md new file mode 100644 index 0000000..ffefeb6 --- /dev/null +++ b/docs/specs/095-scaffold-template-copy/slice-01-tbd.md @@ -0,0 +1,132 @@ +--- +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). +--- + + + + + +## 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 new file mode 100644 index 0000000..f37a013 --- /dev/null +++ b/docs/specs/095-scaffold-template-copy/spec.md @@ -0,0 +1,27 @@ +--- +status: DRAFT +skill: +use_cases: [] +--- + + + +# Spec 095: Scaffold-template copy + +> Reserved on 2026-07-16 via `workflow.py new`. Body to be drafted in a feature branch. + +## Overview + +_TBD_ + +## 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._ + +## Decomposition + +_TBD — SPIDR analysis. See SKILL.md for the five axes (Spike / Paths / Interfaces / Data / Rules)._ + +## Slices + +- [095-01 — tbd](slice-01-tbd.md) From a9a1ae5ac34a06db455fd7cb8a4f262793701b5e Mon Sep 17 00:00:00 2001 From: Marie-Rose <174208342+Kyarha@users.noreply.github.com> Date: Thu, 16 Jul 2026 22:26:30 -0700 Subject: [PATCH 2/6] docs(decisions): reserve adr-0038-claude-scaffold-template-copy --- .../adr-0038-claude-scaffold-template-copy.md | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 docs/decisions/adr-0038-claude-scaffold-template-copy.md 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..d56132e --- /dev/null +++ b/docs/decisions/adr-0038-claude-scaffold-template-copy.md @@ -0,0 +1,64 @@ +--- +status: Proposed +dependencies: [] +last_verified: +frame_review: true +--- + +# ADR-0038: Claude scaffold mode copies templates/ + +## Status + +Proposed (2026-07-16) + +## Context + +_TODO: describe the situation, forces, and constraints driving this decision._ + +## Decision Options Considered + +### Option A: _TODO_ +- **Pros:** _TODO_ +- **Cons:** _TODO_ + +### Option B: _TODO_ +- **Pros:** _TODO_ +- **Cons:** _TODO_ + +## Recommended Decision + +_TODO: state the chosen option and the reasoning._ + +## Consequences + +**Becomes easier:** +- _TODO_ + +**Becomes harder:** +- _TODO_ + +## Assumptions + + + +_Load-bearing factual claims about runnable surfaces (library/API capability, +version/perf behavior, behavior of existing code) must be backed by an executed +probe (run a command, read source/`node_modules`) or a citation — or listed +here explicitly as an assumption. Never assert an unverified claim as fact._ + +_Risk-gated: omit this section (or write "None") when the decision has no +unverified load-bearing assumptions — do not pad with boilerplate._ + +- _TODO_ + +## Kill criteria + +_What would make this decision wrong? List the conditions that, if observed, +should reverse or shelve it. Risk-gated like Assumptions — write "None" or omit +when there is no meaningful kill condition; do not invent ceremonial ones._ + +- _TODO_ + +## Open questions + +_TODO: list any unresolved questions. If none, write "None."_ From 95ffd0440938a4ca94b6bee8d6046f4363bc8583 Mon Sep 17 00:00:00 2001 From: Marie-Rose <174208342+Kyarha@users.noreply.github.com> Date: Thu, 16 Jul 2026 22:56:58 -0700 Subject: [PATCH 3/6] fix(scaffold): copy templates/ in Claude scaffold mode so record helpers resolve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude scaffold mode copied `skills/` and `hooks/` into `.claude/` but not `templates/` — only the Codex path copied templates. jig's record helpers resolve their template as `CLAUDE_PLUGIN_ROOT`, else `parents[2]/templates/`, which for a copied helper at `.claude/skills/jig-/.py` lands on `/.claude`. With no plugin root to fall back on, a scaffolded project could not seed a record home or open an ADR. `copy_machinery(host="claude")` now copies `templates/` into `.claude/templates/`, mirroring `_copy_codex_templates`. The helpers' existing fallback resolves with no change to any helper's template *resolution*. This is the maintainer's pick — option (a) of the three posted to him on #109 and parked in refinement-todo. Recorded as ADR-0038; implemented by spec 095. Closes bug 012's `## Remaining risk` (via an ADR-0010 amendment, since the record is closed) and resolves the refinement-todo entry. Review found the family is four helpers, not two: `workflow.py` (slice-template) and `memory.py` (people.md) read the tree through the same shape, so an allowlist of the two templates in evidence would have left two consumers broken. See the slice deviation log. Tests: red witnessed against a real `--with-machinery` install with CLAUDE_PLUGIN_ROOT unset (both helpers), green after the copy. Full suite on CI's matrix: 3.12 3508 -> 3517 (+9, all new); 3.9 unchanged at 3361 OK (test_scaffold_mode.py is skipped whole below 3.11 by its own tomllib load_tests gate — noted in the PR). Co-Authored-By: Claude Opus 4.8 --- docs/architecture.md | 22 +- .../012-decisions-no-template-backfill.md | 41 +++ docs/decisions/README.md | 1 + .../adr-0038-claude-scaffold-template-copy.md | 265 ++++++++++++++++-- docs/refinement-todo.md | 6 +- .../slice-01-claude-scaffold-templates.md | 224 +++++++++++++++ .../slice-01-tbd.md | 132 --------- docs/specs/095-scaffold-template-copy/spec.md | 119 +++++++- hosts/claude/skills/memory-sync/decisions.py | 34 ++- hosts/claude/skills/memory-sync/memory.py | 18 +- hosts/claude/skills/migrate/SKILL.md | 16 +- hosts/claude/skills/migrate/migrate.py | 19 +- hosts/claude/skills/scaffold-init/scaffold.py | 66 ++++- hosts/claude/skills/spec-workflow/workflow.py | 14 +- .../jig/skills/memory-sync/decisions.py | 34 ++- .../plugins/jig/skills/memory-sync/memory.py | 18 +- .../codex/plugins/jig/skills/migrate/SKILL.md | 3 +- .../plugins/jig/skills/migrate/migrate.py | 19 +- .../jig/skills/scaffold-init/scaffold.py | 66 ++++- .../jig/skills/spec-workflow/workflow.py | 14 +- skills/memory-sync/decisions.py | 34 ++- skills/memory-sync/memory.py | 18 +- skills/memory-sync/test_decisions.py | 30 +- skills/migrate/SKILL.md | 16 +- skills/migrate/migrate.py | 19 +- skills/scaffold-init/scaffold.py | 66 ++++- skills/scaffold-init/test_scaffold_mode.py | 263 +++++++++++++++++ skills/spec-workflow/workflow.py | 14 +- 28 files changed, 1299 insertions(+), 292 deletions(-) create mode 100644 docs/specs/095-scaffold-template-copy/slice-01-claude-scaffold-templates.md delete mode 100644 docs/specs/095-scaffold-template-copy/slice-01-tbd.md diff --git a/docs/architecture.md b/docs/architecture.md index a4a22c7..911282b 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,19 @@ 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`, and `workflow.py`'s slice-template render 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..6d8cd76 100644 --- a/docs/bugs/012-decisions-no-template-backfill.md +++ b/docs/bugs/012-decisions-no-template-backfill.md @@ -312,6 +312,47 @@ 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 helper changes. + +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 index d56132e..87d9385 100644 --- a/docs/decisions/adr-0038-claude-scaffold-template-copy.md +++ b/docs/decisions/adr-0038-claude-scaffold-template-copy.md @@ -1,7 +1,7 @@ --- -status: Proposed -dependencies: [] -last_verified: +status: Accepted +dependencies: [adr-0012] +last_verified: 2026-07-16 frame_review: true --- @@ -9,56 +9,267 @@ frame_review: true ## Status -Proposed (2026-07-16) +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). + +**Accepted** because the person entitled to make the call made it — that is the +acceptance act, and recording it as merely "Proposed" would misattribute his +decision to this branch. What is still pending is his review of **this record and +its implementation**, not of the choice: @ramboz to validate on the spec 095 PR. +If the record misstates his decision, the fix is to correct the record, not to +re-open the decision. ## Context -_TODO: describe the situation, forces, and constraints driving this decision._ +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/` and `hooks/` into `.claude/`, but not +`templates/`. Only the Codex path copied templates — and **that copy is not an +accident Claude should mirror for symmetry's sake; it is a mechanical necessity +of the Codex rewrite table.** `CodexScaffoldRenderer` redirects +`${CLAUDE_PLUGIN_ROOT}/templates/` → `${CODEX_PROJECT_DIR:-$PWD}/.codex/templates/` +(`scaffold.py:944-947`), so copying templates is what makes its own rewritten +prose true. The Claude renderer rewrites only `/skills//` +(`_PLUGIN_SKILL_PATH_RE`) and never touches `/templates/`. So option (a) **creates** +Claude/Codex parity rather than restoring it — the honest framing, and a weaker +argument than "the Claude side is the anomaly" would suggest. What makes it right +is not parity; 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). + +Note the live list is also the strongest argument against a scoped copy: the two +templates this bug is *about* are 2 of the 4. `workflow.py` and `memory.py` read +the tree through the identical `parents[2]` shape and were found only by review — +an allowlist built from the ACs would have shipped, looked right, and left two +consumers broken. + +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: `workflow.py` and + `memory.py` resolve templates the same way (see the live-file table). A + per-helper fix gets applied four 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: _TODO_ -- **Pros:** _TODO_ -- **Cons:** _TODO_ +### 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 3 of those 25 files + are live** (see below), 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. -### Option B: _TODO_ -- **Pros:** _TODO_ -- **Cons:** _TODO_ +- **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; + it does not need a bytes argument, and an earlier draft of this record made a + bytes argument that was backwards by ~30× (caught by the frame-critique pass). + +### 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 -_TODO: state the chosen option and the reasoning._ +**Option (a).** Not because of parity — as Context records, Codex's copy is +forced by its own rewrite table, so this *creates* parity rather than restoring +it. 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 four 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:** -- _TODO_ +- 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 three trees + (skills, hooks, templates). **Becomes harder:** -- _TODO_ +- `.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 - +Each load-bearing claim was probed. An earlier draft of this section said "None +unverified" while asserting two claims that were **not** probed and turned out to +be false (the retrofit claim, and Codex's copy being an unexplained asymmetry); +both are corrected above. That is the exact failure ADR-0020's grounding rule +exists to catch, and it was caught by the frame-critique pass, not by the author. -_Load-bearing factual claims about runnable surfaces (library/API capability, -version/perf behavior, behavior of existing code) must be backed by an executed -probe (run a command, read source/`node_modules`) or a citation — or listed -here explicitly as an assumption. Never assert an unverified claim as fact._ +Probed and verified: -_Risk-gated: omit this section (or write "None") when the decision has no -unverified load-bearing assumptions — do not pad with boilerplate._ +- All four helpers resolve templates via `parents[2]` — `decisions.py:73-81`, + `adr.py:73-81`, `workflow.py` `_render_stub_slice`, `memory.py` + `plugin_root()/templates/…`. +- 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 copies templates because its rewrite table redirects + `${CLAUDE_PLUGIN_ROOT}/templates/` into `.codex/templates/` — + `scaffold.py:944-947`; the Claude rewrite (`_PLUGIN_SKILL_PATH_RE`) matches + only `/skills//`. +- A scaffolded project's own copied `migrate.py` cannot retrofit templates — + probed (Open questions). -- _TODO_ +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 -_What would make this decision wrong? List the conditions that, if observed, -should reverse or shelve it. Risk-gated like Assumptions — write "None" or omit -when there is no meaningful kill condition; do not invent ceremonial ones._ +**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: -- _TODO_ +- **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 (with the warning that the + table was 2 entries long until review found the other 2). ## Open questions -_TODO: list any unresolved questions. If none, write "None."_ +- **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. + + Whether the re-run is the sanctioned backfill, and whether `migrate report` + should flag the gap so an affected project learns about it before the failure + rather than after, is out of scope for spec 095 and posted as a question on + [#109](https://github.com/ramboz/jig/issues/109). + +- **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/refinement-todo.md b/docs/refinement-todo.md index 8620acf..861dca6 100644 --- a/docs/refinement-todo.md +++ b/docs/refinement-todo.md @@ -60,10 +60,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 **four** — `workflow.py` (slice-template) and `memory.py` (people.md) read the tree through the identical shape, so an allowlist of the two templates in evidence would have left two consumers broken. 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/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..f3b0fd6 --- /dev/null +++ b/docs/specs/095-scaffold-template-copy/slice-01-claude-scaffold-templates.md @@ -0,0 +1,224 @@ +--- +status: DRAFT +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:** +- [ ] 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. _(This slice **resolves** the "how should + record helpers reach their templates in Claude scaffold mode?" entry.)_ + +### Close-out (post-DONE) + +- [ ] `docs/specs/README.md` regenerated by `workflow.py status-board`. +- [ ] Primer hygiene per spec 025-01: this slice closes the spec — compress the + Active-specs entry rather than growing it. + +**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". + +### 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 four helpers, not two. The spec undercounted.** Both 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` +through the identical shape, and a follow-up grep found a fourth: `memory.py` +reads `templates/docs/memory/people.md.template` for the people.md bootstrap — +its error message even said the failure "is expected for a scaffold-mode target", +documenting this same gap in a helper nobody had connected to it. Consequences: + +- Both helpers silently changed behaviour in scaffold mode (`workflow.py` stops + using its degraded inline slice template; `memory.py`'s bootstrap starts + working). Neither is in an AC; both are improvements, recorded here rather than + claimed. +- Their now-false comments/messages were re-premised in this slice. +- This is **evidence for the whole-tree copy over a scoped allowlist**: an + allowlist built from the two templates the ACs name would have shipped, looked + correct, and left two consumers broken. ADR-0038 argued the whole tree from + Codex parity and duplication cost; this is the stronger argument, and it was + found by review, not by design. The live-file table in ADR-0038 records all + four. + +**§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 is a + mechanical necessity of the Codex rewrite table, which redirects + `${CLAUDE_PLUGIN_ROOT}/templates/` into `.codex/templates/` + (`scaffold.py:944-947`). So this slice **creates** Claude/Codex parity rather + than restoring it — a weaker justification than the ADR first claimed, and the + Recommended Decision was rewritten to stand on the argument that survives. + +Both found by the frame-critique pass. + +**§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.** `_copy_claude_templates` now 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 Codex sibling still +has this; left alone as pre-existing and out of scope, noted here so it is not +lost. + +**§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. + +**§8 — Doc surfaces reconciled** (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 `skills/migrate/SKILL.md` +(the `copy-machinery` contract, which omitted the templates step for **both** +hosts and is the contract an agent reads before running the repair `decisions.py` +now recommends). + +**§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. Related: the arch +pass noted that a copied `scaffold-init` run in `--plugin-only` mode could now +read an already-rewritten template tree where it previously crashed — narrow, +precedented by Codex, and ADR-0038's first kill criterion covers it. 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..ef3f433 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: +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/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..be51e5e 100644 --- a/hosts/claude/skills/memory-sync/memory.py +++ b/hosts/claude/skills/memory-sync/memory.py @@ -65,10 +65,15 @@ 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 + a fourth member of the template-reading family (with `decisions.py`, + `adr.py`, and `workflow.py`'s slice-template render). 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 +83,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..ece4016 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 + record helpers seed from a template with no plugin root — + `decisions.py` (lightweight-decisions), `adr.py new`, and + `workflow.py`'s slice-template render 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..feb8e4d 100644 --- a/hosts/claude/skills/migrate/migrate.py +++ b/hosts/claude/skills/migrate/migrate.py @@ -1164,16 +1164,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): diff --git a/hosts/claude/skills/scaffold-init/scaffold.py b/hosts/claude/skills/scaffold-init/scaffold.py index dafa5bb..cfd66dd 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,51 @@ 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; no helper changes (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).""" @@ -1916,9 +1964,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 +2010,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..d388b7d 100644 --- a/hosts/claude/skills/spec-workflow/workflow.py +++ b/hosts/claude/skills/spec-workflow/workflow.py @@ -2942,7 +2942,12 @@ 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 a third member of the template-reading family + ADR-0038 describes, 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}" @@ -2951,9 +2956,10 @@ def _render_stub_slice(num_str: str, slice_num: str = "01", except OSError: # 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..be51e5e 100644 --- a/hosts/codex/plugins/jig/skills/memory-sync/memory.py +++ b/hosts/codex/plugins/jig/skills/memory-sync/memory.py @@ -65,10 +65,15 @@ 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 + a fourth member of the template-reading family (with `decisions.py`, + `adr.py`, and `workflow.py`'s slice-template render). 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 +83,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..10c6b62 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. diff --git a/hosts/codex/plugins/jig/skills/migrate/migrate.py b/hosts/codex/plugins/jig/skills/migrate/migrate.py index f7610c8..feb8e4d 100644 --- a/hosts/codex/plugins/jig/skills/migrate/migrate.py +++ b/hosts/codex/plugins/jig/skills/migrate/migrate.py @@ -1164,16 +1164,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): diff --git a/hosts/codex/plugins/jig/skills/scaffold-init/scaffold.py b/hosts/codex/plugins/jig/skills/scaffold-init/scaffold.py index dafa5bb..cfd66dd 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,51 @@ 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; no helper changes (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).""" @@ -1916,9 +1964,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 +2010,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..d388b7d 100644 --- a/hosts/codex/plugins/jig/skills/spec-workflow/workflow.py +++ b/hosts/codex/plugins/jig/skills/spec-workflow/workflow.py @@ -2942,7 +2942,12 @@ 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 a third member of the template-reading family + ADR-0038 describes, 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}" @@ -2951,9 +2956,10 @@ def _render_stub_slice(num_str: str, slice_num: str = "01", except OSError: # 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..be51e5e 100644 --- a/skills/memory-sync/memory.py +++ b/skills/memory-sync/memory.py @@ -65,10 +65,15 @@ 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 + a fourth member of the template-reading family (with `decisions.py`, + `adr.py`, and `workflow.py`'s slice-template render). 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 +83,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..ece4016 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 + record helpers seed from a template with no plugin root — + `decisions.py` (lightweight-decisions), `adr.py new`, and + `workflow.py`'s slice-template render 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..feb8e4d 100644 --- a/skills/migrate/migrate.py +++ b/skills/migrate/migrate.py @@ -1164,16 +1164,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): diff --git a/skills/scaffold-init/scaffold.py b/skills/scaffold-init/scaffold.py index dafa5bb..cfd66dd 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,51 @@ 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; no helper changes (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).""" @@ -1916,9 +1964,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 +2010,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..45ce1c7 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 @@ -462,6 +463,259 @@ 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 record +# helpers' existing `parents[2]/templates/` fallback resolves with no helper +# changes. Before this slice, `copy_machinery` copied `skills/` and `hooks/` +# only, so `decisions.py` and `adr.py` could not 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") + + def test_slice_template_is_reachable_by_the_third_family_consumer(self): + """The spec frames this as a two-helper family; it is three. + `workflow.py`'s `_render_stub_slice` reads + `parents[2]/templates/docs/specs/slice-template.md` with the same + shape, so in a scaffolded project the copy makes it stop falling back + to its degraded inline template. Not an AC — recorded as evidence + (deviation log §2) that copying the whole tree beat an allowlist of + the two templates the ACs happened to name.""" + copied = (self.target / ".claude" / "templates" / "docs" / "specs" + / "slice-template.md") + self.assertTrue(copied.is_file(), + f"slice-template.md not reachable at {copied}") + # ----- 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 +1191,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 diff --git a/skills/spec-workflow/workflow.py b/skills/spec-workflow/workflow.py index c85a7c4..d388b7d 100644 --- a/skills/spec-workflow/workflow.py +++ b/skills/spec-workflow/workflow.py @@ -2942,7 +2942,12 @@ 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 a third member of the template-reading family + ADR-0038 describes, 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}" @@ -2951,9 +2956,10 @@ def _render_stub_slice(num_str: str, slice_num: str = "01", except OSError: # 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 " From ae82950545a536aa5ed2bd164175310c0671ebb7 Mon Sep 17 00:00:00 2001 From: Marie-Rose <174208342+Kyarha@users.noreply.github.com> Date: Thu, 16 Jul 2026 23:26:05 -0700 Subject: [PATCH 4/6] =?UTF-8?q?fix(scaffold):=20reconcile=20095-01=20after?= =?UTF-8?q?=20review=20=E2=80=94=20five-helper=20family,=20gate=20order?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-implementation review (compliance + craft + arch + frame-critique, then a second round of each, plus reconciliation) found real defects. What changed: - workflow.py `_render_stub_slice` read its template at the default encoding inside `except OSError`. This slice makes that file reachable in scaffold mode for the first time, so under LANG=C a UnicodeDecodeError (a ValueError) would escape the fallback and CRASH where the old behaviour degraded cleanly. Fixed — a regression this slice would otherwise have introduced. - The template-reading family is FIVE helpers, not the two the spec assumed: `migrate.py`, `memory.py` and `workflow.py` resolve templates the same way. Found only by review. That is the argument for copying the whole tree rather than an allowlist of the two templates in evidence, which would have shipped looking correct and left consumers broken. - ADR-0038 asserted two claims as fact that were false: that `copy-machinery` retrofits existing projects (it cannot — a scaffolded project can't repair itself from inside), and that Codex's copy was an unexplained asymmetry. Both probed and corrected. - An AC4 test pinned its edge case against two files that never take the rewrite branch — the round-trip had zero coverage. Split and made real. - `status: Accepted` had been hand-stamped onto ADR-0038, bypassing the frame-critique gate that gates exactly this ADR. Reverted to Proposed and accepted through `adr.py accept` on a passing round-4 verdict; the order is frame-critique -> accept -> index -> resolve-todo. - The copy-machinery contract has three surfaces (SKILL.md, the hardcoded Codex render, migrate.py's --help + report text). All three now name templates. Reviews recorded under docs/specs/095-scaffold-template-copy/reviews/ and docs/decisions/reviews/. Slice DONE; spec 095 closed. Deviation log §1-§9 carries the full accounting; three out-of-scope follow-ups inboxed. Suite: 3.12 3508 -> 3518 OK (+10 new); 3.9 3361 OK unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- docs/architecture.md | 3 +- .../012-decisions-no-template-backfill.md | 4 +- .../adr-0038-claude-scaffold-template-copy.md | 141 ++++++---- .../reviews/adr-0038-frame-critique.md | 32 +++ docs/inbox.md | 3 + docs/memory/glossary.md | 4 + docs/refinement-todo.md | 2 +- .../reviews/slice-01-arch.md | 29 ++ .../reviews/slice-01-compliance.md | 30 ++ .../reviews/slice-01-craft.md | 31 +++ .../reviews/slice-01-reconciliation.md | 42 +++ .../slice-01-claude-scaffold-templates.md | 261 ++++++++++++++---- docs/specs/095-scaffold-template-copy/spec.md | 2 +- docs/specs/README.md | 2 + hosts/claude/skills/memory-sync/memory.py | 3 +- hosts/claude/skills/migrate/SKILL.md | 8 +- hosts/claude/skills/migrate/migrate.py | 9 +- hosts/claude/skills/scaffold-init/scaffold.py | 14 +- hosts/claude/skills/spec-workflow/workflow.py | 14 +- .../plugins/jig/skills/memory-sync/memory.py | 3 +- .../codex/plugins/jig/skills/migrate/SKILL.md | 3 +- .../plugins/jig/skills/migrate/migrate.py | 9 +- .../jig/skills/scaffold-init/scaffold.py | 14 +- .../jig/skills/spec-workflow/workflow.py | 14 +- skills/memory-sync/memory.py | 3 +- skills/migrate/SKILL.md | 8 +- skills/migrate/migrate.py | 9 +- skills/scaffold-init/scaffold.py | 14 +- skills/scaffold-init/test_scaffold_mode.py | 87 ++++-- skills/spec-workflow/test_workflow.py | 14 +- skills/spec-workflow/workflow.py | 14 +- 31 files changed, 641 insertions(+), 185 deletions(-) create mode 100644 docs/decisions/reviews/adr-0038-frame-critique.md create mode 100644 docs/specs/095-scaffold-template-copy/reviews/slice-01-arch.md create mode 100644 docs/specs/095-scaffold-template-copy/reviews/slice-01-compliance.md create mode 100644 docs/specs/095-scaffold-template-copy/reviews/slice-01-craft.md create mode 100644 docs/specs/095-scaffold-template-copy/reviews/slice-01-reconciliation.md diff --git a/docs/architecture.md b/docs/architecture.md index 911282b..b84f682 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -295,7 +295,8 @@ scaffold mode writes `AGENTS.md`, `CLAUDE.md`, `.claude/skills/`, 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`, and `workflow.py`'s slice-template render all read it there). Codex +`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) diff --git a/docs/bugs/012-decisions-no-template-backfill.md b/docs/bugs/012-decisions-no-template-backfill.md index 6d8cd76..05c6888 100644 --- a/docs/bugs/012-decisions-no-template-backfill.md +++ b/docs/bugs/012-decisions-no-template-backfill.md @@ -326,7 +326,9 @@ 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 helper changes. +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: diff --git a/docs/decisions/adr-0038-claude-scaffold-template-copy.md b/docs/decisions/adr-0038-claude-scaffold-template-copy.md index 87d9385..9b996eb 100644 --- a/docs/decisions/adr-0038-claude-scaffold-template-copy.md +++ b/docs/decisions/adr-0038-claude-scaffold-template-copy.md @@ -22,12 +22,22 @@ choice and its rejected alternatives outlive the branch — a future agent seein `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). -**Accepted** because the person entitled to make the call made it — that is the -acceptance act, and recording it as merely "Proposed" would misattribute his -decision to this branch. What is still pending is his review of **this record and -its implementation**, not of the choice: @ramboz to validate on the spec 095 PR. -If the record misstates his decision, the fix is to correct the record, not to -re-open the decision. +**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 @@ -48,18 +58,23 @@ and there is no plugin root: | 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` | ❌ | -`copy_machinery` copied `skills/` and `hooks/` into `.claude/`, but not -`templates/`. Only the Codex path copied templates — and **that copy is not an -accident Claude should mirror for symmetry's sake; it is a mechanical necessity -of the Codex rewrite table.** `CodexScaffoldRenderer` redirects -`${CLAUDE_PLUGIN_ROOT}/templates/` → `${CODEX_PROJECT_DIR:-$PWD}/.codex/templates/` -(`scaffold.py:944-947`), so copying templates is what makes its own rewritten -prose true. The Claude renderer rewrites only `/skills/<name>/` -(`_PLUGIN_SKILL_PATH_RE`) and never touches `/templates/`. So option (a) **creates** -Claude/Codex parity rather than restoring it — the honest framing, and a weaker -argument than "the Claude side is the anomaly" would suggest. What makes it right -is not parity; it is that the copy is the only thing that makes the helpers' -existing fallback true in a mode with no plugin root. +`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 `<project>/.codex` or `<project>/.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/<name>/` 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 @@ -88,18 +103,19 @@ 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). -Note the live list is also the strongest argument against a scoped copy: the two -templates this bug is *about* are 2 of the 4. `workflow.py` and `memory.py` read -the tree through the identical `parents[2]` shape and were found only by review — -an allowlist built from the ACs would have shipped, looked right, and left two -consumers broken. +**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: `workflow.py` and - `memory.py` resolve templates the same way (see the live-file table). A - per-helper fix gets applied four times, then again for the next one. + 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 @@ -117,8 +133,9 @@ so the helpers' existing `parents[2]` fallback resolves unchanged. 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 3 of those 25 files - are live** (see below), so the tree is mostly inert weight. And the drift story + 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 @@ -140,9 +157,8 @@ template file. - **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; - it does not need a bytes argument, and an earlier draft of this record made a - bytes argument that was backwards by ~30× (caught by the frame-critique pass). + 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. @@ -156,12 +172,11 @@ Scaffold mode stays unable to seed a record home; bug 012's mitigation stands. ## Recommended Decision -**Option (a).** Not because of parity — as Context records, Codex's copy is -forced by its own rewrite table, so this *creates* parity rather than restoring -it. 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 four 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. +**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", @@ -179,8 +194,8 @@ inside (Open questions). 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 three trees - (skills, hooks, templates). +- 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 @@ -190,30 +205,39 @@ inside (Open questions). ## Assumptions -Each load-bearing claim was probed. An earlier draft of this section said "None -unverified" while asserting two claims that were **not** probed and turned out to -be false (the retrofit claim, and Codex's copy being an unexplained asymmetry); -both are corrected above. That is the exact failure ADR-0020's grounding rule -exists to catch, and it was caught by the frame-critique pass, not by the author. - -Probed and verified: - -- All four helpers resolve templates via `parents[2]` — `decisions.py:73-81`, - `adr.py:73-81`, `workflow.py` `_render_stub_slice`, `memory.py` - `plugin_root()/templates/…`. -- The gap is real and reproduces — probed against a real `--with-machinery` +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: <project>/.claude/templates/…` and `template not found: <project>/.claude/templates/docs/decisions/adr-0000-template.md` (captured in the slice 095-01 deviation log; now pinned by `ClaudeScaffoldTemplatesTests`). -- Codex copies templates because its rewrite table redirects - `${CLAUDE_PLUGIN_ROOT}/templates/` into `.codex/templates/` — - `scaffold.py:944-947`; the Claude rewrite (`_PLUGIN_SKILL_PATH_RE`) matches - only `/skills/<name>/`. -- A scaffolded project's own copied `migrate.py` cannot retrofit templates — +- **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/<name>/`. +- **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 @@ -237,8 +261,9 @@ decision, not a formality: 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 (with the warning that the - table was 2 entries long until review found the other 2). + 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 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+<heading>\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* `## <heading>` 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]+<heading>(?=[ \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..f519bbb 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 `<project>/.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 (ADR-0038 Open questions; asked on [#109](https://github.com/ramboz/jig/issues/109)). + ## 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-<pass>.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-<pass>.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 861dca6..7d6b135 100644 --- a/docs/refinement-todo.md +++ b/docs/refinement-todo.md @@ -65,7 +65,7 @@ Resolved by [Spec 025-01](specs/025-claude-md-hygiene/spec.md) — onboarding co **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 **four** — `workflow.py` (slice-template) and `memory.py` (people.md) read the tree through the identical shape, so an allowlist of the two templates in evidence would have left two consumers broken. 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). +**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 index f3b0fd6..213d3b8 100644 --- 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 @@ -1,5 +1,5 @@ --- -status: DRAFT +status: DONE dependencies: [] last_verified: 2026-07-16 arch_review: true @@ -74,24 +74,34 @@ option was the maintainer's call (spec Overview); the review pass is for the copy would be dead weight. **DoD:** -- [ ] All ACs pass; full test suite green (no regressions). -- [ ] Implementer test coverage exercises each AC with at least one +- [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. -- [ ] Reviewed by `reviewer` subagent. Reviewer prompt built by + _(AC4's edge case was covered vacuously at first — see §5.)_ +- [x] 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 +- [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) -- [ ] `docs/specs/README.md` regenerated by `workflow.py status-board`. -- [ ] Primer hygiene per spec 025-01: this slice closes the spec — compress the - Active-specs entry rather than growing it. +- [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:** @@ -109,6 +119,28 @@ 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: @@ -132,26 +164,43 @@ Test-level red: 3 failures + 2 errors across the new `ClaudeScaffoldTemplatesTes 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 four helpers, not two. The spec undercounted.** Both 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` -through the identical shape, and a follow-up grep found a fourth: `memory.py` -reads `templates/docs/memory/people.md.template` for the people.md bootstrap — -its error message even said the failure "is expected for a scaffold-mode target", -documenting this same gap in a helper nobody had connected to it. Consequences: - -- Both helpers silently changed behaviour in scaffold mode (`workflow.py` stops - using its degraded inline slice template; `memory.py`'s bootstrap starts - working). Neither is in an AC; both are improvements, recorded here rather than - claimed. -- Their now-false comments/messages were re-premised in this slice. -- This is **evidence for the whole-tree copy over a scoped allowlist**: an - allowlist built from the two templates the ACs name would have shipped, looked - correct, and left two consumers broken. ADR-0038 argued the whole tree from - Codex parity and duplication cost; this is the stronger argument, and it was - found by review, not by design. The live-file table in ADR-0038 records all - four. +**§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 @@ -171,14 +220,27 @@ carrying them: <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 is a - mechanical necessity of the Codex rewrite table, which redirects - `${CLAUDE_PLUGIN_ROOT}/templates/` into `.codex/templates/` - (`scaffold.py:944-947`). So this slice **creates** Claude/Codex parity rather - than restoring it — a weaker justification than the ADR first claimed, and the - Recommended Decision was rewritten to stand on the argument that survives. - -Both found by the frame-critique pass. +- *"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 @@ -190,13 +252,29 @@ the byte-copy branch and the rewrite round-trip had **zero** coverage. Split int 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.** `_copy_claude_templates` now 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 Codex sibling still -has this; left alone as pre-existing and out of scope, noted here so it is not -lost. +**§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 @@ -208,17 +286,88 @@ 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. -**§8 — Doc surfaces reconciled** (live prose, corrected inline per ADR-0010): +**§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 `skills/migrate/SKILL.md` -(the `copy-machinery` contract, which omitted the templates step for **both** -hosts and is the contract an agent reads before running the repair `decisions.py` -now recommends). +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. Related: the arch -pass noted that a copied `scaffold-init` run in `--plugin-only` mode could now -read an already-rewritten template tree where it previously crashed — narrow, -precedented by Codex, and ADR-0038's first kill criterion covers it. +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/spec.md b/docs/specs/095-scaffold-template-copy/spec.md index ef3f433..c9b716c 100644 --- a/docs/specs/095-scaffold-template-copy/spec.md +++ b/docs/specs/095-scaffold-template-copy/spec.md @@ -1,5 +1,5 @@ --- -status: DRAFT +status: DONE skill: scaffold-init use_cases: [] --- diff --git a/docs/specs/README.md b/docs/specs/README.md index 40d0b0c..34be121 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -291,6 +291,8 @@ | [091-bug-fix-repository-closure](091-bug-fix-repository-closure/spec.md) | 091-01 — repository-closure evidence and gates | DRAFT | | | [092-existing-subtree-layout-adoption](092-existing-subtree-layout-adoption/spec.md) | 092-01 — adopt an existing custom-root corpus | **DONE** | | | [093-semantic-index-provider-discovery](093-semantic-index-provider-discovery/spec.md) | 093-01 — discover installed providers and explain absence | **DONE** | | +| [094-capture-hygiene](094-capture-hygiene/spec.md) | 094-01 — tbd | DRAFT | | +| [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/memory.py b/hosts/claude/skills/memory-sync/memory.py index be51e5e..ffa0adf 100644 --- a/hosts/claude/skills/memory-sync/memory.py +++ b/hosts/claude/skills/memory-sync/memory.py @@ -71,8 +71,7 @@ def _bootstrap_people_md(target: Path) -> tuple: 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 - a fourth member of the template-reading family (with `decisions.py`, - `adr.py`, and `workflow.py`'s slice-template render). The degrade path now + 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(): diff --git a/hosts/claude/skills/migrate/SKILL.md b/hosts/claude/skills/migrate/SKILL.md index ece4016..d3e0db5 100644 --- a/hosts/claude/skills/migrate/SKILL.md +++ b/hosts/claude/skills/migrate/SKILL.md @@ -365,10 +365,10 @@ What it does: 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 - record helpers seed from a template with no plugin root — - `decisions.py` (lightweight-decisions), `adr.py new`, and - `workflow.py`'s slice-template render all resolve - `parents[2]/templates/`. Claude gained this in + 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 diff --git a/hosts/claude/skills/migrate/migrate.py b/hosts/claude/skills/migrate/migrate.py index feb8e4d..60c9851 100644 --- a/hosts/claude/skills/migrate/migrate.py +++ b/hosts/claude/skills/migrate/migrate.py @@ -658,8 +658,8 @@ 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." ) @@ -2039,8 +2039,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 cfd66dd..25ac361 100644 --- a/hosts/claude/skills/scaffold-init/scaffold.py +++ b/hosts/claude/skills/scaffold-init/scaffold.py @@ -809,8 +809,9 @@ def _copy_claude_templates(plugin: Path, target: Path) -> None: `<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; no helper changes (ADR-0038; the option the - maintainer picked over embedding templates in each helper). + 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 @@ -1187,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` " diff --git a/hosts/claude/skills/spec-workflow/workflow.py b/hosts/claude/skills/spec-workflow/workflow.py index d388b7d..34a3bfe 100644 --- a/hosts/claude/skills/spec-workflow/workflow.py +++ b/hosts/claude/skills/spec-workflow/workflow.py @@ -2946,14 +2946,20 @@ def _render_stub_slice(num_str: str, slice_num: str = "01", 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 a third member of the template-reading family - ADR-0038 describes, and it silently stopped needing the fallback there.""" + 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 # project scaffolded before slice 095-01, which is when copied diff --git a/hosts/codex/plugins/jig/skills/memory-sync/memory.py b/hosts/codex/plugins/jig/skills/memory-sync/memory.py index be51e5e..ffa0adf 100644 --- a/hosts/codex/plugins/jig/skills/memory-sync/memory.py +++ b/hosts/codex/plugins/jig/skills/memory-sync/memory.py @@ -71,8 +71,7 @@ def _bootstrap_people_md(target: Path) -> tuple: 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 - a fourth member of the template-reading family (with `decisions.py`, - `adr.py`, and `workflow.py`'s slice-template render). The degrade path now + 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(): diff --git a/hosts/codex/plugins/jig/skills/migrate/SKILL.md b/hosts/codex/plugins/jig/skills/migrate/SKILL.md index 10c6b62..c6502e0 100644 --- a/hosts/codex/plugins/jig/skills/migrate/SKILL.md +++ b/hosts/codex/plugins/jig/skills/migrate/SKILL.md @@ -349,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 feb8e4d..60c9851 100644 --- a/hosts/codex/plugins/jig/skills/migrate/migrate.py +++ b/hosts/codex/plugins/jig/skills/migrate/migrate.py @@ -658,8 +658,8 @@ 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." ) @@ -2039,8 +2039,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 cfd66dd..25ac361 100644 --- a/hosts/codex/plugins/jig/skills/scaffold-init/scaffold.py +++ b/hosts/codex/plugins/jig/skills/scaffold-init/scaffold.py @@ -809,8 +809,9 @@ def _copy_claude_templates(plugin: Path, target: Path) -> None: `<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; no helper changes (ADR-0038; the option the - maintainer picked over embedding templates in each helper). + 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 @@ -1187,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` " diff --git a/hosts/codex/plugins/jig/skills/spec-workflow/workflow.py b/hosts/codex/plugins/jig/skills/spec-workflow/workflow.py index d388b7d..34a3bfe 100644 --- a/hosts/codex/plugins/jig/skills/spec-workflow/workflow.py +++ b/hosts/codex/plugins/jig/skills/spec-workflow/workflow.py @@ -2946,14 +2946,20 @@ def _render_stub_slice(num_str: str, slice_num: str = "01", 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 a third member of the template-reading family - ADR-0038 describes, and it silently stopped needing the fallback there.""" + 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 # project scaffolded before slice 095-01, which is when copied diff --git a/skills/memory-sync/memory.py b/skills/memory-sync/memory.py index be51e5e..ffa0adf 100644 --- a/skills/memory-sync/memory.py +++ b/skills/memory-sync/memory.py @@ -71,8 +71,7 @@ def _bootstrap_people_md(target: Path) -> tuple: 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 - a fourth member of the template-reading family (with `decisions.py`, - `adr.py`, and `workflow.py`'s slice-template render). The degrade path now + 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(): diff --git a/skills/migrate/SKILL.md b/skills/migrate/SKILL.md index ece4016..d3e0db5 100644 --- a/skills/migrate/SKILL.md +++ b/skills/migrate/SKILL.md @@ -365,10 +365,10 @@ What it does: 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 - record helpers seed from a template with no plugin root — - `decisions.py` (lightweight-decisions), `adr.py new`, and - `workflow.py`'s slice-template render all resolve - `parents[2]/templates/`. Claude gained this in + 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 diff --git a/skills/migrate/migrate.py b/skills/migrate/migrate.py index feb8e4d..60c9851 100644 --- a/skills/migrate/migrate.py +++ b/skills/migrate/migrate.py @@ -658,8 +658,8 @@ 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." ) @@ -2039,8 +2039,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/scaffold-init/scaffold.py b/skills/scaffold-init/scaffold.py index cfd66dd..25ac361 100644 --- a/skills/scaffold-init/scaffold.py +++ b/skills/scaffold-init/scaffold.py @@ -809,8 +809,9 @@ def _copy_claude_templates(plugin: Path, target: Path) -> None: `<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; no helper changes (ADR-0038; the option the - maintainer picked over embedding templates in each helper). + 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 @@ -1187,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` " diff --git a/skills/scaffold-init/test_scaffold_mode.py b/skills/scaffold-init/test_scaffold_mode.py index 45ce1c7..d5fb7c2 100644 --- a/skills/scaffold-init/test_scaffold_mode.py +++ b/skills/scaffold-init/test_scaffold_mode.py @@ -465,10 +465,10 @@ def test_scaffolded_helper_imports_common_at_runtime(self): # -------------------------------------------------------------------------- # Spec 095-01 — Claude scaffold mode copies `templates/` into `.claude/`, -# mirroring the Codex side's `_copy_codex_templates`, so the copied record -# helpers' existing `parents[2]/templates/` fallback resolves with no helper -# changes. Before this slice, `copy_machinery` copied `skills/` and `hooks/` -# only, so `decisions.py` and `adr.py` could not reach a template in the one +# 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. # -------------------------------------------------------------------------- @@ -669,18 +669,73 @@ def test_ac4_non_md_templates_are_byte_copied(self): checked += 1 self.assertTrue(checked, "fixture check: no byte-copied templates found") - def test_slice_template_is_reachable_by_the_third_family_consumer(self): - """The spec frames this as a two-helper family; it is three. - `workflow.py`'s `_render_stub_slice` reads - `parents[2]/templates/docs/specs/slice-template.md` with the same - shape, so in a scaffolded project the copy makes it stop falling back - to its degraded inline template. Not an AC — recorded as evidence - (deviation log §2) that copying the whole tree beat an allowlist of - the two templates the ACs happened to name.""" - copied = (self.target / ".claude" / "templates" / "docs" / "specs" - / "slice-template.md") - self.assertTrue(copied.is_file(), - f"slice-template.md not reachable at {copied}") + # ----- 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 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 d388b7d..34a3bfe 100644 --- a/skills/spec-workflow/workflow.py +++ b/skills/spec-workflow/workflow.py @@ -2946,14 +2946,20 @@ def _render_stub_slice(num_str: str, slice_num: str = "01", 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 a third member of the template-reading family - ADR-0038 describes, and it silently stopped needing the fallback there.""" + 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 # project scaffolded before slice 095-01, which is when copied From 34439fa69438efae03887072ec663c204d2b26cb Mon Sep 17 00:00:00 2001 From: Marie-Rose <174208342+Kyarha@users.noreply.github.com> Date: Fri, 17 Jul 2026 09:57:51 -0700 Subject: [PATCH 5/6] test(scaffold): run the scaffold-mode suite on Python 3.9 instead of skipping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The file-wide `load_tests` hook returned an empty suite below Python 3.11, so CI's 3.9 job silently dropped all ~90 tests in test_scaffold_mode.py — including this spec's new 095 template-copy tests — over just two `tomllib`-dependent Codex assertions. Remove the gate and add `_load_agent_toml`: it uses `tomllib` on 3.11+ and, below that (jig ships zero dependencies, so no `tomli` fallback), parses the exact TOML shape jig's own `render_codex_agent_toml` emits. Writer and reader are both jig's, kept in step — not a general TOML parser. The two Codex tests now pass on 3.9 rather than skip. Full file: 91 tests, all pass on 3.9 (Python 3.9.6). Per maintainer: skipping tests on 3.9 is not acceptable — they must pass there. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- skills/scaffold-init/test_scaffold_mode.py | 68 +++++++++++++++++----- 1 file changed, 54 insertions(+), 14 deletions(-) diff --git a/skills/scaffold-init/test_scaffold_mode.py b/skills/scaffold-init/test_scaffold_mode.py index d5fb7c2..672049c 100644 --- a/skills/scaffold-init/test_scaffold_mode.py +++ b/skills/scaffold-init/test_scaffold_mode.py @@ -24,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() @@ -1687,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"]) @@ -1717,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") @@ -2637,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 From 1e731e26765c94b98caafcdac4c518eb6586529f Mon Sep 17 00:00:00 2001 From: Marie-Rose <174208342+Kyarha@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:28:50 -0700 Subject: [PATCH 6/6] feat(migrate): report flags pre-095 projects missing .claude/templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retrofit already works — copy-machinery carries templates/ since 095-01 — but migrate report never told an affected project to run it: the copy-machinery suggestion was suppressed once any jig machinery was present, so a project scaffolded before 095-01 (skills copied, templates/ absent) got no nudge and would only discover the gap when a record helper failed. Add the discoverability half (ADR-0038 retrofit open question, resolved by the maintainer on #119): - Inventory records `jig_templates_present` (any file under .claude/templates/). - render_operations routes a project with jig machinery but no templates tree to `copy-machinery`, stating the backfill runs from a jig install (a scaffolded project can't repair itself from its own copied files). TDD: TemplatesBackfillOperationsTests (4 cases, red→green). Existing suppression test now seeds a complete install (skills + templates) since templates are part of a complete install. Full migrate suite 162 OK on 3.9. Host mirrors rebuilt (drift guard green). ADR-0038 + glossary updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- .../adr-0038-claude-scaffold-template-copy.md | 14 +++- docs/memory/glossary.md | 2 +- hosts/claude/skills/migrate/migrate.py | 38 +++++++++ .../plugins/jig/skills/migrate/migrate.py | 38 +++++++++ skills/migrate/migrate.py | 38 +++++++++ skills/migrate/test_migrate.py | 82 ++++++++++++++++++- 6 files changed, 205 insertions(+), 7 deletions(-) diff --git a/docs/decisions/adr-0038-claude-scaffold-template-copy.md b/docs/decisions/adr-0038-claude-scaffold-template-copy.md index 9b996eb..d9f312b 100644 --- a/docs/decisions/adr-0038-claude-scaffold-template-copy.md +++ b/docs/decisions/adr-0038-claude-scaffold-template-copy.md @@ -283,10 +283,16 @@ decision, not a formality: That is the honest limit of (a): it fixes every project scaffolded *after* it, not the ones already out there. - Whether the re-run is the sanctioned backfill, and whether `migrate report` - should flag the gap so an affected project learns about it before the failure - rather than after, is out of scope for spec 095 and posted as a question on - [#109](https://github.com/ramboz/jig/issues/109). + **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 diff --git a/docs/memory/glossary.md b/docs/memory/glossary.md index f519bbb..b3a423e 100644 --- a/docs/memory/glossary.md +++ b/docs/memory/glossary.md @@ -172,7 +172,7 @@ EngTip #21's five test *types*, distinguished by **scope** (how much of the syst ## 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 `<project>/.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 (ADR-0038 Open questions; asked on [#109](https://github.com/ramboz/jig/issues/109)). +[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 `<project>/.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 diff --git a/hosts/claude/skills/migrate/migrate.py b/hosts/claude/skills/migrate/migrate.py index 60c9851..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): @@ -663,6 +676,31 @@ def render_operations(inv: Inventory, verdict: str) -> str: "`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. diff --git a/hosts/codex/plugins/jig/skills/migrate/migrate.py b/hosts/codex/plugins/jig/skills/migrate/migrate.py index 60c9851..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): @@ -663,6 +676,31 @@ def render_operations(inv: Inventory, verdict: str) -> str: "`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. diff --git a/skills/migrate/migrate.py b/skills/migrate/migrate.py index 60c9851..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): @@ -663,6 +676,31 @@ def render_operations(inv: Inventory, verdict: str) -> str: "`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. 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."""