Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
}
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`"<root>/**"`), 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.
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>.md`.

The project-side artifacts (MAP, DECISIONS, JOURNAL, SPEC) are plain Markdown and tool-agnostic:
Expand Down
27 changes: 24 additions & 3 deletions bin/lcd-boundary-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <abs-path> — 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.
Expand Down
20 changes: 12 additions & 8 deletions bin/lcd-doctor.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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/<slug>/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

Expand Down
4 changes: 2 additions & 2 deletions commands/doctor.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
10 changes: 4 additions & 6 deletions rules/ac-convention.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
3 changes: 2 additions & 1 deletion rules/lcd.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>.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/<name>.md` — the project's version wins when present.
16 changes: 9 additions & 7 deletions skills/onboard/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`["<root>/**/spec.md", "<root>/**/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
(`["<root>/**/spec.md", "<root>/**/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 `<root>` (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. **`<root>/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
Expand Down Expand Up @@ -200,8 +202,8 @@ list below mechanically and must report **0 FAIL**. Then confirm:
- `<root>/DECISIONS.md` exists (seeded or empty header).
- If `living-spec: on`, `<root>/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
Expand Down
40 changes: 37 additions & 3 deletions tests/test-boundary-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -99,17 +99,51 @@ 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"
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" <<EOF
{"hook_event_name":"PreToolUse","tool_name":"NotebookEdit","tool_input":{"notebook_path":"$1","new_source":"x"}}
EOF
}
out="$(run_nb "$proj/src/other/analysis.ipynb")"
assert_contains "$out" '"permissionDecision":"deny"' "AC-1 (CLI) out-of-boundary NotebookEdit denied"
out="$(run_nb "$proj/src/export/analysis.ipynb")"
[[ -z "$out" ]] || fail "AC-1 (CLI): in-boundary NotebookEdit should be allowed, got: $out"

# --- AC-2 (CLI): . and .. segments are resolved before matching ----------------
out="$(run_hook "$proj/src/export/../../docs/x.md")"
assert_contains "$out" '"permissionDecision":"deny"' "AC-2 (CLI) .. escaping the boundary denied"
assert_contains "$out" "'docs/x.md'" "AC-2 (CLI) deny reason shows the resolved path"
out="$(run_hook "$proj/src/other/../export/./a.ts")"
[[ -z "$out" ]] || fail "AC-2 (CLI): .. resolving inside the boundary should be allowed, got: $out"
out="$(run_hook "src/export/../../src/other/b.ts")"
assert_contains "$out" '"permissionDecision":"deny"' "AC-2 (CLI) relative path with .. denied"
out="$(run_hook "$proj/../elsewhere/c.ts")"
[[ -z "$out" ]] || fail "AC-2 (CLI): a path resolving outside the project is not ours, got: $out"
out="$(run_hook "$proj/src/export/a\\n/../../../docs/x.md")"
assert_contains "$out" '"permissionDecision":"deny"' "AC-2 (CLI) a newline in the path can't truncate resolution"
# a `*` segment must stay literal: from a cwd holding `export`, an unguarded split globs it
mkdir -p "$tmp/cwd/export"
out="$(cd "$tmp/cwd" && run_hook "$proj/src/*/a.ts")"
assert_contains "$out" '"permissionDecision":"deny"' "AC-2 (CLI) glob characters in a path segment are not expanded"
out="$(run_hook "$proj/docs/lcd/../../src/other/d.ts")"
assert_contains "$out" '"permissionDecision":"deny"' "AC-2 (CLI) .. out of the always-allowed artifact root denied"

# --- placeholder-only boundary → no enforcement (fail open) -------------------
sedi 's|- `src/export/`|- `<path>`|; s|- `tests/export.test.ts`|- `<path2>`|' \
"$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|^- `.*|- `<path>`|' "$proj/docs/lcd/work/export-csv/JOURNAL.md"
[[ "$(grep -c '^- `<path>`' "$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"

Expand Down
31 changes: 31 additions & 0 deletions tests/test-doctor.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 (<slug>, <surface>) are substitution markers, NOT placeholders
assert_not_contains "$out" "placeholder" "substitution tokens not flagged"

Expand Down Expand Up @@ -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/<slug>/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=$?
Expand Down
Loading