aboutsummaryrefslogtreecommitdiff
path: root/.ai/scripts/cj-scan.py
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-15 16:16:18 -0500
committerCraig Jennings <c@cjennings.net>2026-05-15 16:16:18 -0500
commitee721ee96f984ccd38233309f0dfe6362057e644 (patch)
treef84a3b21ae846c82a2677a59f54947ee5b557174 /.ai/scripts/cj-scan.py
parent421b17a15219c7061ee92c07451993965fad88ea (diff)
downloadrulesets-ee721ee96f984ccd38233309f0dfe6362057e644.tar.gz
rulesets-ee721ee96f984ccd38233309f0dfe6362057e644.zip
chore(ai): sync scripts and workflows from claude-templates
- todo-cleanup.el: :no-sync: tag now inherits down the outline tree - task-review.org: completion procedure scoped to top-level entries - cj-scan.py + cj-remove-block.py: helpers for cj-comment block handling - inbox-send.py: cross-project messaging via inbox directories
Diffstat (limited to '.ai/scripts/cj-scan.py')
-rw-r--r--.ai/scripts/cj-scan.py162
1 files changed, 162 insertions, 0 deletions
diff --git a/.ai/scripts/cj-scan.py b/.ai/scripts/cj-scan.py
new file mode 100644
index 0000000..54e2bf9
--- /dev/null
+++ b/.ai/scripts/cj-scan.py
@@ -0,0 +1,162 @@
+#!/usr/bin/env python3
+"""cj-scan — Parse an org file for cj annotations and VERIFY-placement audit.
+
+Output: JSON to stdout with three top-level keys:
+- cj_blocks: every cj annotation found (source-block or legacy-inline form)
+- verify_tasks: every VERIFY heading with placement validity + suggested promotion target
+- unclosed_blocks: any source-block fence that opened but never closed
+
+Usage:
+ cj-scan FILE.org
+
+Companion to the /respond-to-cj-comments skill — the skill calls this script
+to get a single structured view of every cj annotation and every VERIFY
+placement violation in a single tool call, instead of stitching the picture
+together from multiple grep + Read round-trips.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import sys
+from dataclasses import asdict, dataclass
+from pathlib import Path
+
+# VERIFY placement: top-level under a `*` section, or first-level child of a
+# `**` parent task. Anything else gets a promotion_target suggestion.
+VALID_VERIFY_DEPTHS = {2, 3}
+
+HEADING_RE = re.compile(r"^(\*+)\s+(.*)$")
+SRC_OPEN_RE = re.compile(r"^\s*#\+begin_src\s+cj:\s*(\S*)\s*$", re.IGNORECASE)
+SRC_CLOSE_RE = re.compile(r"^\s*#\+end_src\s*$", re.IGNORECASE)
+LEGACY_CJ_RE = re.compile(r"^\s*cj:\s*(.*)$")
+VERIFY_KEYWORD_RE = re.compile(r"^VERIFY(\s|\[|$)")
+
+
+@dataclass
+class HeadingFrame:
+ depth: int
+ heading: str
+
+
+def promotion_target(depth: int) -> int | None:
+ """Return the suggested target depth for a misplaced VERIFY, or None if valid."""
+ if depth in VALID_VERIFY_DEPTHS:
+ return None
+ if depth < 2:
+ return 2
+ return 3
+
+
+def is_verify_heading(heading_text: str) -> bool:
+ """True when heading text begins with the VERIFY keyword (optional priority cookie)."""
+ return bool(VERIFY_KEYWORD_RE.match(heading_text))
+
+
+def scan_file(path: Path) -> dict[str, object]:
+ """Scan an org file and return cj_blocks + verify_tasks + unclosed_blocks."""
+ cj_blocks: list[dict[str, object]] = []
+ verify_tasks: list[dict[str, object]] = []
+ unclosed_blocks: list[dict[str, object]] = []
+ heading_stack: list[HeadingFrame] = []
+
+ in_cj_block = False
+ block_start_line: int | None = None
+ block_label: str | None = None
+ block_body: list[str] = []
+
+ file_str = str(path)
+ lines = path.read_text().splitlines()
+
+ for lineno, line in enumerate(lines, start=1):
+ if in_cj_block:
+ if SRC_CLOSE_RE.match(line):
+ cj_blocks.append({
+ "file": file_str,
+ "form": "source-block",
+ "start_line": block_start_line,
+ "end_line": lineno,
+ "body": "\n".join(block_body),
+ "label": block_label,
+ "parent_heading_chain": [asdict(h) for h in heading_stack],
+ "parent_depth": heading_stack[-1].depth if heading_stack else 0,
+ })
+ in_cj_block = False
+ block_start_line = None
+ block_label = None
+ block_body = []
+ else:
+ block_body.append(line)
+ continue
+
+ m_heading = HEADING_RE.match(line)
+ if m_heading:
+ depth = len(m_heading.group(1))
+ heading_text = m_heading.group(2).strip()
+ # Pop frames at this depth or deeper before pushing the new one.
+ while heading_stack and heading_stack[-1].depth >= depth:
+ heading_stack.pop()
+ heading_stack.append(HeadingFrame(depth=depth, heading=heading_text))
+ if is_verify_heading(heading_text):
+ pt = promotion_target(depth)
+ verify_tasks.append({
+ "file": file_str,
+ "line": lineno,
+ "depth": depth,
+ "heading": heading_text,
+ "valid_depth": pt is None,
+ "promotion_target": pt,
+ })
+ continue
+
+ m_src_open = SRC_OPEN_RE.match(line)
+ if m_src_open:
+ in_cj_block = True
+ block_start_line = lineno
+ block_label = m_src_open.group(1) or None
+ block_body = []
+ continue
+
+ m_legacy = LEGACY_CJ_RE.match(line)
+ if m_legacy:
+ cj_blocks.append({
+ "file": file_str,
+ "form": "legacy-inline",
+ "start_line": lineno,
+ "end_line": lineno,
+ "body": m_legacy.group(1).strip(),
+ "parent_heading_chain": [asdict(h) for h in heading_stack],
+ "parent_depth": heading_stack[-1].depth if heading_stack else 0,
+ })
+
+ if in_cj_block:
+ unclosed_blocks.append({
+ "file": file_str,
+ "start_line": block_start_line,
+ "label": block_label,
+ })
+
+ return {
+ "cj_blocks": cj_blocks,
+ "verify_tasks": verify_tasks,
+ "unclosed_blocks": unclosed_blocks,
+ }
+
+
+def main() -> int:
+ if len(sys.argv) != 2:
+ print("Usage: cj-scan FILE.org", file=sys.stderr)
+ return 2
+ path = Path(sys.argv[1])
+ if not path.is_file():
+ print(f"Not a file: {path}", file=sys.stderr)
+ return 2
+ result = scan_file(path)
+ json.dump(result, sys.stdout, indent=2)
+ sys.stdout.write("\n")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())