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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `checks/` — warn-only enforcement suite (14 checks: frontmatter, body structure, placeholders, dependency graph, word limits, manifest via `claude plugin validate --strict`, component census, legacy guards, self-containment, spec fixture, ADR status, filename convention); config `checks/enforcement.config.json`, CI job in `validate.yml`
- Enforcement sections appended to all 11 prior ADRs: testable consequences, check mappings, honest not-machine-checkable lines, empty graduation logs
- Two blocking hooks in checked-in `.claude/settings.json`: Stop-gate (suite BLOCKERs bounce the turn) and PreToolUse guard (existing ADRs append-only, Pattern Guide requires an ADR); kill-switches `PRODUCT_DEV_SKIP_STOP_GATE=1` / `PRODUCT_DEV_SKIP_ADR_GUARD=1`
- ADR index, generated: `checks/adr_index.py` renders the index of records (status, enforcing checks, amended-by links) from the ADR files and the enforcement config. Two new warn-level checks (ADR 0012 TC-3, TC-4): `adr-index` fails when the committed index is stale, `adr-amendment-link` fails when an ADR says it amends another and the amended one never names it
- `skills-claude-ai/` — Claude.ai Agent Skill ports of product-ideation and product-flow (issue #12): bundled prompts with skill-relative paths, conversation-scoped artifact ledger with `product-dev-artifacts.md` export replacing the `.product-dev/` registry

### Changed
- README: the hand-kept ADR table is replaced by a pointer to the generated index
- PreToolUse guard: the Bash rule for inline scripts now requires an existing protected file or iteration of the ADR folder, after it blocked two commands that only mentioned the folder in text
- Enforcement suite: first graduation (ADR 0012 amendment, 2026-10-12). Twelve checks moved warn → block after 7+ clean days: `frontmatter-v2`, `name-uniqueness`, `body-structure`, `placeholder-syntax`, `placeholder-resolvability`, `dependency-graph`, `component-census`, `no-legacy`, `spec-fixture`, `adr-status`, `filename-convention`, `objective-parity`. Findings from these now fail CI and bounce the Stop-gate. `manifest`, `word-limit`, and `self-containment` stay at warn, with reasons in the amendment
- ADRs 0003, 0004, 0008, 0013: appended amendments recording what later ADRs changed (the 6-command / 5-skill / 2-subagent census, registry extensions, the reverse-pass checks that had landed)
- PR and bug-report templates, README, `scripts/README.md`, CONTRIBUTING: removed references to the deleted MCP server and to script paths that moved into `plugin/scripts/`; corrected component counts
Expand Down
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,9 +161,9 @@ When making architectural decisions:
3. Set status to "Proposed"
4. The repo owner updates status to "Accepted" after review

Existing ADRs are append-only. To change one, append a dated `## Amendment (YYYY-MM-DD)` section at the end of the file, or write a new ADR that supersedes it. When a new ADR amends an older one, append a note to the older one saying so. In Claude Code sessions a hook (`checks/hooks/pretooluse_guard.py`) blocks rewrites and allows appends.
Existing ADRs are append-only. To change one, append a dated `## Amendment (YYYY-MM-DD)` section at the end of the file, or write a new ADR that supersedes it. When a new ADR amends an older one, say so in the new one with the words "amends ADR NNNN" and append a note to the older one naming the new one (the `adr-amendment-link` check verifies the pair). In Claude Code sessions a hook (`checks/hooks/pretooluse_guard.py`) blocks rewrites and allows appends.

Run `python checks/run_checks.py --ci` before pushing. Checks at `block` fail CI; see `checks/README.md`.
After adding or amending an ADR, regenerate the index with `python checks/adr_index.py --write` (never edit the index by hand). Run `python checks/run_checks.py --ci` before pushing. Checks at `block` fail CI; see `checks/README.md`.

## Code Style

Expand Down
24 changes: 3 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,27 +135,9 @@ All IDs are cross-referenced. The validation pipeline checks 20 rules across ref

## Architecture Decisions

Key decisions are documented as ADRs in `docs/adrs/`:

| ADR | Decision |
|-----|----------|
| [0001](docs/adrs/0001-prompt-frontmatter-schema.md) | YAML frontmatter as canonical prompt metadata |
| [0002](docs/adrs/0002-mcp-prompt-packaging.md) | MCP prompt packaging (superseded by ADR 0008; server removed) |
| [0003](docs/adrs/0003-context-registry-state-management.md) | File-based context registry at `.product-dev/` |
| [0004](docs/adrs/0004-skill-subagent-decomposition.md) | Skill / command / subagent decomposition (amended by ADR 0013) |
| [0005](docs/adrs/0005-prompt-enhancement-schema.md) | Prompt enhancement schema |
| [0006](docs/adrs/0006-progressive-disclosure-tiered-engagement.md) | Progressive disclosure and tiered engagement |
| [0007](docs/adrs/0007-json-index-deprecation.md) | JSON index deprecation |
| [0008](docs/adrs/0008-plugin-architecture.md) | Plugin as delivery vehicle |
| [0009](docs/adrs/0009-prompt-enhancement-pattern.md) | Prompt Enhancement Pattern v2 |
| [0010](docs/adrs/0010-spec-package-compilation-target.md) | Spec package as compilation target |
| [0011](docs/adrs/0011-plugin-self-containment.md) | Plugin self-containment via `${CLAUDE_PLUGIN_ROOT}` |
| [0012](docs/adrs/0012-adr-enforcement-architecture.md) | ADR enforcement architecture |
| [0013](docs/adrs/0013-ux-optimization-reverse-pass.md) | UX optimization reverse pass (`/optimize`) |
| [0014](docs/adrs/0014-live-site-audit-and-objective.md) | Live-site audit mode + objective metrics |
| [0015](docs/adrs/0015-runtime-audit-mode.md) | Runtime audit mode for locally runnable products |
| [0016](docs/adrs/0016-gstack-derived-patterns.md) | gstack patterns: interrogation, candidate directions, provenance |
| [0017](docs/adrs/0017-gstack-derived-patterns-second-wave.md) | gstack patterns: quality loops, decision classification, scope walk, learnings |
Decisions are recorded as ADRs. The index — one row per record with its status,
the checks that enforce it, and what amended it — is generated from the
records themselves: [docs/adrs/README.md](docs/adrs/README.md).

## ADR Enforcement

Expand Down
1 change: 1 addition & 0 deletions checks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ python checks/run_checks.py --json report.json # + machine-readable report
python checks/run_checks.py --check word-limit # single check
python checks/run_checks.py --list # checks, statuses, graduations
python checks/run_checks.py --ci # exit 1 iff BLOCKERs (CI mode)
python checks/adr_index.py --write # regenerate the ADR index
```

## Read the report
Expand Down
175 changes: 175 additions & 0 deletions checks/adr_index.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
#!/usr/bin/env python3
"""Generate and verify the ADR index (docs/adrs/README.md). See ADR 0012,
amendment 2026-10-12.

The index is derived, never hand-edited: titles and statuses come from the ADR
files, the enforcing checks from checks/enforcement.config.json, and the
"amended by" links from the ADR texts themselves. The `adr-index` check fails
when the committed file differs from what this module renders, so the index
cannot drift the way hand-kept tables did.

Usage:
python checks/adr_index.py # print the index
python checks/adr_index.py --write # regenerate docs/adrs/README.md
python checks/adr_index.py --check # exit 1 if the file is stale
"""
import json
import re
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
ADRS = ROOT / "docs" / "adrs"
CONFIG = ROOT / "checks" / "enforcement.config.json"
INDEX_NAME = "README.md"

ADR_FILE = re.compile(r"^(\d{4})-.+\.md$")
STATUS_WORD = re.compile(r"^(Proposed|Accepted|Deferred|Superseded)\b")
# Phrases by which one ADR declares that it changes another. Deliberately
# narrow: "would amend ADR 0010" or "see ADR 0004" are not declarations.
AMEND_PATTERNS = [
re.compile(r"\bamends\s+ADR\s+(\d{4})", re.IGNORECASE),
re.compile(r"\bADR\s+(\d{4})\s+\([^)]*—\s*amended\b", re.IGNORECASE),
re.compile(r"\bsupersedes\s+ADR\s+(\d{4})", re.IGNORECASE),
re.compile(r"\bSupersedes:\*{0,2}[^.\n]*?\bADR\s+(\d{4})"),
]
IN_FILE_AMENDMENT = re.compile(r"^#{2,3} Amendment \((\d{4}-\d{2}-\d{2})\)", re.MULTILINE)


def adr_files(adr_dir=ADRS):
"""Numbered ADR files only, as [(number, path)] in order."""
out = []
for f in sorted(adr_dir.glob("*.md")):
m = ADR_FILE.match(f.name)
if m:
out.append((m.group(1), f))
return out


def parse_adr(number, path):
text = path.read_text()
title = re.search(r"^#\s+ADR\s+\d{4}:\s*(.+?)\s*$", text, re.MULTILINE)
status_line = ""
m = re.search(r"^## Status\s*\n+([^\n]+)", text, re.MULTILINE)
if m:
status_line = m.group(1).strip().lstrip("> ").strip()
word = STATUS_WORD.match(status_line)
status = word.group(1) if word else "Unknown"
if status == "Superseded":
by = re.search(r"ADR (\d{4})", status_line)
if by:
status = f"Superseded by {by.group(1)}"
return {
"number": number,
"file": path.name,
"title": title.group(1) if title else path.stem,
"status": status,
"text": text,
"amendment_dates": sorted(IN_FILE_AMENDMENT.findall(text)),
}


def declared_amendments(adrs):
"""{target_number: sorted([amender_number, ...])} from the ADR texts."""
known = {a["number"] for a in adrs}
out = {}
for a in adrs:
for pat in AMEND_PATTERNS:
for target in pat.findall(a["text"]):
if target != a["number"] and target in known:
out.setdefault(target, set()).add(a["number"])
return {k: sorted(v) for k, v in out.items()}


def missing_backlinks(adrs):
"""[(amender, target)] where the amender declares it amends the target
but the target never mentions the amender."""
by_number = {a["number"]: a for a in adrs}
out = []
for target, amenders in sorted(declared_amendments(adrs).items()):
for amender in amenders:
if not re.search(rf"\bADR[ -]?{amender}\b", by_number[target]["text"]):
out.append((amender, target))
return out


def checks_by_adr(config):
out = {}
for c in config.get("checks", []):
for number in re.findall(r"\b(\d{4})\b", c.get("adr", "")):
out.setdefault(number, []).append((c["id"], c["status"]))
return out


def render(adr_dir=ADRS, config_path=CONFIG):
adrs = [parse_adr(n, p) for n, p in adr_files(adr_dir)]
amended_by = declared_amendments(adrs)
checks = checks_by_adr(json.loads(config_path.read_text()))
lines = [
"<!-- GENERATED by checks/adr_index.py from the ADR files and",
" checks/enforcement.config.json. Do not edit by hand.",
" Regenerate: python checks/adr_index.py --write -->",
"",
"# Architecture Decision Records",
"",
"One row per record. This table tells you which record to open; it does not",
"replace reading it. A record's later `## Amendment` sections and the ADRs",
"listed under \"Amended by\" override its earlier text.",
"",
"| ADR | Decision | Status | Enforced by | Amended by | In-file amendments |",
"|-----|----------|--------|-------------|------------|--------------------|",
]
for a in adrs:
enforced = ", ".join(f"`{cid}` ({status})" for cid, status in checks.get(a["number"], []))
amenders = ", ".join(amended_by.get(a["number"], []))
dates = a["amendment_dates"]
in_file = f"{len(dates)}, latest {dates[-1]}" if dates else ""
lines.append(
f"| [{a['number']}]({a['file']}) | {a['title']} | {a['status']} | "
f"{enforced or 'no dedicated check'} | {amenders or '—'} | {in_file or '—'} |"
)
lines += [
"",
"## Reading the columns",
"",
"- **Status** — `Accepted` records are in force. `Superseded` and `Deferred`",
" records are history: do not build on them.",
"- **Enforced by** — checks in `checks/run_checks.py` whose config entry names",
" the record. `block` fails CI and stops a Claude Code session from finishing;",
" `warn` only reports. \"No dedicated check\" means the record's own",
" `## Enforcement` section says what, if anything, covers it.",
"- **Amended by** — later ADRs that state they amend or supersede part of the",
" record. The `adr-amendment-link` check requires the amended record to name",
" each of them.",
"",
"## Changing a record",
"",
"Existing records are append-only (ADR 0012). Append a dated",
"`## Amendment (YYYY-MM-DD)` section to the end of the file, or write a new",
"numbered ADR that says which record it amends. Then run",
"`python checks/adr_index.py --write`.",
"",
]
return "\n".join(lines)


def main(argv):
index_path = ADRS / INDEX_NAME
content = render()
if "--write" in argv:
index_path.write_text(content)
print(f"wrote {index_path.relative_to(ROOT)}")
return 0
if "--check" in argv:
current = index_path.read_text() if index_path.exists() else None
if current != content:
print(f"{index_path.relative_to(ROOT)} is missing or stale — run: python checks/adr_index.py --write",
file=sys.stderr)
return 1
return 0
sys.stdout.write(content)
return 0


if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
8 changes: 5 additions & 3 deletions checks/enforcement.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@
{ "id": "dependency-graph", "adr": "0003", "tc": "0003:TC-4", "status": "block", "added": "2026-07-12", "graduated": "2026-10-12" },
{ "id": "word-limit", "adr": "0009", "tc": "0009:TC-5", "status": "warn", "added": "2026-07-12", "graduated": null },
{ "id": "manifest", "adr": "0008", "tc": "0008:TC-1,TC-3", "status": "warn", "added": "2026-07-12", "graduated": null },
{ "id": "component-census", "adr": "0004, 0008", "tc": "0004:TC-1,TC-2,TC-3,TC-4; 0008:TC-2", "status": "block", "added": "2026-07-12", "graduated": "2026-10-12" },
{ "id": "component-census", "adr": "0004, 0008, 0013", "tc": "0004:TC-1,TC-2,TC-3,TC-4; 0008:TC-2", "status": "block", "added": "2026-07-12", "graduated": "2026-10-12" },
{ "id": "no-legacy", "adr": "0001, 0002, 0005, 0007", "tc": "0001:TC-2; 0002:TC-1; 0005:TC-1; 0007:TC-1,TC-2", "status": "block", "added": "2026-07-12", "graduated": "2026-10-12" },
{ "id": "self-containment", "adr": "0011", "tc": "0011:TC-1,TC-2", "status": "warn", "added": "2026-07-12", "graduated": null },
{ "id": "spec-fixture", "adr": "0010", "tc": "0010:TC-1,TC-2", "status": "block", "added": "2026-07-12", "graduated": "2026-10-12" },
{ "id": "adr-status", "adr": "0002, process", "tc": "0002:TC-2; process:adr-status", "status": "block", "added": "2026-07-12", "graduated": "2026-10-12" },
{ "id": "adr-status", "adr": "0002, 0012, process", "tc": "0002:TC-2; process:adr-status", "status": "block", "added": "2026-07-12", "graduated": "2026-10-12" },
{ "id": "filename-convention", "adr": "CONTRIBUTING", "tc": "CONTRIBUTING:naming-convention", "status": "block", "added": "2026-07-12", "graduated": "2026-10-12" },
{ "id": "objective-parity", "adr": "0014", "tc": "0014:TC-parity", "status": "block", "added": "2026-07-25", "graduated": "2026-10-12" }
{ "id": "objective-parity", "adr": "0014", "tc": "0014:TC-parity", "status": "block", "added": "2026-07-25", "graduated": "2026-10-12" },
{ "id": "adr-index", "adr": "0012", "tc": "0012:TC-3", "status": "warn", "added": "2026-10-12", "graduated": null },
{ "id": "adr-amendment-link", "adr": "0012", "tc": "0012:TC-4", "status": "warn", "added": "2026-10-12", "graduated": null }
]
}
18 changes: 14 additions & 4 deletions checks/hooks/pretooluse_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,11 @@ def guard_file_tool(tool_name, tool_input, project_dir):
r"|File\.write|os\.remove|os\.rename|os\.replace|shutil\.|\.unlink\("
)
PROTECTED_MENTION = re.compile(r"docs/adrs|00_ENHANCEMENT_PATTERN")
EMBEDDED_PATH = re.compile(r"[\w./~-]*(?:docs/adrs/[\w.*?\[\]-]+|docs/updates/00_ENHANCEMENT_PATTERN\.md)")
ADR_DIR_ITERATION = re.compile(
r"docs/adrs/?[\"'][^\n]{0,80}?(?:glob|iterdir|listdir|scandir|walk|readdir)"
r"|(?:glob|iterdir|listdir|scandir|walk|readdir)[^\n]{0,80}?docs/adrs"
)


def strip_quotes(token):
Expand Down Expand Up @@ -179,12 +184,17 @@ def bash_violations(command, bases, project_dir):
for t in tokens:
if t.startswith("of="):
hits += existing_protected(t[3:], bases, project_dir)
# An inline interpreter script (python -c, a heredoc) that names a
# protected path and contains a write call. The path sits inside code, so
# it cannot be resolved token by token.
# An inline interpreter script (python -c, a heredoc) that contains a
# write call and either names an existing protected file or iterates the
# ADR directory. The path sits inside code, so it is pulled out by pattern
# rather than token by token. Merely mentioning docs/adrs in text the
# script writes elsewhere is not enough — that was a false positive.
words = {os.path.basename(strip_quotes(t)) for t in command.split()}
if not hits and words & INTERPRETERS and INLINE_WRITE.search(command):
hits.append(Path("an inline script that names docs/adrs or the Pattern Guide and writes files"))
for m in EMBEDDED_PATH.finditer(command):
hits += existing_protected(m.group(0), bases, project_dir)
if not hits and ADR_DIR_ITERATION.search(command):
hits.append(Path("an inline script that iterates docs/adrs and writes files"))
return sorted(set(hits), key=str)


Expand Down
Loading