
Jd Docs
- 1 installs
- 21 repo stars
- Updated August 5, 2026
- joaquimscosta/arkhe-claude-plugins
Organizes project documentation using the Johnny Decimal numbered structure convention.
About
Manages project documentation using a Johnny Decimal numbered folder convention such as docs/20-architecture. A developer uses it to structure and locate docs in a consistent hierarchy.
- Johnny Decimal doc structure
- Detected via .jd-config.json or docs/20-architecture/
Jd Docs by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,366 of 1,879 Documentation skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/joaquimscosta/arkhe-claude-plugins --skill jd-docsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 21 |
| Last updated | August 5, 2026 |
| Repository | joaquimscosta/arkhe-claude-plugins ↗ |
What it does
Organizes project documentation using the Johnny Decimal numbered structure convention.
Files
Johnny.Decimal Documentation
Scaffold, validate, and maintain Johnny.Decimal documentation structure with sensible defaults and per-project customization.
Quick Start
# Scaffold a new structure
uv run scripts/jd_init.py --dry-run # Preview first
uv run scripts/jd_init.py # Create docs/ with defaults
uv run scripts/jd_init.py --diataxis # Include Diataxis areas (41-44)
# Validate existing structure
uv run scripts/jd_validate.py --dir docs
# Regenerate README index
uv run scripts/jd_index.py --dir docs
# Day-2: Add a new area
uv run scripts/jd_add_area.py --prefix 40 --name operations --dry-run
# Day-2: Classify unorganized files
uv run scripts/jd_classify.py docs/*.md
uv run scripts/jd_classify.py docs/*.md --diataxis # With quadrant info
# Day-2: Move a file to an area
uv run scripts/jd_add.py docs/roadmap.md 00 --dry-runSee WORKFLOW.md for the full methodology.
Capabilities
- Scaffolding (
jd_init.py) — Create J.D directory tree with README templates; supports--productfor monorepo sub-trees,--init-configto generate.jd-config.json, and--diataxisto include Diataxis quadrant areas (41-44) - Validation (
jd_validate.py) — CheckNN-kebab-casenaming, detect orphan files, verify README presence per area;--strictfor CI enforcement - Index generation (
jd_index.py) — Generate/update root README with table or tree index; preserves custom content via<!-- JD:INDEX:START/END -->markers - Migration (Claude-driven) — Classify flat docs into J.D areas using naming heuristics, present a move plan, execute interactively
Day-2 Operations
- Add area (
jd_add_area.py) — Create a new J.D area with prefix, name, README stub, config update, and auto re-index - Classify (
jd_classify.py) — Classify files into areas using keyword heuristics with confidence scoring (high/medium/low);--diataxisadds Diataxis quadrant column,--diataxis-moveroutes files to quadrant areas (41-44) - Add/move file (
jd_add.py) — Move a file to an area with auto-normalized kebab-case naming and cross-reference detection
Default Area Scheme
| Prefix | Name | Purpose |
|---|---|---|
00- | getting-started | Onboarding, setup, quick start, MVP |
10- | product | Specs, features, roadmap, design, branding |
20- | architecture | Tech decisions, system design, integration |
30- | research | Spikes, investigations, reference material |
90- | archive | Historical/deprecated docs |
Gap at 40-80 reserved for per-project customization (e.g., 40-operations).
With `--diataxis`, areas 41-44 are added for Diataxis quadrants:
| 41- | tutorials | Tutorials — step-by-step lessons (Diataxis) | | 42- | how-to | How-to guides — practical tasks (Diataxis) | | 43- | reference | Reference — technical descriptions (Diataxis) | | 44- | explanation | Explanation — conceptual discussions (Diataxis) |
Config File (.jd-config.json)
Optional per-project override at project root:
{
"version": 1,
"root": "docs",
"areas": { "00": "getting-started", "10": "product", "20": "architecture", "30": "research", "90": "archive" },
"products": [],
"ignore": ["adr", "*.pdf"],
"readme_format": "table"
}Create with uv run scripts/jd_init.py --init-config. All fields have sensible defaults.
Common Issues
| Issue | Fix |
|---|---|
uv not found | `curl -LsSf https://astral.sh/uv/install.sh \ |
| Orphan files in validation | Move to area dir, or add to "ignore" in .jd-config.json |
| Index appended at wrong position | Move <!-- JD:INDEX:START/END --> markers to desired location after first run |
| Low confidence on all files | Expand keywords in config or use Claude-driven classification |
See TROUBLESHOOTING.md for all error scenarios.
References
- WORKFLOW.md — Full methodology (discovery, config, scaffold, validate, index, migrate, day-2)
- EXAMPLES.md — Real-world examples for all operations
- TROUBLESHOOTING.md — Error handling and debugging tips
Examples: Johnny.Decimal Documentation
Example 1: Scaffolding a New Project
Scenario: Fresh project with no docs directory.
$ uv run scripts/jd_init.py --dry-run
Scaffolding J.D structure at: /projects/my-app/docs/ (dry-run)
Would create: /projects/my-app/docs/
Would create: /projects/my-app/docs/README.md
Would create: /projects/my-app/docs/00-getting-started/
Would create: /projects/my-app/docs/00-getting-started/README.md
Would create: /projects/my-app/docs/10-product/
Would create: /projects/my-app/docs/10-product/README.md
Would create: /projects/my-app/docs/20-architecture/
Would create: /projects/my-app/docs/20-architecture/README.md
Would create: /projects/my-app/docs/30-research/
Would create: /projects/my-app/docs/30-research/README.md
Would create: /projects/my-app/docs/90-archive/
Would create: /projects/my-app/docs/90-archive/README.md
Would create: 12 itemsGenerated `docs/README.md`:
# My App Documentation
[Brief project description]
## Quick Start
- [Getting started guide](./00-getting-started/)
## Documentation Index
<!-- JD:INDEX:START -->
| Prefix | Area | Purpose |
|--------|------|---------|
| `00-` | [`00-getting-started/`](./00-getting-started/) | Onboarding, setup, quick start, MVP, and phase planning |
| `10-` | [`10-product/`](./10-product/) | Product specs, features, roadmap, design, and branding |
| `20-` | [`20-architecture/`](./20-architecture/) | Technical decisions, system design, and integration |
| `30-` | [`30-research/`](./30-research/) | Research notes, spikes, investigations, and reference material |
| `90-` | [`90-archive/`](./90-archive/) | Historical and deprecated documentation |
<!-- JD:INDEX:END -->
## Folder Convention
Documentation uses the [Johnny.Decimal](https://johnnydecimal.com/) numbering system.
...---
Example 2: Monorepo with Product Sub-Trees
Scenario: Monorepo with two products (like Papia Studio).
$ uv run scripts/jd_init.py --root docs/skrebe
$ uv run scripts/jd_init.py --root docs/papia-asrResult:
docs/
├── README.md (manually created hub page)
├── glossary.md (shared terminology)
├── adr/ (shared ADRs)
├── skrebe/
│ ├── README.md
│ ├── 00-getting-started/
│ ├── 10-product/
│ ├── 20-architecture/
│ ├── 30-research/
│ └── 90-archive/
└── papia-asr/
├── README.md
├── 00-getting-started/
├── 10-product/
├── 20-architecture/
├── 30-research/
└── 90-archive/Each product gets its own J.D structure with independent area directories.
---
Example 3: Custom Area Scheme
Scenario: DevOps-heavy project needs 40-operations and 50-runbooks.
$ uv run scripts/jd_init.py --init-configEdit .jd-config.json:
{
"version": 1,
"root": "docs",
"areas": {
"00": "getting-started",
"10": "product",
"20": "architecture",
"30": "research",
"40": "operations",
"50": "runbooks",
"90": "archive"
},
"products": [],
"ignore": ["adr", "*.pdf"],
"readme_format": "table"
}Then scaffold:
$ uv run scripts/jd_init.py --dry-run
Scaffolding J.D structure at: /projects/infra/docs/ (dry-run)
...
Would create: /projects/infra/docs/40-operations/
Would create: /projects/infra/docs/40-operations/README.md
Would create: /projects/infra/docs/50-runbooks/
Would create: /projects/infra/docs/50-runbooks/README.md
...---
Example 4: Validating an Existing Project
Scenario: Running validation against an established docs directory.
$ uv run scripts/jd_validate.py --dir docs/skrebe
Johnny.Decimal Validation Report
========================================
Directory: /projects/papia-studio/docs/skrebe
Areas found: 5
+ 00-mvp
+ 10-product
+ 20-architecture
+ 30-research
+ 90-archive
Warnings: 3
! Orphan file: alupec-linting-deep-dive.md
! Orphan file: article.md
! Orphan file: test-report-alupec-linting.md
Info: 1
- Standard area not present: 00-getting-started/
Result: PASS (5 areas, 0 errors, 3 warnings)With strict mode:
$ uv run scripts/jd_validate.py --dir docs/skrebe --strict
...
Result: FAIL (strict mode) (5 areas, 0 errors, 3 warnings)Fixing the warnings:
- Move orphan files to appropriate areas (e.g.,
alupec-linting-deep-dive.md→30-research/) - The
00-mvpvs00-getting-startedmismatch is informational only (project uses00-mvpinstead of the default name)
---
Example 5: Generating a Documentation Index
Table format (default):
$ uv run scripts/jd_index.py --dir docs/skrebe --dry-run
Scanning: /projects/papia-studio/docs/skrebe
Format: table
Areas found: 5
Total documents: 19
Would update: /projects/papia-studio/docs/skrebe/README.md
--- Index content ---
| Prefix | Area | Docs | Description |
|--------|------|------|-------------|
| `00-` | [`00-mvp/`](./00-mvp/) | 10 docs | Onboarding, setup, quick start, MVP |
| `10-` | [`10-product/`](./10-product/) | 4 docs | Specs, features, roadmap, design |
| `20-` | [`20-architecture/`](./20-architecture/) | 7 docs | Tech decisions, system design |
| `30-` | [`30-research/`](./30-research/) | 7 docs | Research, spikes, investigations |
| `90-` | [`90-archive/`](./90-archive/) | 6 docs | Historical/deprecated docs |
--- End ---Tree format:
$ uv run scripts/jd_index.py --dir docs/skrebe --format tree --dry-run
...
--- Index content ---
- **[`00-mvp/`](./00-mvp/)** — Onboarding, setup, quick start, MVP
- [Dictionary Extraction Strategy](./00-mvp/dictionary-extraction-strategy.md)
- [Execution Plan](./00-mvp/execution-plan.md)
- [Requirements](./00-mvp/requirements.md)
- [Roadmap](./00-mvp/roadmap.md)
...
- **[`10-product/`](./10-product/)** — Specs, features, roadmap, design
- [Design System](./10-product/design-system.md)
- [Editor Features](./10-product/editor-features.md)
...
--- End ------
Example 6: Migrating Flat Docs
Scenario: Project has 8 markdown files in a flat docs/ directory.
When you say "organize my docs into Johnny Decimal areas", Claude will:
1. List all files:
docs/
├── README.md
├── api-design.md
├── deployment-guide.md
├── getting-started.md
├── performance-research.md
├── product-roadmap.md
├── system-architecture.md
├── team-conventions.md
└── old-design-v1.md2. Propose classification:
| File | Target Area | Reason |
|---|---|---|
| getting-started.md | 00-getting-started/ | Filename keyword match |
| product-roadmap.md | 10-product/ | "product" keyword |
| api-design.md | 20-architecture/ | Design/architecture topic |
| system-architecture.md | 20-architecture/ | "architecture" keyword |
| team-conventions.md | 20-architecture/ | Conventions = architecture |
| performance-research.md | 30-research/ | "research" keyword |
| deployment-guide.md | 20-architecture/ | Ops guide (or custom 40-operations/) |
| old-design-v1.md | 90-archive/ | "v1" version suffix |
| README.md | _(keep in place)_ | Root README |
3. Ask for confirmation, then move files and update the README index.
---
Example 7: Adding a Custom Area
Scenario: DevOps-heavy project needs a 40-operations area after initial scaffold.
$ uv run scripts/jd_add_area.py --prefix 40 --name operations \
--description "Deployment, monitoring, and runbooks" --dry-run
Adding area 40-operations (dry-run)
Would create: /projects/infra/docs/40-operations/
Would create: /projects/infra/docs/40-operations/README.md
Would update: /projects/infra/.jd-config.json
+ "40": "operations"
Re-indexing...
Would update: /projects/infra/docs/README.md
--- Index content ---
| Prefix | Area | Docs | Description |
|--------|------|------|-------------|
| `00-` | [`00-getting-started/`](./00-getting-started/) | 3 docs | Onboarding, setup, quick start, MVP |
| `10-` | [`10-product/`](./10-product/) | 2 docs | Specs, features, roadmap, design |
| `20-` | [`20-architecture/`](./20-architecture/) | 4 docs | Tech decisions, system design |
| `30-` | [`30-research/`](./30-research/) | 1 doc | Research, spikes, investigations |
| `40-` | [`40-operations/`](./40-operations/) | — | |
| `90-` | [`90-archive/`](./90-archive/) | — | Historical/deprecated docs |
--- End ---
Would create: 2 itemsError case — prefix already taken:
$ uv run scripts/jd_add_area.py --prefix 20 --name operations
Error: Prefix '20' already in config as '20-architecture'---
Example 8: Classifying Unorganized Files
Scenario: Several markdown files sitting in the docs root need organizing.
$ uv run scripts/jd_classify.py docs/roadmap.md docs/api-design.md \
docs/performance-research.md docs/old-plan-v1.md docs/random-notes.md
File | Suggested Area | Confidence | Reason
-------------------------+----------------------+------------+-------------------------------
roadmap.md | 00-getting-started | high | Filename: 'roadmap'
api-design.md | 20-architecture | high | Filename: 'api'; Content: 'schema'
performance-research.md | 30-research | high | Filename: 'research'
old-plan-v1.md | 90-archive | high | Filename: 'old', 'v1'
random-notes.md | (unknown) | low | No keyword matches
Summary: 4 high, 0 medium, 1 low confidence
1 file(s) need Claude review (low confidence)Moving with preview:
$ uv run scripts/jd_classify.py docs/*.md --move --dry-run
...
Would move: roadmap.md -> 00-getting-started/roadmap.md
Would move: api-design.md -> 20-architecture/api-design.md
Would move: performance-research.md -> 30-research/performance-research.md
Would move: old-plan-v1.md -> 90-archive/old-plan-v1.md
Skip (low confidence): random-notes.md — flag for Claude review
Would move: 4 file(s)JSON output for scripting:
$ uv run scripts/jd_classify.py docs/roadmap.md --json
[
{
"file": "docs/roadmap.md",
"suggested_area": "00-getting-started",
"suggested_prefix": "00",
"confidence": "high",
"score": 0.5,
"reason": "Filename: 'roadmap'",
"filename_matches": ["roadmap"],
"content_matches": []
}
]---
Example 9: Moving a File to an Area
Scenario: Moving a poorly named file to the correct area with auto-normalization.
$ uv run scripts/jd_add.py "docs/My Design Doc.md" 20 --dry-run
Moving file to 20-architecture/ (dry-run)
Source: /projects/my-app/docs/My Design Doc.md
Destination: /projects/my-app/docs/20-architecture/my-design-doc.md
Renamed: My Design Doc.md -> my-design-doc.md
Cross-references found (1 occurrence(s)):
These files reference the old path and may need updating:
00-getting-started/setup.md:15: See [design doc](../My Design Doc.md) for details
Suggested replacement: 'My Design Doc.md' -> '20-architecture/my-design-doc.md'
Would re-index after move.Moving by prefix:
$ uv run scripts/jd_add.py docs/api-design.md 20
Moving file to 20-architecture/
Source: /projects/my-app/docs/api-design.md
Destination: /projects/my-app/docs/20-architecture/api-design.md
Moved successfully.
No cross-references found.
Re-indexing...
Updated: /projects/my-app/docs/README.mdOverride filename:
$ uv run scripts/jd_add.py docs/notes.md 30 --name spike-caching-strategies.md
Moving file to 30-research/
Source: /projects/my-app/docs/notes.md
Destination: /projects/my-app/docs/30-research/spike-caching-strategies.md
Renamed: notes.md -> spike-caching-strategies.md
Moved successfully.---
Quick Reference
| Action | Script | Key Flags |
|---|---|---|
| Scaffold new structure | jd_init.py | --root, --product, --init-config, --dry-run |
| Validate existing structure | jd_validate.py | --dir, --strict, --config |
| Generate/update index | jd_index.py | --dir, --format, --dry-run |
| Migrate flat docs | _(Claude-driven)_ | Natural language request |
| Add new area | jd_add_area.py | --prefix, --name, --description, --dry-run |
| Classify files | jd_classify.py | --move, --yes, --no-content, --json, --dry-run, --diataxis, --diataxis-move |
| Move file to area | jd_add.py | --name, --dry-run |
---
Example 7: Diataxis Integration
Scaffold with Diataxis Areas
$ uv run scripts/jd_init.py --diataxis --root /tmp/test-docs --dry-run
Scaffolding J.D structure at: /tmp/test-docs/ (dry-run)
Would create: /tmp/test-docs/00-getting-started/
Would create: /tmp/test-docs/10-product/
Would create: /tmp/test-docs/20-architecture/
Would create: /tmp/test-docs/30-research/
Would create: /tmp/test-docs/41-tutorials/
Would create: /tmp/test-docs/42-how-to/
Would create: /tmp/test-docs/43-reference/
Would create: /tmp/test-docs/44-explanation/
Would create: /tmp/test-docs/90-archive/Classify with Diataxis Quadrant Column
$ uv run scripts/jd_classify.py docs/*.md --diataxis
File | Suggested Area | Confidence | Quadrant | Q. Conf | Reason
-------------------+-----------------+------------+-------------+---------+----------
deploy-guide.md | 20-architecture | medium | how-to | high | Filename: 'deploy'
api-reference.md | 20-architecture | medium | reference | high | Filename: 'api', 'reference'
getting-started.md | 00-getting... | high | tutorial | high | Filename: 'getting-started'
why-postgres.md | 30-research | medium | explanation | high | Filename: 'why'#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""
Add a new area to an existing Johnny.Decimal documentation structure.
Usage:
uv run jd_add_area.py --prefix 40 --name operations
uv run jd_add_area.py --prefix 40 --name operations --description "Deployment, monitoring, runbooks"
uv run jd_add_area.py --prefix 40 --name operations --dry-run
"""
import argparse
import json
import re
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from shared import (
AREA_DESCRIPTIONS,
AREA_NAME_PATTERN,
DEFAULT_CONFIG,
find_area_by_prefix,
find_git_root,
resolve_config,
)
from jd_index import re_index
def validate_prefix(prefix: str) -> tuple[bool, str | None]:
"""Validate prefix is two digits and a multiple of 10."""
if not re.match(r"^[0-9]{2}$", prefix):
return False, f"Prefix must be exactly two digits, got '{prefix}'"
num = int(prefix)
if num % 10 != 0:
return False, f"Prefix '{prefix}' is not a multiple of 10 (convention: 00, 10, 20, ..., 90)"
return True, None
def validate_name(name: str) -> tuple[bool, str | None]:
"""Validate area name is kebab-case."""
test_name = f"00-{name}"
if not AREA_NAME_PATTERN.match(test_name):
return False, f"Name '{name}' is not valid kebab-case (lowercase letters, numbers, hyphens)"
return True, None
def check_prefix_available(
prefix: str,
docs_dir: Path,
config: dict,
) -> tuple[bool, str | None]:
"""Check if prefix is not already taken in config or on disk."""
# Check config
areas = config.get("areas", {})
if prefix in areas:
return False, f"Prefix '{prefix}' already in config as '{prefix}-{areas[prefix]}'"
# Check on disk
existing = find_area_by_prefix(docs_dir, prefix)
if existing:
return False, f"Prefix '{prefix}' already exists on disk as '{existing.name}'"
return True, None
def create_area(
docs_dir: Path,
prefix: str,
name: str,
description: str,
dry_run: bool,
) -> list[Path]:
"""Create area directory and README stub. Returns created paths."""
created: list[Path] = []
area_dir = docs_dir / f"{prefix}-{name}"
if area_dir.exists():
print(f" Exists (skip): {area_dir}/")
return created
if dry_run:
print(f" Would create: {area_dir}/")
else:
area_dir.mkdir(parents=True, exist_ok=True)
created.append(area_dir)
# Create README stub
readme_path = area_dir / "README.md"
title = name.replace("-", " ").title()
desc = description or AREA_DESCRIPTIONS.get(prefix, f"Documentation for {title.lower()}")
content = f"""# {prefix} — {title}
{desc}.
## Documents
_No documents yet._
"""
if dry_run:
print(f" Would create: {readme_path}")
else:
readme_path.write_text(content)
created.append(readme_path)
return created
def update_config_areas(
config_path: Path,
prefix: str,
name: str,
dry_run: bool,
) -> bool:
"""Add new area to .jd-config.json. Create config if missing."""
if config_path.exists():
with open(config_path) as f:
config = json.load(f)
else:
config = dict(DEFAULT_CONFIG)
areas = config.get("areas", {})
areas[prefix] = name
# Sort areas by prefix
config["areas"] = dict(sorted(areas.items()))
if dry_run:
action = "Would update" if config_path.exists() else "Would create"
print(f" {action}: {config_path}")
print(f" + \"{prefix}\": \"{name}\"")
else:
with open(config_path, "w") as f:
json.dump(config, f, indent=2)
f.write("\n")
action = "Updated" if config_path.exists() else "Created"
print(f" {action}: {config_path}")
return True
def main() -> int:
parser = argparse.ArgumentParser(
description="Add a new area to a Johnny.Decimal documentation structure"
)
parser.add_argument(
"--prefix",
"-p",
required=True,
help="Two-digit area prefix (e.g., '40')",
)
parser.add_argument(
"--name",
"-n",
required=True,
help="Area name in kebab-case (e.g., 'operations')",
)
parser.add_argument(
"--description",
"-D",
default=None,
help="Purpose description for README stub",
)
parser.add_argument(
"--dir",
"-d",
default=None,
help="Docs directory (default: from config or 'docs')",
)
parser.add_argument(
"--config",
"-c",
default=None,
help="Path to .jd-config.json (auto-detected if not specified)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would be created without writing",
)
args = parser.parse_args()
base_path = Path.cwd()
# Validate inputs
valid, err = validate_prefix(args.prefix)
if not valid:
print(f"Error: {err}")
return 1
valid, err = validate_name(args.name)
if not valid:
print(f"Error: {err}")
return 1
# Load config
config = resolve_config(args.config, base_path)
# Resolve docs directory
if args.dir:
docs_dir = Path(args.dir)
if not docs_dir.is_absolute():
docs_dir = base_path / docs_dir
else:
docs_dir = base_path / config.get("root", "docs")
if not docs_dir.exists():
print(f"Error: Docs directory does not exist: {docs_dir}")
print("Run jd_init.py first to create the structure.")
return 1
# Check availability
valid, err = check_prefix_available(args.prefix, docs_dir, config)
if not valid:
print(f"Error: {err}")
return 1
# Execute
mode = "(dry-run)" if args.dry_run else ""
print(f"Adding area {args.prefix}-{args.name} {mode}")
print()
# Create directory and README
created = create_area(
docs_dir, args.prefix, args.name,
args.description or "", args.dry_run,
)
# Update config
git_root = find_git_root(base_path)
config_path = (git_root or base_path) / ".jd-config.json"
if args.config:
config_path = Path(args.config)
if not config_path.is_absolute():
config_path = base_path / config_path
print()
update_config_areas(config_path, args.prefix, args.name, args.dry_run)
# Re-index
print()
print("Re-indexing...")
# Reload config after update
if not args.dry_run:
config = resolve_config(str(config_path) if config_path.exists() else None, base_path)
re_index(docs_dir, config, args.dry_run)
if created:
print(f"\n{'Would create' if args.dry_run else 'Created'}: {len(created)} items")
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""
Move or add a file to a Johnny.Decimal area directory.
Usage:
uv run jd_add.py docs/roadmap.md 00
uv run jd_add.py docs/roadmap.md 00-getting-started
uv run jd_add.py "docs/My Design Doc.md" 20 --name design-doc.md
uv run jd_add.py docs/roadmap.md 00 --dry-run
"""
import argparse
import re
import shutil
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from shared import (
find_area_by_prefix,
normalize_filename,
resolve_config,
)
from jd_index import re_index
def resolve_target_area(
target: str,
docs_dir: Path,
) -> tuple[Path | None, str | None]:
"""Resolve target to an area directory path.
Accepts prefix ("20") or full name ("20-architecture").
Returns (area_path, error_message).
"""
# Try as a prefix (two digits)
if re.match(r"^[0-9]{2}$", target):
area = find_area_by_prefix(docs_dir, target)
if area:
return area, None
return None, f"No area with prefix '{target}' found in {docs_dir}"
# Try as a full directory name
area_path = docs_dir / target
if area_path.is_dir():
return area_path, None
# Try as prefix-name pattern
if re.match(r"^[0-9]{2}-", target):
area_path = docs_dir / target
if not area_path.exists():
return None, f"Area directory '{target}' does not exist in {docs_dir}"
return None, f"Cannot resolve target '{target}' to an area directory"
def find_cross_references(
old_path: Path,
docs_dir: Path,
) -> list[tuple[Path, int, str]]:
"""Search docs for markdown references to old_path.
Returns list of (file, line_number, line_content).
"""
refs: list[tuple[Path, int, str]] = []
# Build patterns to search for
old_name = old_path.name
# Relative path from docs root
try:
old_relative = old_path.relative_to(docs_dir)
except ValueError:
old_relative = None
patterns = [old_name]
if old_relative:
patterns.append(str(old_relative))
patterns.append(f"./{old_relative}")
for md_file in docs_dir.rglob("*.md"):
if md_file == old_path:
continue
try:
with open(md_file) as f:
for lineno, line in enumerate(f, 1):
for pattern in patterns:
if pattern in line:
refs.append((md_file, lineno, line.rstrip()))
break # Only report each line once
except (OSError, UnicodeDecodeError):
continue
return refs
def main() -> int:
parser = argparse.ArgumentParser(
description="Move or add a file to a Johnny.Decimal area directory"
)
parser.add_argument(
"file",
help="Path to the file to move",
)
parser.add_argument(
"target",
help="Target area by prefix ('20') or full name ('20-architecture')",
)
parser.add_argument(
"--name",
"-n",
default=None,
help="Override the output filename (auto-normalized if not provided)",
)
parser.add_argument(
"--dir",
"-d",
default=None,
help="Docs directory (default: from config or 'docs')",
)
parser.add_argument(
"--config",
"-c",
default=None,
help="Path to .jd-config.json (auto-detected if not specified)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Preview without writing",
)
args = parser.parse_args()
base_path = Path.cwd()
# Resolve source file
src = Path(args.file)
if not src.is_absolute():
src = base_path / src
if not src.exists():
print(f"Error: File not found: {src}")
return 1
if not src.is_file():
print(f"Error: Not a file: {src}")
return 1
# Load config
config = resolve_config(args.config, base_path)
# Resolve docs directory
if args.dir:
docs_dir = Path(args.dir)
if not docs_dir.is_absolute():
docs_dir = base_path / docs_dir
else:
docs_dir = base_path / config.get("root", "docs")
if not docs_dir.exists():
print(f"Error: Docs directory does not exist: {docs_dir}")
return 1
# Resolve target area
area_dir, err = resolve_target_area(args.target, docs_dir)
if err:
print(f"Error: {err}")
return 1
# Determine output filename
if args.name:
out_name = normalize_filename(args.name)
else:
out_name = normalize_filename(src.name)
dst = area_dir / out_name
# Check for conflicts
if dst.exists():
print(f"Error: File already exists: {dst}")
print(f"Use --name to specify a different filename.")
return 1
# Execute
mode = "(dry-run)" if args.dry_run else ""
print(f"Moving file to {area_dir.name}/ {mode}")
print()
print(f" Source: {src}")
print(f" Destination: {dst}")
if src.name != out_name:
print(f" Renamed: {src.name} -> {out_name}")
if not args.dry_run:
shutil.move(str(src), str(dst))
print(f"\n Moved successfully.")
# Check for cross-references
refs = find_cross_references(src, docs_dir)
if refs:
print(f"\nCross-references found ({len(refs)} occurrence(s)):")
print("These files reference the old path and may need updating:")
print()
for ref_file, lineno, line in refs[:10]:
try:
rel = ref_file.relative_to(docs_dir)
except ValueError:
rel = ref_file
print(f" {rel}:{lineno}: {line.strip()[:100]}")
if len(refs) > 10:
print(f" ... and {len(refs) - 10} more")
# Suggest the new relative path
try:
new_rel = dst.relative_to(docs_dir)
old_rel = src.relative_to(docs_dir)
print(f"\nSuggested replacement: '{old_rel}' -> '{new_rel}'")
except ValueError:
pass
else:
print("\nNo cross-references found.")
# Re-index
if not args.dry_run:
print("\nRe-indexing...")
re_index(docs_dir, config, dry_run=False)
elif args.dry_run:
print("\nWould re-index after move.")
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""
Classify files into Johnny.Decimal areas using keyword heuristics.
Usage:
uv run jd_classify.py docs/roadmap.md docs/api-design.md
uv run jd_classify.py docs/*.md
uv run jd_classify.py docs/*.md --move --yes
uv run jd_classify.py docs/*.md --no-content
uv run jd_classify.py docs/*.md --diataxis
uv run jd_classify.py docs/*.md --diataxis-move --move --dry-run
"""
import argparse
import json
import re
import shutil
import sys
from dataclasses import dataclass, field
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from shared import (
DEFAULT_AREAS,
DIATAXIS_QUADRANT_TO_PREFIX,
find_area_by_prefix,
normalize_filename,
resolve_config,
)
from jd_index import re_index
def _get_diataxis_classifier():
"""Try to import diataxis classify_file. Returns None if unavailable.
Uses importlib to avoid module name collisions (both skills have shared.py).
"""
import importlib.util
diataxis_scripts = Path(__file__).resolve().parent.parent.parent / "diataxis" / "scripts"
classify_path = diataxis_scripts / "diataxis_classify.py"
shared_path = diataxis_scripts / "shared.py"
if not classify_path.is_file() or not shared_path.is_file():
return None
try:
# Load diataxis shared module under a unique name to avoid collision
shared_spec = importlib.util.spec_from_file_location("diataxis_shared", shared_path)
shared_mod = importlib.util.module_from_spec(shared_spec)
sys.modules["diataxis_shared"] = shared_mod
shared_spec.loader.exec_module(shared_mod)
# Temporarily make diataxis shared importable as "shared" for diataxis_classify
orig_shared = sys.modules.get("shared")
sys.modules["shared"] = shared_mod
classify_spec = importlib.util.spec_from_file_location("diataxis_classify", classify_path)
classify_mod = importlib.util.module_from_spec(classify_spec)
classify_spec.loader.exec_module(classify_mod)
# Restore original shared module
if orig_shared is not None:
sys.modules["shared"] = orig_shared
else:
del sys.modules["shared"]
return classify_mod.classify_file
except Exception:
return None
# Expanded keyword table for classification
CLASSIFICATION_KEYWORDS: dict[str, list[str]] = {
"00": [
"mvp", "requirements", "roadmap", "phase", "setup", "getting-started",
"execution-plan", "next-steps", "checklist", "onboarding", "quickstart",
"quick-start", "install", "installation", "tutorial",
],
"10": [
"product", "branding", "features", "priority", "design-system",
"editor", "spec", "ux", "ui", "wireframe", "mockup", "prototype",
"user-story", "persona", "journey",
],
"20": [
"architecture", "tech-stack", "integration", "structure", "refactor",
"strategy", "stack", "system-design", "api", "schema", "database",
"infrastructure", "convention", "pattern", "ddd", "deployment",
],
"30": [
"research", "resources", "analysis", "investigation", "alternatives",
"sources", "audit", "spike", "benchmark", "comparison", "evaluation",
"reference", "study", "exploration",
],
"90": [
"archive", "old", "deprecated", "historical", "retrospective",
"v0", "v1", "legacy", "retired", "obsolete",
],
}
@dataclass
class ClassificationResult:
"""Result of classifying a single file."""
file_path: Path
suggested_prefix: str = ""
suggested_area: str = ""
confidence: str = "low" # high, medium, low
score: float = 0.0
reason: str = ""
filename_matches: list[str] = field(default_factory=list)
content_matches: list[str] = field(default_factory=list)
diataxis_quadrant: str = "" # populated when --diataxis is used
diataxis_confidence: str = "" # populated when --diataxis is used
def _parse_first_heading(filepath: Path) -> str:
"""Extract the first markdown heading from a file."""
try:
with open(filepath) as f:
for line in f:
line = line.strip()
if line.startswith("# "):
return line[2:].strip().lower()
except (OSError, UnicodeDecodeError):
pass
return ""
def _read_first_n_lines(filepath: Path, n: int = 50) -> str:
"""Read the first N lines of a file as lowercase text."""
try:
lines = []
with open(filepath) as f:
for i, line in enumerate(f):
if i >= n:
break
lines.append(line.lower())
return " ".join(lines)
except (OSError, UnicodeDecodeError):
return ""
def _match_keywords(
text: str,
segments: set[str],
keywords: dict[str, list[str]],
) -> dict[str, tuple[float, list[str]]]:
"""Match text against keyword table.
Returns dict of prefix -> (score, matched_keywords).
Checks both full-text containment and segment-level matching.
"""
scores: dict[str, tuple[float, list[str]]] = {}
for prefix, kws in keywords.items():
matched = []
for kw in kws:
if kw in text or kw in segments:
matched.append(kw)
if matched:
# 0.5 for first match, +0.1 per additional, cap at 0.7
score = min(0.5 + 0.1 * (len(matched) - 1), 0.7)
scores[prefix] = (score, matched)
return scores
def classify_file(
filepath: Path,
config: dict,
scan_content: bool = True,
) -> ClassificationResult:
"""Classify a file by combining filename and content signals."""
result = ClassificationResult(file_path=filepath)
areas = config.get("areas", DEFAULT_AREAS)
# Prepare filename for matching
stem = filepath.stem.lower()
# Normalize separators to hyphens for segment splitting
normalized_stem = re.sub(r"[_ ]+", "-", stem)
segments = set(normalized_stem.split("-"))
# Pass 1: Filename analysis
filename_scores = _match_keywords(
normalized_stem, segments, CLASSIFICATION_KEYWORDS,
)
for prefix, (_, kws) in filename_scores.items():
result.filename_matches.extend(kws)
# Pass 2: Content analysis (optional)
content_scores: dict[str, tuple[float, list[str]]] = {}
if scan_content and filepath.suffix.lower() == ".md" and filepath.is_file():
heading = _parse_first_heading(filepath)
body = _read_first_n_lines(filepath, 50)
# Heading matches (weight 0.3)
heading_segments = set(re.sub(r"[_ ]+", "-", heading).split("-"))
heading_scores = _match_keywords(heading, heading_segments, CLASSIFICATION_KEYWORDS)
# Body matches (weight 0.1 per match, cap 0.3)
body_segments = set(re.sub(r"[_ ]+", "-", body).split("-"))
body_scores = _match_keywords(body, body_segments, CLASSIFICATION_KEYWORDS)
for prefix in set(list(heading_scores) + list(body_scores)):
h_score = heading_scores.get(prefix, (0, []))[0] * 0.3 / 0.5 # Normalize: max 0.3
h_kws = heading_scores.get(prefix, (0, []))[1]
b_raw = body_scores.get(prefix, (0, []))[0]
b_score = min(b_raw * 0.1 / 0.5, 0.3) # Normalize: max 0.3
b_kws = body_scores.get(prefix, (0, []))[1]
combined_score = h_score + b_score
combined_kws = list(set(h_kws + b_kws))
content_scores[prefix] = (min(combined_score, 0.5), combined_kws)
result.content_matches.extend(combined_kws)
# Deduplicate content matches
result.content_matches = list(set(result.content_matches))
# Combine scores
all_prefixes = set(list(filename_scores) + list(content_scores))
combined: dict[str, float] = {}
for prefix in all_prefixes:
fn_score = filename_scores.get(prefix, (0, []))[0]
ct_score = content_scores.get(prefix, (0, []))[0]
# Agreement bonus when both signals point to same area
bonus = 0.15 if fn_score > 0 and ct_score > 0 else 0
combined[prefix] = min(fn_score + ct_score + bonus, 1.0)
if not combined:
result.reason = "No keyword matches"
return result
# Pick the best
best_prefix = max(combined, key=lambda k: combined[k])
result.score = combined[best_prefix]
result.suggested_prefix = best_prefix
result.suggested_area = f"{best_prefix}-{areas.get(best_prefix, 'unknown')}"
if result.score >= 0.7:
result.confidence = "high"
elif result.score >= 0.4:
result.confidence = "medium"
else:
result.confidence = "low"
# Build reason string
reasons = []
fn_kws = filename_scores.get(best_prefix, (0, []))[1]
ct_kws = [k for k in content_scores.get(best_prefix, (0, []))[1] if k not in fn_kws]
if fn_kws:
reasons.append(f"Filename: {', '.join(repr(k) for k in fn_kws[:3])}")
if ct_kws:
reasons.append(f"Content: {', '.join(repr(k) for k in ct_kws[:3])}")
result.reason = "; ".join(reasons) if reasons else "Weak keyword match"
return result
def format_table(results: list[ClassificationResult], show_diataxis: bool = False) -> str:
"""Format classification results as a human-readable table."""
# Calculate column widths
file_w = max(len("File"), max(len(r.file_path.name) for r in results))
area_w = max(len("Suggested Area"), max(len(r.suggested_area or "(unknown)") for r in results))
conf_w = len("Confidence")
if show_diataxis:
quad_w = max(len("Quadrant"), max(len(r.diataxis_quadrant or "-") for r in results))
qconf_w = len("Q. Conf")
header = (
f"{'File':<{file_w}} | {'Suggested Area':<{area_w}} | "
f"{'Confidence':<{conf_w}} | {'Quadrant':<{quad_w}} | "
f"{'Q. Conf':<{qconf_w}} | Reason"
)
sep = (
f"{'-' * file_w}-+-{'-' * area_w}-+-{'-' * conf_w}-+-"
f"{'-' * quad_w}-+-{'-' * qconf_w}-+{'-' * 30}"
)
else:
header = f"{'File':<{file_w}} | {'Suggested Area':<{area_w}} | {'Confidence':<{conf_w}} | Reason"
sep = f"{'-' * file_w}-+-{'-' * area_w}-+-{'-' * conf_w}-+{'-' * 30}"
lines = [header, sep]
for r in results:
area = r.suggested_area or "(unknown)"
if show_diataxis:
quadrant = r.diataxis_quadrant or "-"
qconf = r.diataxis_confidence or "-"
lines.append(
f"{r.file_path.name:<{file_w}} | {area:<{area_w}} | "
f"{r.confidence:<{conf_w}} | {quadrant:<{quad_w}} | "
f"{qconf:<{qconf_w}} | {r.reason}"
)
else:
lines.append(
f"{r.file_path.name:<{file_w}} | {area:<{area_w}} | {r.confidence:<{conf_w}} | {r.reason}"
)
return "\n".join(lines)
def format_json(results: list[ClassificationResult], show_diataxis: bool = False) -> str:
"""Format classification results as JSON."""
data = []
for r in results:
entry = {
"file": str(r.file_path),
"suggested_area": r.suggested_area,
"suggested_prefix": r.suggested_prefix,
"confidence": r.confidence,
"score": round(r.score, 2),
"reason": r.reason,
"filename_matches": r.filename_matches,
"content_matches": r.content_matches,
}
if show_diataxis:
entry["diataxis_quadrant"] = r.diataxis_quadrant
entry["diataxis_confidence"] = r.diataxis_confidence
data.append(entry)
return json.dumps(data, indent=2)
def move_files(
results: list[ClassificationResult],
docs_dir: Path,
config: dict,
dry_run: bool,
) -> list[tuple[Path, Path]]:
"""Move files to their suggested areas. Returns list of (src, dst) pairs."""
moved: list[tuple[Path, Path]] = []
for r in results:
if r.confidence == "low" or not r.suggested_prefix:
print(f" Skip (low confidence): {r.file_path.name} — flag for Claude review")
continue
area_dir = find_area_by_prefix(docs_dir, r.suggested_prefix)
if not area_dir:
print(f" Skip (area not found): {r.file_path.name} — {r.suggested_area} doesn't exist")
continue
new_name = normalize_filename(r.file_path.name)
dst = area_dir / new_name
if dst.exists():
print(f" Skip (conflict): {r.file_path.name} — {dst.name} already exists in {area_dir.name}/")
continue
if dry_run:
print(f" Would move: {r.file_path.name} -> {area_dir.name}/{new_name}")
else:
shutil.move(str(r.file_path), str(dst))
print(f" Moved: {r.file_path.name} -> {area_dir.name}/{new_name}")
moved.append((r.file_path, dst))
return moved
def main() -> int:
parser = argparse.ArgumentParser(
description="Classify files into Johnny.Decimal areas using keyword heuristics"
)
parser.add_argument(
"files",
nargs="+",
help="File paths to classify",
)
parser.add_argument(
"--move",
action="store_true",
help="Move files to suggested areas (high/medium confidence only)",
)
parser.add_argument(
"--yes",
"-y",
action="store_true",
help="Skip confirmation prompt when using --move",
)
parser.add_argument(
"--no-content",
action="store_true",
help="Disable content scanning (filename-only classification)",
)
parser.add_argument(
"--dir",
"-d",
default=None,
help="Docs directory (default: from config or 'docs')",
)
parser.add_argument(
"--config",
"-c",
default=None,
help="Path to .jd-config.json (auto-detected if not specified)",
)
parser.add_argument(
"--json",
action="store_true",
dest="json_output",
help="Output results as JSON",
)
parser.add_argument(
"--diataxis",
action="store_true",
help="Show Diataxis quadrant classification alongside JD area",
)
parser.add_argument(
"--diataxis-move",
action="store_true",
help="With --move, route files to Diataxis areas (41-44) instead of JD areas",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Preview move operations without executing",
)
args = parser.parse_args()
base_path = Path.cwd()
# Load config
config = resolve_config(args.config, base_path)
# Resolve docs directory
if args.dir:
docs_dir = Path(args.dir)
if not docs_dir.is_absolute():
docs_dir = base_path / docs_dir
else:
docs_dir = base_path / config.get("root", "docs")
# Resolve and validate file paths
files: list[Path] = []
for f in args.files:
p = Path(f)
if not p.is_absolute():
p = base_path / p
if not p.exists():
print(f"Warning: File not found: {f}", file=sys.stderr)
continue
if not p.is_file():
continue
files.append(p)
if not files:
print("Error: No valid files to classify")
return 1
# Classify
scan_content = not args.no_content
results = [classify_file(f, config, scan_content) for f in files]
# Diataxis classification (if requested)
show_diataxis = args.diataxis or args.diataxis_move
if show_diataxis:
diataxis_classify = _get_diataxis_classifier()
if diataxis_classify:
for r in results:
dx_result = diataxis_classify(r.file_path, config, scan_content)
r.diataxis_quadrant = dx_result.primary_quadrant
r.diataxis_confidence = dx_result.confidence
else:
print("Note: Diataxis skill not found, quadrant column will be empty",
file=sys.stderr)
# Override prefix for --diataxis-move
if args.diataxis_move and args.move:
for r in results:
if r.diataxis_quadrant and r.diataxis_quadrant in DIATAXIS_QUADRANT_TO_PREFIX:
dx_prefix = DIATAXIS_QUADRANT_TO_PREFIX[r.diataxis_quadrant]
dx_area_dir = find_area_by_prefix(docs_dir, dx_prefix)
if dx_area_dir:
r.suggested_prefix = dx_prefix
r.suggested_area = dx_area_dir.name
else:
print(f" Note: {dx_prefix}-* area not found for {r.file_path.name}, "
f"keeping JD area {r.suggested_area}", file=sys.stderr)
# Output
if args.json_output:
print(format_json(results, show_diataxis))
else:
print(format_table(results, show_diataxis))
# Summary (human-readable only)
high = sum(1 for r in results if r.confidence == "high")
medium = sum(1 for r in results if r.confidence == "medium")
low = sum(1 for r in results if r.confidence == "low")
print(f"\nSummary: {high} high, {medium} medium, {low} low confidence")
if low > 0:
print(f" {low} file(s) need Claude review (low confidence)")
# Move if requested
if args.move:
moveable = [r for r in results if r.confidence != "low" and r.suggested_prefix]
if not moveable:
print("\nNo files to move (all low confidence).")
return 0
if not args.yes and not args.dry_run:
print(f"\nAbout to move {len(moveable)} file(s). Proceed? [y/N] ", end="")
try:
answer = input().strip().lower()
except EOFError:
answer = "n"
if answer not in ("y", "yes"):
print("Aborted.")
return 0
print()
moved = move_files(results, docs_dir, config, args.dry_run)
if moved and not args.dry_run:
print("\nRe-indexing...")
re_index(docs_dir, config, dry_run=False)
print(f"\n{'Would move' if args.dry_run else 'Moved'}: {len(moved)} file(s)")
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""
Generate or update a README.md index for a Johnny.Decimal docs directory.
Usage:
uv run jd_index.py --dir docs
uv run jd_index.py --dir docs --format tree
uv run jd_index.py --dir docs --dry-run
"""
import argparse
import re
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from shared import (
AREA_DESCRIPTIONS_SHORT,
is_ignored,
resolve_config,
)
START_MARKER = "<!-- JD:INDEX:START -->"
END_MARKER = "<!-- JD:INDEX:END -->"
def parse_doc_title(filepath: Path) -> str:
"""Extract the first heading from a markdown file."""
try:
with open(filepath) as f:
for line in f:
line = line.strip()
if line.startswith("# "):
# Remove the # prefix and any leading numbering
title = line[2:].strip()
# Remove common prefixes like "00 —" or "ADR-0001:"
title = re.sub(r"^[0-9]{2,4}\s*[-—:]\s*", "", title)
return title
except (OSError, UnicodeDecodeError):
pass
# Fallback: derive from filename
name = filepath.stem.replace("-", " ").replace("_", " ").title()
return name
def scan_areas(docs_dir: Path, ignore: list[str]) -> list[dict]:
"""Scan all J.D area directories and their contents."""
areas = []
for item in sorted(docs_dir.iterdir()):
if not item.is_dir():
continue
if is_ignored(item.name, ignore):
continue
if not re.match(r"^[0-9]{2}-", item.name):
continue
prefix = item.name[:2]
name = item.name[3:] # After "NN-"
# Collect documents and subdirectories in a single pass
entries = list(item.iterdir())
docs = []
for doc in sorted(e for e in entries if e.is_file()):
if doc.suffix.lower() == ".md" and doc.name.lower() != "readme.md":
if not is_ignored(doc.name, ignore):
title = parse_doc_title(doc)
docs.append({
"path": doc,
"name": doc.name,
"title": title,
})
subdirs = [d for d in entries if d.is_dir() and not is_ignored(d.name, ignore)]
desc = AREA_DESCRIPTIONS_SHORT.get(prefix, "")
areas.append({
"prefix": prefix,
"name": name,
"folder": item.name,
"path": item,
"docs": docs,
"subdirs": subdirs,
"description": desc,
"doc_count": len(docs),
})
return areas
def generate_table_index(areas: list[dict]) -> str:
"""Generate a table-format index."""
lines = [
"| Prefix | Area | Docs | Description |",
"|--------|------|------|-------------|",
]
for area in areas:
folder = area["folder"]
desc = area["description"]
count = area["doc_count"]
count_str = f"{count} doc{'s' if count != 1 else ''}" if count > 0 else "—"
lines.append(f"| `{area['prefix']}-` | [`{folder}/`](./{folder}/) | {count_str} | {desc} |")
return "\n".join(lines)
def generate_tree_index(areas: list[dict]) -> str:
"""Generate a tree-format index with document listings."""
lines = []
for area in areas:
folder = area["folder"]
desc = area["description"]
title = area["name"].replace("-", " ").title()
lines.append(f"- **[`{folder}/`](./{folder}/)** — {desc or title}")
for doc in area["docs"]:
rel_path = f"./{folder}/{doc['name']}"
lines.append(f" - [{doc['title']}]({rel_path})")
if area["subdirs"]:
for subdir in area["subdirs"]:
lines.append(f" - `{subdir.name}/` (directory)")
return "\n".join(lines)
def update_readme(
docs_dir: Path,
index_content: str,
dry_run: bool,
) -> bool:
"""Update README.md with the generated index.
Uses marker comments to preserve custom content.
Returns True if changes were made.
"""
readme_path = docs_dir / "README.md"
if not readme_path.exists():
# Generate minimal README with index
content = f"""# Documentation
## Documentation Index
{START_MARKER}
{index_content}
{END_MARKER}
"""
if dry_run:
print(f"Would create: {readme_path}")
print()
print(content)
else:
readme_path.write_text(content)
print(f"Created: {readme_path}")
return True
# Read existing README
existing = readme_path.read_text()
# Check for markers
if START_MARKER in existing and END_MARKER in existing:
# Replace content between markers
pattern = re.compile(
rf"{re.escape(START_MARKER)}.*?{re.escape(END_MARKER)}",
re.DOTALL,
)
new_content = f"{START_MARKER}\n\n{index_content}\n\n{END_MARKER}"
updated = pattern.sub(new_content, existing, count=1)
if updated == existing:
print("Index is already up to date.")
return False
if dry_run:
print(f"Would update: {readme_path}")
print()
print("--- Index content ---")
print(index_content)
print("--- End ---")
else:
readme_path.write_text(updated)
print(f"Updated: {readme_path}")
return True
else:
# No markers found — append index with markers
section = f"""
{START_MARKER}
{index_content}
{END_MARKER}
"""
if dry_run:
print(f"Would append index to: {readme_path}")
print("(No markers found — adding markers and index)")
print()
print("--- Index content ---")
print(index_content)
print("--- End ---")
else:
with open(readme_path, "a") as f:
f.write(section)
print(f"Appended index to: {readme_path}")
print("Tip: Move the index markers to the desired position in your README")
return True
def re_index(docs_dir: Path, config: dict, dry_run: bool) -> None:
"""Regenerate the README index for a docs directory.
Convenience wrapper used by other jd-docs scripts after mutations.
"""
ignore = config.get("ignore", ["adr", "*.pdf"])
fmt = config.get("readme_format", "table")
areas = scan_areas(docs_dir, ignore)
if not areas:
return
if fmt == "tree":
index_content = generate_tree_index(areas)
else:
index_content = generate_table_index(areas)
update_readme(docs_dir, index_content, dry_run)
def main() -> int:
parser = argparse.ArgumentParser(
description="Generate or update a README.md index for Johnny.Decimal docs"
)
parser.add_argument(
"--dir",
"-d",
required=True,
help="Docs directory to index",
)
parser.add_argument(
"--format",
"-f",
choices=["table", "tree"],
default=None,
help="Index format (default: from config or 'table')",
)
parser.add_argument(
"--config",
"-c",
default=None,
help="Path to .jd-config.json (auto-detected if not specified)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Print index without writing",
)
args = parser.parse_args()
# Resolve directory
docs_dir = Path(args.dir)
if not docs_dir.is_absolute():
docs_dir = Path.cwd() / docs_dir
if not docs_dir.exists():
print(f"Error: Directory does not exist: {docs_dir}")
return 1
# Load config
config = resolve_config(args.config, Path.cwd())
ignore = config.get("ignore", ["adr", "*.pdf"])
fmt = args.format or config.get("readme_format", "table")
# Scan areas
areas = scan_areas(docs_dir, ignore)
if not areas:
print(f"No J.D areas found in: {docs_dir}")
print("Expected directories matching NN-name pattern (e.g., 00-getting-started/)")
return 1
print(f"Scanning: {docs_dir}")
print(f"Format: {fmt}")
print(f"Areas found: {len(areas)}")
total_docs = sum(a["doc_count"] for a in areas)
print(f"Total documents: {total_docs}")
print()
# Generate index
if fmt == "tree":
index_content = generate_tree_index(areas)
else:
index_content = generate_table_index(areas)
# Update README
try:
update_readme(docs_dir, index_content, args.dry_run)
except OSError as e:
print(f"Error writing README: {e}")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""
Scaffold a Johnny.Decimal documentation structure.
Usage:
uv run jd_init.py # Create docs/ with defaults
uv run jd_init.py --root docs/skrebe # Product sub-tree
uv run jd_init.py --init-config # Also create .jd-config.json
uv run jd_init.py --dry-run # Preview only
uv run jd_init.py --diataxis # Include Diataxis areas (41-44)
"""
import argparse
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from shared import (
AREA_DESCRIPTIONS,
DEFAULT_AREAS,
DEFAULT_CONFIG,
DIATAXIS_AREAS,
find_git_root,
resolve_config,
)
def generate_root_readme(project_name: str, areas: dict[str, str]) -> str:
"""Generate the docs root README.md content."""
lines = [
f"# {project_name} Documentation",
"",
"[Brief project description]",
"",
"## Quick Start",
"",
]
if "00" in areas:
lines.append("- [Getting started guide](./00-getting-started/)")
lines.append("")
lines.extend([
"## Documentation Index",
"",
"<!-- JD:INDEX:START -->",
"",
"| Prefix | Area | Purpose |",
"|--------|------|---------|",
])
for prefix in sorted(areas.keys()):
name = areas[prefix]
folder = f"{prefix}-{name}"
desc = AREA_DESCRIPTIONS.get(prefix, "")
lines.append(f"| `{prefix}-` | [`{folder}/`](./{folder}/) | {desc} |")
lines.extend([
"",
"<!-- JD:INDEX:END -->",
"",
"## Folder Convention",
"",
"Documentation uses the [Johnny.Decimal](https://johnnydecimal.com/) numbering system.",
"Numeric prefixes ensure folders sort in a logical order:",
"",
"- `00-09` — Getting started and immediate work",
"- `10-19` — Product decisions",
"- `20-29` — Architecture and technical design",
"- `30-39` — Research and reference material",
])
# Include Diataxis range if those areas are present
if any(k in ("41", "42", "43", "44") for k in areas):
lines.append("- `41-44` — Diataxis quadrants (tutorials, how-to, reference, explanation)")
lines.extend([
"- `90-99` — Archive",
"",
"This convention provides consistent sorting and helps developers",
"and LLMs quickly locate documentation.",
"",
])
return "\n".join(lines)
def generate_area_readme(prefix: str, name: str) -> str:
"""Generate a per-area README.md content."""
title = name.replace("-", " ").title()
desc = AREA_DESCRIPTIONS.get(prefix, f"Documentation for {title.lower()}")
return f"""# {prefix} — {title}
{desc}.
## Documents
_No documents yet._
"""
def scaffold_structure(
root: Path,
areas: dict[str, str],
project_name: str,
dry_run: bool,
) -> list[Path]:
"""Create J.D directory structure with README stubs.
Returns list of created paths.
"""
created: list[Path] = []
# Create root directory
if not root.exists():
if dry_run:
print(f" Would create: {root}/")
else:
root.mkdir(parents=True, exist_ok=True)
created.append(root)
# Create root README.md
readme_path = root / "README.md"
if not readme_path.exists():
content = generate_root_readme(project_name, areas)
if dry_run:
print(f" Would create: {readme_path}")
else:
readme_path.write_text(content)
created.append(readme_path)
else:
print(f" Exists (skip): {readme_path}")
# Create area directories and READMEs
for prefix in sorted(areas.keys()):
name = areas[prefix]
area_dir = root / f"{prefix}-{name}"
if not area_dir.exists():
if dry_run:
print(f" Would create: {area_dir}/")
else:
area_dir.mkdir(parents=True, exist_ok=True)
created.append(area_dir)
area_readme = area_dir / "README.md"
if not area_readme.exists():
content = generate_area_readme(prefix, name)
if dry_run:
print(f" Would create: {area_readme}")
else:
area_readme.write_text(content)
created.append(area_readme)
else:
print(f" Exists (skip): {area_readme}")
return created
def create_config(base_path: Path, dry_run: bool) -> Path | None:
"""Write .jd-config.json with defaults."""
config_path = base_path / ".jd-config.json"
if config_path.exists():
print(f" Exists (skip): {config_path}")
return None
if dry_run:
print(f" Would create: {config_path}")
return config_path
with open(config_path, "w") as f:
json.dump(DEFAULT_CONFIG, f, indent=2)
f.write("\n")
return config_path
def main() -> int:
parser = argparse.ArgumentParser(
description="Scaffold a Johnny.Decimal documentation structure"
)
parser.add_argument(
"--root",
"-r",
default=None,
help="Docs root directory (default: from config or 'docs')",
)
parser.add_argument(
"--product",
"-p",
default=None,
help="Product name for sub-tree (e.g., 'skrebe')",
)
parser.add_argument(
"--config",
"-c",
default=None,
help="Path to .jd-config.json (auto-detected if not specified)",
)
parser.add_argument(
"--init-config",
action="store_true",
help="Create .jd-config.json with defaults",
)
parser.add_argument(
"--diataxis",
action="store_true",
help="Include Diataxis quadrant areas (41-44) alongside standard areas",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would be created without writing",
)
args = parser.parse_args()
base_path = Path.cwd()
# Load config
config = resolve_config(args.config, base_path)
if args.config and not Path(args.config).exists():
# resolve_config prints warning, but init needs hard error
config_path = Path(args.config)
if not config_path.is_absolute():
config_path = base_path / config_path
if not config_path.exists():
print(f"Error: Config file not found: {config_path}")
return 1
areas = config.get("areas", DEFAULT_AREAS)
# Include Diataxis quadrant areas if requested
if args.diataxis:
areas = {**areas, **DIATAXIS_AREAS}
# Determine root directory
if args.root:
root = Path(args.root)
if not root.is_absolute():
root = base_path / root
else:
root = base_path / config.get("root", "docs")
# Handle product sub-tree
if args.product:
root = root / args.product
# Derive project name from git root or directory
git_root = find_git_root(base_path)
project_name = (git_root or base_path).name.replace("-", " ").replace("_", " ").title()
# Optionally create config file
if args.init_config:
config_target = git_root or base_path
if args.dry_run:
print("Config:")
create_config(config_target, args.dry_run)
if args.dry_run:
print()
# Scaffold
mode = "(dry-run)" if args.dry_run else ""
print(f"Scaffolding J.D structure at: {root}/ {mode}")
print()
created = scaffold_structure(root, areas, project_name, args.dry_run)
if not created:
print("\nNothing to create — structure already exists.")
else:
print(f"\n{'Would create' if args.dry_run else 'Created'}: {len(created)} items")
if not args.dry_run and created:
print(f"\nNext: Run jd_validate.py --dir {root} to verify the structure")
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""
Validate a Johnny.Decimal documentation structure.
Usage:
uv run jd_validate.py --dir docs
uv run jd_validate.py --dir docs/skrebe --strict
uv run jd_validate.py --dir docs --config .jd-config.json
"""
import argparse
import re
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from shared import (
AREA_NAME_PATTERN,
DEFAULT_AREAS,
ROOT_ALLOWED_FILES,
is_ignored,
resolve_config,
)
def find_areas(docs_dir: Path, ignore: list[str]) -> list[Path]:
"""Find all NN-* directories in the docs root."""
areas = []
for item in sorted(docs_dir.iterdir()):
if item.is_dir() and not is_ignored(item.name, ignore):
if re.match(r"^[0-9]{2}-", item.name):
areas.append(item)
return areas
def validate_directory_name(name: str) -> tuple[bool, str | None]:
"""Check if a directory name matches the J.D convention.
Returns:
tuple: (is_valid, error_message or None)
"""
if not AREA_NAME_PATTERN.match(name):
return False, f"'{name}' does not match NN-kebab-case pattern"
return True, None
def check_numbering(areas: list[Path]) -> list[str]:
"""Check area prefix numbering conventions.
Returns list of warning messages.
"""
warnings = []
for area in areas:
prefix = area.name[:2]
try:
num = int(prefix)
if num % 10 != 0 and num < 90:
warnings.append(
f"Area prefix '{prefix}' is not a multiple of 10 "
f"(convention: 00, 10, 20, ..., 90)"
)
except ValueError:
pass # Invalid prefix caught by validate_directory_name
return warnings
def check_orphan_files(
docs_dir: Path,
ignore: list[str],
) -> list[Path]:
"""Find markdown files in docs root that should be in an area."""
orphans = []
for item in docs_dir.iterdir():
if item.is_file() and not is_ignored(item.name, ignore):
if item.name.lower() not in ROOT_ALLOWED_FILES:
if item.suffix.lower() == ".md":
orphans.append(item)
return orphans
def check_readme_presence(areas: list[Path]) -> list[Path]:
"""Find area directories missing README.md."""
missing = []
for area in areas:
readme = area / "README.md"
if not readme.exists():
missing.append(area)
return missing
def check_missing_standard_areas(
areas: list[Path],
expected_areas: dict[str, str],
) -> list[str]:
"""Report standard areas that are missing."""
found_prefixes = {a.name[:2] for a in areas}
missing = []
for prefix, name in sorted(expected_areas.items()):
if prefix not in found_prefixes:
missing.append(f"{prefix}-{name}")
return missing
def main() -> int:
parser = argparse.ArgumentParser(
description="Validate a Johnny.Decimal documentation structure"
)
parser.add_argument(
"--dir",
"-d",
required=True,
help="Docs directory to validate",
)
parser.add_argument(
"--config",
"-c",
default=None,
help="Path to .jd-config.json (auto-detected if not specified)",
)
parser.add_argument(
"--strict",
action="store_true",
help="Treat warnings as errors (exit code 1)",
)
args = parser.parse_args()
# Resolve directory
docs_dir = Path(args.dir)
if not docs_dir.is_absolute():
docs_dir = Path.cwd() / docs_dir
if not docs_dir.exists():
print(f"Error: Directory does not exist: {docs_dir}")
return 1
if not docs_dir.is_dir():
print(f"Error: Not a directory: {docs_dir}")
return 1
# Load config
config = resolve_config(args.config, Path.cwd())
expected_areas = config.get("areas", DEFAULT_AREAS)
ignore = config.get("ignore", ["adr", "*.pdf"])
# Run checks
errors: list[str] = []
warnings: list[str] = []
info: list[str] = []
# Find areas
areas = find_areas(docs_dir, ignore)
# Check root README
root_readme = docs_dir / "README.md"
if not root_readme.exists():
warnings.append("Missing README.md in docs root")
# Validate area names
for area in areas:
valid, msg = validate_directory_name(area.name)
if not valid:
errors.append(f"Invalid area name: {msg}")
# Check numbering conventions
numbering_warnings = check_numbering(areas)
warnings.extend(numbering_warnings)
# Check orphan files
orphans = check_orphan_files(docs_dir, ignore)
for orphan in orphans:
warnings.append(f"Orphan file: {orphan.name}")
# Check README presence in areas
missing_readme = check_readme_presence(areas)
for area in missing_readme:
warnings.append(f"Missing README.md in {area.name}/")
# Check missing standard areas
missing_areas = check_missing_standard_areas(areas, expected_areas)
for area_name in missing_areas:
info.append(f"Standard area not present: {area_name}/")
# Print report
print("Johnny.Decimal Validation Report")
print("=" * 40)
print(f"Directory: {docs_dir}")
print()
# Areas found
print(f"Areas found: {len(areas)}")
for area in areas:
valid, _ = validate_directory_name(area.name)
mark = "+" if valid else "x"
print(f" {mark} {area.name}")
print()
# Errors
if errors:
print(f"Errors: {len(errors)}")
for err in errors:
print(f" x {err}")
print()
# Warnings
if warnings:
print(f"Warnings: {len(warnings)}")
for warn in warnings:
print(f" ! {warn}")
print()
# Info
if info:
print(f"Info: {len(info)}")
for inf in info:
print(f" - {inf}")
print()
# Result
if errors:
result = "FAIL"
elif warnings and args.strict:
result = "FAIL (strict mode)"
else:
result = "PASS"
print(f"Result: {result} ({len(areas)} areas, {len(errors)} errors, {len(warnings)} warnings)")
if errors or (warnings and args.strict):
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""
Shared utilities for jd-docs scripts.
Common helpers for config loading, git root detection, file naming,
and area management. Uses only standard library. Python 3.11+.
"""
import fnmatch
import json
import re
import subprocess
import sys
from pathlib import Path
# Pattern for valid J.D area directory names: NN-kebab-case
AREA_NAME_PATTERN = re.compile(r"^[0-9]{2}-[a-z0-9]+(?:-[a-z0-9]+)*$")
# Default area scheme
DEFAULT_AREAS: dict[str, str] = {
"00": "getting-started",
"10": "product",
"20": "architecture",
"30": "research",
"90": "archive",
}
# Purpose descriptions for each default area
AREA_DESCRIPTIONS: dict[str, str] = {
"00": "Onboarding, setup, quick start, MVP, and phase planning",
"10": "Product specs, features, roadmap, design, and branding",
"20": "Technical decisions, system design, and integration",
"30": "Research notes, spikes, investigations, and reference material",
"90": "Historical and deprecated documentation",
"41": "Learning-oriented, step-by-step lessons (Diataxis: Tutorial)",
"42": "Task-oriented, practical problem-solving guides (Diataxis: How-to)",
"43": "Information-oriented, technical descriptions (Diataxis: Reference)",
"44": "Understanding-oriented, conceptual discussions (Diataxis: Explanation)",
}
# Short descriptions for index tables
AREA_DESCRIPTIONS_SHORT: dict[str, str] = {
"00": "Onboarding, setup, quick start, MVP",
"10": "Specs, features, roadmap, design",
"20": "Tech decisions, system design",
"30": "Research, spikes, investigations",
"90": "Historical/deprecated docs",
"41": "Tutorials (Diataxis)",
"42": "How-to guides (Diataxis)",
"43": "Reference docs (Diataxis)",
"44": "Explanation docs (Diataxis)",
}
# Diataxis quadrant areas (range 41-44, within the 40-49 custom zone)
DIATAXIS_AREAS: dict[str, str] = {
"41": "tutorials",
"42": "how-to",
"43": "reference",
"44": "explanation",
}
# Mapping from Diataxis quadrant name to JD prefix
DIATAXIS_QUADRANT_TO_PREFIX: dict[str, str] = {
"tutorial": "41",
"how-to": "42",
"reference": "43",
"explanation": "44",
}
DEFAULT_CONFIG: dict = {
"version": 1,
"root": "docs",
"areas": DEFAULT_AREAS,
"products": [],
"ignore": ["adr", "*.pdf"],
"readme_format": "table",
}
# Files that are expected at the docs root (not orphans)
ROOT_ALLOWED_FILES: set[str] = {
"readme.md",
"glossary.md",
".jd-config.json",
".ds_store",
}
def find_git_root(start: Path) -> Path | None:
"""Walk up from start to find the git repository root."""
try:
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
cwd=start,
)
if result.returncode == 0:
return Path(result.stdout.strip())
except FileNotFoundError:
pass
return None
def load_config(base_path: Path) -> dict:
"""Find .jd-config.json walking up to git root, or return defaults."""
current = base_path.resolve()
git_root = find_git_root(current)
stop_at = git_root or current
while True:
config_path = current / ".jd-config.json"
if config_path.exists():
with open(config_path) as f:
config = json.load(f)
# Merge with defaults for missing keys
for key, value in DEFAULT_CONFIG.items():
if key not in config:
config[key] = value
return config
if current == stop_at or current == current.parent:
break
current = current.parent
return dict(DEFAULT_CONFIG)
def resolve_config(args_config: str | None, base_path: Path) -> dict:
"""Load config from an explicit path or auto-detect."""
if args_config:
config_path = Path(args_config)
if not config_path.is_absolute():
config_path = base_path / config_path
if config_path.exists():
with open(config_path) as f:
config = json.load(f)
for key, value in DEFAULT_CONFIG.items():
if key not in config:
config[key] = value
return config
else:
print(f"Warning: Config not found: {config_path}, using defaults",
file=sys.stderr)
return dict(DEFAULT_CONFIG)
return load_config(base_path)
def is_ignored(name: str, ignore_patterns: list[str]) -> bool:
"""Check if a directory or file name matches any ignore pattern."""
return any(
fnmatch.fnmatch(name, p) or fnmatch.fnmatch(name.lower(), p.lower())
for p in ignore_patterns
)
def find_area_by_prefix(docs_dir: Path, prefix: str) -> Path | None:
"""Find an area directory by its two-digit prefix."""
for item in docs_dir.iterdir():
if item.is_dir() and item.name.startswith(f"{prefix}-"):
return item
return None
def normalize_filename(name: str) -> str:
"""Normalize a filename to kebab-case, preserving the extension.
Examples:
"My Design Doc.md" -> "my-design-doc.md"
"Tech_Stack_v2.md" -> "tech-stack-v2.md"
"SETUP Guide!.md" -> "setup-guide.md"
"a--b---c.md" -> "a-b-c.md"
".gitkeep" -> ".gitkeep" (hidden files unchanged)
"""
# Skip hidden files
if name.startswith("."):
return name
# Split name and extension
path = Path(name)
stem = path.stem
suffix = path.suffix.lower()
# Lowercase
stem = stem.lower()
# Replace underscores and spaces with hyphens
stem = re.sub(r"[_ ]+", "-", stem)
# Strip characters that aren't alphanumeric, hyphen, or dot
stem = re.sub(r"[^a-z0-9\-.]", "", stem)
# Collapse multiple hyphens
stem = re.sub(r"-{2,}", "-", stem)
# Strip leading/trailing hyphens
stem = stem.strip("-")
if not stem:
stem = "untitled"
return f"{stem}{suffix}"
Troubleshooting: Johnny.Decimal Documentation
1. No Docs Directory Found
Symptom: jd_init.py doesn't know where to create the structure.
Solutions:
- Use
--rootto specify explicitly:uv run scripts/jd_init.py --root docs - Ensure you're running from the project root (where
.git/lives) - Create the directory manually first:
mkdir docs
2. Config File Not Found
Symptom: Scripts use defaults instead of custom config.
Solutions:
- Create config:
uv run scripts/jd_init.py --init-config - Place
.jd-config.jsonat project root (same level as.git/) - Specify path explicitly:
--config path/to/.jd-config.json
3. Invalid Area Name
Symptom: Validation reports "does not match NN-kebab-case pattern".
Expected pattern: ^[0-9]{2}-[a-z0-9]+(-[a-z0-9]+)*$
Common causes:
- Uppercase letters:
20-Architecture→ rename to20-architecture - Underscores:
20-tech_stack→ rename to20-tech-stack - Spaces:
20 - architecture→ rename to20-architecture - Missing prefix:
architecture→ rename to20-architecture
4. Orphan Files Detected
Symptom: Validation warns about .md files in docs root.
Solutions:
- Move files to the appropriate area directory
- If the file belongs at root (like
glossary.md), it's expected — the script only flags non-standard root files - Add to ignore patterns in
.jd-config.json:"ignore": ["adr", "*.pdf", "glossary.md"]
5. Area Numbering Mismatch
Symptom: Validation reports "Standard area not present: 00-getting-started/" but project uses 00-mvp.
Explanation: This is informational, not an error. The project uses a custom name for the 00- area.
Solutions:
- Ignore the info message (it's not a warning or error)
- Update
.jd-config.jsonto match your naming:"00": "mvp"
6. Index Appended to End of README
Symptom: jd_index.py appends the index at the bottom of README.md instead of where you want it.
Explanation: On first run without markers, the script appends the index with markers at the end. This is by design — the script prints a tip to reposition them.
Solutions:
- After the first run, move the marker block to the desired position in your README:
## Documentation Index
<!-- JD:INDEX:START -->
(generated content)
<!-- JD:INDEX:END -->- On subsequent runs,
jd_index.pywill update content between the markers - Content outside markers is always preserved
7. Permission Errors
Symptom: "Permission denied" when creating directories or files.
Solutions:
- Check directory permissions:
ls -la docs/ - Ensure the current user owns the docs directory
- On macOS, check for extended attributes:
xattr -l docs/
8. uv Not Found
Symptom: command not found: uv when running scripts.
Solutions:
- Install uv:
curl -LsSf https://astral.sh/uv/install.sh | sh - Or run with Python directly:
python3 scripts/jd_init.py --help - Check PATH includes uv:
which uv
9. Products Config Not Working
Symptom: Product sub-trees not created automatically from products in config.
Explanation: The products config field records which products exist for reference, but jd_init.py scaffolds one tree per invocation. Run it once per product.
Solutions:
- Use
--productfor each product:
uv run scripts/jd_init.py --product skrebe
uv run scripts/jd_init.py --product papia-asrThis creates docs/skrebe/ and docs/papia-asr/ with J.D areas in each.
- Or use
--rootdirectly:
uv run scripts/jd_init.py --root docs/skrebe
uv run scripts/jd_init.py --root docs/papia-asr10. Index Shows Wrong Document Count
Symptom: jd_index.py reports fewer documents than expected.
Common causes:
- Files with non-
.mdextensions are not counted (only Markdown) README.mdfiles inside areas are excluded from count (they're structure, not content)- Files matching ignore patterns are skipped
- Check ignore list:
"ignore": ["adr", "*.pdf"]in config
11. Area Prefix Already Taken
Symptom: jd_add_area.py reports "Prefix 'XX' already in config" or "already exists on disk".
Solutions:
- Check existing areas:
ls -d docs/[0-9][0-9]-*/ - Choose a different prefix (multiples of 10: 40, 50, 60, 70, 80)
- If the area exists on disk but not in config, add it manually to
.jd-config.json
12. Invalid Prefix (Not Multiple of 10)
Symptom: jd_add_area.py reports "Prefix 'XX' is not a multiple of 10".
Explanation: J.D convention uses multiples of 10 for top-level areas (00, 10, 20, ..., 90).
Solutions:
- Use a valid prefix:
--prefix 40,--prefix 50, etc. - If you intentionally need a non-standard prefix (e.g.,
41for a sub-category), create the directory manually
13. Classification Shows All Low Confidence
Symptom: jd_classify.py reports all files as low confidence.
Common causes:
- Filenames are generic (e.g.,
notes.md,draft.md,todo.md) - Content doesn't contain classification keywords
- Custom areas not in the keyword table
Solutions:
- Use
--no-contentto see filename-only results, then add content - Use Claude-driven classification for ambiguous files (ask Claude to suggest areas)
- Move files manually with
jd_add.pyand specify the target area
14. File Already Exists in Target Area
Symptom: jd_add.py reports "File already exists" at destination.
Solutions:
- Use
--nameto specify a different filename:--name alternative-name.md - Check if the existing file is the same document (duplicate)
- Remove or archive the existing file first
15. Cross-References Not Auto-Updated
Symptom: After moving a file with jd_add.py, other docs still link to the old path.
Explanation: Cross-reference updates are intentionally NOT automated — the script prints suggestions but does not modify other files, to avoid accidental breakage.
Solutions:
- Review the suggested replacements printed by
jd_add.py - Use find-and-replace to update links in the affected files
- Run
jd_validate.pyto catch any remaining orphan references
16. Filename Normalization Unexpected
Symptom: The auto-normalized filename is not what you expected.
Examples:
Tech_Stack_v2.md→tech-stack-v2.md(underscores become hyphens)SETUP Guide!.md→setup-guide.md(special chars stripped).gitkeep→.gitkeep(hidden files unchanged)
Solutions:
- Preview first with
--dry-runto see the normalized name - Use
--nameto override:jd_add.py file.md 20 --name my-preferred-name.md
Debugging Tips
Verify structure:
# List all J.D area directories
ls -d docs/[0-9][0-9]-*/
# Count docs per area
for d in docs/[0-9][0-9]-*/; do
echo "$d: $(ls $d/*.md 2>/dev/null | wc -l) docs"
doneTest naming regex:
# Valid names
echo "00-getting-started" | grep -E '^[0-9]{2}-[a-z0-9]+(-[a-z0-9]+)*$'
echo "20-architecture" | grep -E '^[0-9]{2}-[a-z0-9]+(-[a-z0-9]+)*$'
# Invalid names (should not match)
echo "20-Architecture" | grep -E '^[0-9]{2}-[a-z0-9]+(-[a-z0-9]+)*$'Preview before changing: Always use --dry-run before any write operation:
uv run scripts/jd_init.py --dry-run
uv run scripts/jd_index.py --dir docs --dry-run---
Diataxis Integration Issues
Quadrant column shows "-" for all files
Symptom: --diataxis flag shows - in the Quadrant and Q. Conf columns.
Cause: The diataxis skill scripts are not found at the expected path.
Fix: Ensure the diataxis skill is installed alongside jd-docs:
ls plugins/doc/skills/diataxis/scripts/diataxis_classify.pyIf missing, install the diataxis skill or the quadrant column will remain empty (JD classification still works).
--diataxis-move fails with "area not found"
Symptom: Files not routed to Diataxis areas, warning printed.
Cause: The 41-44 Diataxis area directories don't exist in the docs structure.
Fix: Scaffold with --diataxis first:
uv run scripts/jd_init.py --diataxisWorkflow: Johnny.Decimal Documentation
Six-phase methodology for creating and maintaining J.D documentation structures.
Phase 1: Discovery and Context Analysis
Determine the project's current state before taking action.
1. Detect project root — Look for .git/ or .jd-config.json 2. Search for existing docs — Check docs/, doc/, documentation/, project root 3. Load configuration — Read .jd-config.json if present, otherwise use defaults 4. Classify current state:
- Empty: No docs directory exists
- Flat: Docs directory with unorganized files
- Partial J.D: Some numbered areas but incomplete
- Full J.D: Complete structure following conventions
Phase 2: Configuration Resolution
Resolve the area scheme and settings for this project.
1. Check for `.jd-config.json` in project root 2. Merge with defaults — Missing fields get default values 3. Resolve product sub-trees — Monorepo vs single-product 4. Determine docs root — From config root field or default docs/
Config Format
{
"version": 1,
"root": "docs",
"areas": {
"00": "getting-started",
"10": "product",
"20": "architecture",
"30": "research",
"90": "archive"
},
"products": [],
"ignore": ["adr", "*.pdf"],
"readme_format": "table"
}Fields:
version— Config format version (always1)root— Docs root relative to project rootareas— Map of two-digit prefix to kebab-case area nameproducts— Product sub-tree names (each gets its own J.D structure)ignore— Directories and glob patterns to skipreadme_format—"table"or"tree"for index generation
Phase 3: Scaffolding (New Projects)
For Empty or New sub-tree states:
1. Run jd_init.py to create the structure:
uv run scripts/jd_init.py --dry-run # Preview first
uv run scripts/jd_init.py # Create structure2. What gets created:
- Root
docs/directory - Area directories:
00-getting-started/,10-product/, etc. - Root
README.mdwith area index table and folder convention explanation - Per-area
README.mdstubs with purpose descriptions
3. For monorepos with product sub-trees:
uv run scripts/jd_init.py --root docs/product-a
uv run scripts/jd_init.py --root docs/product-b4. Optionally create .jd-config.json:
uv run scripts/jd_init.py --init-configPhase 4: Validation
For Partial J.D or Full J.D states:
1. Run jd_validate.py:
uv run scripts/jd_validate.py --dir docs2. Checks performed:
- ERROR: Directory name doesn't match
NN-kebab-casepattern - WARNING: Orphan
.mdfiles in docs root - WARNING: Area missing
README.md - INFO: Standard area not present (compared to config/defaults)
3. For strict CI enforcement:
uv run scripts/jd_validate.py --dir docs --strictPhase 5: Index Generation
For any state with existing areas:
1. Run jd_index.py:
uv run scripts/jd_index.py --dir docs --dry-run # Preview
uv run scripts/jd_index.py --dir docs # Update README2. Index formats:
- Table (default): Area, doc count, description
- Tree: Hierarchical with individual document links
3. Marker comments for README preservation:
<!-- JD:INDEX:START -->
(generated content)
<!-- JD:INDEX:END -->Content outside markers is preserved on re-generation.
Phase 6: Migration (Claude-Driven)
For Flat state — existing docs that need reorganization.
This phase is handled interactively by Claude, not by a script.
Step 1: Analyze Existing Files
List all files in the docs directory and classify them:
File → Suggested Area
requirements.md → 00-getting-started/
roadmap.md → 00-getting-started/
design-system.md → 10-product/
branding.md → 10-product/
tech-stack.md → 20-architecture/
integration-strategy.md → 20-architecture/
kriolu-resources.md → 30-research/
alternative-strategies.md → 30-research/
old-plan-v0.md → 90-archive/Classification Heuristics
| Area | Filename Keywords |
|---|---|
00 | mvp, requirements, roadmap, phase, setup, getting-started, execution-plan, next-steps, checklist |
10 | product, branding, features, priority, design-system, editor, spec, ux |
20 | architecture, tech-stack, integration, structure, refactor, strategy, stack, system-design |
30 | research, resources, analysis, investigation, alternatives, sources, audit, spike |
90 | archive, old, deprecated, historical, retrospective, v0, v1, legacy |
Step 2: Present Migration Plan
Show the user a table of proposed moves and ask for confirmation.
Step 3: Execute
Move files using standard file operations. For each file: 1. Create target area directory if it doesn't exist 2. Move the file 3. Check for internal cross-references (relative links to moved files) and update if found — some links may need manual review
Step 4: Regenerate Index
Run jd_index.py to update the README with the new structure.
Ongoing Maintenance
After initial setup, use the Day-2 scripts below or these manual steps:
- Adding new docs: Place in the appropriate
NN-area/directory - Re-indexing: Run
jd_index.py --dir docsafter adding/removing docs - Validation: Run
jd_validate.py --dir docsperiodically or in CI - New areas: Add to
.jd-config.jsonareas map, create directory manually or re-runjd_init.py
Phase 7: Day-2 Operations
Scripted workflows for evolving the documentation structure as the project grows.
Adding a New Area
Use jd_add_area.py to create a new area with a single command:
# Preview first
uv run scripts/jd_add_area.py --prefix 40 --name operations --dry-run
# Create with a custom description
uv run scripts/jd_add_area.py --prefix 40 --name operations \
--description "Deployment, monitoring, and runbooks"What happens: 1. Validates prefix (two digits, multiple of 10, not already taken) 2. Creates 40-operations/ directory with README stub 3. Updates .jd-config.json (creates if missing) 4. Regenerates the root README index
Classifying Unorganized Files
Use jd_classify.py to determine which area files belong to:
# Classify files (table output)
uv run scripts/jd_classify.py docs/*.md
# JSON output for scripting
uv run scripts/jd_classify.py docs/*.md --json
# Filename-only (skip content scanning)
uv run scripts/jd_classify.py docs/*.md --no-contentThe script uses a two-pass classification:
1. Filename analysis — Matches filename segments against a keyword table 2. Content analysis — Scans the first heading and first 50 lines for keywords
Confidence levels:
- High (score >= 0.7): Strong keyword match — safe to auto-move
- Medium (0.4-0.69): Partial match — review suggested destination
- Low (< 0.4): No clear match — flag for Claude review
To classify AND move files in one step:
# Preview moves
uv run scripts/jd_classify.py docs/*.md --move --dry-run
# Move with confirmation prompt
uv run scripts/jd_classify.py docs/*.md --move
# Move without prompt (CI-friendly)
uv run scripts/jd_classify.py docs/*.md --move --yesLow-confidence files are always skipped during --move and flagged for manual review.
Moving Files to Areas
Use jd_add.py to move a file to a specific area:
# Move by area prefix
uv run scripts/jd_add.py docs/roadmap.md 00
# Move by full area name
uv run scripts/jd_add.py docs/roadmap.md 00-getting-started
# Override output filename
uv run scripts/jd_add.py "docs/My Design Doc.md" 20 --name design-doc.md
# Preview first
uv run scripts/jd_add.py docs/api-design.md 20 --dry-runWhat happens: 1. Resolves target area (by prefix or full name) 2. Auto-normalizes filename to kebab-case (e.g., My Design Doc.md → my-design-doc.md) 3. Checks for destination conflicts 4. Moves the file 5. Scans for cross-references in other markdown files and prints suggestions 6. Regenerates the root README index
Filename Normalization
All Day-2 scripts auto-normalize filenames:
| Input | Output |
|---|---|
My Design Doc.md | my-design-doc.md |
Tech_Stack_v2.md | tech-stack-v2.md |
SETUP Guide!.md | setup-guide.md |
a--b---c.md | a-b-c.md |
.gitkeep | .gitkeep (hidden files unchanged) |
Use --name with jd_add.py to override the normalized name.
---
Phase 7: Diataxis Integration (Optional)
Goal: Combine Johnny.Decimal's structural organization with Diataxis content-type classification.
Scaffold with Diataxis Areas
Add Diataxis quadrant areas (41-44) alongside standard areas:
uv run scripts/jd_init.py --diataxis --dry-run
uv run scripts/jd_init.py --diataxisThis creates 9 areas: 5 standard (00-90) + 4 Diataxis (41-44):
41-tutorials— Learning-oriented, step-by-step lessons42-how-to— Task-oriented, practical guides43-reference— Information-oriented, technical descriptions44-explanation— Understanding-oriented, conceptual discussions
Classify with Diataxis Quadrant
Show Diataxis quadrant alongside JD area classification:
uv run scripts/jd_classify.py docs/*.md --diataxisThis adds Quadrant and Q. Conf columns to the output table.
Route Files to Diataxis Areas
Move files to their Diataxis area instead of their JD area:
uv run scripts/jd_classify.py docs/*.md --diataxis-move --move --dry-runThis requires the 41-44 areas to exist (created via --diataxis init).
Relationship Between JD and Diataxis
| Dimension | JD Area | Diataxis Quadrant |
|---|---|---|
| Classifies by | Topic (where) | Content type (what) |
| Example | 20-architecture | explanation |
| Structure | Numbered directories | Content-type directories |
| Overlap | A doc in 20-architecture/ can be an explanation or reference | A tutorial can be about any topic |
Both systems are complementary: JD organizes by topic, Diataxis classifies by user need.