diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 60738a3..aab78f8 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -12,7 +12,7 @@ "name": "lcd", "source": "./", "description": "Lean Context Development — adaptive-weight agentic dev methodology (Quick / Standard / Deep lanes).", - "version": "0.20.0" + "version": "0.20.1" } ] } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index d9ace34..3a3c607 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "lcd", "displayName": "Lean Context Development", - "version": "0.20.0", + "version": "0.20.1", "description": "Context-economy agentic dev methodology: process weight adapts to the work (Quick / Standard / Deep lanes) and durable artifacts make a context reset cheap.", "author": { "name": "stid" diff --git a/CHANGELOG.md b/CHANGELOG.md index 95d1584..e853429 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ All notable changes to this project are documented here. Format loosely follows Development is tracked by decision IDs (`D-NNN`) in the project's internal decision log; entries cite them for traceability. +## [0.20.1] — 2026-10-24 + +### Fixed +- **Edit-boundary hook** checks `NotebookEdit`, whose path arrives as + `tool_input.notebook_path`. Before this, notebook edits outside the boundary were allowed. It + also resolves `.` and `..` before matching: `src/x/../../docs/y` is checked as `docs/y`, + and a newline in the path can't cut the resolution short (#21). +- **The AC convention now actually auto-loads (#22).** Plugins have no rules component, so the + plugin's `ac-convention` never loaded on its own. Onboard now always writes the project copy + `.claude/rules/ac-convention.md`. If a copy exists whose `paths:` doesn't cover the artifact + root, onboard rewrites that one line. Doctor checks the copy at every root: WARN on the + default root (projects onboarded earlier: re-run `/lcd:onboard`), FAIL on a custom root as + before. Coverage is judged on the `paths:` globs (`"/**"`), so root `docs` no longer + passes on the stock `docs/lcd` globs. Docs no longer claim the plugin copy auto-loads. +- **Boundary-hook tests:** the fixture's branch switch-back failed silently in a repo with no + commits, so every later case ran with no active work-item and passed without testing anything. + ## [0.20.0] — 2026-10-24 Vendoring: LCD frozen into a project, so contributors get it by cloning, no plugin install. diff --git a/README.md b/README.md index a94a60e..a33eb7a 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,8 @@ All namespaced under `lcd:` (e.g. `/lcd:plan`). LCD **bundles** the rules its lanes depend on (`testing`, `no-overengineering`, `no-downgrade`, `refinement-protocol`, `versioning`, `commits`, `ac-convention`) so it works on any project without -relying on your machine's global config. A project may override any rule with its own +relying on your machine's global config. Onboarding copies `ac-convention` into the project's +`.claude/rules/` so it auto-loads on specs and JOURNALs. A project may override any rule with its own `.claude/rules/.md`. The project-side artifacts (MAP, DECISIONS, JOURNAL, SPEC) are plain Markdown and tool-agnostic: diff --git a/bin/lcd-boundary-check.sh b/bin/lcd-boundary-check.sh index dcb092c..071b503 100755 --- a/bin/lcd-boundary-check.sh +++ b/bin/lcd-boundary-check.sh @@ -18,13 +18,34 @@ input="$(cat)" lcd_resolve_project || exit 0 file_path="$(lcd_json_field "$input" "tool_input.file_path")" +# NotebookEdit names its target notebook_path, not file_path. +[[ -n "$file_path" ]] || file_path="$(lcd_json_field "$input" "tool_input.notebook_path")" [[ -n "$file_path" ]] || exit 0 +# resolve_dots — drop `.` and resolve `..` lexically (the file may not exist yet), +# so `src/x/../../docs/y` is matched as `docs/y`, not as a path under src/. +resolve_dots() { + # Split on / by word splitting, not `read` (which stops at a newline and would resolve only + # the first line of the path). set -f: a segment like `*` must not glob. + local part parts out=() IFS=/ + set -f; parts=($1); set +f + for part in ${parts[@]+"${parts[@]}"}; do + case "$part" in + ""|.) ;; + ..) [[ ${#out[@]} -gt 0 ]] && unset 'out[${#out[@]}-1]' ;; + *) out+=("$part") ;; + esac + done + printf '/%s' ${out[@]+"${out[@]}"} +} + # Normalize to project-relative; paths outside the project are not ours to police. +[[ "$file_path" == /* ]] || file_path="$LCD_PROJ/$file_path" +file_path="$(resolve_dots "$file_path")" +proj_abs="$(resolve_dots "$LCD_PROJ")" case "$file_path" in - "$LCD_PROJ"/*) rel="${file_path#"$LCD_PROJ"/}" ;; - /*) exit 0 ;; - *) rel="$file_path" ;; + "$proj_abs"/*) rel="${file_path#"$proj_abs"/}" ;; + *) exit 0 ;; esac # Always-allowed: LCD's own artifacts and project Claude config. diff --git a/bin/lcd-doctor.sh b/bin/lcd-doctor.sh index 4b87b9a..18fd1e0 100755 --- a/bin/lcd-doctor.sh +++ b/bin/lcd-doctor.sh @@ -93,14 +93,18 @@ if [[ -n "$root" ]] && ! is_placeholder "$root"; then || warn "living-spec: on but $specrel missing — reconcile has nowhere to fold (seed it from the living-spec template, or set living-spec: off)" fi - # B1 invariant: non-default root needs the project-local ac-convention override. - if [[ "$root" != "docs/lcd" ]]; then - acf="$proj/.claude/rules/ac-convention.md" - if [[ -f "$acf" ]] && grep -q "$root" "$acf"; then - ok "ac-convention: project override present for non-default root" - else - fail "ac-convention: artifact-root is '$root' but no project override at .claude/rules/ac-convention.md covers it — the AC rule will never auto-load (re-run /lcd:onboard)" - fi + # B1 invariant: the AC rule auto-loads only from a project copy — plugins have no rules/ + # component. FAIL on a non-default root (as before); WARN on the default root, so projects + # onboarded before onboard wrote the copy don't start failing the gate. + # Coverage is judged on the paths: globs alone — a bare substring would let root `docs` + # pass on the stock `"docs/lcd/**…"` globs, which never match docs/work//spec.md. + acf="$proj/.claude/rules/ac-convention.md" + if [[ -f "$acf" ]] && grep -m1 '^paths:' "$acf" | grep -qF "\"$root/**"; then + ok "ac-convention: project copy covers $root" + elif [[ "$root" != "docs/lcd" ]]; then + fail "ac-convention: artifact-root is '$root' but no project override at .claude/rules/ac-convention.md covers it — the AC rule will never auto-load (re-run /lcd:onboard)" + else + warn "ac-convention: no .claude/rules/ac-convention.md covering $root — the AC rule never auto-loads from the plugin (re-run /lcd:onboard to write it)" fi fi diff --git a/commands/doctor.md b/commands/doctor.md index 9eadb94..c60bcd1 100644 --- a/commands/doctor.md +++ b/commands/doctor.md @@ -27,8 +27,8 @@ artifacts). `/lcd:onboard` (retrofit mode re-detects and fills the block in place). - watch-mode test commands (`vitest` without `run`, `jest` without `--watchAll=false`) → edit the key to the non-watch form; this is what hangs the red-green loop. - - missing `ac-convention` override on a non-default artifact root → re-run - `/lcd:onboard` (it writes `.claude/rules/ac-convention.md` with the right globs). + - missing `.claude/rules/ac-convention.md` (WARN on the default root, FAIL on a custom + one) → re-run `/lcd:onboard`; it writes the copy with the right globs. 3. **Fix only on approval**, then re-run the script to confirm it goes green. diff --git a/rules/ac-convention.md b/rules/ac-convention.md index f59cea7..8562a1e 100644 --- a/rules/ac-convention.md +++ b/rules/ac-convention.md @@ -3,12 +3,10 @@ paths: ["docs/lcd/**/spec.md", "docs/lcd/**/JOURNAL.md"] --- # Acceptance Criteria Convention -> The AC convention for LCD. `paths:` covers the **default** artifact root (`docs/lcd/**`), so this -> rule auto-loads when you open a spec or JOURNAL there. If a project chose a different -> `artifact-root` at onboarding, `/lcd:onboard` writes a project-local copy of this rule to -> `.claude/rules/ac-convention.md` with `paths:` globs derived from that root (a project rule -> overrides the bundled one of the same name). Used in the Deep lane and in any Standard-lane -> JOURNAL that declares a surface. +> The AC convention for LCD. Plugins can't ship auto-loading rules, so `/lcd:onboard` writes a +> copy of this file to the project's `.claude/rules/ac-convention.md`, where the `paths:` scope +> makes it auto-load when you open a spec or JOURNAL (globs rewritten for a non-default +> `artifact-root`). Used in the Deep lane and in any Standard-lane JOURNAL that declares a surface. Every acceptance criterion in a work-item `spec.md` (or a Standard-lane `JOURNAL.md`) MUST follow this format: diff --git a/rules/lcd.md b/rules/lcd.md index 13ffb1d..9d07209 100644 --- a/rules/lcd.md +++ b/rules/lcd.md @@ -122,6 +122,7 @@ project, a missing dependency, or an unfilled artifact always means "allow": LCD **ships** the rules its lanes depend on, so it works on any project without relying on your machine's global config. They live in the plugin and are read via `${CLAUDE_PLUGIN_ROOT}/rules/.md`: `testing`, `no-overengineering`, `no-downgrade`, `refinement-protocol`, `versioning`, `commits`, -plus `ac-convention` (the one with `paths:` frontmatter, so it auto-loads on specs/JOURNALs). The +plus `ac-convention` (the one with `paths:` frontmatter; onboard copies it into the project's +`.claude/rules/`, the only place it auto-loads from, since plugins have no rules component). The Deep-lane plan's Constitution check reads them from there. **A project may override any rule** with its own `.claude/rules/.md` — the project's version wins when present. diff --git a/skills/onboard/SKILL.md b/skills/onboard/SKILL.md index d1cc3ae..0a837fb 100644 --- a/skills/onboard/SKILL.md +++ b/skills/onboard/SKILL.md @@ -108,11 +108,13 @@ the path to `lcd:onboard-skipped.log`; otherwise write it. Never overwrite. **Constitution notes** by scanning the project's `CLAUDE.md` + `.claude/rules/*` for must-restate constraints (no `any`, design tokens, version bump, dual-engine parity, etc.). -2. **`.claude/rules/ac-convention.md`** — **only when the chosen artifact root is NOT the default - `docs/lcd`**: copy `${CLAUDE_PLUGIN_ROOT}/rules/ac-convention.md` and rewrite its `paths:` - frontmatter to the chosen root (`["/**/spec.md", "/**/JOURNAL.md"]`), so the rule - still auto-loads on specs/JOURNALs. The bundled rule's globs only cover the default root; without - this override the convention never auto-loads on a custom root. Skip when root = `docs/lcd`. +2. **`.claude/rules/ac-convention.md`** — **always**: copy `${CLAUDE_PLUGIN_ROOT}/rules/ac-convention.md` + and, for a non-default root, rewrite its `paths:` frontmatter to that root + (`["/**/spec.md", "/**/JOURNAL.md"]`). Plugins can't ship auto-loading rules, so + this project copy is the only way the convention auto-loads on specs/JOURNALs. **The one + exception to never-overwrite:** if the file exists but its `paths:` doesn't cover `` (e.g. + a vendored copy seeded for `docs/lcd`), rewrite only that `paths:` line, keep the body, and + log it in the skip-log as `rewrote paths:`. 3. **`/MAP.md`** — from `${CLAUDE_PLUGIN_ROOT}/templates/MAP.md`, filled from the **actual** structure (Step 1): real top-level zones with their paths and responsibilities, real entry points, the surfaces actually exposed, and any invariants you can infer from CLAUDE.md/rules (e.g. the @@ -200,8 +202,8 @@ list below mechanically and must report **0 FAIL**. Then confirm: - `/DECISIONS.md` exists (seeded or empty header). - If `living-spec: on`, `/SPEC.md` exists (from the living-spec template); if `off`, it is absent and that's correct. -- Non-default artifact root → `.claude/rules/ac-convention.md` exists with `paths:` globs matching - that root (otherwise the AC convention never auto-loads). +- `.claude/rules/ac-convention.md` exists with `paths:` globs matching the artifact root + (otherwise the AC convention never auto-loads). - `settings.json` (or `settings.local.json` if the user chose machine-local) still parses as JSON. - CLAUDE.md has exactly one LCD pointer; AGENTS.md (if the project has one) too. - **If Step 1 found nothing that reviews the diff, say so once, in one line, and move on.** LCD's diff --git a/tests/test-boundary-check.sh b/tests/test-boundary-check.sh index 02913f8..9a423d7 100755 --- a/tests/test-boundary-check.sh +++ b/tests/test-boundary-check.sh @@ -99,7 +99,10 @@ out="$(run_hook "/somewhere/else/file.txt")" git -C "$proj" checkout -q -b another-branch out="$(run_hook "$proj/src/other/unrelated.ts")" [[ -z "$out" ]] || fail "no branch match must allow, got: $out" -git -C "$proj" checkout -q feat/export-csv +# symbolic-ref, not checkout: the fixture repo has no commits, so `checkout` back to the +# unborn feat/export-csv fails silently and every later case would run with nothing active. +git -C "$proj" symbolic-ref HEAD refs/heads/feat/export-csv +[[ "$(git -C "$proj" symbolic-ref --short HEAD)" == "feat/export-csv" ]] || fail "fixture: branch switch-back failed" # --- all steps done → work-item inactive → allow ------------------------------ sedi 's/- \[ \] S2/- [x] S2/' "$proj/docs/lcd/work/export-csv/JOURNAL.md" @@ -107,9 +110,40 @@ out="$(run_hook "$proj/src/other/unrelated.ts")" [[ -z "$out" ]] || fail "done work-item must not enforce, got: $out" sedi 's/- \[x\] S2/- [ ] S2/' "$proj/docs/lcd/work/export-csv/JOURNAL.md" +# --- AC-1 (CLI): NotebookEdit sends notebook_path, not file_path --------------- +run_nb() { + CLAUDE_PROJECT_DIR="$proj" bash ${DBG:+-x} "$PLUGIN_BIN/lcd-boundary-check.sh" <`|; s|- `tests/export.test.ts`|- ``|' \ - "$proj/docs/lcd/work/export-csv/JOURNAL.md" +# Every boundary bullet becomes a template placeholder (no real entry left). +sedi '/^## EDIT BOUNDARY/,/lcd-resume -->/s|^- `.*|- ``|' "$proj/docs/lcd/work/export-csv/JOURNAL.md" +[[ "$(grep -c '^- ``' "$proj/docs/lcd/work/export-csv/JOURNAL.md")" -ge 2 ]] || fail "fixture: placeholders not written" out="$(run_hook "$proj/src/other/unrelated.ts")" [[ -z "$out" ]] || fail "placeholder boundary must fail open, got: $out" diff --git a/tests/test-doctor.sh b/tests/test-doctor.sh index 92a7883..e888933 100755 --- a/tests/test-doctor.sh +++ b/tests/test-doctor.sh @@ -36,9 +36,21 @@ assert_contains "$out" "not onboarded" "names the problem" write_conventions touch "$proj/docs/lcd/MAP.md" "$proj/docs/lcd/DECISIONS.md" printf '## LCD\nlcd:triage routes new work.\n' > "$proj/CLAUDE.md" +default_ac() { + printf -- '---\npaths: ["docs/lcd/**/spec.md", "docs/lcd/**/JOURNAL.md"]\n---\n' > "$proj/.claude/rules/ac-convention.md" +} + +# --- AC-3 (CLI): default root without the project ac-convention copy → WARN, not FAIL --- +# (plugins have no rules/ component, so only the project copy ever auto-loads) +out="$(run_doctor)"; code=$? +assert_exit 0 "$code" "AC-3 (CLI) missing default-root ac-convention does not fail" +assert_contains "$out" "WARN — ac-convention" "AC-3 (CLI) missing default-root ac-convention warns" + +default_ac out="$(run_doctor)"; code=$? assert_exit 0 "$code" "healthy setup passes" assert_contains "$out" "doctor: 0 FAIL, 0 WARN" "fully green" +assert_contains "$out" "OK — ac-convention" "AC-3 (CLI) project ac-convention covering the root is OK" # lowercase angle tokens (, ) are substitution markers, NOT placeholders assert_not_contains "$out" "placeholder" "substitution tokens not flagged" @@ -68,6 +80,25 @@ printf -- '---\npaths: [".lcd/**/spec.md", ".lcd/**/JOURNAL.md"]\n---\n' \ out="$(run_doctor)"; code=$? assert_exit 0 "$code" "override satisfies the check" +# --- AC-3 (CLI): back on the default root, a copy that covers another root warns ------- +write_conventions docs/lcd +out="$(run_doctor)"; code=$? +assert_exit 0 "$code" "AC-3 (CLI) wrong-root copy at default root does not fail" +assert_contains "$out" "WARN — ac-convention" "AC-3 (CLI) wrong-root copy at default root warns" + +# --- AC-3 (CLI): coverage is judged on the paths: globs, not a substring anywhere --------- +# root `docs` is a substring of the stock docs/lcd globs (and `spec` of the body), but +# `docs/lcd/**` never matches docs/work//spec.md +default_ac +printf '\nEvery spec in docs and lcd work dirs.\n' >> "$proj/.claude/rules/ac-convention.md" +for r in docs spec; do + write_conventions "$r" + mkdir -p "$proj/$r"; touch "$proj/$r/MAP.md" "$proj/$r/DECISIONS.md" + out="$(run_doctor)"; code=$? + assert_exit 1 "$code" "AC-3 (CLI) root '$r' not covered by docs/lcd globs fails" +done +default_ac + # --- living-spec: on but SPEC.md missing → WARN (not FAIL) -------------------------- write_conventions docs/lcd "vitest run {path}" "vitest run --bail 1 {path}" on out="$(run_doctor)"; code=$?