Skip to content

Commit fb16d4d

Browse files
csacsiclaude
andcommitted
feat(renderer+rules): recursive section chunking + adoption gate for rerun-discovered rules
Recursive chunking: a section chunk still over 8 KB with >=2 subsections splits one level deeper (topic -> section -> entry), the section file becoming a sub-index. OpenMeter validation: data-models/models.md went from 85 KB to a 9.8 KB routing sub-index + 84 per-model files; largest non-index chunk is now ~6 KB. Stale-cleanup prunes nested empty dirs. Adoption gate: on a deep-scan rerun (rules.json already populated), extract_output cmd_rules routes brand-new rule ids to proposed_rules.json instead of activating them — the user adopts/rejects in the viewer's Rules card (existing flow) before hooks enforce. Updates to active ids still apply directly; ignored/pending ids are not re-proposed. First scan keeps auto-adopting the baseline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 7d261db commit fb16d4d

8 files changed

Lines changed: 355 additions & 71 deletions

File tree

archie/assets/workflow/deep-scan/steps/step-6-rule-synthesis.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,13 @@ python3 .archie/extract_output.py rules .archie/tmp/archie_rules_$PROJECT_NAME.j
356356

357357
**IMPORTANT: Do NOT try to extract or parse JSON yourself. Do NOT copy the agent's transcript. Always use the pre-installed scripts on the file the agent already wrote.**
358358

359+
On a rerun (rules.json already had rules), the extractor routes brand-new rule
360+
ids to `.archie/proposed_rules.json` instead of activating them — the user
361+
adopts or rejects them in the viewer's Rules card before hooks enforce them.
362+
If the extractor printed a `NEW rule(s) -> proposed_rules.json` line, tell the
363+
user in your final summary how many rules await review and that they can adopt
364+
them in the Archie viewer's Rules card.
365+
359366
Build the Phase 2 trigger index so the pre-validate hook can narrow candidates fast on every edit:
360367

361368
```bash

archie/standalone/extract_output.py

Lines changed: 56 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,29 @@
3030
# rules — extract rules JSON from agent output
3131
# ---------------------------------------------------------------------------
3232

33+
def _read_rule_ids(path: Path) -> set:
34+
"""Rule ids in a {"rules": [...]} file; empty set on missing/malformed."""
35+
try:
36+
data = json.loads(path.read_text())
37+
except (OSError, json.JSONDecodeError):
38+
return set()
39+
return {r.get("id") for r in data.get("rules", []) if isinstance(r, dict) and r.get("id")}
40+
41+
3342
def cmd_rules(input_file: str, output_path: str):
3443
"""Extract rules JSON from raw agent output, merge with existing rules, save.
3544
3645
Defensively stamps `source: "deep_scan"` on any new rule emitted without one,
3746
so downstream tooling and humans can trace lineage even if the model omits
3847
the field. Existing `source` values (e.g., `adopted`, `scan`, `scan-amended`)
3948
are never overwritten.
49+
50+
Adoption gate: on a RERUN (output rules.json already has rules), rules with
51+
an id not seen before go to proposed_rules.json — the user adopts or rejects
52+
them in the viewer's Rules card before hooks enforce them. Updates to
53+
already-active ids still apply directly. Ids sitting in proposed_rules.json
54+
or ignored_rules.json are not re-proposed. The first scan (empty baseline)
55+
keeps auto-adopting, otherwise a fresh install would enforce nothing.
4056
"""
4157
text = Path(input_file).read_text()
4258
data = extract_json_from_text(text)
@@ -59,25 +75,52 @@ def cmd_rules(input_file: str, output_path: str):
5975

6076
# Merge with existing rules — preserve user-adopted rules from prior runs
6177
out = Path(output_path)
78+
existing_by_id = {}
6279
if out.exists():
6380
try:
6481
existing = json.loads(out.read_text())
6582
existing_rules = existing.get("rules", [])
66-
# Index existing rules by id
6783
existing_by_id = {r.get("id", ""): r for r in existing_rules if isinstance(r, dict)}
68-
# Index new rules by id
69-
new_by_id = {r.get("id", ""): r for r in new_rules if isinstance(r, dict)}
70-
# Keep existing rules that aren't replaced by new ones (user-adopted rules)
71-
# Also keep existing rules that have source="adopted" — these came from prior incremental runs
72-
preserved = 0
73-
for rid, rule in existing_by_id.items():
74-
if rid not in new_by_id:
75-
new_rules.append(rule)
76-
preserved += 1
77-
if preserved:
78-
print(f" Preserved {preserved} existing rules not in new set", file=sys.stderr)
7984
except (json.JSONDecodeError, OSError):
80-
pass
85+
existing_by_id = {}
86+
87+
if existing_by_id:
88+
# RERUN — route brand-new rules through the proposal queue.
89+
proposed_path = out.parent / "proposed_rules.json"
90+
ignored_ids = _read_rule_ids(out.parent / "ignored_rules.json")
91+
already_proposed = _read_rule_ids(proposed_path)
92+
93+
active, to_propose = [], []
94+
for r in new_rules:
95+
rid = r.get("id") if isinstance(r, dict) else None
96+
if rid in existing_by_id:
97+
active.append(r) # update of an already-active rule
98+
elif rid in ignored_ids or rid in already_proposed:
99+
continue # user already rejected it, or it's awaiting review
100+
else:
101+
to_propose.append(r)
102+
103+
new_by_id = {r.get("id", ""): r for r in active if isinstance(r, dict)}
104+
preserved = 0
105+
for rid, rule in existing_by_id.items():
106+
if rid not in new_by_id:
107+
active.append(rule)
108+
preserved += 1
109+
if preserved:
110+
print(f" Preserved {preserved} existing rules not in new set", file=sys.stderr)
111+
112+
if to_propose:
113+
try:
114+
proposed = json.loads(proposed_path.read_text())
115+
except (OSError, json.JSONDecodeError):
116+
proposed = {}
117+
proposed.setdefault("rules", []).extend(to_propose)
118+
proposed_path.write_text(json.dumps(proposed, indent=2))
119+
print(f" {len(to_propose)} NEW rule(s) -> {proposed_path.name} — "
120+
f"awaiting adoption (review in /archie-viewer Rules card); "
121+
f"hooks will not enforce them until adopted", file=sys.stderr)
122+
123+
new_rules = active
81124

82125
data["rules"] = new_rules
83126
out.write_text(json.dumps(data, indent=2))

archie/standalone/renderer.py

Lines changed: 75 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1309,18 +1309,39 @@ def _est_tokens(text: str) -> int:
13091309
return max(1, len(text) // _CHARS_PER_TOKEN)
13101310

13111311

1312-
def _chunk_topic_file(rule: dict, level: int = 2) -> dict:
1313-
"""Return {relative_path: content} for one oversized topic rule:
1314-
`<topic>.md` index + `<topic>/<section-slug>.md` chunks."""
1315-
topic = rule["topic"]
1316-
preamble, sections = _split_h2_sections(rule["body"], level)
1317-
# A split below H2 leaves the wrapping heading dangling at the end of
1318-
# the preamble — drop trailing heading-only lines.
1319-
pre_lines = preamble.splitlines()
1320-
while pre_lines and (not pre_lines[-1].strip() or pre_lines[-1].startswith("#")):
1321-
pre_lines.pop()
1322-
preamble = "\n".join(pre_lines).strip()
1312+
# An oversized section chunk recurses one heading level deeper (topic →
1313+
# section → entry), so e.g. an 85 KB Models section becomes per-model files
1314+
# behind a sub-index. Depth is capped: entries below H4 don't split further.
1315+
_MAX_CHUNK_DEPTH = 2
1316+
1317+
1318+
def _strip_dangling_headings(preamble: str) -> str:
1319+
"""A split below the top level leaves the wrapping heading dangling at
1320+
the end of the preamble — drop trailing heading-only/blank lines."""
1321+
lines = preamble.splitlines()
1322+
while lines and (not lines[-1].strip() or lines[-1].startswith("#")):
1323+
lines.pop()
1324+
return "\n".join(lines).strip()
1325+
13231326

1327+
def _chunk_level(rule: dict, title: str, index_title: str, body: str,
1328+
rel_dir: str, intro: str, level: int, depth: int) -> dict:
1329+
"""Chunk `body` at `level` headings into files under `rel_dir`/ and
1330+
return {rel_path: content} including `rel_dir`.md as the routing index.
1331+
1332+
Recurses one level deeper for sections that are still oversized and have
1333+
enough subsections, turning the section file into a sub-index.
1334+
"""
1335+
preamble = ""
1336+
sections: list[tuple[str, str]] = []
1337+
for lv in (level, level + 1):
1338+
preamble, sections = _split_h2_sections(body, lv)
1339+
if len(sections) >= 2:
1340+
level = lv
1341+
break
1342+
preamble = _strip_dangling_headings(preamble)
1343+
1344+
dirname = rel_dir.rsplit("/", 1)[-1]
13241345
out: dict[str, str] = {}
13251346
rows: list[str] = []
13261347
seen: dict[str, int] = {}
@@ -1331,33 +1352,64 @@ def _chunk_topic_file(rule: dict, level: int = 2) -> dict:
13311352
slug = f"{slug}-{seen[slug]}"
13321353
else:
13331354
seen[slug] = 1
1334-
chunk_body = f"# {topic.replace('-', ' ').title()}: {heading}\n\n{text}\n"
1335-
out[f"{topic}/{slug}.md"] = _render_claude({**rule, "body": chunk_body})
1355+
chunk_title = f"{title}: {heading}"
1356+
chunk_body = f"# {chunk_title}\n\n{text}\n"
1357+
rel_path = f"{rel_dir}/{slug}.md"
1358+
rendered = _render_claude({**rule, "body": chunk_body})
1359+
_, subsections = _split_h2_sections(text, level + 1)
1360+
if (depth < _MAX_CHUNK_DEPTH
1361+
and len(rendered.encode("utf-8")) > _CHUNK_THRESHOLD_BYTES
1362+
and len(subsections) >= 2):
1363+
out.update(_chunk_level(
1364+
rule, chunk_title, chunk_title, text, f"{rel_dir}/{slug}",
1365+
f"This section is chunked. Load only the entry file(s) under "
1366+
f"`{slug}/` relevant to your task — this index is the routing table.",
1367+
level + 1, depth + 1,
1368+
))
1369+
else:
1370+
out[rel_path] = rendered
13361371
summary = _section_summary(text)
13371372
rows.append(
1338-
f"| {_escape_table_cell(heading)} | [`{topic}/{slug}.md`]({topic}/{slug}.md) "
1373+
f"| {_escape_table_cell(heading)} | [`{dirname}/{slug}.md`]({dirname}/{slug}.md) "
13391374
f"| ~{_est_tokens(chunk_body)} | {_escape_table_cell(summary)} |"
13401375
)
13411376

13421377
index_lines = [
1343-
f"# {rule.get('description') or topic}",
1378+
f"# {index_title}",
13441379
"",
1345-
f"This topic is chunked. Load only the section file(s) under "
1346-
f"`.claude/rules/{topic}/` relevant to your task — this index is the "
1347-
f"routing table.",
1380+
intro,
13481381
"",
13491382
"| Section | File | ~Tokens | Contains |",
13501383
"|---------|------|---------|----------|",
13511384
*rows,
13521385
]
13531386
if preamble:
13541387
index_lines += ["", preamble]
1355-
out[f"{topic}.md"] = _render_claude(
1388+
out[f"{rel_dir}.md"] = _render_claude(
13561389
{**rule, "body": "\n".join(index_lines).rstrip() + "\n"}
13571390
)
13581391
return out
13591392

13601393

1394+
def _chunk_topic_file(rule: dict, level: int = 2) -> dict:
1395+
"""Return {relative_path: content} for one oversized topic rule:
1396+
`<topic>.md` index + `<topic>/<section-slug>.md` chunks (recursing into
1397+
`<topic>/<section>/<entry>.md` when a section is itself oversized)."""
1398+
topic = rule["topic"]
1399+
return _chunk_level(
1400+
rule,
1401+
topic.replace("-", " ").title(),
1402+
rule.get("description") or topic,
1403+
rule["body"],
1404+
topic,
1405+
f"This topic is chunked. Load only the section file(s) under "
1406+
f"`.claude/rules/{topic}/` relevant to your task — this index is the "
1407+
f"routing table.",
1408+
level,
1409+
1,
1410+
)
1411+
1412+
13611413
def _render_topic_files(rule: dict) -> dict:
13621414
"""Render one topic rule into its output file(s), chunking when the
13631415
rendered body crosses the size threshold and has enough H2 sections."""
@@ -2127,6 +2179,10 @@ def _rm(p: Path):
21272179
rel = str(md.relative_to(project_root))
21282180
if rel not in files:
21292181
_rm(md)
2182+
# Prune empty dirs bottom-up (nested entry dirs first, then the topic dir).
2183+
for sub in sorted((d for d in chunk_dir.rglob("*") if d.is_dir()), reverse=True):
2184+
if not any(sub.iterdir()):
2185+
sub.rmdir()
21302186
if not any(chunk_dir.iterdir()):
21312187
chunk_dir.rmdir()
21322188
# Stale enforcement by-topic files (topic disappeared from rules.json).

npm-package/assets/extract_output.py

Lines changed: 56 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,29 @@
3030
# rules — extract rules JSON from agent output
3131
# ---------------------------------------------------------------------------
3232

33+
def _read_rule_ids(path: Path) -> set:
34+
"""Rule ids in a {"rules": [...]} file; empty set on missing/malformed."""
35+
try:
36+
data = json.loads(path.read_text())
37+
except (OSError, json.JSONDecodeError):
38+
return set()
39+
return {r.get("id") for r in data.get("rules", []) if isinstance(r, dict) and r.get("id")}
40+
41+
3342
def cmd_rules(input_file: str, output_path: str):
3443
"""Extract rules JSON from raw agent output, merge with existing rules, save.
3544
3645
Defensively stamps `source: "deep_scan"` on any new rule emitted without one,
3746
so downstream tooling and humans can trace lineage even if the model omits
3847
the field. Existing `source` values (e.g., `adopted`, `scan`, `scan-amended`)
3948
are never overwritten.
49+
50+
Adoption gate: on a RERUN (output rules.json already has rules), rules with
51+
an id not seen before go to proposed_rules.json — the user adopts or rejects
52+
them in the viewer's Rules card before hooks enforce them. Updates to
53+
already-active ids still apply directly. Ids sitting in proposed_rules.json
54+
or ignored_rules.json are not re-proposed. The first scan (empty baseline)
55+
keeps auto-adopting, otherwise a fresh install would enforce nothing.
4056
"""
4157
text = Path(input_file).read_text()
4258
data = extract_json_from_text(text)
@@ -59,25 +75,52 @@ def cmd_rules(input_file: str, output_path: str):
5975

6076
# Merge with existing rules — preserve user-adopted rules from prior runs
6177
out = Path(output_path)
78+
existing_by_id = {}
6279
if out.exists():
6380
try:
6481
existing = json.loads(out.read_text())
6582
existing_rules = existing.get("rules", [])
66-
# Index existing rules by id
6783
existing_by_id = {r.get("id", ""): r for r in existing_rules if isinstance(r, dict)}
68-
# Index new rules by id
69-
new_by_id = {r.get("id", ""): r for r in new_rules if isinstance(r, dict)}
70-
# Keep existing rules that aren't replaced by new ones (user-adopted rules)
71-
# Also keep existing rules that have source="adopted" — these came from prior incremental runs
72-
preserved = 0
73-
for rid, rule in existing_by_id.items():
74-
if rid not in new_by_id:
75-
new_rules.append(rule)
76-
preserved += 1
77-
if preserved:
78-
print(f" Preserved {preserved} existing rules not in new set", file=sys.stderr)
7984
except (json.JSONDecodeError, OSError):
80-
pass
85+
existing_by_id = {}
86+
87+
if existing_by_id:
88+
# RERUN — route brand-new rules through the proposal queue.
89+
proposed_path = out.parent / "proposed_rules.json"
90+
ignored_ids = _read_rule_ids(out.parent / "ignored_rules.json")
91+
already_proposed = _read_rule_ids(proposed_path)
92+
93+
active, to_propose = [], []
94+
for r in new_rules:
95+
rid = r.get("id") if isinstance(r, dict) else None
96+
if rid in existing_by_id:
97+
active.append(r) # update of an already-active rule
98+
elif rid in ignored_ids or rid in already_proposed:
99+
continue # user already rejected it, or it's awaiting review
100+
else:
101+
to_propose.append(r)
102+
103+
new_by_id = {r.get("id", ""): r for r in active if isinstance(r, dict)}
104+
preserved = 0
105+
for rid, rule in existing_by_id.items():
106+
if rid not in new_by_id:
107+
active.append(rule)
108+
preserved += 1
109+
if preserved:
110+
print(f" Preserved {preserved} existing rules not in new set", file=sys.stderr)
111+
112+
if to_propose:
113+
try:
114+
proposed = json.loads(proposed_path.read_text())
115+
except (OSError, json.JSONDecodeError):
116+
proposed = {}
117+
proposed.setdefault("rules", []).extend(to_propose)
118+
proposed_path.write_text(json.dumps(proposed, indent=2))
119+
print(f" {len(to_propose)} NEW rule(s) -> {proposed_path.name} — "
120+
f"awaiting adoption (review in /archie-viewer Rules card); "
121+
f"hooks will not enforce them until adopted", file=sys.stderr)
122+
123+
new_rules = active
81124

82125
data["rules"] = new_rules
83126
out.write_text(json.dumps(data, indent=2))

0 commit comments

Comments
 (0)