aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-11 14:25:55 -0500
committerCraig Jennings <c@cjennings.net>2026-06-11 14:25:55 -0500
commit8d790f371e54a8cc3e79a5ce72cd4dd5b3fa4513 (patch)
treeff6c1a496c4e7727bd823979a582dc21ef25b811
parentbdc9a5d6e1320032770f54c747c210e4f465c399 (diff)
downloadrulesets-8d790f371e54a8cc3e79a5ce72cd4dd5b3fa4513.tar.gz
rulesets-8d790f371e54a8cc3e79a5ce72cd4dd5b3fa4513.zip
feat(org): table standard as a rule, reflow helper, and lint check
Wide org tables overflow the page in exported PDF/docx, and hand-wrapping a cell into continuation rows is tedious and error-prone. The standard existed only as a work-project convention with nothing enforcing it. claude-rules/org-tables.md carries the generalized standard: 120-column budget measured at render width (a link counts as its visible label and is never split), over-budget cells wrap onto continuation rows, and a rule sits under the header and every logical row. wrap-org-table.el reflows a table to that shape mechanically. Columns shrink from natural width toward a floor of their widest atomic token, cells wrap link-safe, and rule-delimited continuation groups merge back into their logical row before re-wrapping, which makes the reflow idempotent. A table whose floors still exceed the budget reflows best-effort and stays flagged for restructuring. lint-org.el gains an org-table-standard judgment check: width overruns and missing rules surface during the sweep with a pointer to the helper. Conformant wrapped tables don't false-flag, since the check reuses the helper's continuation-group reading. The check is judgment-only by design: reflowing is a visible layout change the sweep shouldn't make silently.
-rw-r--r--.ai/scripts/lint-org.el60
-rw-r--r--.ai/scripts/tests/test-lint-org.el42
-rw-r--r--.ai/scripts/tests/test-wrap-org-table.el188
-rw-r--r--.ai/scripts/wrap-org-table.el296
-rw-r--r--claude-rules/org-tables.md60
-rw-r--r--claude-templates/.ai/scripts/lint-org.el60
-rw-r--r--claude-templates/.ai/scripts/tests/test-lint-org.el42
-rw-r--r--claude-templates/.ai/scripts/tests/test-wrap-org-table.el188
-rw-r--r--claude-templates/.ai/scripts/wrap-org-table.el296
-rw-r--r--todo.org7
10 files changed, 1237 insertions, 2 deletions
diff --git a/.ai/scripts/lint-org.el b/.ai/scripts/lint-org.el
index 85886af..5d47644 100644
--- a/.ai/scripts/lint-org.el
+++ b/.ai/scripts/lint-org.el
@@ -42,6 +42,7 @@
(require 'org-lint)
(require 'cl-lib)
(require 'subr-x)
+(require 'wrap-org-table) ; render-width + table parsing for the table check
(defvar lo-fixes 0
"Count of mechanical fixes applied (or would-apply in --check) on the last file.")
@@ -285,6 +286,62 @@ Craig-specific annotation marker rather than Babel src-block syntax."
(lo--emit-judgment name line msg)))))
;;; ---------------------------------------------------------------------------
+;;; org-table-standard check (claude-rules/org-tables.md)
+;;
+;; Not an org-lint checker — a custom scan run alongside the org-lint pass.
+;; Violations surface as judgment items (checker `org-table-standard'), never
+;; auto-fixed: reflowing a table is a visible layout change that
+;; wrap-org-table.el performs on request, not something a lint sweep does
+;; silently.
+
+(defun lo--table-violations (lines)
+ "Standard violations for the table given as LINES, as message strings.
+Width is render-measured (links count as their labels, per wot-render-width).
+Rules: an hline must follow the header and every logical data row, closing
+rule included; continuation lines inside a rule-delimited group are one
+logical row, matching wrap-org-table.el's grouping."
+ (let ((violations nil)
+ (max-width (apply #'max (mapcar #'wot-render-width lines))))
+ (when (> max-width wot-default-budget)
+ (push (format "renders %d wide (budget %d)" max-width wot-default-budget)
+ violations))
+ (let* ((parsed (mapcar #'wot--parse-row lines))
+ (header-p (and (listp (car parsed)) (eq (cadr parsed) 'hline)))
+ (data (if header-p (cddr parsed) parsed)))
+ (when (and (cl-some #'listp data)
+ (not (eq (car (last data)) 'hline)))
+ (push "no closing rule" violations))
+ (let ((group nil))
+ (cl-loop for e in data
+ if (eq e 'hline) do (setq group nil)
+ else do (push e group)
+ when (and (> (length group) 1)
+ (not (wot--continuation-group-p (reverse group))))
+ return (push "missing rule between rows" violations))))
+ (nreverse violations)))
+
+(defun lo--check-tables ()
+ "Scan the current buffer for org tables violating the table standard.
+Emits one judgment item per violating table."
+ (save-excursion
+ (goto-char (point-min))
+ (while (re-search-forward "^[ \t]*|" nil t)
+ (let ((start-line (line-number-at-pos))
+ (lines nil))
+ (beginning-of-line)
+ (while (and (not (eobp)) (looking-at "[ \t]*|"))
+ (push (buffer-substring-no-properties (line-beginning-position)
+ (line-end-position))
+ lines)
+ (forward-line 1))
+ (let ((violations (lo--table-violations (nreverse lines))))
+ (when violations
+ (lo--emit-judgment
+ 'org-table-standard start-line
+ (format "table violates the org-table standard: %s — wrap-org-table.el reflows it"
+ (string-join violations "; ")))))))))
+
+;;; ---------------------------------------------------------------------------
;;; File processing
(defun lo--backup (file)
@@ -314,6 +371,9 @@ left unmodified and mechanical entries are recorded with :preview t."
(lambda (a b) (> (lo--line a) (lo--line b))))))
(dolist (item sorted)
(lo--handle-item item)))
+ ;; After org-lint items: the custom table-standard scan. Runs on the
+ ;; post-fix buffer; judgment-only, so order doesn't perturb fixes.
+ (lo--check-tables)
(when (and (not lo-check-only) (buffer-modified-p))
(save-buffer)))
(with-current-buffer buf (set-buffer-modified-p nil))
diff --git a/.ai/scripts/tests/test-lint-org.el b/.ai/scripts/tests/test-lint-org.el
index 60deb8c..d4b3ba0 100644
--- a/.ai/scripts/tests/test-lint-org.el
+++ b/.ai/scripts/tests/test-lint-org.el
@@ -599,5 +599,47 @@ followups file on the next run."
(lo-test--drop-buffer file)
(when (file-exists-p file) (delete-file file)))))
+;;; ---------------------------------------------------------------------------
+;;; org-table-standard check (width budget + rules between rows)
+
+(ert-deftest lo-table-over-budget-emits-judgment ()
+ "A table line rendering wider than 120 surfaces as an org-table-standard judgment."
+ (let* ((wide (make-string 130 ?x))
+ (run (lo-test--run (format "* H\n\n| a |\n|---|\n| %s |\n|---|\n" wide)))
+ (judgments (lo-test--judgments (plist-get run :issues))))
+ (should (memq 'org-table-standard (lo-test--checkers judgments)))
+ (should (cl-some (lambda (i) (lo-test--has (plist-get i :msg) "120"))
+ judgments))))
+
+(ert-deftest lo-table-compliant-not-flagged ()
+ "A narrow table with rules under header and every row passes silently."
+ (let* ((run (lo-test--run "* H\n\n| a |\n|---|\n| ok |\n|---|\n"))
+ (judgments (lo-test--judgments (plist-get run :issues))))
+ (should-not (memq 'org-table-standard (lo-test--checkers judgments)))))
+
+(ert-deftest lo-table-missing-interrow-rules-emits-judgment ()
+ "Data rows without a rule between them violate the standard."
+ (let* ((run (lo-test--run "* H\n\n| a | b |\n|---+---|\n| 1 | 2 |\n| 3 | 4 |\n"))
+ (judgments (lo-test--judgments (plist-get run :issues))))
+ (should (memq 'org-table-standard (lo-test--checkers judgments)))
+ (should (cl-some (lambda (i) (lo-test--has (plist-get i :msg) "rule"))
+ judgments))))
+
+(ert-deftest lo-table-wide-link-source-measures-render-width ()
+ "A long link target doesn't trip the budget — width is render-measured."
+ (let* ((target (concat "https://example.com/" (make-string 120 ?p)))
+ (run (lo-test--run
+ (format "* H\n\n| [[%s][ok]] |\n|---|\n| x |\n|---|\n" target)))
+ (judgments (lo-test--judgments (plist-get run :issues))))
+ (should-not (memq 'org-table-standard (lo-test--checkers judgments)))))
+
+(ert-deftest lo-table-conformant-wrapped-table-not-flagged ()
+ "Continuation rows inside a rule-delimited group are one logical row, not a
+missing-rules violation."
+ (let* ((run (lo-test--run
+ "* H\n\n| Name | Notes |\n|-------+-------------------|\n| alpha | wrapped text that |\n| | continues here |\n|-------+-------------------|\n"))
+ (judgments (lo-test--judgments (plist-get run :issues))))
+ (should-not (memq 'org-table-standard (lo-test--checkers judgments)))))
+
(provide 'test-lint-org)
;;; test-lint-org.el ends here
diff --git a/.ai/scripts/tests/test-wrap-org-table.el b/.ai/scripts/tests/test-wrap-org-table.el
new file mode 100644
index 0000000..8d1ecb6
--- /dev/null
+++ b/.ai/scripts/tests/test-wrap-org-table.el
@@ -0,0 +1,188 @@
+;;; test-wrap-org-table.el --- ERT tests for wrap-org-table.el -*- lexical-binding: t; -*-
+;;
+;; Run from the repo root:
+;; emacs --batch -q -L .ai/scripts -l ert \
+;; -l .ai/scripts/tests/test-wrap-org-table.el \
+;; -f ert-run-tests-batch-and-exit
+;;
+;; Covers the pure core (render width, tokenizing, cell wrap, column
+;; allocation), the table reformat (exact-output cases at small budgets), and
+;; the file layer.
+
+(require 'ert)
+(require 'cl-lib)
+
+(defconst wot-test--dir
+ (file-name-directory (or load-file-name buffer-file-name default-directory))
+ "Directory of this test file, captured at load time.")
+
+(add-to-list 'load-path (expand-file-name ".." wot-test--dir))
+(require 'wrap-org-table)
+
+;;; ---------------------------------------------------------------------------
+;;; render width — links measure at their visible label
+
+(ert-deftest wot-render-width-plain-text ()
+ (should (= (wot-render-width "hello") 5)))
+
+(ert-deftest wot-render-width-empty ()
+ (should (= (wot-render-width "") 0)))
+
+(ert-deftest wot-render-width-descriptive-link-counts-label-only ()
+ (should (= (wot-render-width "[[file:long-target-path.org][label]]") 5)))
+
+(ert-deftest wot-render-width-bare-link-counts-target ()
+ (should (= (wot-render-width "[[file:x.org]]") 10)))
+
+(ert-deftest wot-render-width-mixed-text-and-link ()
+ ;; "see doc now" = 11
+ (should (= (wot-render-width "see [[file:a.org][doc]] now") 11)))
+
+;;; ---------------------------------------------------------------------------
+;;; tokenize — links are atomic tokens
+
+(ert-deftest wot-tokenize-plain-words ()
+ (should (equal (wot-tokenize "a quick fox") '("a" "quick" "fox"))))
+
+(ert-deftest wot-tokenize-link-is-one-token ()
+ (should (equal (wot-tokenize "see [[file:a.org][the doc]] now")
+ '("see" "[[file:a.org][the doc]]" "now"))))
+
+(ert-deftest wot-tokenize-empty-string ()
+ (should (equal (wot-tokenize "") nil)))
+
+;;; ---------------------------------------------------------------------------
+;;; cell wrap
+
+(ert-deftest wot-wrap-cell-fits-on-one-line ()
+ (should (equal (wot-wrap-cell "short text" 20) '("short text"))))
+
+(ert-deftest wot-wrap-cell-wraps-at-word-boundary ()
+ (should (equal (wot-wrap-cell "The quick brown fox jumps over the lazy dog" 28)
+ '("The quick brown fox jumps" "over the lazy dog"))))
+
+(ert-deftest wot-wrap-cell-empty-cell ()
+ (should (equal (wot-wrap-cell "" 10) '(""))))
+
+(ert-deftest wot-wrap-cell-overlong-token-sits-alone-unsplit ()
+ (should (equal (wot-wrap-cell "tiny incomprehensibilities end" 10)
+ '("tiny" "incomprehensibilities" "end"))))
+
+(ert-deftest wot-wrap-cell-link-never-split ()
+ ;; label is 17 chars, wider than the 10 budget — the link stays whole
+ (should (equal (wot-wrap-cell "[[file:a.org][a very wide label x]]" 10)
+ '("[[file:a.org][a very wide label x]]"))))
+
+;;; ---------------------------------------------------------------------------
+;;; column allocation
+
+(ert-deftest wot-allocate-widths-fits-naturally ()
+ ;; cells total 1+1, overhead 3*2+1=7 → 9 ≤ 30: keep natural widths
+ (should (equal (wot-allocate-widths '(("A" "B") ("1" "2")) 30) '(1 1))))
+
+(ert-deftest wot-allocate-widths-shrinks-widest-column ()
+ ;; naturals 5 + 44, overhead 7 → 56 > 40; Notes shrinks to 28, floor 5
+ (should (equal (wot-allocate-widths
+ '(("Name" "Notes")
+ ("alpha" "The quick brown fox jumps over the lazy dog"))
+ 40)
+ '(5 28))))
+
+(ert-deftest wot-allocate-widths-respects-token-floor ()
+ ;; the long token (21) floors column 2 even when the budget wants less
+ (should (equal (wot-allocate-widths
+ '(("A" "B") ("x" "incomprehensibilities yes"))
+ 20)
+ '(1 21))))
+
+;;; ---------------------------------------------------------------------------
+;;; table reformat — exact output
+
+(defconst wot-test--wide-input
+ "| Name | Notes |
+|------+-------|
+| alpha | The quick brown fox jumps over the lazy dog |
+")
+
+(defconst wot-test--wide-expected
+ "| Name | Notes |
+|-------+------------------------------|
+| alpha | The quick brown fox jumps |
+| | over the lazy dog |
+|-------+------------------------------|
+")
+
+(ert-deftest wot-reformat-wraps-wide-cell-with-continuation-rows ()
+ (should (equal (wot-reformat-table-string wot-test--wide-input 40)
+ wot-test--wide-expected)))
+
+(ert-deftest wot-reformat-narrow-table-gains-rules-between-rows ()
+ (should (equal (wot-reformat-table-string
+ "| A | B |
+|---+---|
+| 1 | 2 |
+| 3 | 4 |
+" 120)
+ "| A | B |
+|---+---|
+| 1 | 2 |
+|---+---|
+| 3 | 4 |
+|---+---|
+")))
+
+(ert-deftest wot-reformat-is-idempotent ()
+ (let ((once (wot-reformat-table-string wot-test--wide-input 40)))
+ (should (equal (wot-reformat-table-string once 40) once))))
+
+(ert-deftest wot-reformat-preserves-link-source-verbatim ()
+ (let ((out (wot-reformat-table-string
+ "| Doc | Notes |
+|-----+-------|
+| [[file:very-long-target-path.org][d1]] | ok |
+" 120)))
+ (should (string-match-p
+ (regexp-quote "[[file:very-long-target-path.org][d1]]") out))))
+
+(ert-deftest wot-reformat-headerless-table-rules-every-row ()
+ (should (equal (wot-reformat-table-string
+ "| 1 | 2 |
+| 3 | 4 |
+" 120)
+ "| 1 | 2 |
+|---+---|
+| 3 | 4 |
+|---+---|
+")))
+
+(ert-deftest wot-reformat-preserves-indentation ()
+ (should (equal (wot-reformat-table-string
+ " | A | B |
+ |---+---|
+ | 1 | 2 |
+" 120)
+ " | A | B |
+ |---+---|
+ | 1 | 2 |
+ |---+---|
+")))
+
+;;; ---------------------------------------------------------------------------
+;;; file layer
+
+(ert-deftest wot-process-file-reformats-tables-in-place ()
+ (let ((file (make-temp-file "wot-test" nil ".org")))
+ (unwind-protect
+ (progn
+ (with-temp-file file
+ (insert "* Heading\n\nProse before.\n\n"
+ wot-test--wide-input
+ "\nProse after.\n"))
+ (wot-process-file file 40)
+ (let ((content (with-temp-buffer
+ (insert-file-contents file)
+ (buffer-string))))
+ (should (string-match-p (regexp-quote wot-test--wide-expected) content))
+ (should (string-match-p "Prose before\\." content))
+ (should (string-match-p "Prose after\\." content))))
+ (delete-file file))))
diff --git a/.ai/scripts/wrap-org-table.el b/.ai/scripts/wrap-org-table.el
new file mode 100644
index 0000000..ddbea65
--- /dev/null
+++ b/.ai/scripts/wrap-org-table.el
@@ -0,0 +1,296 @@
+;;; wrap-org-table.el --- reflow org tables to the width standard -*- lexical-binding: t; -*-
+;;
+;; Reformats org tables to the org-table standard (claude-rules/org-tables.md):
+;;
+;; 1. Max 120 columns wide, measured at RENDER width — an org link counts as
+;; its visible label, not its [[target][label]] source. Links are never
+;; split to chase a source-width number.
+;; 2. Cells that would push a row past the budget wrap onto continuation
+;; rows (the other columns left blank).
+;; 3. A horizontal rule under the header and under every logical data row,
+;; closing rule included.
+;;
+;; Usage:
+;; emacs --batch -q -l wrap-org-table.el [--width=120] FILE.org [FILE.org ...]
+;; reformat every table in each file, in place. A backup of each file is
+;; copied to /tmp/<basename>.before-table-wrap.<YYYYMMDD-HHMMSS> first.
+;;
+;; As a library: (wot-reformat-table-string STRING &optional BUDGET) is the
+;; pure core; (wot-process-file FILE &optional BUDGET) is the file layer.
+;;
+;; Column widths: each column starts at its natural width (the widest cell it
+;; holds, render-measured). When the row total exceeds the budget, the widest
+;; columns shrink first, never below the column's floor — its longest atomic
+;; token (a word, or a whole link) — because going lower would force a
+;; mid-word or mid-link split. A table whose floors alone exceed the budget is
+;; reflowed to the floors (best effort): the source stays over budget and the
+;; lint check keeps flagging it for a human to restructure (merge or drop
+;; columns — a judgment call this helper doesn't make).
+
+(require 'cl-lib)
+(require 'subr-x)
+
+(defconst wot-default-budget 120
+ "Default table width budget in render columns, pipes included.")
+
+;;; ---------------------------------------------------------------------------
+;;; pure core
+
+(defun wot-render-width (s)
+ "Render width of cell text S: org links count as their visible label.
+A descriptive link [[target][label]] measures as its label; a bare [[target]]
+measures as the target text. Everything else is literal."
+ (let ((rendered (replace-regexp-in-string
+ "\\[\\[\\([^][]*\\)\\]\\(?:\\[\\([^][]*\\)\\]\\)?\\]"
+ (lambda (m)
+ (save-match-data
+ (if (string-match
+ "\\[\\[\\([^][]*\\)\\]\\[\\([^][]*\\)\\]\\]" m)
+ (match-string 2 m)
+ (string-match "\\[\\[\\([^][]*\\)\\]\\]" m)
+ (match-string 1 m))))
+ s t t)))
+ (length rendered)))
+
+(defun wot-tokenize (s)
+ "Split cell text S into tokens; org links are atomic tokens."
+ (let ((tokens nil)
+ (pos 0)
+ (link-re "\\[\\[[^][]*\\]\\(?:\\[[^][]*\\]\\)?\\]"))
+ (while (string-match link-re s pos)
+ ;; Capture the bounds first: split-string below runs its own matches
+ ;; and clobbers the global match data.
+ (let ((mb (match-beginning 0))
+ (me (match-end 0)))
+ (dolist (w (split-string (substring s pos mb) nil t)) (push w tokens))
+ (push (substring s mb me) tokens)
+ (setq pos me)))
+ (dolist (w (split-string (substring s pos) nil t)) (push w tokens))
+ (nreverse tokens)))
+
+(defun wot-wrap-cell (s width)
+ "Greedy-wrap cell text S into lines of at most WIDTH render columns.
+Tokens (words and whole links) are never split; a token wider than WIDTH sits
+alone on its own over-width line."
+ (let ((tokens (wot-tokenize s))
+ (lines nil)
+ (current ""))
+ (dolist (tok tokens)
+ (cond
+ ((string-empty-p current)
+ (setq current tok))
+ ((<= (+ (wot-render-width current) 1 (wot-render-width tok)) width)
+ (setq current (concat current " " tok)))
+ (t
+ (push current lines)
+ (setq current tok))))
+ (push current lines)
+ (nreverse lines)))
+
+(defun wot--column-floor (cells)
+ "Floor width for a column holding CELLS: its widest atomic token."
+ (let ((floor 1))
+ (dolist (cell cells)
+ (dolist (tok (wot-tokenize cell))
+ (setq floor (max floor (wot-render-width tok)))))
+ floor))
+
+(defun wot-allocate-widths (rows budget)
+ "Column widths for ROWS (lists of cell strings) under BUDGET total width.
+Row overhead is `| ' + ` | ' separators + ` |', i.e. 3*ncols + 1. Columns
+start at natural width; the widest shrink first, never below their floor."
+ (let* ((ncols (apply #'max (mapcar #'length rows)))
+ (cols (cl-loop for i below ncols
+ collect (mapcar (lambda (r) (or (nth i r) "")) rows)))
+ (widths (mapcar (lambda (col)
+ (apply #'max 1 (mapcar #'wot-render-width col)))
+ cols))
+ (floors (mapcar #'wot--column-floor cols))
+ (cell-budget (- budget (+ (* 3 ncols) 1))))
+ (cl-loop while (> (apply #'+ widths) cell-budget)
+ for idx = (cl-loop with best = nil with best-w = -1
+ for i below ncols
+ when (and (> (nth i widths) (nth i floors))
+ (> (nth i widths) best-w))
+ do (setq best i best-w (nth i widths))
+ finally return best)
+ while idx
+ do (setf (nth idx widths) (1- (nth idx widths))))
+ widths))
+
+(defun wot--pad (cell width)
+ "Pad CELL source text with spaces so its render width is at least WIDTH."
+ (concat cell (make-string (max 0 (- width (wot-render-width cell))) ?\s)))
+
+(defun wot--hline (widths indent)
+ (concat indent "|"
+ (mapconcat (lambda (w) (make-string (+ w 2) ?-)) widths "+")
+ "|"))
+
+(defun wot--emit-row (cells widths indent)
+ "Physical lines for one logical row: CELLS wrapped to WIDTHS, link-safe."
+ (let* ((wrapped (cl-loop for i below (length widths)
+ collect (wot-wrap-cell (or (nth i cells) "")
+ (nth i widths))))
+ (height (apply #'max (mapcar #'length wrapped))))
+ (cl-loop for line below height
+ collect (concat indent "| "
+ (mapconcat
+ (lambda (i)
+ (wot--pad (or (nth line (nth i wrapped)) "")
+ (nth i widths)))
+ (number-sequence 0 (1- (length widths)))
+ " | ")
+ " |"))))
+
+(defun wot--parse-row (line)
+ "Cell strings of table LINE, or the symbol `hline'."
+ (let ((trimmed (string-trim line)))
+ (if (string-prefix-p "|-" trimmed)
+ 'hline
+ (mapcar #'string-trim
+ (split-string (string-remove-suffix "|"
+ (string-remove-prefix "|" trimmed))
+ "|")))))
+
+(defun wot--merge-group (group)
+ "Merge GROUP (a list of cell-lists) into one logical row.
+Each column's non-empty values join with a space — the inverse of the
+continuation-row split `wot--emit-row' produces."
+ (let ((ncols (apply #'max (mapcar #'length group))))
+ (cl-loop for i below ncols
+ collect (string-join
+ (cl-remove-if #'string-empty-p
+ (mapcar (lambda (r) (or (nth i r) ""))
+ group))
+ " "))))
+
+(defun wot--continuation-group-p (group)
+ "Non-nil when GROUP's lines after the first read as continuation rows.
+A continuation row carries overflow text in some columns and leaves the rest
+empty, so every line past the first must have at least one empty cell. A
+group of fully-populated lines is distinct rows that merely share a rule."
+ (and (> (length group) 1)
+ (cl-every (lambda (r) (cl-some #'string-empty-p r))
+ (cdr group))))
+
+(defun wot--logical-rows (elems)
+ "Logical rows from ELEMS, a list of cell-lists and `hline' symbols.
+With no hlines, every line is its own row. With hlines, lines group between
+rules; a group whose trailing lines look like continuations (each has an
+empty cell) merges into one logical row — that makes re-running on
+already-conformant output a no-op — while fully-populated groups keep their
+line-per-row meaning."
+ (if (not (memq 'hline elems))
+ elems
+ (let ((groups nil) (current nil))
+ (dolist (e elems)
+ (if (eq e 'hline)
+ (when current
+ (push (nreverse current) groups)
+ (setq current nil))
+ (push e current)))
+ (when current (push (nreverse current) groups))
+ (cl-loop for g in (nreverse groups)
+ if (wot--continuation-group-p g)
+ collect (wot--merge-group g)
+ else append g))))
+
+(defun wot-reformat-table-string (table-string &optional budget)
+ "Reformat TABLE-STRING to the org-table standard at BUDGET width.
+Wraps over-budget cells onto continuation rows, puts a rule under the header
+and under every logical data row, and preserves the table's indentation.
+Re-running on already-conformant output is a no-op: rule-delimited
+continuation lines merge back into their logical row before re-wrapping."
+ (let* ((budget (or budget wot-default-budget))
+ (lines (split-string (string-remove-suffix "\n" table-string) "\n"))
+ (indent (if (string-match "^[ \t]*" (car lines))
+ (match-string 0 (car lines))
+ ""))
+ (parsed (mapcar #'wot--parse-row lines))
+ ;; Header = first row when the source separates it with an hline.
+ (header-p (and (listp (car parsed)) (eq (cadr parsed) 'hline)))
+ (header (and header-p (car parsed)))
+ (data-elems (if header-p (cddr parsed) parsed))
+ (rows (wot--logical-rows data-elems))
+ (widths (wot-allocate-widths (if header (cons header rows) rows)
+ budget))
+ (out nil))
+ (when header
+ (dolist (l (wot--emit-row header widths indent)) (push l out))
+ (push (wot--hline widths indent) out))
+ (dolist (row rows)
+ (dolist (l (wot--emit-row row widths indent)) (push l out))
+ (push (wot--hline widths indent) out))
+ (concat (string-join (nreverse out) "\n") "\n")))
+
+;;; ---------------------------------------------------------------------------
+;;; file layer
+
+(defun wot-process-file (file &optional budget)
+ "Reformat every org table in FILE in place to BUDGET width."
+ (with-temp-buffer
+ (insert-file-contents file)
+ (goto-char (point-min))
+ (while (re-search-forward "^[ \t]*|" nil t)
+ (let ((start (line-beginning-position)))
+ (while (and (not (eobp))
+ (save-excursion (beginning-of-line)
+ (looking-at "[ \t]*|")))
+ (forward-line 1))
+ (let* ((end (point))
+ (table (buffer-substring-no-properties start end))
+ (reformatted (wot-reformat-table-string table budget)))
+ (delete-region start end)
+ (goto-char start)
+ (insert reformatted))))
+ (write-region (point-min) (point-max) file)))
+
+;;; ---------------------------------------------------------------------------
+;;; CLI
+
+(defun wot--backup (file)
+ (copy-file file
+ (format "/tmp/%s.before-table-wrap.%s"
+ (file-name-nondirectory file)
+ (format-time-string "%Y%m%d-%H%M%S"))
+ t))
+
+(defun wot-main ()
+ (let ((budget wot-default-budget)
+ (width-arg (cl-find-if (lambda (a) (string-prefix-p "--width=" a))
+ command-line-args-left)))
+ (when width-arg
+ (setq budget (string-to-number (substring width-arg (length "--width="))))
+ (setq command-line-args-left (delete width-arg command-line-args-left)))
+ (if (null command-line-args-left)
+ (progn
+ (princ "Usage: emacs --batch -q -l wrap-org-table.el [--width=120] FILE.org ...\n")
+ (kill-emacs 1))
+ (let ((files command-line-args-left))
+ (setq command-line-args-left nil)
+ (dolist (file files)
+ (if (file-readable-p file)
+ (progn
+ (wot--backup file)
+ (wot-process-file file budget)
+ (princ (format ";; wrap-org-table: file=%s reformatted (budget %d)\n"
+ file budget)))
+ (princ (format ";; wrap-org-table: file=%s not readable — skipping\n"
+ file))))))))
+
+(defun wot--cli-invocation-p ()
+ "Non-nil when the trailing args look like a real invocation (flags + files),
+so the ERT suite can `require' this file without firing the CLI dispatch."
+ (and command-line-args-left
+ (cl-every (lambda (a)
+ (cond ((string-prefix-p "--width=" a) t)
+ ((string-prefix-p "-" a) nil)
+ (t (file-readable-p a))))
+ command-line-args-left)))
+
+(when (and noninteractive (wot--cli-invocation-p))
+ (wot-main))
+
+(provide 'wrap-org-table)
+;;; wrap-org-table.el ends here
diff --git a/claude-rules/org-tables.md b/claude-rules/org-tables.md
new file mode 100644
index 0000000..1b70085
--- /dev/null
+++ b/claude-rules/org-tables.md
@@ -0,0 +1,60 @@
+# Org Table Standard
+
+Applies to: `**/*.org`
+
+Every org table in project docs follows one shape. Wide tables overflow the
+page in exported PDF/docx and run off the edge of the org buffer; this is the
+standing fix. Promoted from the work project's local convention 2026-06-11.
+
+## Three requirements
+
+1. **Max width 120 columns — measured at render width.** The whole table
+ line, leading/trailing pipes included, is ≤120 characters as the table
+ *renders* (exported output, or the org buffer). An org link counts as its
+ visible label, not its full `[[target][label]]` source, because export and
+ the live buffer show only the label. This is the one place source width
+ and render width diverge; **never split a link** to chase a source-width
+ number — the render is what overflows the page. Non-link cells have no
+ source/render gap.
+2. **Multi-line cells.** When a cell's text would push the row past 120, wrap
+ it onto continuation rows: repeat the row with the overflow column's text
+ continued and the other columns left blank, as many continuation rows as
+ the content needs. Never truncate content to hit the width; wrap it.
+3. **A rule under the header and under every logical row.** Put a horizontal
+ rule (`|---+---|`) after the header and after every data row, closing rule
+ included. Each logical row then reads as a bordered block, and the rules
+ are what mark where a logical row (with its continuation lines) ends.
+
+Example — the logical row "arch-00" wrapped across two physical rows, rules
+between every row:
+
+ | Document | Doc Status | Notes |
+ |----------+------------+----------------------------------|
+ | arch-00 | Current | Source-of-truth spec; references |
+ | | | arch-NN as authority |
+ |----------+------------+----------------------------------|
+ | arch-01 | Current | Linear introduction for |
+ | | | first-time readers |
+ |----------+------------+----------------------------------|
+
+## How to apply
+
+When authoring or editing any table, produce this shape from the start. When
+a table already violates it, reformat in place — preserve every cell's
+content and any links verbatim, only change the layout.
+
+Tooling (in every project's `.ai/scripts/` via the template sync):
+
+- `wrap-org-table.el` reflows tables to the standard mechanically:
+ `emacs --batch -q -l .ai/scripts/wrap-org-table.el [--width=120] FILE.org`.
+ It wraps over-budget cells onto continuation rows, adds the rules, measures
+ links at label width, and never splits a token or a link. Re-running on a
+ conformant table is a no-op.
+- `lint-org.el` flags violating tables as judgment items (checker
+ `org-table-standard`) during its sweep — width overruns, missing rules, or
+ both — and names the helper in the message.
+
+The helper can't fix a table whose single narrowest-possible columns still
+exceed the budget (some token or link label is just too wide). That table
+needs restructuring — merge or drop columns, shorten labels — which is a
+judgment call: the lint item stays until a human reshapes it.
diff --git a/claude-templates/.ai/scripts/lint-org.el b/claude-templates/.ai/scripts/lint-org.el
index 85886af..5d47644 100644
--- a/claude-templates/.ai/scripts/lint-org.el
+++ b/claude-templates/.ai/scripts/lint-org.el
@@ -42,6 +42,7 @@
(require 'org-lint)
(require 'cl-lib)
(require 'subr-x)
+(require 'wrap-org-table) ; render-width + table parsing for the table check
(defvar lo-fixes 0
"Count of mechanical fixes applied (or would-apply in --check) on the last file.")
@@ -285,6 +286,62 @@ Craig-specific annotation marker rather than Babel src-block syntax."
(lo--emit-judgment name line msg)))))
;;; ---------------------------------------------------------------------------
+;;; org-table-standard check (claude-rules/org-tables.md)
+;;
+;; Not an org-lint checker — a custom scan run alongside the org-lint pass.
+;; Violations surface as judgment items (checker `org-table-standard'), never
+;; auto-fixed: reflowing a table is a visible layout change that
+;; wrap-org-table.el performs on request, not something a lint sweep does
+;; silently.
+
+(defun lo--table-violations (lines)
+ "Standard violations for the table given as LINES, as message strings.
+Width is render-measured (links count as their labels, per wot-render-width).
+Rules: an hline must follow the header and every logical data row, closing
+rule included; continuation lines inside a rule-delimited group are one
+logical row, matching wrap-org-table.el's grouping."
+ (let ((violations nil)
+ (max-width (apply #'max (mapcar #'wot-render-width lines))))
+ (when (> max-width wot-default-budget)
+ (push (format "renders %d wide (budget %d)" max-width wot-default-budget)
+ violations))
+ (let* ((parsed (mapcar #'wot--parse-row lines))
+ (header-p (and (listp (car parsed)) (eq (cadr parsed) 'hline)))
+ (data (if header-p (cddr parsed) parsed)))
+ (when (and (cl-some #'listp data)
+ (not (eq (car (last data)) 'hline)))
+ (push "no closing rule" violations))
+ (let ((group nil))
+ (cl-loop for e in data
+ if (eq e 'hline) do (setq group nil)
+ else do (push e group)
+ when (and (> (length group) 1)
+ (not (wot--continuation-group-p (reverse group))))
+ return (push "missing rule between rows" violations))))
+ (nreverse violations)))
+
+(defun lo--check-tables ()
+ "Scan the current buffer for org tables violating the table standard.
+Emits one judgment item per violating table."
+ (save-excursion
+ (goto-char (point-min))
+ (while (re-search-forward "^[ \t]*|" nil t)
+ (let ((start-line (line-number-at-pos))
+ (lines nil))
+ (beginning-of-line)
+ (while (and (not (eobp)) (looking-at "[ \t]*|"))
+ (push (buffer-substring-no-properties (line-beginning-position)
+ (line-end-position))
+ lines)
+ (forward-line 1))
+ (let ((violations (lo--table-violations (nreverse lines))))
+ (when violations
+ (lo--emit-judgment
+ 'org-table-standard start-line
+ (format "table violates the org-table standard: %s — wrap-org-table.el reflows it"
+ (string-join violations "; ")))))))))
+
+;;; ---------------------------------------------------------------------------
;;; File processing
(defun lo--backup (file)
@@ -314,6 +371,9 @@ left unmodified and mechanical entries are recorded with :preview t."
(lambda (a b) (> (lo--line a) (lo--line b))))))
(dolist (item sorted)
(lo--handle-item item)))
+ ;; After org-lint items: the custom table-standard scan. Runs on the
+ ;; post-fix buffer; judgment-only, so order doesn't perturb fixes.
+ (lo--check-tables)
(when (and (not lo-check-only) (buffer-modified-p))
(save-buffer)))
(with-current-buffer buf (set-buffer-modified-p nil))
diff --git a/claude-templates/.ai/scripts/tests/test-lint-org.el b/claude-templates/.ai/scripts/tests/test-lint-org.el
index 60deb8c..d4b3ba0 100644
--- a/claude-templates/.ai/scripts/tests/test-lint-org.el
+++ b/claude-templates/.ai/scripts/tests/test-lint-org.el
@@ -599,5 +599,47 @@ followups file on the next run."
(lo-test--drop-buffer file)
(when (file-exists-p file) (delete-file file)))))
+;;; ---------------------------------------------------------------------------
+;;; org-table-standard check (width budget + rules between rows)
+
+(ert-deftest lo-table-over-budget-emits-judgment ()
+ "A table line rendering wider than 120 surfaces as an org-table-standard judgment."
+ (let* ((wide (make-string 130 ?x))
+ (run (lo-test--run (format "* H\n\n| a |\n|---|\n| %s |\n|---|\n" wide)))
+ (judgments (lo-test--judgments (plist-get run :issues))))
+ (should (memq 'org-table-standard (lo-test--checkers judgments)))
+ (should (cl-some (lambda (i) (lo-test--has (plist-get i :msg) "120"))
+ judgments))))
+
+(ert-deftest lo-table-compliant-not-flagged ()
+ "A narrow table with rules under header and every row passes silently."
+ (let* ((run (lo-test--run "* H\n\n| a |\n|---|\n| ok |\n|---|\n"))
+ (judgments (lo-test--judgments (plist-get run :issues))))
+ (should-not (memq 'org-table-standard (lo-test--checkers judgments)))))
+
+(ert-deftest lo-table-missing-interrow-rules-emits-judgment ()
+ "Data rows without a rule between them violate the standard."
+ (let* ((run (lo-test--run "* H\n\n| a | b |\n|---+---|\n| 1 | 2 |\n| 3 | 4 |\n"))
+ (judgments (lo-test--judgments (plist-get run :issues))))
+ (should (memq 'org-table-standard (lo-test--checkers judgments)))
+ (should (cl-some (lambda (i) (lo-test--has (plist-get i :msg) "rule"))
+ judgments))))
+
+(ert-deftest lo-table-wide-link-source-measures-render-width ()
+ "A long link target doesn't trip the budget — width is render-measured."
+ (let* ((target (concat "https://example.com/" (make-string 120 ?p)))
+ (run (lo-test--run
+ (format "* H\n\n| [[%s][ok]] |\n|---|\n| x |\n|---|\n" target)))
+ (judgments (lo-test--judgments (plist-get run :issues))))
+ (should-not (memq 'org-table-standard (lo-test--checkers judgments)))))
+
+(ert-deftest lo-table-conformant-wrapped-table-not-flagged ()
+ "Continuation rows inside a rule-delimited group are one logical row, not a
+missing-rules violation."
+ (let* ((run (lo-test--run
+ "* H\n\n| Name | Notes |\n|-------+-------------------|\n| alpha | wrapped text that |\n| | continues here |\n|-------+-------------------|\n"))
+ (judgments (lo-test--judgments (plist-get run :issues))))
+ (should-not (memq 'org-table-standard (lo-test--checkers judgments)))))
+
(provide 'test-lint-org)
;;; test-lint-org.el ends here
diff --git a/claude-templates/.ai/scripts/tests/test-wrap-org-table.el b/claude-templates/.ai/scripts/tests/test-wrap-org-table.el
new file mode 100644
index 0000000..8d1ecb6
--- /dev/null
+++ b/claude-templates/.ai/scripts/tests/test-wrap-org-table.el
@@ -0,0 +1,188 @@
+;;; test-wrap-org-table.el --- ERT tests for wrap-org-table.el -*- lexical-binding: t; -*-
+;;
+;; Run from the repo root:
+;; emacs --batch -q -L .ai/scripts -l ert \
+;; -l .ai/scripts/tests/test-wrap-org-table.el \
+;; -f ert-run-tests-batch-and-exit
+;;
+;; Covers the pure core (render width, tokenizing, cell wrap, column
+;; allocation), the table reformat (exact-output cases at small budgets), and
+;; the file layer.
+
+(require 'ert)
+(require 'cl-lib)
+
+(defconst wot-test--dir
+ (file-name-directory (or load-file-name buffer-file-name default-directory))
+ "Directory of this test file, captured at load time.")
+
+(add-to-list 'load-path (expand-file-name ".." wot-test--dir))
+(require 'wrap-org-table)
+
+;;; ---------------------------------------------------------------------------
+;;; render width — links measure at their visible label
+
+(ert-deftest wot-render-width-plain-text ()
+ (should (= (wot-render-width "hello") 5)))
+
+(ert-deftest wot-render-width-empty ()
+ (should (= (wot-render-width "") 0)))
+
+(ert-deftest wot-render-width-descriptive-link-counts-label-only ()
+ (should (= (wot-render-width "[[file:long-target-path.org][label]]") 5)))
+
+(ert-deftest wot-render-width-bare-link-counts-target ()
+ (should (= (wot-render-width "[[file:x.org]]") 10)))
+
+(ert-deftest wot-render-width-mixed-text-and-link ()
+ ;; "see doc now" = 11
+ (should (= (wot-render-width "see [[file:a.org][doc]] now") 11)))
+
+;;; ---------------------------------------------------------------------------
+;;; tokenize — links are atomic tokens
+
+(ert-deftest wot-tokenize-plain-words ()
+ (should (equal (wot-tokenize "a quick fox") '("a" "quick" "fox"))))
+
+(ert-deftest wot-tokenize-link-is-one-token ()
+ (should (equal (wot-tokenize "see [[file:a.org][the doc]] now")
+ '("see" "[[file:a.org][the doc]]" "now"))))
+
+(ert-deftest wot-tokenize-empty-string ()
+ (should (equal (wot-tokenize "") nil)))
+
+;;; ---------------------------------------------------------------------------
+;;; cell wrap
+
+(ert-deftest wot-wrap-cell-fits-on-one-line ()
+ (should (equal (wot-wrap-cell "short text" 20) '("short text"))))
+
+(ert-deftest wot-wrap-cell-wraps-at-word-boundary ()
+ (should (equal (wot-wrap-cell "The quick brown fox jumps over the lazy dog" 28)
+ '("The quick brown fox jumps" "over the lazy dog"))))
+
+(ert-deftest wot-wrap-cell-empty-cell ()
+ (should (equal (wot-wrap-cell "" 10) '(""))))
+
+(ert-deftest wot-wrap-cell-overlong-token-sits-alone-unsplit ()
+ (should (equal (wot-wrap-cell "tiny incomprehensibilities end" 10)
+ '("tiny" "incomprehensibilities" "end"))))
+
+(ert-deftest wot-wrap-cell-link-never-split ()
+ ;; label is 17 chars, wider than the 10 budget — the link stays whole
+ (should (equal (wot-wrap-cell "[[file:a.org][a very wide label x]]" 10)
+ '("[[file:a.org][a very wide label x]]"))))
+
+;;; ---------------------------------------------------------------------------
+;;; column allocation
+
+(ert-deftest wot-allocate-widths-fits-naturally ()
+ ;; cells total 1+1, overhead 3*2+1=7 → 9 ≤ 30: keep natural widths
+ (should (equal (wot-allocate-widths '(("A" "B") ("1" "2")) 30) '(1 1))))
+
+(ert-deftest wot-allocate-widths-shrinks-widest-column ()
+ ;; naturals 5 + 44, overhead 7 → 56 > 40; Notes shrinks to 28, floor 5
+ (should (equal (wot-allocate-widths
+ '(("Name" "Notes")
+ ("alpha" "The quick brown fox jumps over the lazy dog"))
+ 40)
+ '(5 28))))
+
+(ert-deftest wot-allocate-widths-respects-token-floor ()
+ ;; the long token (21) floors column 2 even when the budget wants less
+ (should (equal (wot-allocate-widths
+ '(("A" "B") ("x" "incomprehensibilities yes"))
+ 20)
+ '(1 21))))
+
+;;; ---------------------------------------------------------------------------
+;;; table reformat — exact output
+
+(defconst wot-test--wide-input
+ "| Name | Notes |
+|------+-------|
+| alpha | The quick brown fox jumps over the lazy dog |
+")
+
+(defconst wot-test--wide-expected
+ "| Name | Notes |
+|-------+------------------------------|
+| alpha | The quick brown fox jumps |
+| | over the lazy dog |
+|-------+------------------------------|
+")
+
+(ert-deftest wot-reformat-wraps-wide-cell-with-continuation-rows ()
+ (should (equal (wot-reformat-table-string wot-test--wide-input 40)
+ wot-test--wide-expected)))
+
+(ert-deftest wot-reformat-narrow-table-gains-rules-between-rows ()
+ (should (equal (wot-reformat-table-string
+ "| A | B |
+|---+---|
+| 1 | 2 |
+| 3 | 4 |
+" 120)
+ "| A | B |
+|---+---|
+| 1 | 2 |
+|---+---|
+| 3 | 4 |
+|---+---|
+")))
+
+(ert-deftest wot-reformat-is-idempotent ()
+ (let ((once (wot-reformat-table-string wot-test--wide-input 40)))
+ (should (equal (wot-reformat-table-string once 40) once))))
+
+(ert-deftest wot-reformat-preserves-link-source-verbatim ()
+ (let ((out (wot-reformat-table-string
+ "| Doc | Notes |
+|-----+-------|
+| [[file:very-long-target-path.org][d1]] | ok |
+" 120)))
+ (should (string-match-p
+ (regexp-quote "[[file:very-long-target-path.org][d1]]") out))))
+
+(ert-deftest wot-reformat-headerless-table-rules-every-row ()
+ (should (equal (wot-reformat-table-string
+ "| 1 | 2 |
+| 3 | 4 |
+" 120)
+ "| 1 | 2 |
+|---+---|
+| 3 | 4 |
+|---+---|
+")))
+
+(ert-deftest wot-reformat-preserves-indentation ()
+ (should (equal (wot-reformat-table-string
+ " | A | B |
+ |---+---|
+ | 1 | 2 |
+" 120)
+ " | A | B |
+ |---+---|
+ | 1 | 2 |
+ |---+---|
+")))
+
+;;; ---------------------------------------------------------------------------
+;;; file layer
+
+(ert-deftest wot-process-file-reformats-tables-in-place ()
+ (let ((file (make-temp-file "wot-test" nil ".org")))
+ (unwind-protect
+ (progn
+ (with-temp-file file
+ (insert "* Heading\n\nProse before.\n\n"
+ wot-test--wide-input
+ "\nProse after.\n"))
+ (wot-process-file file 40)
+ (let ((content (with-temp-buffer
+ (insert-file-contents file)
+ (buffer-string))))
+ (should (string-match-p (regexp-quote wot-test--wide-expected) content))
+ (should (string-match-p "Prose before\\." content))
+ (should (string-match-p "Prose after\\." content))))
+ (delete-file file))))
diff --git a/claude-templates/.ai/scripts/wrap-org-table.el b/claude-templates/.ai/scripts/wrap-org-table.el
new file mode 100644
index 0000000..ddbea65
--- /dev/null
+++ b/claude-templates/.ai/scripts/wrap-org-table.el
@@ -0,0 +1,296 @@
+;;; wrap-org-table.el --- reflow org tables to the width standard -*- lexical-binding: t; -*-
+;;
+;; Reformats org tables to the org-table standard (claude-rules/org-tables.md):
+;;
+;; 1. Max 120 columns wide, measured at RENDER width — an org link counts as
+;; its visible label, not its [[target][label]] source. Links are never
+;; split to chase a source-width number.
+;; 2. Cells that would push a row past the budget wrap onto continuation
+;; rows (the other columns left blank).
+;; 3. A horizontal rule under the header and under every logical data row,
+;; closing rule included.
+;;
+;; Usage:
+;; emacs --batch -q -l wrap-org-table.el [--width=120] FILE.org [FILE.org ...]
+;; reformat every table in each file, in place. A backup of each file is
+;; copied to /tmp/<basename>.before-table-wrap.<YYYYMMDD-HHMMSS> first.
+;;
+;; As a library: (wot-reformat-table-string STRING &optional BUDGET) is the
+;; pure core; (wot-process-file FILE &optional BUDGET) is the file layer.
+;;
+;; Column widths: each column starts at its natural width (the widest cell it
+;; holds, render-measured). When the row total exceeds the budget, the widest
+;; columns shrink first, never below the column's floor — its longest atomic
+;; token (a word, or a whole link) — because going lower would force a
+;; mid-word or mid-link split. A table whose floors alone exceed the budget is
+;; reflowed to the floors (best effort): the source stays over budget and the
+;; lint check keeps flagging it for a human to restructure (merge or drop
+;; columns — a judgment call this helper doesn't make).
+
+(require 'cl-lib)
+(require 'subr-x)
+
+(defconst wot-default-budget 120
+ "Default table width budget in render columns, pipes included.")
+
+;;; ---------------------------------------------------------------------------
+;;; pure core
+
+(defun wot-render-width (s)
+ "Render width of cell text S: org links count as their visible label.
+A descriptive link [[target][label]] measures as its label; a bare [[target]]
+measures as the target text. Everything else is literal."
+ (let ((rendered (replace-regexp-in-string
+ "\\[\\[\\([^][]*\\)\\]\\(?:\\[\\([^][]*\\)\\]\\)?\\]"
+ (lambda (m)
+ (save-match-data
+ (if (string-match
+ "\\[\\[\\([^][]*\\)\\]\\[\\([^][]*\\)\\]\\]" m)
+ (match-string 2 m)
+ (string-match "\\[\\[\\([^][]*\\)\\]\\]" m)
+ (match-string 1 m))))
+ s t t)))
+ (length rendered)))
+
+(defun wot-tokenize (s)
+ "Split cell text S into tokens; org links are atomic tokens."
+ (let ((tokens nil)
+ (pos 0)
+ (link-re "\\[\\[[^][]*\\]\\(?:\\[[^][]*\\]\\)?\\]"))
+ (while (string-match link-re s pos)
+ ;; Capture the bounds first: split-string below runs its own matches
+ ;; and clobbers the global match data.
+ (let ((mb (match-beginning 0))
+ (me (match-end 0)))
+ (dolist (w (split-string (substring s pos mb) nil t)) (push w tokens))
+ (push (substring s mb me) tokens)
+ (setq pos me)))
+ (dolist (w (split-string (substring s pos) nil t)) (push w tokens))
+ (nreverse tokens)))
+
+(defun wot-wrap-cell (s width)
+ "Greedy-wrap cell text S into lines of at most WIDTH render columns.
+Tokens (words and whole links) are never split; a token wider than WIDTH sits
+alone on its own over-width line."
+ (let ((tokens (wot-tokenize s))
+ (lines nil)
+ (current ""))
+ (dolist (tok tokens)
+ (cond
+ ((string-empty-p current)
+ (setq current tok))
+ ((<= (+ (wot-render-width current) 1 (wot-render-width tok)) width)
+ (setq current (concat current " " tok)))
+ (t
+ (push current lines)
+ (setq current tok))))
+ (push current lines)
+ (nreverse lines)))
+
+(defun wot--column-floor (cells)
+ "Floor width for a column holding CELLS: its widest atomic token."
+ (let ((floor 1))
+ (dolist (cell cells)
+ (dolist (tok (wot-tokenize cell))
+ (setq floor (max floor (wot-render-width tok)))))
+ floor))
+
+(defun wot-allocate-widths (rows budget)
+ "Column widths for ROWS (lists of cell strings) under BUDGET total width.
+Row overhead is `| ' + ` | ' separators + ` |', i.e. 3*ncols + 1. Columns
+start at natural width; the widest shrink first, never below their floor."
+ (let* ((ncols (apply #'max (mapcar #'length rows)))
+ (cols (cl-loop for i below ncols
+ collect (mapcar (lambda (r) (or (nth i r) "")) rows)))
+ (widths (mapcar (lambda (col)
+ (apply #'max 1 (mapcar #'wot-render-width col)))
+ cols))
+ (floors (mapcar #'wot--column-floor cols))
+ (cell-budget (- budget (+ (* 3 ncols) 1))))
+ (cl-loop while (> (apply #'+ widths) cell-budget)
+ for idx = (cl-loop with best = nil with best-w = -1
+ for i below ncols
+ when (and (> (nth i widths) (nth i floors))
+ (> (nth i widths) best-w))
+ do (setq best i best-w (nth i widths))
+ finally return best)
+ while idx
+ do (setf (nth idx widths) (1- (nth idx widths))))
+ widths))
+
+(defun wot--pad (cell width)
+ "Pad CELL source text with spaces so its render width is at least WIDTH."
+ (concat cell (make-string (max 0 (- width (wot-render-width cell))) ?\s)))
+
+(defun wot--hline (widths indent)
+ (concat indent "|"
+ (mapconcat (lambda (w) (make-string (+ w 2) ?-)) widths "+")
+ "|"))
+
+(defun wot--emit-row (cells widths indent)
+ "Physical lines for one logical row: CELLS wrapped to WIDTHS, link-safe."
+ (let* ((wrapped (cl-loop for i below (length widths)
+ collect (wot-wrap-cell (or (nth i cells) "")
+ (nth i widths))))
+ (height (apply #'max (mapcar #'length wrapped))))
+ (cl-loop for line below height
+ collect (concat indent "| "
+ (mapconcat
+ (lambda (i)
+ (wot--pad (or (nth line (nth i wrapped)) "")
+ (nth i widths)))
+ (number-sequence 0 (1- (length widths)))
+ " | ")
+ " |"))))
+
+(defun wot--parse-row (line)
+ "Cell strings of table LINE, or the symbol `hline'."
+ (let ((trimmed (string-trim line)))
+ (if (string-prefix-p "|-" trimmed)
+ 'hline
+ (mapcar #'string-trim
+ (split-string (string-remove-suffix "|"
+ (string-remove-prefix "|" trimmed))
+ "|")))))
+
+(defun wot--merge-group (group)
+ "Merge GROUP (a list of cell-lists) into one logical row.
+Each column's non-empty values join with a space — the inverse of the
+continuation-row split `wot--emit-row' produces."
+ (let ((ncols (apply #'max (mapcar #'length group))))
+ (cl-loop for i below ncols
+ collect (string-join
+ (cl-remove-if #'string-empty-p
+ (mapcar (lambda (r) (or (nth i r) ""))
+ group))
+ " "))))
+
+(defun wot--continuation-group-p (group)
+ "Non-nil when GROUP's lines after the first read as continuation rows.
+A continuation row carries overflow text in some columns and leaves the rest
+empty, so every line past the first must have at least one empty cell. A
+group of fully-populated lines is distinct rows that merely share a rule."
+ (and (> (length group) 1)
+ (cl-every (lambda (r) (cl-some #'string-empty-p r))
+ (cdr group))))
+
+(defun wot--logical-rows (elems)
+ "Logical rows from ELEMS, a list of cell-lists and `hline' symbols.
+With no hlines, every line is its own row. With hlines, lines group between
+rules; a group whose trailing lines look like continuations (each has an
+empty cell) merges into one logical row — that makes re-running on
+already-conformant output a no-op — while fully-populated groups keep their
+line-per-row meaning."
+ (if (not (memq 'hline elems))
+ elems
+ (let ((groups nil) (current nil))
+ (dolist (e elems)
+ (if (eq e 'hline)
+ (when current
+ (push (nreverse current) groups)
+ (setq current nil))
+ (push e current)))
+ (when current (push (nreverse current) groups))
+ (cl-loop for g in (nreverse groups)
+ if (wot--continuation-group-p g)
+ collect (wot--merge-group g)
+ else append g))))
+
+(defun wot-reformat-table-string (table-string &optional budget)
+ "Reformat TABLE-STRING to the org-table standard at BUDGET width.
+Wraps over-budget cells onto continuation rows, puts a rule under the header
+and under every logical data row, and preserves the table's indentation.
+Re-running on already-conformant output is a no-op: rule-delimited
+continuation lines merge back into their logical row before re-wrapping."
+ (let* ((budget (or budget wot-default-budget))
+ (lines (split-string (string-remove-suffix "\n" table-string) "\n"))
+ (indent (if (string-match "^[ \t]*" (car lines))
+ (match-string 0 (car lines))
+ ""))
+ (parsed (mapcar #'wot--parse-row lines))
+ ;; Header = first row when the source separates it with an hline.
+ (header-p (and (listp (car parsed)) (eq (cadr parsed) 'hline)))
+ (header (and header-p (car parsed)))
+ (data-elems (if header-p (cddr parsed) parsed))
+ (rows (wot--logical-rows data-elems))
+ (widths (wot-allocate-widths (if header (cons header rows) rows)
+ budget))
+ (out nil))
+ (when header
+ (dolist (l (wot--emit-row header widths indent)) (push l out))
+ (push (wot--hline widths indent) out))
+ (dolist (row rows)
+ (dolist (l (wot--emit-row row widths indent)) (push l out))
+ (push (wot--hline widths indent) out))
+ (concat (string-join (nreverse out) "\n") "\n")))
+
+;;; ---------------------------------------------------------------------------
+;;; file layer
+
+(defun wot-process-file (file &optional budget)
+ "Reformat every org table in FILE in place to BUDGET width."
+ (with-temp-buffer
+ (insert-file-contents file)
+ (goto-char (point-min))
+ (while (re-search-forward "^[ \t]*|" nil t)
+ (let ((start (line-beginning-position)))
+ (while (and (not (eobp))
+ (save-excursion (beginning-of-line)
+ (looking-at "[ \t]*|")))
+ (forward-line 1))
+ (let* ((end (point))
+ (table (buffer-substring-no-properties start end))
+ (reformatted (wot-reformat-table-string table budget)))
+ (delete-region start end)
+ (goto-char start)
+ (insert reformatted))))
+ (write-region (point-min) (point-max) file)))
+
+;;; ---------------------------------------------------------------------------
+;;; CLI
+
+(defun wot--backup (file)
+ (copy-file file
+ (format "/tmp/%s.before-table-wrap.%s"
+ (file-name-nondirectory file)
+ (format-time-string "%Y%m%d-%H%M%S"))
+ t))
+
+(defun wot-main ()
+ (let ((budget wot-default-budget)
+ (width-arg (cl-find-if (lambda (a) (string-prefix-p "--width=" a))
+ command-line-args-left)))
+ (when width-arg
+ (setq budget (string-to-number (substring width-arg (length "--width="))))
+ (setq command-line-args-left (delete width-arg command-line-args-left)))
+ (if (null command-line-args-left)
+ (progn
+ (princ "Usage: emacs --batch -q -l wrap-org-table.el [--width=120] FILE.org ...\n")
+ (kill-emacs 1))
+ (let ((files command-line-args-left))
+ (setq command-line-args-left nil)
+ (dolist (file files)
+ (if (file-readable-p file)
+ (progn
+ (wot--backup file)
+ (wot-process-file file budget)
+ (princ (format ";; wrap-org-table: file=%s reformatted (budget %d)\n"
+ file budget)))
+ (princ (format ";; wrap-org-table: file=%s not readable — skipping\n"
+ file))))))))
+
+(defun wot--cli-invocation-p ()
+ "Non-nil when the trailing args look like a real invocation (flags + files),
+so the ERT suite can `require' this file without firing the CLI dispatch."
+ (and command-line-args-left
+ (cl-every (lambda (a)
+ (cond ((string-prefix-p "--width=" a) t)
+ ((string-prefix-p "-" a) nil)
+ (t (file-readable-p a))))
+ command-line-args-left)))
+
+(when (and noninteractive (wot--cli-invocation-p))
+ (wot-main))
+
+(provide 'wrap-org-table)
+;;; wrap-org-table.el ends here
diff --git a/todo.org b/todo.org
index bae9d09..94d2f78 100644
--- a/todo.org
+++ b/todo.org
@@ -34,14 +34,17 @@ Tags are assigned and refreshed by =task-audit=; =task-review= keeps them honest
* Rulesets Open Work
-** TODO [#C] Wide org-table handling — helper/lint/standard :spec:
+** DONE [#C] Wide org-table handling — helper/lint/standard :spec:
+CLOSED: [2026-06-11 Thu]
:PROPERTIES:
-:LAST_REVIEWED: 2026-06-10
+:LAST_REVIEWED: 2026-06-11
:END:
The org-table standard keeps project-doc tables <=120 cols with multi-line wrapped cells and a rule between rows, but nothing enforces it and hand-wrapping a wide cell into multi-row form is tedious and error-prone. Decide among: (a) a helper that auto-wraps a wide table into multi-row cells at a target width, (b) a lint check that flags tables over the width budget, (c) tighten the written standard with a worked before/after example. Likely some combination. A worked before/after example exists in a work-project prep doc (a 6-col table reformatted by hand to a 4-col multi-row-cell version), to be reproduced generically when this lands.
Out of a work-project handoff 2026-06-09.
+Resolution 2026-06-11: all three shipped. (c) The standard, generalized from the work project's notes.org local copy, is now claude-rules/org-tables.md (globally loaded; render-width semantics — links measure at their visible label, never split a link) with the worked wrapped-table example. (a) .ai/scripts/wrap-org-table.el reflows tables mechanically: render-width measurement, link-atomic tokenizing, column shrink-to-floor allocation, continuation rows, rules between logical rows; idempotent (rule-delimited continuation groups merge back before re-wrapping); 23 ERT tests. (b) lint-org.el gained an org-table-standard judgment check (width overruns, missing rules; conformant wrapped tables not false-flagged); 5 new ERT tests, 32 total. Verified end-to-end on a demo file: 150-col table reflowed to budget, idempotent second pass, lint clean on the result.
+
** DONE [#C] SessionStart-on-clear hook for auto-resume :feature:
CLOSED: [2026-06-11 Thu]
:PROPERTIES: