feat(our-gdrive-organizer): add new skill at slot 82, rename old 82 → 92

New Python CLI + dual SKILL.md (Code + Desktop) for organizing Google
Drive folders under OurDigital conventions:

- Refresh root README index (preserves manual Topics/Notes between
  AUTO-STRUCTURE markers)
- Ensure per-subfolder README.md meta files
- Propose filename + folder renames (D.intelligence → OurDigital with
  SEO-context caveat documented in patterns/gotchas.md)
- Propose moves for cluttered files (screenshots, temp downloads)
- Sensitive-folder skip list (04_Case Studies, 99_Project Archive,
  *Archive*, 진단*)
- shared/patterns/ gotcha library: canonical-files, canonical-folders,
  categorization-rules, 12 known gotchas — grows over time as the
  system encounters new edge cases

Slash command: /organize. CLI: ~/.local/bin/our-gdrive-organize.

82-tui-design-template renumbered to 92 (no content change) to free
slot 82. AGENTS.md and CLAUDE.md updated for both moves.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-10 23:02:45 +09:00
parent e1bd799672
commit c750fa7f5e
16 changed files with 1731 additions and 5 deletions

View File

@@ -0,0 +1,209 @@
---
name: our-gdrive-organizer
description: |
Organize a Google Drive folder under OurDigital conventions: refresh root
README.md index, refresh per-subfolder README.md meta files, propose renames
for files using the old D.intelligence brand, and propose moves for cluttered
files (screenshots at the wrong level, temp/partial downloads).
Triggers:
- "organize the Drive folder", "organize this folder"
- "refresh the index", "rescan the folder", "update README"
- "clean up cluttered files", "propose renames"
- "/organize", "/organize-drive", "/our-gdrive-organizer"
Default target is the current working directory. Generalized to work on any
2nd-level subfolder of the user's Google Drive Stream (My Drive/00_..., 01_...,
02_..., etc.) — not specific to one folder.
version: "1.0"
author: OurDigital
environment: Code
---
# our-gdrive-organizer (Code)
Walks a target directory (3 levels deep), refreshes the index README, proposes
renames + moves under OurDigital naming conventions, and optionally applies.
Source of truth for the conventions: `../shared/conventions.md`.
## Activation
The user wants to organize one of their Drive folders. Cues:
- An explicit ask: "organize", "refresh index", "rename per convention", "scan for changes"
- The user is sitting inside a 2nd-level Drive folder (`~/Library/CloudStorage/GoogleDrive-*/My Drive/NN_*/`)
- Slash invocation: `/organize`, `/our-gdrive-organizer`
## Workflow
### Step 1 — Run dry-run, then summarize
```bash
~/.local/bin/our-gdrive-organize "$TARGET"
```
(`$TARGET` defaults to cwd; pass an absolute path for a specific folder.)
The script writes the README index immediately (idempotent — skips when no
structural change), but never renames or moves files without `--apply`.
Read the report and tell the user, in plain language:
- How many structural changes were folded into the README
- Each rename proposal (old → new, reason)
- Each move proposal (file → destination subfolder, reason)
- Subfolders that were skipped because they're "sensitive" (`04_Case Studies`,
`99_Project Archive`, `*Archive*`, `진단*`)
### Step 2 — Confirm with user before applying
If the user says go ahead (or "apply", "yes", "do it"):
```bash
~/.local/bin/our-gdrive-organize "$TARGET" --apply
```
If the user wants only part of the work:
```bash
~/.local/bin/our-gdrive-organize "$TARGET" --scope rename --apply
~/.local/bin/our-gdrive-organize "$TARGET" --scope move --apply
~/.local/bin/our-gdrive-organize "$TARGET" --scope index # always writes
~/.local/bin/our-gdrive-organize "$TARGET" --scope subreadmes # always writes
```
### Step 3 — Verify
After applying, run the dry-run once more and confirm the proposal list is
empty (or only contains items the user explicitly skipped).
## Important guardrails
- **Never rename or move files inside `04_Case Studies/`, `99_Project Archive/`,
any `*Archive*` folder, or any folder starting with `진단`.** Those contain
real client engagement records that must keep their original filenames.
- The script's rename/move rules live in `code/organizer.py` near the top of
the file (`RENAME_RULES`, `MOVE_RULES`, `SENSITIVE_SUBFOLDER_PATTERNS`). If
the user asks to add or change a rule, edit there and re-run.
- Filename renames on `.gsheet` / `.gdoc` stub files only change the local
filename — the actual Google Drive document and its sharing links are
preserved (the stub holds a Doc ID, not the content).
## What the script does NOT do
- Does not edit cell content inside `.gsheet` / `.gdoc` / `.xlsx` / `.pdf`
only local-filesystem renames. Cell-level neutralization stays a manual task.
- Does not delete anything.
- Does not modify the manually-curated Topics / Notes sections of the root
README — only the AUTO-STRUCTURE block.
- Does not categorize files by reading their **content** — for that, use the
Content-based reorganization workflow below.
## Folder-rename support
The script proposes folder renames (in addition to file renames) using the
same `RENAME_RULES`. Guardrails:
- **Top-level subfolders are NEVER renamed automatically.** Names like
`00_Brand Management/` or `02_SEO Audit Toolkit/` are user-curated
practice areas. If the user wants one renamed, do it as a one-off `mv`.
- Sensitive folders are skipped entirely (not renamed, not recursed into).
- Eligible folders: depth-2 and deeper (e.g.,
`00_Brand Management/D.intelligence SEO Audit/``…/OurDigital SEO Audit/`).
When `--scope rename --apply` runs, file renames execute first, then folder
renames — order matters because renaming a folder first would invalidate
the file rename paths inside it.
## Content-based reorganization (interactive)
The script handles deterministic naming-pattern work. For judgment calls
(which folder does this file truly belong in?), use this workflow.
### When to use
The user says any of:
- "look at the contents and reorganize"
- "this folder feels cluttered, suggest a better layout"
- "categorize the files in {subfolder}"
- "audit my folder structure"
Or you notice during a regular `/organize` run that:
- A subfolder root has many loose files that should plausibly be grouped
- Files appear duplicated across subfolders
- Filenames hint at content that doesn't match their location
### Workflow
1. **Anchor yourself** — read the patterns library before proposing anything:
- `../shared/patterns/canonical-folders.md` — what well-organized shapes look like
- `../shared/patterns/canonical-files.md` — what well-named files look like
- `../shared/patterns/categorization-rules.md` — IF→THEN placement rules
- `../shared/patterns/gotchas.md` — known edge cases
These are the gotcha library. Re-read them every session — they grow over time.
2. **Pick one subfolder at a time.** Don't try to reorganize an entire
2nd-level folder in one pass — too much for the user to review.
3. **Sample file contents.** For each file in the chosen subfolder:
- Markdown / txt / json: `Read` directly.
- `.gsheet` / `.gdoc` stubs: read the JSON to extract the doc_id, but
accept that you can't see actual cell content. Use the FILENAME
pattern + adjacent context.
- PDFs / .docx / .pptx / .xlsx: you can't read content with stdlib.
Either ask the user to summarize, or skip and rely on filename.
- For each file, note 12 sentences: what's it about, where would it
belong by content?
4. **Build proposals**, grouped by destination:
```
Move from `02_SEO Audit Toolkit/` → `04_Case Studies/`:
- `signed-acme-contract.pdf` (real client name + signed contract content)
Move from `02_SEO Audit Toolkit/` → `05_Working Template/`:
- `OurDigital-OOO Audit Template.gsheet` (placeholder name → template)
```
5. **Present one batch (one source folder) at a time.** Ask:
"Should I apply these N moves from `{source}/`? Yes / No / partial (which)."
6. **Apply via `Bash mv`** for each confirmed move:
```bash
mv "/path/to/source/file" "/path/to/destination/file"
```
Then refresh the index:
```bash
our-gdrive-organize "$TARGET" --scope index
```
7. **Capture new gotchas.** If you encountered an ambiguous case the
patterns library didn't cover, append it to
`../shared/patterns/gotchas.md` before ending the session — that's how
the system learns.
### Sensitive-folder reminder
When proposing moves in content-based mode, the same guardrails apply:
**never propose moving files INTO or OUT OF**:
- `04_Case Studies/`
- `99_Project Archive/`
- Any `*Archive*` folder
- Any folder starting with `진단`
If you notice something in those folders that looks misplaced, flag it to
the user as a manual review item — don't propose an automated move.
## When the user asks to extend
To add a new rename rule, edit `RENAME_RULES` in `code/organizer.py`:
```python
(re.compile(r"oldpattern", re.I), "newpattern", "human-readable reason"),
```
To add a new move rule, edit `MOVE_RULES`:
```python
(re.compile(r"^pattern\.ext$"), "destination_subfolder", "reason"),
```
Update `../shared/conventions.md` whenever rules change.

View File

@@ -0,0 +1,520 @@
#!/usr/bin/env python3
"""
our-gdrive-organizer — organize a Google Drive folder under OurDigital conventions.
Scans the target directory (default: 2nd-level Drive subfolder), refreshes the
README.md index, proposes renames + moves to bring files into convention, and
optionally applies them.
Usage:
organizer.py [TARGET] # dry-run report
organizer.py [TARGET] --apply # apply all proposed changes
organizer.py [TARGET] --scope index # only refresh root README
organizer.py [TARGET] --scope subreadmes # refresh per-subfolder READMEs
organizer.py [TARGET] --scope rename # only propose/apply renames
organizer.py [TARGET] --scope move # only propose/apply moves
organizer.py [TARGET] --scope full # everything (default)
organizer.py [TARGET] --json # machine-readable output
If TARGET is omitted, uses the current working directory.
The script is stdlib-only (Python 3.9+). Safe by default — never writes unless
--apply is passed (or running --scope index, which always writes the README).
"""
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import sys
from dataclasses import dataclass, field, asdict
from datetime import datetime, timezone
from pathlib import Path
from typing import Iterable
# ---------------------------------------------------------------------------
# Configuration: naming convention enforcement.
# Edit shared/conventions.md for the human-readable spec; this dict is the
# source of truth for the script.
# ---------------------------------------------------------------------------
RENAME_RULES: list[tuple[re.Pattern[str], str, str]] = [
# (pattern, replacement, reason)
# SEO context only — D.intelligence is the parent company; OurDigital is its
# SEO-specialty child brand. Only apply these renames when operating inside
# an SEO folder (02_SEO in Action, 04_SEO, etc.). Outside SEO context, keep
# D.intelligence names intact (they belong to the parent company). The
# script doesn't enforce this caveat — it relies on the caller pointing at
# the right target. See shared/patterns/gotchas.md "D.intelligence vs
# OurDigital" for the full rule.
(re.compile(r"D\.intelligence Lab-", re.I), "OurDigital-",
"parent-co SEO asset → OurDigital-"),
(re.compile(r"D\.intelligence", re.I), "OurDigital",
"parent-co SEO asset → OurDigital"),
# Variant without the dot (typo / informal spelling).
(re.compile(r"\bD intelligence\b", re.I), "OurDigital",
"parent-co SEO asset (no-dot variant) → OurDigital"),
]
# Files matching these patterns at the root of a top-level subfolder are
# proposed to be moved into a sibling subfolder.
MOVE_RULES: list[tuple[re.Pattern[str], str, str]] = [
(re.compile(r"^Screenshot \d{4}-\d{2}-\d{2}.*\.png$"), "screenshots",
"screenshot file → screenshots/ subfolder"),
(re.compile(r"^.*\.(crdownload|tmp|partial)$"), "_unsorted",
"incomplete / temp file → _unsorted/"),
]
# Skip these subfolders for invasive changes (renames + moves).
# Indexing still includes them, but their internal files aren't reorganized.
SENSITIVE_SUBFOLDER_PATTERNS = [
re.compile(r"^04_Case Studies$"),
re.compile(r"^99_Project Archive$"),
re.compile(r".*Archive$", re.I),
re.compile(r"^진단.*$"),
]
EXCLUDE_NAMES = {".DS_Store", "Icon\r"}
BEGIN_MARK = "<!-- AUTO-STRUCTURE:BEGIN -->"
END_MARK = "<!-- AUTO-STRUCTURE:END -->"
# ---------------------------------------------------------------------------
# Data classes
# ---------------------------------------------------------------------------
@dataclass
class FolderScan:
path: Path
files: list[str] = field(default_factory=list)
subfolders: dict[str, "FolderScan"] = field(default_factory=dict)
@dataclass
class Proposal:
kind: str # "rename" | "move"
src: Path
dst: Path
reason: str
def as_dict(self) -> dict:
return {"kind": self.kind, "src": str(self.src),
"dst": str(self.dst), "reason": self.reason}
# ---------------------------------------------------------------------------
# Scanning
# ---------------------------------------------------------------------------
def is_visible(name: str) -> bool:
return not name.startswith(".") and name not in EXCLUDE_NAMES
def list_visible_files(path: Path) -> list[str]:
try:
return sorted(e.name for e in os.scandir(path)
if e.is_file(follow_symlinks=False) and is_visible(e.name))
except OSError:
return []
def list_visible_dirs(path: Path) -> list[str]:
try:
return sorted(e.name for e in os.scandir(path)
if e.is_dir(follow_symlinks=False) and is_visible(e.name))
except OSError:
return []
def scan(path: Path, depth: int = 3) -> FolderScan:
"""Walk depth levels deep. depth=3 → root + 2 levels of subfolders."""
s = FolderScan(path=path, files=list_visible_files(path))
if depth > 1:
for d in list_visible_dirs(path):
s.subfolders[d] = scan(path / d, depth - 1)
else:
for d in list_visible_dirs(path):
s.subfolders[d] = FolderScan(path=path / d)
return s
def is_sensitive(name: str) -> bool:
return any(p.search(name) for p in SENSITIVE_SUBFOLDER_PATTERNS)
# ---------------------------------------------------------------------------
# Index generation (root README.md and per-subfolder READMEs)
# ---------------------------------------------------------------------------
def render_structure_block(scan_root: FolderScan) -> str:
out: list[str] = []
out.append("## Structure (3 levels)")
out.append("")
ts = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M %z")
out.append(f"_Auto-regenerated {ts}. Edits inside the AUTO-STRUCTURE markers will be overwritten._")
out.append("")
for d1_name in sorted(scan_root.subfolders):
d1 = scan_root.subfolders[d1_name]
nf1 = len(d1.files)
nd1 = len(d1.subfolders)
out.append(f"- **{d1_name}/** ({nf1} files, {nd1} subfolders)")
for d2_name in sorted(d1.subfolders):
d2 = d1.subfolders[d2_name]
nf2 = len(d2.files)
nd2 = len(d2.subfolders)
if nd2 > 0:
out.append(f" - {d2_name}/ ({nf2}f, {nd2}d)")
for d3_name in sorted(d2.subfolders):
d3 = d2.subfolders[d3_name]
nf3 = len(d3.files)
out.append(f" - {d3_name}/ ({nf3}f)")
else:
out.append(f" - {d2_name}/ ({nf2}f)")
if nf1 > 0:
preview = ", ".join(f"`{n}`" for n in d1.files[:5])
out.append(f" - _files_: {preview}")
out.append("")
return "\n".join(out).rstrip() + "\n"
def render_subfolder_block(scan_node: FolderScan) -> str:
"""Smaller block for per-subfolder README.md (2 levels deep)."""
out: list[str] = []
out.append("## Contents")
out.append("")
ts = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M %z")
out.append(f"_Auto-regenerated {ts}._")
out.append("")
if scan_node.files:
out.append(f"**Files at root** ({len(scan_node.files)}):")
for f in scan_node.files[:20]:
out.append(f"- `{f}`")
if len(scan_node.files) > 20:
out.append(f"- … and {len(scan_node.files) - 20} more")
out.append("")
if scan_node.subfolders:
out.append(f"**Subfolders** ({len(scan_node.subfolders)}):")
for d_name in sorted(scan_node.subfolders):
d = scan_node.subfolders[d_name]
nf = len(d.files)
nd = len(d.subfolders)
extras = f", {nd} dirs" if nd else ""
out.append(f"- `{d_name}/` ({nf} files{extras})")
out.append("")
return "\n".join(out).rstrip() + "\n"
def splice_readme(readme_path: Path, new_block: str, *, header_default: str | None = None) -> tuple[bool, str]:
"""Insert/replace the AUTO-STRUCTURE block inside readme_path.
Returns (changed, summary). Creates the file with header_default if missing.
"""
if not readme_path.exists():
if header_default is None:
return (False, f"skip: {readme_path} does not exist")
readme_path.write_text(
f"{header_default}\n\n{BEGIN_MARK}\n{new_block}{END_MARK}\n",
encoding="utf-8",
)
return (True, f"created: {readme_path}")
text = readme_path.read_text(encoding="utf-8")
# Update top-level Snapshot counts if they exist
parent = readme_path.parent
total_files = len(list_visible_files(parent))
total_dirs = len(list_visible_dirs(parent))
text = re.sub(r"^- \*\*Top-level files\*\*:.*$",
f"- **Top-level files**: {total_files}", text, count=1, flags=re.M)
text = re.sub(r"^- \*\*Top-level subfolders\*\*:.*$",
f"- **Top-level subfolders**: {total_dirs}", text, count=1, flags=re.M)
if BEGIN_MARK in text and END_MARK in text:
pattern = re.compile(re.escape(BEGIN_MARK) + r".*?" + re.escape(END_MARK), re.S)
new_text = pattern.sub(BEGIN_MARK + "\n" + new_block + END_MARK, text)
else:
# Append marker block before the trailing footer (if any) or at EOF
new_text = text.rstrip() + "\n\n" + BEGIN_MARK + "\n" + new_block + END_MARK + "\n"
if structurally_equal(text, new_text):
return (False, f"no structural changes: {readme_path}")
readme_path.write_text(new_text, encoding="utf-8")
return (True, f"updated: {readme_path}")
def structurally_equal(a: str, b: str) -> bool:
"""Strip the auto-regenerated timestamp lines before comparing."""
strip = re.compile(r"^_Auto-regenerated .*$", re.M)
return strip.sub("", a) == strip.sub("", b)
# ---------------------------------------------------------------------------
# Rename + move proposals
# ---------------------------------------------------------------------------
def propose_renames(scan_root: FolderScan) -> list[Proposal]:
proposals: list[Proposal] = []
def visit(node: FolderScan):
for f in node.files:
new = f
reasons: list[str] = []
for pat, repl, why in RENAME_RULES:
next_new = pat.sub(repl, new)
if next_new != new:
reasons.append(why)
new = next_new
if new != f:
proposals.append(Proposal("rename", node.path / f,
node.path / new, "; ".join(reasons)))
for sub_name, sub in node.subfolders.items():
if is_sensitive(sub_name):
continue
visit(sub)
visit(scan_root)
return proposals
def propose_folder_renames(scan_root: FolderScan) -> list[Proposal]:
"""Propose folder renames matching RENAME_RULES.
Guardrails:
- Top-level subfolders (depth 1, e.g. "00_Brand Management/") are NEVER
renamed automatically — they're the user's manually curated practice
areas. Use a one-off `mv` if you need to rename one.
- Sensitive folders are skipped entirely (not renamed, not recursed into).
"""
proposals: list[Proposal] = []
def visit(node: FolderScan, parent_depth: int):
sub_depth = parent_depth + 1
for sub_name, sub in node.subfolders.items():
if is_sensitive(sub_name):
continue
if sub_depth >= 2: # depth-2+ folders are eligible for rename
new_name = sub_name
reasons: list[str] = []
for pat, repl, why in RENAME_RULES:
nxt = pat.sub(repl, new_name)
if nxt != new_name:
reasons.append(why)
new_name = nxt
if new_name != sub_name:
proposals.append(Proposal(
"rename-folder", sub.path,
sub.path.parent / new_name,
"; ".join(reasons),
))
visit(sub, sub_depth)
visit(scan_root, parent_depth=0)
return proposals
def propose_moves(scan_root: FolderScan) -> list[Proposal]:
proposals: list[Proposal] = []
# Only operate on top-level subfolders of the target — that's where the
# "cluttered files at root" pattern most commonly appears.
for sub_name, sub in scan_root.subfolders.items():
if is_sensitive(sub_name):
continue
for f in sub.files:
for pat, dest_subdir, why in MOVE_RULES:
if pat.search(f):
proposals.append(Proposal(
"move",
sub.path / f,
sub.path / dest_subdir / f,
why,
))
break
return proposals
def apply_proposals(props: Iterable[Proposal]) -> list[tuple[Proposal, str]]:
results: list[tuple[Proposal, str]] = []
for p in props:
try:
if p.kind == "move":
p.dst.parent.mkdir(parents=True, exist_ok=True)
if p.dst.exists():
results.append((p, "skip: destination exists"))
continue
shutil.move(str(p.src), str(p.dst))
results.append((p, "ok"))
except OSError as e:
results.append((p, f"error: {e}"))
return results
# ---------------------------------------------------------------------------
# Per-subfolder README.md ensuring + refresh
# ---------------------------------------------------------------------------
def refresh_subfolder_readmes(scan_root: FolderScan, *, write: bool) -> list[str]:
"""For each top-level subfolder, ensure README.md exists with AUTO block."""
log: list[str] = []
for sub_name in sorted(scan_root.subfolders):
if is_sensitive(sub_name):
log.append(f"skip (sensitive): {sub_name}/")
continue
sub = scan_root.subfolders[sub_name]
readme = sub.path / "README.md"
block = render_subfolder_block(sub)
header = f"# {sub_name}\n\n_OurDigital — auto-indexed subfolder README._"
if not write:
log.append(f"would refresh: {readme}")
continue
changed, summary = splice_readme(readme, block, header_default=header)
log.append(summary)
return log
# ---------------------------------------------------------------------------
# Reporting
# ---------------------------------------------------------------------------
def text_report(target: Path, renames: list[Proposal], folder_renames: list[Proposal],
moves: list[Proposal], index_summary: str, sub_log: list[str]) -> str:
lines = []
lines.append(f"# our-gdrive-organizer report")
lines.append(f"target: {target}")
lines.append(f"scan time: {datetime.now().strftime('%F %T')}")
lines.append("")
lines.append("## Index")
lines.append(index_summary or "(skipped)")
lines.append("")
lines.append("## Subfolder READMEs")
if sub_log:
for line in sub_log:
lines.append(f"- {line}")
else:
lines.append("(skipped)")
lines.append("")
lines.append(f"## File rename proposals ({len(renames)})")
if renames:
for p in renames:
lines.append(f"- {p.src.relative_to(target)}")
lines.append(f"{p.dst.relative_to(target)}")
lines.append(f" ({p.reason})")
else:
lines.append("(none)")
lines.append("")
lines.append(f"## Folder rename proposals ({len(folder_renames)})")
if folder_renames:
for p in folder_renames:
lines.append(f"- {p.src.relative_to(target)}/")
lines.append(f"{p.dst.relative_to(target)}/")
lines.append(f" ({p.reason})")
else:
lines.append("(none)")
lines.append("")
lines.append(f"## Move proposals ({len(moves)})")
if moves:
for p in moves:
lines.append(f"- {p.src.relative_to(target)}")
lines.append(f"{p.dst.relative_to(target)}")
lines.append(f" ({p.reason})")
else:
lines.append("(none)")
return "\n".join(lines) + "\n"
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="OurDigital Google Drive folder organizer",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument("target", nargs="?", default=os.getcwd(),
help="target directory (default: cwd)")
parser.add_argument("--apply", action="store_true",
help="actually perform renames and moves (default: dry-run)")
parser.add_argument("--scope", choices=["full", "index", "subreadmes",
"rename", "move"], default="full",
help="restrict the operation (default: full)")
parser.add_argument("--json", action="store_true",
help="emit machine-readable JSON instead of a text report")
args = parser.parse_args(argv)
target = Path(args.target).expanduser().resolve()
if not target.is_dir():
print(f"target is not a directory: {target}", file=sys.stderr)
return 1
scan_root = scan(target, depth=3)
index_summary = ""
sub_log: list[str] = []
renames: list[Proposal] = []
folder_renames: list[Proposal] = []
moves: list[Proposal] = []
if args.scope in ("full", "index"):
readme = target / "README.md"
block = render_structure_block(scan_root)
header = f"# {target.name}\n\n_OurDigital — auto-indexed root README._\n\n## Snapshot\n\n- **Top-level files**: {len(scan_root.files)}\n- **Top-level subfolders**: {len(scan_root.subfolders)}"
changed, index_summary = splice_readme(readme, block, header_default=header)
if args.scope in ("full", "subreadmes"):
sub_log = refresh_subfolder_readmes(scan_root, write=True)
# Order matters when --apply: file renames first, then folder renames.
# If folders are renamed first, file paths inside them become invalid.
if args.scope in ("full", "rename"):
renames = propose_renames(scan_root)
if args.apply and renames:
results = apply_proposals(renames)
for p, status in results:
p.reason = f"{p.reason} [{status}]"
folder_renames = propose_folder_renames(scan_root)
if args.apply and folder_renames:
# Apply deepest folders first — renaming a parent invalidates the
# path of any nested rename. shutil.move on the deepest target
# leaves the parent's name intact and avoids stale paths.
ordered = sorted(folder_renames, key=lambda p: len(p.src.parts), reverse=True)
results = apply_proposals(ordered)
for p, status in results:
p.reason = f"{p.reason} [{status}]"
if args.scope in ("full", "move"):
moves = propose_moves(scan_root)
if args.apply and moves:
results = apply_proposals(moves)
for p, status in results:
p.reason = f"{p.reason} [{status}]"
if args.json:
out = {
"target": str(target),
"scope": args.scope,
"applied": args.apply,
"index": index_summary,
"subfolder_readmes": sub_log,
"renames": [p.as_dict() for p in renames],
"folder_renames": [p.as_dict() for p in folder_renames],
"moves": [p.as_dict() for p in moves],
}
print(json.dumps(out, ensure_ascii=False, indent=2))
else:
print(text_report(target, renames, folder_renames, moves, index_summary, sub_log))
return 0
if __name__ == "__main__":
sys.exit(main())