No description.

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:
| Agent | Adapter | Loaded by |
|---|---|---|
| Claude Code | hooks/register.ts, a mod: tool.call on Write, Edit, and Bash aligns after next | CLAUDE_CODE_PLUGIN_DIRS in settings.json.tmpl |
| pi | pi.ts: tool_result on write, edit, and bash | extensions in dot_pi/agent/modify_settings.json |
| omp | pi.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 eval | extensions in dot_omp/private_agent/private_config.yml |
| Codex | command.ts: the amend hint as hookSpecificOutput JSON on stdout | PostToolUse 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.
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.
The call names one file in file_path. The hook aligns that file.
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:
.org file without its name, for example a script that it calls or a find -exec.cd to ~, to -, or to a folder with a variable. The hook does not follow such a cd.The hook skips each of these files, with no output:
.org./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.|. See Files without tables.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.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
AGENT_HOOKS_DISABLE names it.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.emacs --batch --no-init-file run, and stops Emacs after 10 s.--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.
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.
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.
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:
⌘ ⇧ ⌃ ⌥ align correctly.Emacs writes no backup file (file.org~) and no lock file next to the Org file.
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.
Usually nothing. The hook changes the file and adds no output. The only output for the model is the amend hint.
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:
git, then optional global options such as -C <dir>, then commit. A Write or an Edit never gets the amend hint.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:
| Adapter | How the model gets the amend hint |
|---|---|
| Mod | context on the tool result |
pi.ts, omp.ts | A text block added after the content of the tool result |
command.ts | hookSpecificOutput.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.
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.
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:
| Agent | Remove |
|---|---|
| Claude Code | format-org-tables from CLAUDE_CODE_PLUGIN_DIRS in settings.json.tmpl |
| pi | The format-org-tables/pi.ts line in dot_pi/agent/modify_settings.json |
| omp | The format-org-tables lines in dot_omp/private_agent/private_config.yml |
| Codex | The 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.
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.
| Adapter | On a failure |
|---|---|
| Mod | Claude Code skips a hook that throws, and the result stands. |
pi.ts, omp.ts | The handler catches the error and returns nothing. |
command.ts | Node exits 1, which Codex shows as a failed hook and does not block. A missing Emacs exits 0. |
node test/check.ts runs command.ts and the adapters with the real Emacs and git, see docs/testing.md.
hooks/register.ts 34 lines1/**
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};
34core.ts 188 lines1/**
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