SLOPSHOPPER

format-org-tables

No description.

newguardprocess
A shopper browsing a rack in a slop shop
README

format-org-tables

Aligns the Org mode tables in the .org files that a Write, an Edit, or a Bash call changed, with Emacs in batch mode. The model writes files outside an editor, so nothing else runs org-table-align, and each table that the model touches stays without column padding.

One core, core.ts, serves four agents through three adapters:

AgentAdapterLoaded by
Claude Codehooks/register.ts, a mod: tool.call on Write, Edit, and Bash aligns after nextCLAUDE_CODE_PLUGIN_DIRS in settings.json.tmpl
pipi.ts: tool_result on write, edit, and bashextensions in dot_pi/agent/modify_settings.json
omppi.ts as in pi, and omp.ts: tool_result on an omp edit of more than one file, an ast_edit that a write to xd://resolve applies, and evalextensions in dot_omp/private_agent/private_config.yml
Codexcommand.ts: the amend hint as hookSpecificOutput JSON on stdoutPostToolUse on Bash, and on apply_patch through dot_codex/hooks/apply-patch.ts

The git pre-commit hook format-staged-files also runs command.ts, once for each commit. It names all staged files in tool_input.file_paths, a field that no agent payload has.

What it checks

The hook runs after each Write, Edit, and Bash call. It also runs after a call that failed, because a Bash command that exits with a code other than 0 can still have written a file. An interrupted command counts too: it can leave a half-written file, and org-table-align is safe to run on one.

Write and Edit

The call names one file in file_path. The hook aligns that file.

Bash

A Bash call names no file, but a shell command (a heredoc, sed, a redirect) often writes .org files. The hook takes each .org path that the command text names, absolute or relative to the session cwd (the working folder of the session before the command). A relative path also resolves against each folder that a cd in the command moves to, in order: cd pkg && cd sub && sed -i … notes.org tries notes.org in the session cwd, in pkg, and in pkg/sub.

A named path counts only when its mtime is at most 120 s old. Thus a file that the command only read stays as it is, and so does a file of the same name in another folder. lint-code uses the same 120 s window.

The hook misses these Bash writes, and the file stays without alignment until the next Edit:

  • A command that writes a .org file without its name, for example a script that it calls or a find -exec.
  • A relative path after a cd to ~, to -, or to a folder with a variable. The hook does not follow such a cd.
  • A command that writes the file and then runs for more than 120 s.

Skipped files

The hook skips each of these files, with no output:

  • A file that is not .org.
  • A path that does not exist or is not a regular file.
  • A path under /tmp, /var/tmp, or $TMPDIR. The scratchpad of the agent is there, and a file that the agent will throw away is not project prose.
  • A file with no line that starts with |. See Files without tables.

Requirements

  • Emacs on PATH. Org mode comes with Emacs, so nothing else is necessary. Tested with GNU Emacs 30.1, 30.2, and 31.1. Without Emacs, the hook does nothing.
  • git, for the amend hint.
  • Node 23.6 or later for command.ts, which Codex and format-staged-files run. With no node, the Codex entry exits 0, and the SessionStart check of Codex shows a warning. The mod and pi.ts need no node of their own.

To install Emacs on macOS:

brew install emacs

How it works

  1. The hook stops when AGENT_HOOKS_DISABLE names it.
  2. It finds the target files, as What it checks tells.
  3. It drops each target whose repository sets agent-hook.format-org-tables.enabled to false in git config. It runs one git config for each folder, and only for the files with a table.
  4. If the call is a Bash command that commits, it keeps the text of each target file.
  5. It aligns all target files in one emacs --batch --no-init-file run, and stops Emacs after 10 s.
  6. If it kept the text in step 4, it compares the text and sends the amend hint when necessary.

--no-init-file keeps your Emacs init (Doom, in this setup) out of the run, so only the Org mode that comes with Emacs loads. Without it, each run takes much longer than the timeout. One Emacs run costs about 1.4 s, and almost all of it is the start of Emacs, see docs/research.md.

One Emacs run for every file

A Bash call can name several files. The hook gives all of them to one Emacs run, in the ORG_TABLE_FILES environment variable, one path for each line. Five files take 1.6 s in one run and 6.7 s in five runs.

Files without tables

Before it starts Emacs, the hook reads each file and looks for a line that starts with |. A file with no such line has no table, so the hook does not start Emacs for it. Most Org files are prose, so this check keeps Emacs out of the common case.

Alignment only

The hook calls org-table-align on each table, and does nothing else. It does not evaluate a #+TBLFM formula, because a formatter must not recalculate data. A | line in a #+begin_example block, or in a similar block, is not a table, and the hook does not change it.

The alignment follows the rules of Org, not of Prettier:

  • The padding uses the display width, so wide glyphs such as ⌘ ⇧ ⌃ ⌥ align correctly.
  • A column with only numbers is aligned to the right.
  • A second pass over an aligned file changes no byte.

Emacs writes no backup file (file.org~) and no lock file next to the Org file.

Links count at their raw width

By default, Org hides the brackets and the target of a link such as [[*Cache][Cache]], and org-table-align measures only the text that it shows. The table is then straight in Emacs, but not straight in a diff, in Magit, or in a different editor. The hook counts each link at its raw width, so each | is at the same column in the file.

In Emacs, the org-markup-kinds package of the Doom config aligns the same way, and pads the hidden link text on the screen.

What the model sees

Usually nothing. The hook changes the file and adds no output. The only output for the model is the amend hint.

Write and commit in one command

A Bash call can write a file and commit it in one command. Alignment after the call fixes the file, but not the commit: the commit holds the version without alignment, and the working tree is dirty. Only the model can amend the commit, so the hook gives the model an amend hint that names the files.

The hook sends the amend hint only when all of these conditions are true:

  • The call is a Bash command that commits: git, then optional global options such as -C <dir>, then commit. A Write or an Edit never gets the amend hint.
  • Emacs changed the file in this run.
  • The last commit holds the file: git diff-tree --no-commit-id --name-only -r --root HEAD. A file outside a git repository does not count.

The amend hint is this text, with the paths:

Emacs realigned Org tables after your commit, so the commit holds the unaligned version and the working tree is now dirty: <files>. Fold the alignment into that commit (amend, or a fixup commit if it is already pushed).

Each adapter sends it in a different way:

AdapterHow the model gets the amend hint
Modcontext on the tool result
pi.ts, omp.tsA text block added after the content of the tool result
command.tshookSpecificOutput.additionalContext on stdout, with hookEventName from the payload, exit 0

In an agent session, the format-staged-files pre-commit hook aligns the file before the commit. Thus the amend hint appears only when that hook skipped the file: a file with unstaged changes, a --no-verify commit, or a machine with no node for git. A test of the amend hint must turn that hook off, or the amend hint never appears.

What you see

Nothing, when the hook works. The aligned table shows in the diff of the file. The hook drops the stderr of Emacs, so the locale warning of Emacs batch runs (LANG=… cannot be used, using en_US.UTF-8 instead) does not show.

Settings

To turn the hook off until you quit the agent, run /toggle-hook, or add format-org-tables to AGENT_HOOKS_DISABLE. This variable holds a list of hook names, separated by spaces or commas. In Codex, set it before you start Codex.

To turn the hook off in one repository, across sessions, run /toggle-hook --repo format-org-tables. In Codex, run git config agent-hook.format-org-tables.enabled false in your own terminal. The key also applies in format-staged-files. Outside git, or with a value that is not a boolean, the hook stays on. The dcg pack local.agent_hook blocks an agent command that writes this key.

To turn the hook off for good, remove it from the agent config:

AgentRemove
Claude Codeformat-org-tables from CLAUDE_CODE_PLUGIN_DIRS in settings.json.tmpl
piThe format-org-tables/pi.ts line in dot_pi/agent/modify_settings.json
ompThe format-org-tables lines in dot_omp/private_agent/private_config.yml
CodexThe two format-org-tables entries in dot_codex/modify_hooks.json

The hook reads TMPDIR to find the scratch files that it skips. It has no config file.

Failure behavior

The hook fails open: it never blocks a call and never marks a result as an error. A formatter changes working files, and must not reject an edit. A missing emacs, a failed Emacs run, or a run longer than 10 s leaves the file as it is. Codex also stops command.ts after 10 s, the timeout of its entry.

AdapterOn a failure
ModClaude Code skips a hook that throws, and the result stands.
pi.ts, omp.tsThe handler catches the error and returns nothing.
command.tsNode exits 1, which Codex shows as a failed hook and does not block. A missing Emacs exits 0.

Testing

node test/check.ts runs command.ts and the adapters with the real Emacs and git, see docs/testing.md.

Source 2 files
hooks/register.ts 34 lines
1/**
2 * Claude Code mod: after a Write, Edit, or Bash call, aligns the Org tables in
3 * the files it changed. A failed Bash call is aligned too: it may still have
4 * written files. Adds the amend hint as context when the call committed a
5 * file that Emacs then changed. Fails open: a hook that throws is skipped and
6 * the result stands.
7 */
8
9import type { Register } from "claude-code";
10import { align, type Io } from "../core.ts";
11
12export const register: Register = (on) => {
13  on("tool.call", { tool: ["Write", "Edit", "Bash"] }, async ($, e, next) => {
14    const ran = await next(e);
15    if (ran.deny !== undefined) return ran;
16    const cwd = await $.session.cwd();
17    // The engine only takes $ calls spelled at the call site, so each
18    // function of the core is written out here.
19    const io: Io = {
20      stat: (path) => $.fs.stat(path).catch(() => undefined),
21      read: (path) => $.fs.read(path).catch(() => undefined),
22      // A run fails when the program is not installed, or timed out.
23      run: (argv, options) => $.process.run(argv, options).catch(() => undefined),
24    };
25    const call = e.tool === "Bash" ? { command: e.command, cwd } : { filePath: e.file_path, cwd };
26    const hint = await align(call, io, {
27      tmpdir: await $.env.get("TMPDIR"),
28      hooksOff: await $.env.get("AGENT_HOOKS_DISABLE"),
29    });
30    if (!hint) return ran;
31    return { ...ran, context: [...(ran.context ?? []), hint] };
32  });
33};
34
core.ts 188 lines
1/**
2 * The format-org-tables logic: finds the Org files a tool call changed and
3 * aligns their tables with one batch Emacs run. oxfmt has no Org parser, so
4 * format-oxfmt skips .org. Never blocks: a missing Emacs or a
5 * failed run leaves the files as they are. The adapters pass in the file and
6 * process functions, because a mod reaches them only through `$`.
7 *
8 * Adapters: hooks/register.ts (Claude Code mod), pi.ts (pi and omp), omp.ts
9 * (omp only), command.ts (Codex, and the format-staged-files pre-commit hook).
10 * Design notes: docs/implementation.md.
11 */
12
13export interface Io {
14  /** The kind of a path and its mtime; undefined if missing. */
15  stat(path: string): Promise<{ kind: "file" | "dir" | "other"; mtimeMs: number } | undefined>;
16  /** The text of a file; undefined if it cannot be read. */
17  read(path: string): Promise<string | undefined>;
18  /** The stdout of a program; undefined when it cannot start. */
19  run(
20    argv: string[],
21    options: { cwd: string; env?: Record<string, string>; timeoutMs?: number },
22  ): Promise<{ stdout: string } | undefined>;
23}
24
25export interface Settings {
26  /** TMPDIR: files under it are throwaway and not aligned. */
27  tmpdir?: string;
28  /** AGENT_HOOKS_DISABLE: the hooks /toggle-hook turned off. */
29  hooksOff?: string;
30}
31
32/**
33 * The tool call: a Write or Edit names a file, a Bash call a command. The
34 * format-staged-files pre-commit hook names all staged files in filePaths, so
35 * one run takes them all.
36 */
37export interface Call {
38  filePath?: string;
39  filePaths?: string[];
40  command?: string;
41  cwd: string;
42}
43
44/** An Org path that a command names. */
45const NAMED = /[^\s:;|&"'`()<>=]+\.org\b/g;
46
47/**
48 * A command that commits: `git`, then global options such as `-C <dir>`, then
49 * `commit` as a word, in one simple command. `git commit-tree` does not match.
50 */
51const COMMIT = /\bgit[ \t](?:[^;&|\n]*[ \t])?commit(?![\w-])/;
52
53/** The folder of a `cd` in a command. */
54const CD = /(?:^|[\s;&|(])cd\s+["']?([^\s;&|()"']+)/g;
55
56/**
57 * The folders a relative path of a command can be relative to: the session
58 * cwd, and each folder that a `cd` in the command moves to, in order. The
59 * session cwd is the one from before the command, so without the `cd`
60 * folders a `cd docs && sed -i … notes.org` is a silent miss. shortcut: a
61 * `cd` to `~`, `-` or a folder with a variable is not followed. The upgrade
62 * is the cwd of the shell after the command, which the adapters do not get.
63 */
64function bases(command: string, cwd: string): string[] {
65  const found = [cwd];
66  let at = cwd;
67  for (const [, dir = ""] of command.matchAll(CD)) {
68    if (/^[~-]|\$/.test(dir)) continue;
69    at = dir.startsWith("/") ? dir : `${at}/${dir}`;
70    found.push(at.replace(/\/+$/, ""));
71  }
72  return found;
73}
74
75/**
76 * Aligns each table in the files of ORG_TABLE_FILES, one per line. Alignment
77 * only: a #+TBLFM formula is not evaluated. org-link-descriptive nil counts a
78 * link at its raw width, so the table is straight in a diff too. README
79 * "Alignment only" and "Links count at their raw width".
80 */
81const ELISP = `
82(progn
83  (setq make-backup-files nil create-lockfiles nil org-link-descriptive nil)
84  (dolist (file (split-string (getenv "ORG_TABLE_FILES") "\\n" t))
85    (with-current-buffer (find-file-noselect file)
86      (org-mode)
87      (goto-char (point-min))
88      (while (re-search-forward "^[ \\t]*|" nil t)
89        (when (org-at-table-p)
90          (org-table-align)
91          (goto-char (org-table-end))))
92      (save-buffer))))`;
93
94/**
95 * A file under /tmp, /var/tmp, or TMPDIR is a scratch file, not project
96 * prose. macOS resolves /tmp to /private/tmp, so both sides drop that prefix.
97 */
98function throwaway(file: string, settings: Settings): boolean {
99  const tmpdir = (settings.tmpdir || "/nonexistent").replace(/^\/private/, "");
100  const plain = file.replace(/^\/private/, "");
101  return plain.startsWith("/tmp/") || plain.startsWith("/var/tmp/") || plain.startsWith(tmpdir);
102}
103
104/**
105 * Whether the git config of the repository that holds `dir` sets
106 * agent-hook.format-org-tables.enabled to false, as /toggle-hook --repo does.
107 * Outside git, a missing key, or a value that is not a boolean: false.
108 */
109async function repoOff(dir: string, io: Io): Promise<boolean> {
110  const key = "agent-hook.format-org-tables.enabled";
111  const ran = await io.run(["git", "config", "--type=bool", "--get", key], {
112    cwd: dir,
113  });
114  return ran?.stdout.trim() === "false";
115}
116
117/**
118 * The files to align, each once. A Write or Edit: the file it names. A Bash
119 * call: the .org paths its command names that it just wrote, by an mtime of
120 * at most 120 s, so a file the command only read stays as it is. A relative
121 * path is tried against each folder of `bases`. shortcut: a
122 * command that writes early and then runs for minutes falls outside the
123 * window. The upgrade is a start time stamped before the call. Only a file
124 * with a line that starts with `|` counts, which keeps Emacs out of the
125 * common case. README "Files without tables". A file whose repository turns
126 * off the hook stays as it is.
127 */
128async function targets(call: Call, io: Io, settings: Settings): Promise<string[]> {
129  const command = call.command ?? "";
130  const folders = bases(command, call.cwd);
131  const given = call.filePaths ?? (call.filePath ? [call.filePath] : undefined);
132  const named =
133    given ??
134    [...command.matchAll(NAMED)].flatMap(([path]) =>
135      path.startsWith("/") ? [path] : folders.map((dir) => `${dir}/${path}`),
136    );
137  const kept: string[] = [];
138  // One git run for each folder: a commit can name many files.
139  const asked = new Map<string, Promise<boolean>>();
140  for (const file of new Set(named)) {
141    if (!file.endsWith(".org") || throwaway(file, settings)) continue;
142    const stat = await io.stat(file);
143    if (stat?.kind !== "file") continue;
144    if (!given && Date.now() - stat.mtimeMs > 120_000) continue;
145    if (!/^[ \t]*\|/m.test((await io.read(file)) ?? "")) continue;
146    const dir = file.slice(0, file.lastIndexOf("/")) || "/";
147    if (!asked.has(dir)) asked.set(dir, repoOff(dir, io));
148    if (!(await asked.get(dir))) kept.push(file);
149  }
150  return kept;
151}
152
153/**
154 * Aligns the tables in the files a call changed. Returns the amend hint for
155 * the model when the call committed a file that Emacs then changed, else
156 * undefined. README "Write and commit in one command".
157 */
158export async function align(call: Call, io: Io, settings: Settings): Promise<string | undefined> {
159  if ((settings.hooksOff ?? "").split(/[\s,]+/).includes("format-org-tables")) return undefined;
160  const files = await targets(call, io, settings);
161  if (!files.length) return undefined;
162  // Only a command that commits gets the hint, and the hint names only the
163  // files that Emacs itself changed, so keep the text from before the run.
164  const watched = !call.filePath && COMMIT.test(call.command ?? "") ? files : [];
165  const before = await Promise.all(watched.map((file) => io.read(file)));
166  // One Emacs for all files: the 1.4 s is the start of Emacs. --no-init-file
167  // keeps the user's init out. README "One Emacs run for every file".
168  const ran = await io.run(["emacs", "--batch", "--no-init-file", "--eval", ELISP], {
169    cwd: call.cwd,
170    env: { ORG_TABLE_FILES: files.join("\n") },
171    timeoutMs: 10_000,
172  });
173  if (!ran) return undefined;
174  const dirty: string[] = [];
175  for (const [i, file] of watched.entries()) {
176    if ((await io.read(file)) === before[i]) continue;
177    // Only a file that the last commit holds needs an amend. diff-tree --root
178    // also answers for the first commit of a repository.
179    const held = await io.run(
180      ["git", "diff-tree", "--no-commit-id", "--name-only", "-r", "--root", "HEAD", "--", file],
181      { cwd: file.slice(0, file.lastIndexOf("/")) || "/" },
182    );
183    if (held?.stdout.trim()) dirty.push(file);
184  }
185  if (!dirty.length) return undefined;
186  return `Emacs realigned Org tables after your commit, so the commit holds the unaligned version and the working tree is now dirty: ${dirty.join(" ")}. Fold the alignment into that commit (amend, or a fixup commit if it is already pushed).`;
187}
188