aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-23 20:48:44 -0500
committerCraig Jennings <c@cjennings.net>2026-07-23 20:48:44 -0500
commitc238afbd4150ef53737f16e7dd84a565d2838ecc (patch)
tree588a25dc318cb854c57d8146639d400674baed18
parent10ea44b6de3be1872f7f0bd4501ccf3878105bc4 (diff)
downloadrulesets-c238afbd4150ef53737f16e7dd84a565d2838ecc.tar.gz
rulesets-c238afbd4150ef53737f16e7dd84a565d2838ecc.zip
feat(languages): ship the missing python and typescript hooks
The python and typescript bundles carried rules and a coverage script but no pre-commit hook, so any project installing one got no credential scan on commit. Both now ship all four components the README documents: the shared secret scan, a validate-on-edit hook, settings wiring, and a seed CLAUDE.md. Verified against a real repo: a commit carrying an AWS key is refused. install-lang now warns when a bundle is missing a documented component. That's the half that keeps this from recurring. Whoever adds the sixth bundle will forget something too, and today the installer prints success either way. Two things fell out of the build. node --check is unusable on TypeScript: it ignores --experimental-strip-types, so it rejects valid TS and accepts broken TS. The hook uses tsc filtered to syntactic diagnostics instead. And completing the bundles means every pair now collides on settings.json and pre-commit, so no two compose without FORCE=1. The earlier "bundles already compose" reading rested on these two being incomplete. Filed for a real decision; clock-panel is the project that wants it. Also stamps :LAST_REVIEWED: at task creation, with a lint checker to catch misses. Writing a task is reviewing it, so leaving the stamp off pushed every fresh task to the top of the next review batch to be re-derived by someone with less context than its author had. Tonight's sweep sent the staleness count from 13 to 22 while the list got more accurate.
-rw-r--r--.ai/scripts/lint-org.el38
-rw-r--r--.ai/scripts/tests/test-lint-org.el51
-rw-r--r--claude-rules/todo-format.md50
-rw-r--r--claude-templates/.ai/scripts/lint-org.el38
-rw-r--r--claude-templates/.ai/scripts/tests/test-lint-org.el51
-rwxr-xr-xlanguages/elisp/githooks/pre-commit2
-rwxr-xr-xlanguages/go/githooks/pre-commit2
-rw-r--r--languages/python/CLAUDE.md80
-rwxr-xr-xlanguages/python/claude/hooks/validate-python.sh95
-rw-r--r--languages/python/claude/settings.json79
-rwxr-xr-xlanguages/python/githooks/pre-commit79
-rw-r--r--languages/python/tests/pre-commit.bats138
-rw-r--r--languages/python/tests/validate-python.bats117
-rw-r--r--languages/typescript/CLAUDE.md82
-rwxr-xr-xlanguages/typescript/claude/hooks/validate-typescript.sh94
-rw-r--r--languages/typescript/claude/settings.json80
-rwxr-xr-xlanguages/typescript/githooks/pre-commit92
-rw-r--r--languages/typescript/tests/pre-commit.bats150
-rw-r--r--languages/typescript/tests/validate-typescript.bats125
-rwxr-xr-xscripts/install-lang.sh25
-rw-r--r--scripts/tests/install-lang-collision.bats53
-rw-r--r--scripts/tests/install-lang-completeness.bats93
-rw-r--r--scripts/tests/install-lang.bats62
-rw-r--r--todo.org52
24 files changed, 1707 insertions, 21 deletions
diff --git a/.ai/scripts/lint-org.el b/.ai/scripts/lint-org.el
index 47e8bf1..1bd4582 100644
--- a/.ai/scripts/lint-org.el
+++ b/.ai/scripts/lint-org.el
@@ -38,6 +38,7 @@
;; empty-heading bare stars with no title
;; malformed-priority-cookie [#x]-shaped token org rejected
;; level2-done-without-closed completed level-2 task with no CLOSED
+;; task-missing-last-reviewed open level-2 task with no :LAST_REVIEWED:
;; subtask-done-not-dated level-3+ done sub-task still a DONE keyword
;; dated-log-heading-active-timestamp dated-log heading with a live SCHEDULED/DEADLINE
;; (anything else) surfaced as judgment with checker name
@@ -526,6 +527,42 @@ the live file on the next `task-sorted'."
"level-2 DONE/CANCELLED has no CLOSED date — add CLOSED: [YYYY-MM-DD Day]; task-sorted's aging step archives an undated completed task immediately"))))))))
;;; ---------------------------------------------------------------------------
+;;; task-missing-last-reviewed check (claude-rules/todo-format.md)
+;;
+;; A task is stamped `:LAST_REVIEWED:' when it is *created*, not a review cycle
+;; later. An agent filing a task has just written its body and graded its
+;; priority, which is a review by any honest reading — so a fresh task that
+;; carries no stamp reads as "never reviewed" and lands at the top of the next
+;; staleness batch, where re-reviewing it is pure ceremony. Every task filed
+;; during the 2026-07-23 sweep hit exactly that, which is what prompted the rule.
+;;
+;; Judgment-only, deliberately. The stamp's whole value is that its date is
+;; true, and nothing here can know when an unstamped task was actually last
+;; looked at. Auto-stamping today's date would convert a "nobody has reviewed
+;; this" signal into a false "reviewed today" one — worse than the gap it
+;; closes. Flag it; a human or the filing workflow supplies the honest date.
+;;
+;; Scope matches `task-review-staleness.sh' exactly (level-2, open keyword,
+;; priority cookie), so the checker and the staleness count never disagree
+;; about which headings are in the review pool.
+
+(defun lo--check-task-missing-last-reviewed ()
+ "Flag an open level-2 task with a priority cookie and no `:LAST_REVIEWED:'."
+ (save-excursion
+ (goto-char (point-min))
+ (let ((case-fold-search nil))
+ (while (re-search-forward "^\\*\\* \\(TODO\\|DOING\\|VERIFY\\) \\[#[A-D]\\]" nil t)
+ (let ((hline (line-number-at-pos))
+ (entry-end (save-excursion (outline-next-heading) (point))))
+ (save-excursion
+ (forward-line 1)
+ (unless (re-search-forward "^[ \t]*:LAST_REVIEWED:[ \t]*[[0-9]"
+ entry-end t)
+ (lo--emit-judgment
+ 'task-missing-last-reviewed hline
+ "task has no :LAST_REVIEWED: — stamp it at creation with today's date (a task you just wrote and graded is reviewed); otherwise it enters the next staleness batch as never-reviewed"))))))))
+
+;;; ---------------------------------------------------------------------------
;;; level-3+ dated-header check (claude-rules/todo-format.md)
;;
;; The inverse of the level-2 check above. A completed sub-task — a heading at
@@ -627,6 +664,7 @@ left unmodified and mechanical entries are recorded with :preview t."
(lo--check-empty-headings)
(lo--check-malformed-priority-cookies)
(lo--check-level2-done-without-closed)
+ (lo--check-task-missing-last-reviewed)
(lo--check-subtask-done-not-dated)
(lo--check-dated-log-active-timestamp)
(when (and (not lo-check-only) (buffer-modified-p))
diff --git a/.ai/scripts/tests/test-lint-org.el b/.ai/scripts/tests/test-lint-org.el
index 30a79bd..9a72bef 100644
--- a/.ai/scripts/tests/test-lint-org.el
+++ b/.ai/scripts/tests/test-lint-org.el
@@ -859,3 +859,54 @@ heading, so it is not flagged — only two-or-more indented stars are."
(provide 'test-lint-org)
;;; test-lint-org.el ends here
+
+;;; ---------------------------------------------------------------------------
+;;; task-missing-last-reviewed (claude-rules/todo-format.md)
+
+(ert-deftest lo-task-without-last-reviewed-is-judgment ()
+ "An open level-2 task with no :LAST_REVIEWED: is flagged."
+ (let* ((out (lo-test--run "* Open Work\n** TODO [#B] A task :feature:\nBody.\n"))
+ (js (lo-test--judgments (plist-get out :issues))))
+ (should (memq 'task-missing-last-reviewed (lo-test--checkers js)))))
+
+(ert-deftest lo-task-with-last-reviewed-is-clean ()
+ "A task carrying the property is not flagged."
+ (let* ((out (lo-test--run (concat "* Open Work\n** TODO [#B] A task :feature:\n"
+ ":PROPERTIES:\n:LAST_REVIEWED: 2026-07-23\n:END:\n"
+ "Body.\n")))
+ (js (lo-test--judgments (plist-get out :issues))))
+ (should-not (memq 'task-missing-last-reviewed (lo-test--checkers js)))))
+
+(ert-deftest lo-task-last-reviewed-accepts-org-timestamp ()
+ "The org-native [YYYY-MM-DD Day] form counts, matching the staleness script."
+ (let* ((out (lo-test--run (concat "* Open Work\n** TODO [#B] A task :feature:\n"
+ ":PROPERTIES:\n:LAST_REVIEWED: [2026-07-23 Thu]\n:END:\n")))
+ (js (lo-test--judgments (plist-get out :issues))))
+ (should-not (memq 'task-missing-last-reviewed (lo-test--checkers js)))))
+
+(ert-deftest lo-done-task-without-last-reviewed-is-clean ()
+ "Completed tasks leave the review pool, so they are never flagged."
+ (let* ((out (lo-test--run (concat "* Open Work\n** DONE [#B] A task :feature:\n"
+ "CLOSED: [2026-07-23 Thu]\n")))
+ (js (lo-test--judgments (plist-get out :issues))))
+ (should-not (memq 'task-missing-last-reviewed (lo-test--checkers js)))))
+
+(ert-deftest lo-subtask-without-last-reviewed-is-clean ()
+ "Only level-2 tasks are in the review pool; deeper headings are not."
+ (let* ((out (lo-test--run (concat "* Open Work\n** TODO [#B] Parent :feature:\n"
+ ":PROPERTIES:\n:LAST_REVIEWED: 2026-07-23\n:END:\n"
+ "*** TODO A sub-task\n")))
+ (js (lo-test--judgments (plist-get out :issues))))
+ (should-not (memq 'task-missing-last-reviewed (lo-test--checkers js)))))
+
+(ert-deftest lo-cookieless-task-without-last-reviewed-is-clean ()
+ "The staleness script selects on a priority cookie, so match that scope."
+ (let* ((out (lo-test--run "* Open Work\n** TODO Manual testing and validation\n"))
+ (js (lo-test--judgments (plist-get out :issues))))
+ (should-not (memq 'task-missing-last-reviewed (lo-test--checkers js)))))
+
+(ert-deftest lo-verify-task-without-last-reviewed-is-judgment ()
+ "VERIFY is in the review pool too."
+ (let* ((out (lo-test--run "* Open Work\n** VERIFY [#B] Waiting on Craig\n"))
+ (js (lo-test--judgments (plist-get out :issues))))
+ (should (memq 'task-missing-last-reviewed (lo-test--checkers js)))))
diff --git a/claude-rules/todo-format.md b/claude-rules/todo-format.md
index a4ffc34..e6a34e9 100644
--- a/claude-rules/todo-format.md
+++ b/claude-rules/todo-format.md
@@ -5,6 +5,56 @@ Applies to: `**/*.org` (org-mode todo and inbox files)
How task entries are structured in org-mode todo files (`todo.org`,
`inbox.org`, any GTD-style org file). Same shape across every project.
+## Stamp `:LAST_REVIEWED:` when you create the task, not a cycle later
+
+Every task filed at `**` with a priority cookie carries a `:LAST_REVIEWED:`
+property from the moment it's written:
+
+```
+** TODO [#B] Terse topic phrase :tag:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-23
+:END:
+Body.
+```
+
+Use today's date, from `date +%F`. The org-native `[YYYY-MM-DD Day]` form is
+equally valid; both parse.
+
+**Why.** Writing a task *is* reviewing it. Whoever files it has just written
+the body, chosen the wording, and graded the priority against the scheme — that
+is the same judgment `task-review` applies, made with better context, because
+the reason for the task is still in the room. Leaving the stamp off asserts the
+opposite: `task-review-staleness.sh` sorts a missing property first, as
+never-reviewed, so a task filed today arrives at the top of tomorrow's review
+batch and gets "reviewed" by someone re-deriving what its author knew a day
+earlier. That is ceremony, and ceremony teaches people to click through the
+real thing.
+
+The concrete case: the 2026-07-23 sweep filed eight tasks in one night. Every
+one landed unstamped, and the staleness count went 13 → 22 while the list got
+*more* accurate, not less. The number stopped measuring drift and started
+measuring recent activity.
+
+**This applies to every path that files a task**, not just the inbox: inbox
+filing, triage intake, spec decomposition, task audit, a bug found mid-session,
+a task you write by hand. If you wrote a task body today, stamp it today.
+
+**What it does not do.** The stamp never means "correct forever" — it means
+"a person judged this on that date." A task filed today still enters the
+review rotation on the normal cycle; it just enters it on the *next* cycle
+rather than immediately. And it's a claim about a real event, so don't stamp
+a task you didn't actually consider: a bulk import of someone else's list
+is genuinely unreviewed, and stamping it would convert "nobody has read
+these" into a false "reviewed today."
+
+**Enforcement.** `lint-org.el`'s `task-missing-last-reviewed` checker flags any
+open `**` task with a priority cookie and no stamp, scoped to exactly the
+headings `task-review-staleness.sh` selects, so the checker and the staleness
+count never disagree. It's judgment-only and never auto-fixes: nothing can know
+when an unstamped task was actually last considered, and writing today's date
+onto an old one would destroy the very signal the property carries.
+
## Priority and Tag Scheme Header
Every project's `todo.org` opens with a top-level section named
diff --git a/claude-templates/.ai/scripts/lint-org.el b/claude-templates/.ai/scripts/lint-org.el
index 47e8bf1..1bd4582 100644
--- a/claude-templates/.ai/scripts/lint-org.el
+++ b/claude-templates/.ai/scripts/lint-org.el
@@ -38,6 +38,7 @@
;; empty-heading bare stars with no title
;; malformed-priority-cookie [#x]-shaped token org rejected
;; level2-done-without-closed completed level-2 task with no CLOSED
+;; task-missing-last-reviewed open level-2 task with no :LAST_REVIEWED:
;; subtask-done-not-dated level-3+ done sub-task still a DONE keyword
;; dated-log-heading-active-timestamp dated-log heading with a live SCHEDULED/DEADLINE
;; (anything else) surfaced as judgment with checker name
@@ -526,6 +527,42 @@ the live file on the next `task-sorted'."
"level-2 DONE/CANCELLED has no CLOSED date — add CLOSED: [YYYY-MM-DD Day]; task-sorted's aging step archives an undated completed task immediately"))))))))
;;; ---------------------------------------------------------------------------
+;;; task-missing-last-reviewed check (claude-rules/todo-format.md)
+;;
+;; A task is stamped `:LAST_REVIEWED:' when it is *created*, not a review cycle
+;; later. An agent filing a task has just written its body and graded its
+;; priority, which is a review by any honest reading — so a fresh task that
+;; carries no stamp reads as "never reviewed" and lands at the top of the next
+;; staleness batch, where re-reviewing it is pure ceremony. Every task filed
+;; during the 2026-07-23 sweep hit exactly that, which is what prompted the rule.
+;;
+;; Judgment-only, deliberately. The stamp's whole value is that its date is
+;; true, and nothing here can know when an unstamped task was actually last
+;; looked at. Auto-stamping today's date would convert a "nobody has reviewed
+;; this" signal into a false "reviewed today" one — worse than the gap it
+;; closes. Flag it; a human or the filing workflow supplies the honest date.
+;;
+;; Scope matches `task-review-staleness.sh' exactly (level-2, open keyword,
+;; priority cookie), so the checker and the staleness count never disagree
+;; about which headings are in the review pool.
+
+(defun lo--check-task-missing-last-reviewed ()
+ "Flag an open level-2 task with a priority cookie and no `:LAST_REVIEWED:'."
+ (save-excursion
+ (goto-char (point-min))
+ (let ((case-fold-search nil))
+ (while (re-search-forward "^\\*\\* \\(TODO\\|DOING\\|VERIFY\\) \\[#[A-D]\\]" nil t)
+ (let ((hline (line-number-at-pos))
+ (entry-end (save-excursion (outline-next-heading) (point))))
+ (save-excursion
+ (forward-line 1)
+ (unless (re-search-forward "^[ \t]*:LAST_REVIEWED:[ \t]*[[0-9]"
+ entry-end t)
+ (lo--emit-judgment
+ 'task-missing-last-reviewed hline
+ "task has no :LAST_REVIEWED: — stamp it at creation with today's date (a task you just wrote and graded is reviewed); otherwise it enters the next staleness batch as never-reviewed"))))))))
+
+;;; ---------------------------------------------------------------------------
;;; level-3+ dated-header check (claude-rules/todo-format.md)
;;
;; The inverse of the level-2 check above. A completed sub-task — a heading at
@@ -627,6 +664,7 @@ left unmodified and mechanical entries are recorded with :preview t."
(lo--check-empty-headings)
(lo--check-malformed-priority-cookies)
(lo--check-level2-done-without-closed)
+ (lo--check-task-missing-last-reviewed)
(lo--check-subtask-done-not-dated)
(lo--check-dated-log-active-timestamp)
(when (and (not lo-check-only) (buffer-modified-p))
diff --git a/claude-templates/.ai/scripts/tests/test-lint-org.el b/claude-templates/.ai/scripts/tests/test-lint-org.el
index 30a79bd..9a72bef 100644
--- a/claude-templates/.ai/scripts/tests/test-lint-org.el
+++ b/claude-templates/.ai/scripts/tests/test-lint-org.el
@@ -859,3 +859,54 @@ heading, so it is not flagged — only two-or-more indented stars are."
(provide 'test-lint-org)
;;; test-lint-org.el ends here
+
+;;; ---------------------------------------------------------------------------
+;;; task-missing-last-reviewed (claude-rules/todo-format.md)
+
+(ert-deftest lo-task-without-last-reviewed-is-judgment ()
+ "An open level-2 task with no :LAST_REVIEWED: is flagged."
+ (let* ((out (lo-test--run "* Open Work\n** TODO [#B] A task :feature:\nBody.\n"))
+ (js (lo-test--judgments (plist-get out :issues))))
+ (should (memq 'task-missing-last-reviewed (lo-test--checkers js)))))
+
+(ert-deftest lo-task-with-last-reviewed-is-clean ()
+ "A task carrying the property is not flagged."
+ (let* ((out (lo-test--run (concat "* Open Work\n** TODO [#B] A task :feature:\n"
+ ":PROPERTIES:\n:LAST_REVIEWED: 2026-07-23\n:END:\n"
+ "Body.\n")))
+ (js (lo-test--judgments (plist-get out :issues))))
+ (should-not (memq 'task-missing-last-reviewed (lo-test--checkers js)))))
+
+(ert-deftest lo-task-last-reviewed-accepts-org-timestamp ()
+ "The org-native [YYYY-MM-DD Day] form counts, matching the staleness script."
+ (let* ((out (lo-test--run (concat "* Open Work\n** TODO [#B] A task :feature:\n"
+ ":PROPERTIES:\n:LAST_REVIEWED: [2026-07-23 Thu]\n:END:\n")))
+ (js (lo-test--judgments (plist-get out :issues))))
+ (should-not (memq 'task-missing-last-reviewed (lo-test--checkers js)))))
+
+(ert-deftest lo-done-task-without-last-reviewed-is-clean ()
+ "Completed tasks leave the review pool, so they are never flagged."
+ (let* ((out (lo-test--run (concat "* Open Work\n** DONE [#B] A task :feature:\n"
+ "CLOSED: [2026-07-23 Thu]\n")))
+ (js (lo-test--judgments (plist-get out :issues))))
+ (should-not (memq 'task-missing-last-reviewed (lo-test--checkers js)))))
+
+(ert-deftest lo-subtask-without-last-reviewed-is-clean ()
+ "Only level-2 tasks are in the review pool; deeper headings are not."
+ (let* ((out (lo-test--run (concat "* Open Work\n** TODO [#B] Parent :feature:\n"
+ ":PROPERTIES:\n:LAST_REVIEWED: 2026-07-23\n:END:\n"
+ "*** TODO A sub-task\n")))
+ (js (lo-test--judgments (plist-get out :issues))))
+ (should-not (memq 'task-missing-last-reviewed (lo-test--checkers js)))))
+
+(ert-deftest lo-cookieless-task-without-last-reviewed-is-clean ()
+ "The staleness script selects on a priority cookie, so match that scope."
+ (let* ((out (lo-test--run "* Open Work\n** TODO Manual testing and validation\n"))
+ (js (lo-test--judgments (plist-get out :issues))))
+ (should-not (memq 'task-missing-last-reviewed (lo-test--checkers js)))))
+
+(ert-deftest lo-verify-task-without-last-reviewed-is-judgment ()
+ "VERIFY is in the review pool too."
+ (let* ((out (lo-test--run "* Open Work\n** VERIFY [#B] Waiting on Craig\n"))
+ (js (lo-test--judgments (plist-get out :issues))))
+ (should (memq 'task-missing-last-reviewed (lo-test--checkers js)))))
diff --git a/languages/elisp/githooks/pre-commit b/languages/elisp/githooks/pre-commit
index 27f280c..2c0cf0b 100755
--- a/languages/elisp/githooks/pre-commit
+++ b/languages/elisp/githooks/pre-commit
@@ -5,7 +5,7 @@
set -u
REPO_ROOT="$(git rev-parse --show-toplevel)"
-cd "$REPO_ROOT"
+cd "$REPO_ROOT" || exit 1
# --- 1. Secret scan ---
# Patterns for common credentials. Scans only added lines in the staged diff.
diff --git a/languages/go/githooks/pre-commit b/languages/go/githooks/pre-commit
index a6297c8..26b0db0 100755
--- a/languages/go/githooks/pre-commit
+++ b/languages/go/githooks/pre-commit
@@ -5,7 +5,7 @@
set -u
REPO_ROOT="$(git rev-parse --show-toplevel)"
-cd "$REPO_ROOT"
+cd "$REPO_ROOT" || exit 1
# --- 1. Secret scan ---
# Patterns for common credentials. Scans only added lines in the staged diff.
diff --git a/languages/python/CLAUDE.md b/languages/python/CLAUDE.md
new file mode 100644
index 0000000..a2d0a82
--- /dev/null
+++ b/languages/python/CLAUDE.md
@@ -0,0 +1,80 @@
+# CLAUDE.md
+
+## Project
+
+Python project. Customize this section with your own description, layout,
+and conventions.
+
+**Typical layout:**
+- `src/<package>/` or a top-level package directory — importable code
+- `tests/` — pytest tests mirroring the package layout
+- `pyproject.toml` — dependencies, tool config (ruff, pytest, coverage)
+
+## Build & Test Commands
+
+If the project has a Makefile, document targets here. Common pattern:
+
+```bash
+make test # run the pytest suite
+make test FILE=tests/x.py # one file
+make coverage # suite + coverage report
+make lint # ruff across the tree
+make typecheck # mypy (if the project adopts it)
+make fmt # ruff format / black
+```
+
+Direct equivalents: `python3 -m pytest`, `pytest tests/test_x.py::test_name`,
+`ruff check .`, `ruff format --diff .`, `mypy src/`.
+
+## Language Rules
+
+See rule files in `.claude/rules/`:
+- `python-testing.md` — pytest conventions and fixture discipline
+- `verification.md` — verify-before-claim-done discipline
+
+## Git Workflow
+
+Commit conventions: see `.claude/rules/commits.md` (author identity,
+no AI attribution, message format).
+
+Pre-commit hook in `githooks/` scans for secrets, syntax-checks staged Python,
+and runs `ruff` when it's installed. Activate on a fresh clone with
+`git config core.hooksPath githooks`.
+
+## Problem-Solving Approach
+
+Investigate before fixing. When diagnosing a bug:
+1. Read the relevant module and trace what actually happens
+2. Identify the root cause, not a surface symptom
+3. Write a failing test that captures the correct behavior
+4. Fix, then re-run tests
+
+## Testing Discipline
+
+TDD is the default: write a failing test before any implementation. If you can't
+write the test, you don't yet understand the change. Details in
+`.claude/rules/python-testing.md`.
+
+## Editing Discipline
+
+A PostToolUse hook syntax-checks every Python file after Edit/Write/MultiEdit
+and blocks on a parse error, then runs `ruff` when it's installed. The hook
+covers `.py`, `.pyi`, and extensionless files with a python shebang.
+
+Type checking is not enforced by the hook — it needs the whole package and its
+dependencies resolved, which is a build-scale operation rather than a
+per-keystroke one. Run it via `make typecheck`.
+
+Formatting is likewise not enforced: a project picks its own line length and
+quote style, so blocking on an unconfigured default would impose a contested
+choice. Adopt one per project in `pyproject.toml`.
+
+## What Not to Do
+
+- Don't add features beyond what was asked
+- Don't refactor surrounding code when fixing a bug
+- Don't use a bare `except:` or swallow an exception without handling it
+- Don't use a mutable default argument (`def f(xs=[])`)
+- Don't add comments to code you didn't change
+- Don't commit `.env` files, credentials, or API keys — the pre-commit hook
+ catches common patterns but isn't a substitute for care
diff --git a/languages/python/claude/hooks/validate-python.sh b/languages/python/claude/hooks/validate-python.sh
new file mode 100755
index 0000000..e43ad77
--- /dev/null
+++ b/languages/python/claude/hooks/validate-python.sh
@@ -0,0 +1,95 @@
+#!/usr/bin/env bash
+# Validate Python files after Edit/Write/MultiEdit.
+# PostToolUse hook: receives tool-call JSON on stdin.
+#
+# On success: exit 0 silent.
+# On failure: emit JSON with hookSpecificOutput.additionalContext so Claude
+# sees a structured error in its context, THEN exit 2 to block the tool
+# pipeline. stderr still echoes the error for terminal visibility.
+#
+# Phase 1: syntax — python3 compiles the file. Always available wherever this
+# hook can meaningfully run, so it's the floor rather than an optional
+# gate: a file that doesn't parse is never worth passing on.
+# Phase 2: ruff — lint, when installed. Catches undefined names, unused
+# imports, and the rest of the pyflakes set. Absent ruff doesn't block
+# the edit, matching how the bash bundle treats shellcheck.
+#
+# Formatters (black, ruff format) are deliberately NOT enforced here. A project
+# picks its own line length and quote style, so blocking on an unconfigured
+# default would impose a contested choice. python.md recommends a formatter;
+# this hook enforces correctness.
+#
+# Type checking (mypy, pyright) is also out: it needs the whole package and its
+# dependencies resolved, which is a build-scale operation, not a per-keystroke
+# one. Run it via `make lint` / `make typecheck`.
+#
+# Scope: .py and .pyi files, plus extensionless files whose first line is a
+# python shebang (the CLI tools that fill a script-heavy repo carry no
+# extension).
+
+set -u
+
+# Emit a JSON failure payload and exit 2. Arguments:
+# $1 — short failure type (e.g. "PYTHON SYNTAX ERROR")
+# $2 — file path
+# $3 — tool output (error body)
+fail_json() {
+ local ctx
+ ctx="$(printf '%s: %s\n\n%s\n\nFix before proceeding.' "$1" "$2" "$3" \
+ | jq -Rs .)"
+ cat <<EOF
+{"hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": $ctx}}
+EOF
+ printf '%s: %s\n%s\n' "$1" "$2" "$3" >&2
+ exit 2
+}
+
+f="$(jq -r '.tool_input.file_path // .tool_response.filePath // empty')"
+[ -z "$f" ] && exit 0
+[ -f "$f" ] || exit 0
+
+# Is this a Python file? By extension, or by shebang when it has no extension.
+# Match on the basename, not the full path — a temp/parent dir can carry a dot
+# (e.g. my.project/) and misfire the "*.*" extension test.
+is_python=0
+base="${f##*/}"
+case "$base" in
+ *.py | *.pyi) is_python=1 ;;
+ *.*) is_python=0 ;; # some other extension — not ours
+ *)
+ # No extension: sniff the shebang.
+ if head -1 "$f" 2>/dev/null | grep -qE '^#!.*\bpython[0-9.]*\b'; then
+ is_python=1
+ fi
+ ;;
+esac
+[ "$is_python" -eq 1 ] || exit 0
+
+# No python3 on this machine — nothing to validate, don't block the edit.
+command -v python3 >/dev/null 2>&1 || exit 0
+
+# --- Phase 1: syntax ---
+# compile() rather than py_compile so no __pycache__ lands beside the source;
+# the hook is a checker and must not leave build artifacts in the tree.
+if ! out="$(python3 -c '
+import sys
+p = sys.argv[1]
+with open(p, "rb") as fh:
+ src = fh.read()
+try:
+ compile(src, p, "exec")
+except SyntaxError as e:
+ print(f"{e.msg} ({p}, line {e.lineno})", file=sys.stderr)
+ sys.exit(1)
+' "$f" 2>&1)"; then
+ fail_json "PYTHON SYNTAX ERROR" "$f" "$out"
+fi
+
+# --- Phase 2: lint (optional) ---
+command -v ruff >/dev/null 2>&1 || exit 0
+
+if ! out="$(ruff check "$f" 2>&1)"; then
+ fail_json "RUFF FAILED" "$f" "$out"
+fi
+
+exit 0
diff --git a/languages/python/claude/settings.json b/languages/python/claude/settings.json
new file mode 100644
index 0000000..9c6b2a9
--- /dev/null
+++ b/languages/python/claude/settings.json
@@ -0,0 +1,79 @@
+{
+ "attribution": {
+ "commit": "",
+ "pr": ""
+ },
+ "permissions": {
+ "allow": [
+ "Bash(make)",
+ "Bash(make help)",
+ "Bash(make targets)",
+ "Bash(make test)",
+ "Bash(make test *)",
+ "Bash(make lint)",
+ "Bash(make fmt)",
+ "Bash(make coverage)",
+ "Bash(make coverage-summary)",
+ "Bash(make typecheck)",
+ "Bash(pytest)",
+ "Bash(pytest *)",
+ "Bash(python3 -m pytest *)",
+ "Bash(ruff check *)",
+ "Bash(ruff format --diff *)",
+ "Bash(black --check *)",
+ "Bash(black --diff *)",
+ "Bash(mypy *)",
+ "Bash(python3 -m py_compile *)",
+ "Bash(python3 --version)",
+ "Bash(pip list)",
+ "Bash(pip show *)",
+ "Bash(git status)",
+ "Bash(git status *)",
+ "Bash(git diff)",
+ "Bash(git diff *)",
+ "Bash(git log)",
+ "Bash(git log *)",
+ "Bash(git show)",
+ "Bash(git show *)",
+ "Bash(git blame *)",
+ "Bash(git branch)",
+ "Bash(git branch -v)",
+ "Bash(git branch -a)",
+ "Bash(git branch --list *)",
+ "Bash(git remote)",
+ "Bash(git remote -v)",
+ "Bash(git remote show *)",
+ "Bash(git ls-files *)",
+ "Bash(git rev-parse *)",
+ "Bash(git cat-file *)",
+ "Bash(git stash list)",
+ "Bash(git stash show *)",
+ "Bash(jq *)",
+ "Bash(date)",
+ "Bash(date *)",
+ "Bash(which *)",
+ "Bash(file *)",
+ "Bash(ls)",
+ "Bash(ls *)",
+ "Bash(wc *)",
+ "Bash(du *)",
+ "Bash(readlink *)",
+ "Bash(realpath *)",
+ "Bash(basename *)",
+ "Bash(dirname *)"
+ ]
+ },
+ "hooks": {
+ "PostToolUse": [
+ {
+ "matcher": "Edit|Write|MultiEdit",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/validate-python.sh"
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/languages/python/githooks/pre-commit b/languages/python/githooks/pre-commit
new file mode 100755
index 0000000..0a578b4
--- /dev/null
+++ b/languages/python/githooks/pre-commit
@@ -0,0 +1,79 @@
+#!/usr/bin/env bash
+# Pre-commit hook: secret scan + syntax/lint check on staged Python files.
+# Use `git commit --no-verify` to bypass for confirmed false positives.
+
+set -u
+
+REPO_ROOT="$(git rev-parse --show-toplevel)"
+cd "$REPO_ROOT" || exit 1
+
+# --- 1. Secret scan ---
+# Patterns for common credentials. Scans only added lines in the staged diff.
+#
+# Two passes because case-sensitivity differs. AWS keys are uppercase, sk- keys
+# lowercase, PEM headers fixed, so those match case-SENSITIVELY: under -i,
+# AKIA[0-9A-Z]{16} matches any mixed-case 20-char run, which random base64 in an
+# embedded image blob hits ~6% of the time per 100KB and blocks real commits.
+# Only the keyword=value patterns need -i.
+SECRET_PATTERNS_CS='(AKIA[0-9A-Z]{16}|sk-[a-zA-Z0-9_-]{20,}|-----BEGIN (RSA|DSA|EC|OPENSSH|PGP)( PRIVATE)?( KEY| KEY BLOCK)?-----)'
+SECRET_PATTERNS_CI='(api[_-]?key|api[_-]?secret|auth[_-]?token|secret[_-]?key|bearer[_-]?token|access[_-]?token|password)[[:space:]]*[:=][[:space:]]*["'"'"'][^"'"'"']{16,}["'"'"']'
+
+added_lines="$(git diff --cached -U0 --diff-filter=AM \
+ | grep '^+' | grep -v '^+++' || true)"
+
+cs_hits="$(printf '%s\n' "$added_lines" | grep -nE "$SECRET_PATTERNS_CS" || true)"
+ci_hits="$(printf '%s\n' "$added_lines" | grep -niE "$SECRET_PATTERNS_CI" || true)"
+# awk dedupes lines both passes matched, keeping first-seen order.
+secret_hits="$(printf '%s\n%s' "$cs_hits" "$ci_hits" \
+ | grep -v '^[[:space:]]*$' | awk '!seen[$0]++' || true)"
+
+if [ -n "$secret_hits" ]; then
+ echo "pre-commit: potential secret in staged changes:" >&2
+ echo "$secret_hits" >&2
+ echo "" >&2
+ echo "Review the lines above. If this is a false positive (test fixture, documentation)," >&2
+ echo "bypass with: git commit --no-verify" >&2
+ exit 1
+fi
+
+# --- 2. Syntax check on staged Python files ---
+staged_py="$(git diff --cached --name-only --diff-filter=AM \
+ | grep -E '\.pyi?$' || true)"
+
+if [ -n "$staged_py" ] && command -v python3 >/dev/null 2>&1; then
+ failed=""
+ while IFS= read -r f; do
+ [ -z "$f" ] && continue
+ [ -f "$f" ] || continue
+ # compile() rather than py_compile so no __pycache__ lands in the tree.
+ if ! python3 -c 'import sys; compile(open(sys.argv[1], "rb").read(), sys.argv[1], "exec")' "$f" >/dev/null 2>&1; then
+ failed="${failed}${f}"$'\n'
+ fi
+ done <<< "$staged_py"
+
+ if [ -n "$failed" ]; then
+ printf 'pre-commit: Python syntax errors in staged files:\n\n%s\n' "$failed" >&2
+ echo "Run: python3 -m py_compile <file> to see the error, then re-stage." >&2
+ exit 1
+ fi
+fi
+
+# --- 3. ruff on staged Python files (when installed) ---
+if [ -n "$staged_py" ] && command -v ruff >/dev/null 2>&1; then
+ failed=""
+ while IFS= read -r f; do
+ [ -z "$f" ] && continue
+ [ -f "$f" ] || continue
+ if ! ruff check "$f" >/dev/null 2>&1; then
+ failed="${failed}${f}"$'\n'
+ fi
+ done <<< "$staged_py"
+
+ if [ -n "$failed" ]; then
+ printf 'pre-commit: ruff failed on staged files:\n\n%s\n' "$failed" >&2
+ echo "Run: ruff check <file> and fix the findings, then re-stage." >&2
+ exit 1
+ fi
+fi
+
+exit 0
diff --git a/languages/python/tests/pre-commit.bats b/languages/python/tests/pre-commit.bats
new file mode 100644
index 0000000..1ac82ee
--- /dev/null
+++ b/languages/python/tests/pre-commit.bats
@@ -0,0 +1,138 @@
+#!/usr/bin/env bats
+#
+# Tests for languages/python/githooks/pre-commit — the secret scan plus
+# syntax/lint gate that runs on staged Python files.
+#
+# The secret scan is the security-critical half and is language-independent, so
+# it gets the same coverage here as in the bash bundle: a real key blocks, a
+# clean diff passes, and the case-sensitivity split that keeps base64 blobs from
+# false-positiving is exercised directly.
+#
+# Each test builds a throwaway git repo, stages content, and runs the hook from
+# inside it — the hook reads `git diff --cached`, so a real index is required.
+
+HOOK="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)/githooks/pre-commit"
+
+setup() {
+ TEST_DIR="$(mktemp -d -t pre-commit-py-bats.XXXXXX)"
+ cd "$TEST_DIR" || exit 1
+ git init -q .
+ git config user.email t@example.com
+ git config user.name Test
+ # A base commit so `git diff --cached` has a parent to diff against.
+ echo "seed" > seed.txt
+ git add seed.txt
+ git commit -qm seed
+}
+
+teardown() {
+ cd / || true
+ rm -rf "$TEST_DIR"
+}
+
+# ---- Normal ----------------------------------------------------------
+
+@test "pre-commit(py): a clean staged Python file passes (exit 0)" {
+ printf 'def f(x):\n return x + 1\n' > ok.py
+ git add ok.py
+ run bash "$HOOK"
+ [ "$status" -eq 0 ]
+}
+
+@test "pre-commit(py): an empty staging area passes (exit 0)" {
+ run bash "$HOOK"
+ [ "$status" -eq 0 ]
+}
+
+# ---- Error: the secret scan ------------------------------------------
+
+@test "pre-commit(py): an AWS key in a staged file blocks (exit 1)" {
+ printf 'KEY = "AKIAIOSFODNN7EXAMPLE"\n' > conf.py
+ git add conf.py
+ run bash "$HOOK"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"potential secret"* ]]
+}
+
+@test "pre-commit(py): an sk- style token blocks (exit 1)" {
+ printf 'TOKEN = "sk-abcdefghijklmnopqrstuvwxyz0123"\n' > conf.py
+ git add conf.py
+ run bash "$HOOK"
+ [ "$status" -eq 1 ]
+}
+
+@test "pre-commit(py): a quoted api_key assignment blocks (exit 1)" {
+ printf 'api_key = "abcdefghijklmnopqrstuvwxyz"\n' > conf.py
+ git add conf.py
+ run bash "$HOOK"
+ [ "$status" -eq 1 ]
+}
+
+@test "pre-commit(py): a private-key header blocks (exit 1)" {
+ printf 'PEM = """-----BEGIN RSA PRIVATE KEY-----"""\n' > conf.py
+ git add conf.py
+ run bash "$HOOK"
+ [ "$status" -eq 1 ]
+}
+
+# ---- Boundary: the case-sensitivity split ----------------------------
+
+@test "pre-commit(py): a mixed-case base64 blob does NOT false-positive" {
+ # The AWS pattern is uppercase-only by design. Under -i it would match any
+ # 20-char mixed-case run, which random base64 hits often enough to block
+ # real commits. This is the regression test for that split.
+ printf 'BLOB = "AKIAbcdefGHIJklmnOPqr0123456789abcdefGHIJ"\n' > data.py
+ git add data.py
+ run bash "$HOOK"
+ [ "$status" -eq 0 ]
+}
+
+@test "pre-commit(py): a short quoted password value does NOT block" {
+ # The keyword patterns require 16+ chars, so a placeholder stays quiet.
+ printf 'password = "short"\n' > conf.py
+ git add conf.py
+ run bash "$HOOK"
+ [ "$status" -eq 0 ]
+}
+
+@test "pre-commit(py): a secret only in a REMOVED line does not block" {
+ printf 'KEY = "AKIAIOSFODNN7EXAMPLE"\n' > conf.py
+ git add conf.py
+ git commit -qm "add key"
+ rm conf.py
+ git add -A
+ run bash "$HOOK"
+ [ "$status" -eq 0 ]
+}
+
+# ---- Error: the syntax gate ------------------------------------------
+
+@test "pre-commit(py): a staged Python syntax error blocks (exit 1)" {
+ printf 'def f(:\n return 1\n' > bad.py
+ git add bad.py
+ run bash "$HOOK"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"syntax"* ]]
+}
+
+@test "pre-commit(py): a .pyi stub with a syntax error blocks (exit 1)" {
+ printf 'def f( -> int: ...\n' > bad.pyi
+ git add bad.pyi
+ run bash "$HOOK"
+ [ "$status" -eq 1 ]
+}
+
+@test "pre-commit(py): a broken NON-Python file does not trip the syntax gate" {
+ printf 'this is (((not python\n' > notes.txt
+ git add notes.txt
+ run bash "$HOOK"
+ [ "$status" -eq 0 ]
+}
+
+@test "pre-commit(py): the syntax gate leaves no __pycache__ in the repo" {
+ printf 'def f():\n return 1\n' > ok.py
+ git add ok.py
+ run bash "$HOOK"
+ [ "$status" -eq 0 ]
+ [ ! -d __pycache__ ]
+}
diff --git a/languages/python/tests/validate-python.bats b/languages/python/tests/validate-python.bats
new file mode 100644
index 0000000..b5e4957
--- /dev/null
+++ b/languages/python/tests/validate-python.bats
@@ -0,0 +1,117 @@
+#!/usr/bin/env bats
+#
+# Tests for languages/python/claude/hooks/validate-python.sh — the PostToolUse
+# hook that syntax-checks edited Python files and blocks on a violation.
+#
+# The hook reads tool-call JSON on stdin and extracts the file path, so each
+# test pipes a JSON payload naming a real file it wrote into a temp dir.
+#
+# The syntax gate is python3's own compiler, which is present wherever the hook
+# can meaningfully run, so those tests never skip. The lint gate (ruff) is
+# optional and its tests skip when it's absent, matching the bash bundle's
+# treatment of shellcheck.
+
+HOOK="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)/claude/hooks/validate-python.sh"
+
+setup() {
+ TEST_DIR="$(mktemp -d -t validate-python-bats.XXXXXX)"
+}
+
+teardown() {
+ rm -rf "$TEST_DIR"
+}
+
+payload() {
+ printf '{"tool_input": {"file_path": "%s"}}' "$1"
+}
+
+# ---- Normal ----------------------------------------------------------
+
+@test "validate-python: a clean .py file passes silently (exit 0)" {
+ printf 'def f(x):\n return x + 1\n' > "$TEST_DIR/clean.py"
+ run bash "$HOOK" <<< "$(payload "$TEST_DIR/clean.py")"
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
+}
+
+@test "validate-python: a .pyi stub is validated too" {
+ printf 'def f(x: int) -> int: ...\n' > "$TEST_DIR/clean.pyi"
+ run bash "$HOOK" <<< "$(payload "$TEST_DIR/clean.pyi")"
+ [ "$status" -eq 0 ]
+}
+
+# ---- Error -----------------------------------------------------------
+
+@test "validate-python: a syntax error blocks (exit 2, names the failure)" {
+ printf 'def f(:\n return 1\n' > "$TEST_DIR/bad.py"
+ run bash "$HOOK" <<< "$(payload "$TEST_DIR/bad.py")"
+ [ "$status" -eq 2 ]
+ [[ "$output" == *"SYNTAX"* ]]
+}
+
+@test "validate-python: the block payload is valid JSON carrying the context" {
+ printf 'def f(:\n' > "$TEST_DIR/bad.py"
+ run bash "$HOOK" <<< "$(payload "$TEST_DIR/bad.py")"
+ [ "$status" -eq 2 ]
+ # The first line of stdout must parse as JSON and carry the hook event name.
+ echo "$output" | head -1 | jq -e '.hookSpecificOutput.hookEventName == "PostToolUse"'
+}
+
+@test "validate-python: a ruff violation blocks when ruff is installed" {
+ command -v ruff >/dev/null 2>&1 || skip "ruff not installed"
+ # F821: reference to an undefined name — syntactically valid, lint-caught.
+ printf 'def f():\n return undefined_name\n' > "$TEST_DIR/lint.py"
+ run bash "$HOOK" <<< "$(payload "$TEST_DIR/lint.py")"
+ [ "$status" -eq 2 ]
+ [[ "$output" == *"RUFF"* ]]
+}
+
+# ---- Boundary --------------------------------------------------------
+
+@test "validate-python: a non-Python file is ignored (exit 0)" {
+ printf 'not python at all (((\n' > "$TEST_DIR/notes.txt"
+ run bash "$HOOK" <<< "$(payload "$TEST_DIR/notes.txt")"
+ [ "$status" -eq 0 ]
+}
+
+@test "validate-python: an extensionless file with a python shebang is validated" {
+ printf '#!/usr/bin/env python3\ndef f(:\n' > "$TEST_DIR/cli-tool"
+ run bash "$HOOK" <<< "$(payload "$TEST_DIR/cli-tool")"
+ [ "$status" -eq 2 ]
+}
+
+@test "validate-python: an extensionless non-python file is ignored (exit 0)" {
+ printf '#!/usr/bin/env bash\necho hi\n' > "$TEST_DIR/shell-tool"
+ run bash "$HOOK" <<< "$(payload "$TEST_DIR/shell-tool")"
+ [ "$status" -eq 0 ]
+}
+
+@test "validate-python: a dotted parent directory does not misfire the extension test" {
+ mkdir -p "$TEST_DIR/my.project"
+ printf '#!/usr/bin/env python3\ndef f(:\n' > "$TEST_DIR/my.project/cli-tool"
+ run bash "$HOOK" <<< "$(payload "$TEST_DIR/my.project/cli-tool")"
+ [ "$status" -eq 2 ]
+}
+
+@test "validate-python: empty file_path is a no-op (exit 0)" {
+ run bash "$HOOK" <<< '{"tool_input": {}}'
+ [ "$status" -eq 0 ]
+}
+
+@test "validate-python: a missing file is a no-op (exit 0)" {
+ run bash "$HOOK" <<< "$(payload "$TEST_DIR/does-not-exist.py")"
+ [ "$status" -eq 0 ]
+}
+
+@test "validate-python: an empty .py file passes (valid, compiles to nothing)" {
+ : > "$TEST_DIR/empty.py"
+ run bash "$HOOK" <<< "$(payload "$TEST_DIR/empty.py")"
+ [ "$status" -eq 0 ]
+}
+
+@test "validate-python: compiling leaves no __pycache__ beside the file" {
+ printf 'def f():\n return 1\n' > "$TEST_DIR/clean.py"
+ run bash "$HOOK" <<< "$(payload "$TEST_DIR/clean.py")"
+ [ "$status" -eq 0 ]
+ [ ! -d "$TEST_DIR/__pycache__" ]
+}
diff --git a/languages/typescript/CLAUDE.md b/languages/typescript/CLAUDE.md
new file mode 100644
index 0000000..1794115
--- /dev/null
+++ b/languages/typescript/CLAUDE.md
@@ -0,0 +1,82 @@
+# CLAUDE.md
+
+## Project
+
+TypeScript/JavaScript project. Customize this section with your own
+description, layout, and conventions.
+
+**Typical layout:**
+- `src/` — source modules
+- `tests/` or `*.test.ts` beside the source — test files
+- `package.json` — scripts and dependencies
+- `tsconfig.json` — compiler options
+
+## Build & Test Commands
+
+If the project has a Makefile, document targets here. Common pattern:
+
+```bash
+make test # run the test suite
+make coverage # suite + coverage report
+make typecheck # tsc --noEmit across the project
+make lint # eslint
+make build # production build
+```
+
+Direct equivalents: `npm test`, `npx tsc --noEmit`, `npx eslint src/`,
+`npx prettier --check .`, `node --test`.
+
+## Language Rules
+
+See rule files in `.claude/rules/`:
+- `typescript-testing.md` — test conventions and mocking discipline
+- `verification.md` — verify-before-claim-done discipline
+
+## Git Workflow
+
+Commit conventions: see `.claude/rules/commits.md` (author identity,
+no AI attribution, message format).
+
+Pre-commit hook in `githooks/` scans for secrets and parse-checks staged TS/JS.
+Activate on a fresh clone with `git config core.hooksPath githooks`.
+
+## Problem-Solving Approach
+
+Investigate before fixing. When diagnosing a bug:
+1. Read the relevant module and trace what actually happens
+2. Identify the root cause, not a surface symptom
+3. Write a failing test that captures the correct behavior
+4. Fix, then re-run tests
+
+## Testing Discipline
+
+TDD is the default: write a failing test before any implementation. If you can't
+write the test, you don't yet understand the change. Details in
+`.claude/rules/typescript-testing.md`.
+
+## Editing Discipline
+
+A PostToolUse hook parse-checks every TS/JS file after Edit/Write/MultiEdit and
+blocks on a syntax error. It covers `.ts`, `.tsx`, `.mts`, `.cts`, `.js`,
+`.jsx`, `.mjs`, and `.cjs`.
+
+Two checkers, because one tool can't do both jobs: `node --check` for
+JavaScript, `tsc` filtered to syntax diagnostics for TypeScript. Do not
+substitute `node --check` for the TypeScript path — it ignores
+`--experimental-strip-types`, so it rejects valid TypeScript and accepts broken
+TypeScript (measured on node v26.4.0).
+
+Full type checking is not enforced by the hook: it needs the whole project graph
+and its dependencies resolved, which is a build-scale operation rather than a
+per-keystroke one. Run it via `make typecheck`. Formatting is likewise not
+enforced; adopt a style per project in the project's own config.
+
+## What Not to Do
+
+- Don't add features beyond what was asked
+- Don't refactor surrounding code when fixing a bug
+- Don't reach for `any` to silence a type error — narrow the type instead
+- Don't use `==` where `===` is meant
+- Don't add comments to code you didn't change
+- Don't commit `.env` files, credentials, or API keys — the pre-commit hook
+ catches common patterns but isn't a substitute for care
diff --git a/languages/typescript/claude/hooks/validate-typescript.sh b/languages/typescript/claude/hooks/validate-typescript.sh
new file mode 100755
index 0000000..b76f1df
--- /dev/null
+++ b/languages/typescript/claude/hooks/validate-typescript.sh
@@ -0,0 +1,94 @@
+#!/usr/bin/env bash
+# Validate TypeScript/JavaScript files after Edit/Write/MultiEdit.
+# PostToolUse hook: receives tool-call JSON on stdin.
+#
+# On success: exit 0 silent.
+# On failure: emit JSON with hookSpecificOutput.additionalContext so Claude
+# sees a structured error in its context, THEN exit 2 to block the tool
+# pipeline. stderr still echoes the error for terminal visibility.
+#
+# Gate: parseability. A file that doesn't parse is never worth passing on.
+# Full type checking is deliberately NOT enforced here — it needs the whole
+# project graph and its dependencies resolved, which is a build-scale
+# operation, not a per-keystroke one. A type error that parses cleanly passes
+# this hook; `make typecheck` / `tsc --noEmit` over the project owns it.
+#
+# Formatting (prettier) is also out: a project picks its own style, so blocking
+# on an unconfigured default would impose a contested choice.
+#
+# Two checkers, because one tool can't do both jobs:
+#
+# .js/.jsx/.mjs/.cjs → `node --check`, a straight parse.
+# .ts/.tsx/.mts/.cts → `tsc`, filtered to syntax-category diagnostics.
+#
+# `node --check` must NOT be used on TypeScript. It ignores
+# --experimental-strip-types, so it is wrong in *both* directions: it rejects
+# valid TS (an `interface` declaration reads as a syntax error) and accepts
+# broken TS (a genuinely unparseable file exits 0). Measured on node v26.4.0,
+# 2026-07-23. tsc is the only correct parser for these extensions.
+#
+# The tsc call is filtered to TS1xxx codes, which is TypeScript's syntactic
+# diagnostic range; TS2xxx and up are semantic (type) errors and are out of
+# scope by the paragraph above. Without the filter this hook would block every
+# unresolved import in a file whose dependencies aren't installed yet.
+
+set -u
+
+# Emit a JSON failure payload and exit 2. Arguments:
+# $1 — short failure type (e.g. "TYPESCRIPT SYNTAX ERROR")
+# $2 — file path
+# $3 — tool output (error body)
+fail_json() {
+ local ctx
+ ctx="$(printf '%s: %s\n\n%s\n\nFix before proceeding.' "$1" "$2" "$3" \
+ | jq -Rs .)"
+ cat <<EOF
+{"hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": $ctx}}
+EOF
+ printf '%s: %s\n%s\n' "$1" "$2" "$3" >&2
+ exit 2
+}
+
+f="$(jq -r '.tool_input.file_path // .tool_response.filePath // empty')"
+[ -z "$f" ] && exit 0
+[ -f "$f" ] || exit 0
+
+# Classify by extension. Match on the basename, not the full path — a parent
+# dir can carry a dot (e.g. my.project/) and confuse a path-wide match.
+kind=""
+base="${f##*/}"
+case "$base" in
+ *.ts | *.tsx | *.mts | *.cts) kind="ts" ;;
+ *.js | *.jsx | *.mjs | *.cjs) kind="js" ;;
+ *) exit 0 ;;
+esac
+
+if [ "$kind" = "js" ]; then
+ command -v node >/dev/null 2>&1 || exit 0
+ if ! out="$(node --check "$f" 2>&1)"; then
+ fail_json "JAVASCRIPT SYNTAX ERROR" "$f" "$out"
+ fi
+ exit 0
+fi
+
+# TypeScript. Prefer a project-local tsc so the project's own version decides,
+# falling back to one on PATH.
+tsc_bin=""
+if [ -x "./node_modules/.bin/tsc" ]; then
+ tsc_bin="./node_modules/.bin/tsc"
+elif command -v tsc >/dev/null 2>&1; then
+ tsc_bin="tsc"
+else
+ exit 0 # no TypeScript compiler available — don't block the edit
+fi
+
+# --moduleDetection force so a file with no import/export still parses as a
+# module rather than tripping global-scope collisions against lib types.
+out="$("$tsc_bin" --noEmit --skipLibCheck --target es2022 --moduleDetection force "$f" 2>&1 || true)"
+syntax_errors="$(printf '%s\n' "$out" | grep -E 'error TS1[0-9]{3}:' || true)"
+
+if [ -n "$syntax_errors" ]; then
+ fail_json "TYPESCRIPT SYNTAX ERROR" "$f" "$syntax_errors"
+fi
+
+exit 0
diff --git a/languages/typescript/claude/settings.json b/languages/typescript/claude/settings.json
new file mode 100644
index 0000000..f4c9211
--- /dev/null
+++ b/languages/typescript/claude/settings.json
@@ -0,0 +1,80 @@
+{
+ "attribution": {
+ "commit": "",
+ "pr": ""
+ },
+ "permissions": {
+ "allow": [
+ "Bash(make)",
+ "Bash(make help)",
+ "Bash(make targets)",
+ "Bash(make test)",
+ "Bash(make test *)",
+ "Bash(make lint)",
+ "Bash(make fmt)",
+ "Bash(make coverage)",
+ "Bash(make coverage-summary)",
+ "Bash(make typecheck)",
+ "Bash(make build)",
+ "Bash(npm test)",
+ "Bash(npm test *)",
+ "Bash(npm run *)",
+ "Bash(npm ci)",
+ "Bash(npm ls *)",
+ "Bash(node --check *)",
+ "Bash(node --test *)",
+ "Bash(node --version)",
+ "Bash(tsc --noEmit *)",
+ "Bash(npx tsc --noEmit *)",
+ "Bash(eslint *)",
+ "Bash(prettier --check *)",
+ "Bash(git status)",
+ "Bash(git status *)",
+ "Bash(git diff)",
+ "Bash(git diff *)",
+ "Bash(git log)",
+ "Bash(git log *)",
+ "Bash(git show)",
+ "Bash(git show *)",
+ "Bash(git blame *)",
+ "Bash(git branch)",
+ "Bash(git branch -v)",
+ "Bash(git branch -a)",
+ "Bash(git branch --list *)",
+ "Bash(git remote)",
+ "Bash(git remote -v)",
+ "Bash(git remote show *)",
+ "Bash(git ls-files *)",
+ "Bash(git rev-parse *)",
+ "Bash(git cat-file *)",
+ "Bash(git stash list)",
+ "Bash(git stash show *)",
+ "Bash(jq *)",
+ "Bash(date)",
+ "Bash(date *)",
+ "Bash(which *)",
+ "Bash(file *)",
+ "Bash(ls)",
+ "Bash(ls *)",
+ "Bash(wc *)",
+ "Bash(du *)",
+ "Bash(readlink *)",
+ "Bash(realpath *)",
+ "Bash(basename *)",
+ "Bash(dirname *)"
+ ]
+ },
+ "hooks": {
+ "PostToolUse": [
+ {
+ "matcher": "Edit|Write|MultiEdit",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/validate-typescript.sh"
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/languages/typescript/githooks/pre-commit b/languages/typescript/githooks/pre-commit
new file mode 100755
index 0000000..1628080
--- /dev/null
+++ b/languages/typescript/githooks/pre-commit
@@ -0,0 +1,92 @@
+#!/usr/bin/env bash
+# Pre-commit hook: secret scan + syntax check on staged TypeScript/JavaScript files.
+# Use `git commit --no-verify` to bypass for confirmed false positives.
+
+set -u
+
+REPO_ROOT="$(git rev-parse --show-toplevel)"
+cd "$REPO_ROOT" || exit 1
+
+# --- 1. Secret scan ---
+# Patterns for common credentials. Scans only added lines in the staged diff.
+#
+# Two passes because case-sensitivity differs. AWS keys are uppercase, sk- keys
+# lowercase, PEM headers fixed, so those match case-SENSITIVELY: under -i,
+# AKIA[0-9A-Z]{16} matches any mixed-case 20-char run, which random base64 in an
+# embedded image blob hits ~6% of the time per 100KB and blocks real commits.
+# Only the keyword=value patterns need -i.
+SECRET_PATTERNS_CS='(AKIA[0-9A-Z]{16}|sk-[a-zA-Z0-9_-]{20,}|-----BEGIN (RSA|DSA|EC|OPENSSH|PGP)( PRIVATE)?( KEY| KEY BLOCK)?-----)'
+SECRET_PATTERNS_CI='(api[_-]?key|api[_-]?secret|auth[_-]?token|secret[_-]?key|bearer[_-]?token|access[_-]?token|password)[[:space:]]*[:=][[:space:]]*["'"'"'][^"'"'"']{16,}["'"'"']'
+
+added_lines="$(git diff --cached -U0 --diff-filter=AM \
+ | grep '^+' | grep -v '^+++' || true)"
+
+cs_hits="$(printf '%s\n' "$added_lines" | grep -nE "$SECRET_PATTERNS_CS" || true)"
+ci_hits="$(printf '%s\n' "$added_lines" | grep -niE "$SECRET_PATTERNS_CI" || true)"
+# awk dedupes lines both passes matched, keeping first-seen order.
+secret_hits="$(printf '%s\n%s' "$cs_hits" "$ci_hits" \
+ | grep -v '^[[:space:]]*$' | awk '!seen[$0]++' || true)"
+
+if [ -n "$secret_hits" ]; then
+ echo "pre-commit: potential secret in staged changes:" >&2
+ echo "$secret_hits" >&2
+ echo "" >&2
+ echo "Review the lines above. If this is a false positive (test fixture, documentation)," >&2
+ echo "bypass with: git commit --no-verify" >&2
+ exit 1
+fi
+
+# --- 2. Syntax check on staged TS/JS files ---
+# Two checkers, because one tool can't do both jobs. `node --check` ignores
+# --experimental-strip-types, so on TypeScript it is wrong in BOTH directions:
+# it rejects valid TS (an `interface` reads as a syntax error) and accepts
+# broken TS. Measured on node v26.4.0, 2026-07-23. tsc is the only correct
+# parser for .ts; node is correct and much faster for .js.
+staged_js="$(git diff --cached --name-only --diff-filter=AM \
+ | grep -E '\.(js|jsx|mjs|cjs)$' || true)"
+staged_ts="$(git diff --cached --name-only --diff-filter=AM \
+ | grep -E '\.(ts|tsx|mts|cts)$' || true)"
+
+failed=""
+
+if [ -n "$staged_js" ] && command -v node >/dev/null 2>&1; then
+ while IFS= read -r f; do
+ [ -z "$f" ] && continue
+ [ -f "$f" ] || continue
+ if ! node --check "$f" >/dev/null 2>&1; then
+ failed="${failed}${f}"$'\n'
+ fi
+ done <<< "$staged_js"
+fi
+
+if [ -n "$staged_ts" ]; then
+ tsc_bin=""
+ if [ -x "./node_modules/.bin/tsc" ]; then
+ tsc_bin="./node_modules/.bin/tsc"
+ elif command -v tsc >/dev/null 2>&1; then
+ tsc_bin="tsc"
+ fi
+
+ if [ -n "$tsc_bin" ]; then
+ while IFS= read -r f; do
+ [ -z "$f" ] && continue
+ [ -f "$f" ] || continue
+ # Filter to TS1xxx, TypeScript's syntactic diagnostic range. TS2xxx and
+ # up are type errors, which need the whole project graph and are the
+ # build's job, not this hook's.
+ out="$("$tsc_bin" --noEmit --skipLibCheck --target es2022 \
+ --moduleDetection force "$f" 2>&1 || true)"
+ if printf '%s\n' "$out" | grep -qE 'error TS1[0-9]{3}:'; then
+ failed="${failed}${f}"$'\n'
+ fi
+ done <<< "$staged_ts"
+ fi
+fi
+
+if [ -n "$failed" ]; then
+ printf 'pre-commit: syntax errors in staged files:\n\n%s\n' "$failed" >&2
+ echo "Fix the parse errors above, then re-stage." >&2
+ exit 1
+fi
+
+exit 0
diff --git a/languages/typescript/tests/pre-commit.bats b/languages/typescript/tests/pre-commit.bats
new file mode 100644
index 0000000..5519baa
--- /dev/null
+++ b/languages/typescript/tests/pre-commit.bats
@@ -0,0 +1,150 @@
+#!/usr/bin/env bats
+#
+# Tests for languages/typescript/githooks/pre-commit — the secret scan plus
+# syntax/lint gate that runs on staged TS/JS files.
+#
+# The secret scan is the security-critical half and is language-independent, so
+# it gets the same coverage here as in the bash bundle: a real key blocks, a
+# clean diff passes, and the case-sensitivity split that keeps base64 blobs from
+# false-positiving is exercised directly.
+#
+# Each test builds a throwaway git repo, stages content, and runs the hook from
+# inside it — the hook reads `git diff --cached`, so a real index is required.
+
+HOOK="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)/githooks/pre-commit"
+
+setup() {
+ TEST_DIR="$(mktemp -d -t pre-commit-ts-bats.XXXXXX)"
+ cd "$TEST_DIR" || exit 1
+ git init -q .
+ git config user.email t@example.com
+ git config user.name Test
+ # A base commit so `git diff --cached` has a parent to diff against.
+ echo "seed" > seed.txt
+ git add seed.txt
+ git commit -qm seed
+}
+
+teardown() {
+ cd / || true
+ rm -rf "$TEST_DIR"
+}
+
+# ---- Normal ----------------------------------------------------------
+
+@test "pre-commit(ts): a clean staged JS file passes (exit 0)" {
+ printf 'export const f = (x) => x + 1;\n' > ok.js
+ git add ok.js
+ run bash "$HOOK"
+ [ "$status" -eq 0 ]
+}
+
+@test "pre-commit(ts): an empty staging area passes (exit 0)" {
+ run bash "$HOOK"
+ [ "$status" -eq 0 ]
+}
+
+# ---- Error: the secret scan ------------------------------------------
+
+@test "pre-commit(ts): an AWS key in a staged file blocks (exit 1)" {
+ printf 'const KEY = "AKIAIOSFODNN7EXAMPLE";\n' > conf.ts
+ git add conf.ts
+ run bash "$HOOK"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"potential secret"* ]]
+}
+
+@test "pre-commit(ts): an sk- style token blocks (exit 1)" {
+ printf 'const TOKEN = "sk-abcdefghijklmnopqrstuvwxyz0123";\n' > conf.ts
+ git add conf.ts
+ run bash "$HOOK"
+ [ "$status" -eq 1 ]
+}
+
+@test "pre-commit(ts): a quoted api_key assignment blocks (exit 1)" {
+ printf 'const api_key = "abcdefghijklmnopqrstuvwxyz";\n' > conf.ts
+ git add conf.ts
+ run bash "$HOOK"
+ [ "$status" -eq 1 ]
+}
+
+@test "pre-commit(ts): a private-key header blocks (exit 1)" {
+ printf 'const PEM = "-----BEGIN RSA PRIVATE KEY-----";\n' > conf.ts
+ git add conf.ts
+ run bash "$HOOK"
+ [ "$status" -eq 1 ]
+}
+
+# ---- Boundary: the case-sensitivity split ----------------------------
+
+@test "pre-commit(ts): a mixed-case base64 blob does NOT false-positive" {
+ # The AWS pattern is uppercase-only by design. Under -i it would match any
+ # 20-char mixed-case run, which random base64 hits often enough to block
+ # real commits. This is the regression test for that split.
+ printf 'const BLOB = "AKIAbcdefGHIJklmnOPqr0123456789abcdefGHIJ";\n' > data.ts
+ git add data.ts
+ run bash "$HOOK"
+ [ "$status" -eq 0 ]
+}
+
+@test "pre-commit(ts): a short quoted password value does NOT block" {
+ # The keyword patterns require 16+ chars, so a placeholder stays quiet.
+ printf 'const password = "short";\n' > conf.ts
+ git add conf.ts
+ run bash "$HOOK"
+ [ "$status" -eq 0 ]
+}
+
+@test "pre-commit(ts): a secret only in a REMOVED line does not block" {
+ printf 'const KEY = "AKIAIOSFODNN7EXAMPLE";\n' > conf.ts
+ git add conf.ts
+ git commit -qm "add key"
+ rm conf.ts
+ git add -A
+ run bash "$HOOK"
+ [ "$status" -eq 0 ]
+}
+
+# ---- Error: the syntax gate ------------------------------------------
+
+@test "pre-commit(ts): a staged JS syntax error blocks (exit 1)" {
+ command -v node >/dev/null 2>&1 || skip "node not installed"
+ printf 'const x = ;\n' > bad.js
+ git add bad.js
+ run bash "$HOOK"
+ [ "$status" -eq 1 ]
+ [[ "$output" == *"syntax"* ]]
+}
+
+@test "pre-commit(ts): a staged TS syntax error blocks (exit 1)" {
+ command -v tsc >/dev/null 2>&1 || skip "tsc not installed"
+ printf 'export function f( {\n return 1;\n}\n' > bad.ts
+ git add bad.ts
+ run bash "$HOOK"
+ [ "$status" -eq 1 ]
+}
+
+@test "pre-commit(ts): valid TS-only syntax is NOT read as broken JS" {
+ command -v tsc >/dev/null 2>&1 || skip "tsc not installed"
+ # The regression guard for the node --check trap: `node --check` rejects
+ # valid TypeScript, so using it on .ts would block every real commit.
+ printf 'interface P { a: string }\nexport const p: P = { a: "x" };\n' > types.ts
+ git add types.ts
+ run bash "$HOOK"
+ [ "$status" -eq 0 ]
+}
+
+@test "pre-commit(ts): a TYPE error that parses does not block (out of scope)" {
+ command -v tsc >/dev/null 2>&1 || skip "tsc not installed"
+ printf 'const n: number = "nope";\nexport { n };\n' > typeerr.ts
+ git add typeerr.ts
+ run bash "$HOOK"
+ [ "$status" -eq 0 ]
+}
+
+@test "pre-commit(ts): a broken NON-TS/JS file does not trip the syntax gate" {
+ printf 'this is (((not javascript\n' > notes.txt
+ git add notes.txt
+ run bash "$HOOK"
+ [ "$status" -eq 0 ]
+}
diff --git a/languages/typescript/tests/validate-typescript.bats b/languages/typescript/tests/validate-typescript.bats
new file mode 100644
index 0000000..c5da5d4
--- /dev/null
+++ b/languages/typescript/tests/validate-typescript.bats
@@ -0,0 +1,125 @@
+#!/usr/bin/env bats
+#
+# Tests for languages/typescript/claude/hooks/validate-typescript.sh — the
+# PostToolUse hook that syntax-checks edited TS/JS files and blocks on a
+# violation.
+#
+# The hook reads tool-call JSON on stdin and extracts the file path, so each
+# test pipes a JSON payload naming a real file it wrote into a temp dir.
+#
+# The syntax gate needs node, so those tests skip when node is absent. Full
+# type checking is deliberately out of scope for the hook (it needs the whole
+# project graph), so a type error that is syntactically valid must pass.
+
+HOOK="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)/claude/hooks/validate-typescript.sh"
+
+setup() {
+ TEST_DIR="$(mktemp -d -t validate-ts-bats.XXXXXX)"
+}
+
+teardown() {
+ rm -rf "$TEST_DIR"
+}
+
+payload() {
+ printf '{"tool_input": {"file_path": "%s"}}' "$1"
+}
+
+# ---- Normal ----------------------------------------------------------
+
+@test "validate-typescript: a clean .ts file passes silently (exit 0)" {
+ command -v node >/dev/null 2>&1 || skip "node not installed"
+ printf 'export function f(x: number): number {\n return x + 1;\n}\n' > "$TEST_DIR/clean.ts"
+ run bash "$HOOK" <<< "$(payload "$TEST_DIR/clean.ts")"
+ [ "$status" -eq 0 ]
+ [ -z "$output" ]
+}
+
+@test "validate-typescript: a clean .js file passes silently (exit 0)" {
+ command -v node >/dev/null 2>&1 || skip "node not installed"
+ printf 'export function f(x) {\n return x + 1;\n}\n' > "$TEST_DIR/clean.js"
+ run bash "$HOOK" <<< "$(payload "$TEST_DIR/clean.js")"
+ [ "$status" -eq 0 ]
+}
+
+@test "validate-typescript: a .tsx file is validated too" {
+ command -v node >/dev/null 2>&1 || skip "node not installed"
+ printf 'export const A = 1;\n' > "$TEST_DIR/clean.tsx"
+ run bash "$HOOK" <<< "$(payload "$TEST_DIR/clean.tsx")"
+ [ "$status" -eq 0 ]
+}
+
+# ---- Error -----------------------------------------------------------
+
+@test "validate-typescript: a syntax error blocks (exit 2, names the failure)" {
+ command -v node >/dev/null 2>&1 || skip "node not installed"
+ printf 'export function f( {\n return 1;\n}\n' > "$TEST_DIR/bad.ts"
+ run bash "$HOOK" <<< "$(payload "$TEST_DIR/bad.ts")"
+ [ "$status" -eq 2 ]
+ [[ "$output" == *"SYNTAX"* ]]
+}
+
+@test "validate-typescript: the block payload is valid JSON carrying the context" {
+ command -v node >/dev/null 2>&1 || skip "node not installed"
+ printf 'const x = ;\n' > "$TEST_DIR/bad.js"
+ run bash "$HOOK" <<< "$(payload "$TEST_DIR/bad.js")"
+ [ "$status" -eq 2 ]
+ echo "$output" | head -1 | jq -e '.hookSpecificOutput.hookEventName == "PostToolUse"'
+}
+
+# ---- Boundary --------------------------------------------------------
+
+@test "validate-typescript: a type error that parses is NOT blocked (out of scope)" {
+ command -v node >/dev/null 2>&1 || skip "node not installed"
+ # Assigning a string to a number is a type error, not a syntax error. The
+ # hook checks parseability only; tsc over the project graph owns this.
+ printf 'const n: number = "not a number";\nexport { n };\n' > "$TEST_DIR/typeerr.ts"
+ run bash "$HOOK" <<< "$(payload "$TEST_DIR/typeerr.ts")"
+ [ "$status" -eq 0 ]
+}
+
+@test "validate-typescript: TS-only syntax in a .ts file parses (not read as JS)" {
+ command -v node >/dev/null 2>&1 || skip "node not installed"
+ # Interfaces and type annotations are invalid JS. Stripping types must happen
+ # before the parse, or every real .ts file would be reported as broken.
+ printf 'interface P { a: string }\nexport const p: P = { a: "x" };\n' > "$TEST_DIR/types.ts"
+ run bash "$HOOK" <<< "$(payload "$TEST_DIR/types.ts")"
+ [ "$status" -eq 0 ]
+}
+
+@test "validate-typescript: a non-TS/JS file is ignored (exit 0)" {
+ printf 'not javascript at all (((\n' > "$TEST_DIR/notes.txt"
+ run bash "$HOOK" <<< "$(payload "$TEST_DIR/notes.txt")"
+ [ "$status" -eq 0 ]
+}
+
+@test "validate-typescript: a .json file is ignored (exit 0)" {
+ printf '{"a": 1}\n' > "$TEST_DIR/data.json"
+ run bash "$HOOK" <<< "$(payload "$TEST_DIR/data.json")"
+ [ "$status" -eq 0 ]
+}
+
+@test "validate-typescript: empty file_path is a no-op (exit 0)" {
+ run bash "$HOOK" <<< '{"tool_input": {}}'
+ [ "$status" -eq 0 ]
+}
+
+@test "validate-typescript: a missing file is a no-op (exit 0)" {
+ run bash "$HOOK" <<< "$(payload "$TEST_DIR/does-not-exist.ts")"
+ [ "$status" -eq 0 ]
+}
+
+@test "validate-typescript: an empty .ts file passes" {
+ command -v node >/dev/null 2>&1 || skip "node not installed"
+ : > "$TEST_DIR/empty.ts"
+ run bash "$HOOK" <<< "$(payload "$TEST_DIR/empty.ts")"
+ [ "$status" -eq 0 ]
+}
+
+@test "validate-typescript: a file in a dotted parent dir is still matched" {
+ command -v node >/dev/null 2>&1 || skip "node not installed"
+ mkdir -p "$TEST_DIR/my.project"
+ printf 'const x = ;\n' > "$TEST_DIR/my.project/bad.ts"
+ run bash "$HOOK" <<< "$(payload "$TEST_DIR/my.project/bad.ts")"
+ [ "$status" -eq 2 ]
+}
diff --git a/scripts/install-lang.sh b/scripts/install-lang.sh
index 6e8d806..653747d 100755
--- a/scripts/install-lang.sh
+++ b/scripts/install-lang.sh
@@ -195,5 +195,30 @@ if [ -f "$SRC/gitignore-add.txt" ]; then
fi
fi
+# --- Bundle completeness check ---
+# Every component copy above is guarded by a plain existence test, so a bundle
+# missing one installs silently and reports success. That is how the python and
+# typescript bundles shipped for nearly two months with no pre-commit hook, and
+# therefore no credential scan, on every project that installed them: nothing
+# ever said the component was absent.
+#
+# This can't be fixed by never missing a component — someone adding the seventh
+# bundle will miss one too. It's fixed by the install saying so. Warn, never
+# block: a partial bundle is still worth installing, and turning this into a
+# failure would just teach people to skip the installer.
+missing=""
+[ -f "$SRC/githooks/pre-commit" ] || missing="${missing} - githooks/pre-commit (secret scan on commit)"$'\n'
+[ -f "$SRC/claude/settings.json" ] || missing="${missing} - claude/settings.json (permissions + PostToolUse hook wiring)"$'\n'
+ls "$SRC"/claude/hooks/*.sh >/dev/null 2>&1 || missing="${missing} - claude/hooks/*.sh (validate-on-edit hook)"$'\n'
+[ -f "$SRC/CLAUDE.md" ] || missing="${missing} - CLAUDE.md (seed project instructions)"$'\n'
+
+if [ -n "$missing" ]; then
+ echo ""
+ echo "WARNING: the '$LANG' bundle is incomplete. Not installed, because the bundle doesn't ship them:" >&2
+ printf '%s' "$missing" >&2
+ echo "The install above succeeded; these components are simply absent upstream." >&2
+ echo "Add them under languages/$LANG/ in rulesets, then re-run this install." >&2
+fi
+
echo ""
echo "Install complete."
diff --git a/scripts/tests/install-lang-collision.bats b/scripts/tests/install-lang-collision.bats
index 36abb5b..e03e136 100644
--- a/scripts/tests/install-lang-collision.bats
+++ b/scripts/tests/install-lang-collision.bats
@@ -5,10 +5,14 @@
# (appended, deduped) and CLAUDE.md is seed-only, so both compose across
# bundles. Three do not:
#
-# claude/settings.json elisp, bash, go — cp -rT, silently overwritten
-# githooks/pre-commit elisp, bash, go — cp -rT, silently overwritten
+# claude/settings.json all 5 bundles — cp -rT, silently overwritten
+# githooks/pre-commit all 5 bundles — cp -rT, silently overwritten
# coverage-makefile.txt 4 bundles — [skip]ped, fragment dropped
#
+# The first two read "elisp, bash, go" until 2026-07-23, when python and
+# typescript gained the components they had been missing. The consequence is
+# that no two shipping bundles compose any more; see the polyglot task.
+#
# Installing a second bundle used to replace the first's settings.json and
# pre-commit while printing [ok], so a project could lose its paren check or
# secret scan and read the output as success. The guard refuses instead, naming
@@ -68,8 +72,11 @@ teardown() {
}
@test "install-lang: bundles colliding only on coverage-makefile.txt are refused too" {
- # python and typescript ship no settings.json or githooks, but both ship a
- # coverage fragment. The second one's used to be silently dropped.
+ # Named for the pre-2026-07-23 reason: python and typescript shipped no
+ # settings.json or githooks, so the coverage fragment was their only overlap
+ # and the second one's used to be silently dropped. They now overlap on all
+ # three, so this exercises the multi-file refusal path — the coverage
+ # fragment must still be named among them.
bash "$INSTALL" python "$PROJ"
run bash "$INSTALL" typescript "$PROJ"
[ "$status" -ne 0 ] || { echo "typescript installed over python's coverage fragment"; return 1; }
@@ -77,17 +84,41 @@ teardown() {
}
@test "install-lang: two bundles that share no overwritten file install together" {
- # bash ships settings.json + githooks and no coverage fragment; python ships
- # only the coverage fragment. Nothing overlaps, so the guard must stay out of
- # the way. This is the path where a false refusal would be easiest to
- # introduce: the bundle IS detected, and only the empty file-list stops it.
+ # The guard must stay out of the way when nothing overlaps. This is the path
+ # where a false refusal would be easiest to introduce: the bundle IS
+ # detected, and only the empty file-list stops it.
+ #
+ # This used to be tested with bash + python, which composed because python
+ # shipped no settings.json and no githooks. That was the incomplete-bundle
+ # bug (fixed 2026-07-23), not a design property — so no pair of *shipping*
+ # bundles is non-colliding any more, and the case needs a synthetic bundle.
+ # See the polyglot task in todo.org: whether every pair now colliding is
+ # acceptable is an open question, but the guard's own no-false-refusal
+ # behavior is not, and stays pinned here.
+ fake="${BATS_TEST_DIRNAME}/../../languages/zz-test-rulesonly"
+ mkdir -p "$fake/claude/rules"
+ printf '# rule\n' > "$fake/claude/rules/zz-testing.md"
+
bash "$INSTALL" bash "$PROJ"
- run bash "$INSTALL" python "$PROJ"
+ run bash "$INSTALL" zz-test-rulesonly "$PROJ"
+ rm -rf "$fake"
+
[ "$status" -eq 0 ] || { echo "guard falsely refused a non-colliding pair: $output"; return 1; }
- [ -f "$PROJ/coverage-makefile.txt" ] || { echo "python's coverage fragment did not land"; return 1; }
# bash's config survives untouched.
grep -q 'validate-bash.sh' "$PROJ/.claude/settings.json"
- [ -f "$PROJ/.claude/rules/bash.md" ] && [ -f "$PROJ/.claude/rules/python-testing.md" ]
+ [ -f "$PROJ/.claude/rules/bash.md" ] && [ -f "$PROJ/.claude/rules/zz-testing.md" ]
+}
+
+@test "install-lang: completing python made it collide with bash (documents the tradeoff)" {
+ # Pins the consequence of the 2026-07-23 bundle completion so it can't drift
+ # back unnoticed: python now ships settings.json + githooks/pre-commit, so
+ # bash + python is a genuine overwrite conflict and the guard refuses it.
+ # Whether that's the right trade is Craig's open call; that it IS the current
+ # behavior is what this test records.
+ bash "$INSTALL" bash "$PROJ"
+ run bash "$INSTALL" python "$PROJ"
+ [ "$status" -ne 0 ]
+ [[ "$output" == *"collision"* ]]
}
# ---- The escape hatch ----
diff --git a/scripts/tests/install-lang-completeness.bats b/scripts/tests/install-lang-completeness.bats
new file mode 100644
index 0000000..42832dc
--- /dev/null
+++ b/scripts/tests/install-lang-completeness.bats
@@ -0,0 +1,93 @@
+#!/usr/bin/env bats
+#
+# Tests for install-lang.sh's bundle-completeness warning.
+#
+# Background: the python and typescript bundles shipped for nearly two months
+# with no githooks/pre-commit, so any project installing them got no
+# credential scan on commit. install-lang guarded each component copy with a
+# plain `[ -d ... ]`, so a missing component was indistinguishable from a
+# complete install — it printed nothing and exited 0.
+#
+# The fix is not "never miss a component" (a person will), it's "say so when
+# you do". These tests pin that: a complete bundle installs quietly, an
+# incomplete one names exactly what's absent, and neither case fails the
+# install — a warning must not become a new way to block work.
+
+REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)"
+INSTALL="$REPO_ROOT/scripts/install-lang.sh"
+
+setup() {
+ TEST_DIR="$(mktemp -d -t install-lang-bats.XXXXXX)"
+ PROJECT="$TEST_DIR/proj"
+ mkdir -p "$PROJECT"
+ git init -q "$PROJECT"
+}
+
+teardown() {
+ rm -rf "$TEST_DIR"
+}
+
+# ---- Normal: a complete bundle is quiet ------------------------------
+
+@test "install-lang: a complete bundle warns about nothing" {
+ run env LANG_ARG=bash bash "$INSTALL" bash "$PROJECT"
+ [ "$status" -eq 0 ]
+ [[ "$output" != *"incomplete"* ]]
+}
+
+@test "install-lang: python is now a complete bundle" {
+ run bash "$INSTALL" python "$PROJECT"
+ [ "$status" -eq 0 ]
+ [[ "$output" != *"incomplete"* ]]
+ [ -f "$PROJECT/githooks/pre-commit" ]
+ [ -f "$PROJECT/.claude/settings.json" ]
+ [ -f "$PROJECT/.claude/hooks/validate-python.sh" ]
+}
+
+@test "install-lang: typescript is now a complete bundle" {
+ run bash "$INSTALL" typescript "$PROJECT"
+ [ "$status" -eq 0 ]
+ [[ "$output" != *"incomplete"* ]]
+ [ -f "$PROJECT/githooks/pre-commit" ]
+ [ -f "$PROJECT/.claude/settings.json" ]
+ [ -f "$PROJECT/.claude/hooks/validate-typescript.sh" ]
+}
+
+@test "install-lang: the installed pre-commit is executable" {
+ run bash "$INSTALL" python "$PROJECT"
+ [ "$status" -eq 0 ]
+ [ -x "$PROJECT/githooks/pre-commit" ]
+}
+
+# ---- Error: an incomplete bundle announces itself --------------------
+
+@test "install-lang: a bundle missing githooks/ warns and names it" {
+ fake="$REPO_ROOT/languages/zz-test-partial"
+ mkdir -p "$fake/claude/rules"
+ printf '# rule\n' > "$fake/claude/rules/zz.md"
+ run bash "$INSTALL" zz-test-partial "$PROJECT"
+ rm -rf "$fake"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"incomplete"* ]]
+ [[ "$output" == *"githooks/pre-commit"* ]]
+}
+
+@test "install-lang: the warning names every missing component, not just the first" {
+ fake="$REPO_ROOT/languages/zz-test-partial"
+ mkdir -p "$fake/claude/rules"
+ printf '# rule\n' > "$fake/claude/rules/zz.md"
+ run bash "$INSTALL" zz-test-partial "$PROJECT"
+ rm -rf "$fake"
+ [[ "$output" == *"githooks/pre-commit"* ]]
+ [[ "$output" == *"settings.json"* ]]
+}
+
+@test "install-lang: an incomplete bundle still installs (warn, never block)" {
+ fake="$REPO_ROOT/languages/zz-test-partial"
+ mkdir -p "$fake/claude/rules"
+ printf '# rule\n' > "$fake/claude/rules/zz.md"
+ run bash "$INSTALL" zz-test-partial "$PROJECT"
+ rm -rf "$fake"
+ [ "$status" -eq 0 ]
+ [ -f "$PROJECT/.claude/rules/zz.md" ]
+}
diff --git a/scripts/tests/install-lang.bats b/scripts/tests/install-lang.bats
index 8518852..c00b915 100644
--- a/scripts/tests/install-lang.bats
+++ b/scripts/tests/install-lang.bats
@@ -79,19 +79,42 @@ teardown() {
grep -qxF "coverage/" "$PROJECT/.gitignore"
}
-@test "install-lang python: seeds the language-neutral default CLAUDE.md when the bundle ships none" {
- run bash "$INSTALL_LANG" python "$PROJECT"
+@test "install-lang: seeds the language-neutral default CLAUDE.md when the bundle ships none" {
+ # Every shipping bundle now carries its own CLAUDE.md (python and typescript
+ # gained theirs 2026-07-23), so the fallback needs a synthetic bundle to
+ # exercise. Keep testing it: the fallback is what stops a bundle added later,
+ # before its CLAUDE.md is written, from inheriting another language's header.
+ fake="$REAL_REPO/languages/zz-test-noclaude"
+ mkdir -p "$fake/claude/rules"
+ printf '# rule\n' > "$fake/claude/rules/zz.md"
+ run bash "$INSTALL_LANG" zz-test-noclaude "$PROJECT"
+ rm -rf "$fake"
[ "$status" -eq 0 ]
[ -f "$PROJECT/CLAUDE.md" ]
- # The default names no language, so it can't mislabel a python (or bash, or
- # multi-bundle) project the way inheriting elisp's "Elisp project" header did.
- ! grep -qi "Python project" "$PROJECT/CLAUDE.md"
+ # The default names no language, so it can't mislabel a project the way
+ # inheriting elisp's "Elisp project" header did.
! grep -qi "Elisp project" "$PROJECT/CLAUDE.md"
grep -qF "names no language" "$PROJECT/CLAUDE.md"
[[ "$output" == *"language-neutral default"* ]]
}
+@test "install-lang python: seeds the bundle's own CLAUDE.md, not the default" {
+ run bash "$INSTALL_LANG" python "$PROJECT"
+
+ [ "$status" -eq 0 ]
+ grep -qF "Python project." "$PROJECT/CLAUDE.md"
+ [[ "$output" == *"CLAUDE.md installed (python)"* ]]
+}
+
+@test "install-lang typescript: seeds the bundle's own CLAUDE.md, not the default" {
+ run bash "$INSTALL_LANG" typescript "$PROJECT"
+
+ [ "$status" -eq 0 ]
+ grep -qF "TypeScript/JavaScript project." "$PROJECT/CLAUDE.md"
+ [[ "$output" == *"CLAUDE.md installed (typescript)"* ]]
+}
+
@test "install-lang elisp: seeds the bundle's own CLAUDE.md, not the default" {
run bash "$INSTALL_LANG" elisp "$PROJECT"
@@ -147,3 +170,32 @@ teardown() {
grep -qxF ".claude/" "$PROJECT/.gitignore"
grep -qxF "cover.out" "$PROJECT/.gitignore"
}
+
+@test "install-lang python: full bundle lands (rules, hook, settings, githook, CLAUDE.md, coverage)" {
+ run bash "$INSTALL_LANG" python "$PROJECT"
+
+ [ "$status" -eq 0 ]
+ [ -f "$PROJECT/.claude/rules/python-testing.md" ]
+ # PostToolUse validate hook, executable and wired into settings
+ [ -x "$PROJECT/.claude/hooks/validate-python.sh" ]
+ grep -qF "validate-python.sh" "$PROJECT/.claude/settings.json"
+ # Pre-commit githook — the secret scan. Absent until 2026-07-23.
+ [ -x "$PROJECT/githooks/pre-commit" ]
+ grep -qF "potential secret" "$PROJECT/githooks/pre-commit"
+ # Coverage slice
+ [ -f "$PROJECT/.claude/scripts/coverage-summary.py" ]
+ grep -qxF ".claude/" "$PROJECT/.gitignore"
+}
+
+@test "install-lang typescript: full bundle lands (rules, hook, settings, githook, CLAUDE.md, coverage)" {
+ run bash "$INSTALL_LANG" typescript "$PROJECT"
+
+ [ "$status" -eq 0 ]
+ [ -f "$PROJECT/.claude/rules/typescript-testing.md" ]
+ [ -x "$PROJECT/.claude/hooks/validate-typescript.sh" ]
+ grep -qF "validate-typescript.sh" "$PROJECT/.claude/settings.json"
+ [ -x "$PROJECT/githooks/pre-commit" ]
+ grep -qF "potential secret" "$PROJECT/githooks/pre-commit"
+ [ -f "$PROJECT/.claude/scripts/coverage-summary.js" ]
+ grep -qxF ".claude/" "$PROJECT/.gitignore"
+}
diff --git a/todo.org b/todo.org
index 2e613b9..75bac4c 100644
--- a/todo.org
+++ b/todo.org
@@ -39,8 +39,16 @@ Tags are assigned and refreshed by =task-audit=; =task-review= keeps them honest
* Rulesets Open Work
-** TODO [#A] Python and TypeScript bundles ship no secret-scan hook :bug:
-SCHEDULED: <2026-07-23 Thu>
+** DONE [#A] Python and TypeScript bundles ship no secret-scan hook :bug:
+CLOSED: [2026-07-23 Thu]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-23
+:END:
+Fixed 2026-07-23. Both bundles now ship all four components: =githooks/pre-commit= (the secret scan, shared verbatim with the other bundles, plus a language-appropriate syntax gate), =claude/hooks/validate-python.sh= / =validate-typescript.sh=, =claude/settings.json= wiring the PostToolUse hook, and a seed =CLAUDE.md=. 53 new tests (13 + 13 hook, 13 + 14 pre-commit), suite green.
+
+Two findings from the build worth keeping. First, =node --check= must never be used on TypeScript: it ignores =--experimental-strip-types=, so it rejects valid TS (an =interface= reads as a syntax error) and accepts broken TS. Measured on node v26.4.0. The hook uses =tsc= filtered to TS1xxx (syntactic) diagnostics instead, which also keeps type errors out of scope — those need the whole project graph. Second, the install now warns when a bundle lacks a documented component, which is the half that stops this recurring: nobody will remember the seventh bundle either, so the installer says so.
+
+Left undone deliberately: re-running =make install-lang= on =work= and =clock-panel=. Both are other projects, so that's a cross-project action for Craig. See the polyglot task below — =clock-panel= is python + typescript and can no longer install both.
The =python= and =typescript= language bundles carry rules, a coverage script, and a gitignore fragment. They carry no =githooks/pre-commit=, no =claude/hooks/=, no =claude/settings.json=, and no =CLAUDE.md=. The =bash=, =elisp=, and =go= bundles carry all four.
The pre-commit hook is the secret scanner. So a project installing the Python or TypeScript bundle gets no credential scan on commit, no validate-on-edit hook, and no settings — while README's "Bundle structure" section documents all four as what each bundle follows, and notes.org describes the bundles as "rules + hooks + settings".
@@ -53,7 +61,12 @@ Grading: Major severity (a documented security control absent, silently, with th
Fix direction: port =githooks/pre-commit= to both bundles, adapting the language-specific half (the secret scan is common; =gofmt=/=shellcheck= becomes a formatter/linter check per language), add =claude/hooks/validate-*.sh= and =claude/settings.json=, and seed =CLAUDE.md=. Then make the gap loud: =install-lang= should warn when a bundle lacks a component the README documents, so the next partial bundle announces itself instead of installing quietly. Re-run =make install-lang= on =work= and =clock-panel= afterward.
-** TODO [#C] Two language bundles' pre-commit hooks lost the cd guard :bug:quick:solo:
+** DONE [#C] Two language bundles' pre-commit hooks lost the cd guard :bug:quick:solo:
+CLOSED: [2026-07-23 Thu]
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-23
+:END:
+Fixed 2026-07-23. =go= and =elisp= now read =cd "$REPO_ROOT" || exit 1=, matching =bash=; the two new bundles were written with the guard. All five siblings agree, all five shellcheck clean.
=languages/bash/githooks/pre-commit= line 8 reads =cd "$REPO_ROOT" || exit 1=. The =go= and =elisp= copies of the same line read a bare =cd "$REPO_ROOT"=. Three siblings of one file, one hardened and two not — the guard landed in bash and never propagated.
Low impact, stated honestly: git chdirs to the working-tree root before running a hook, so if =cd= fails the cwd is already correct and the checks still run against the right tree. Neither script sets =-e=, so a failure wouldn't abort them either. It takes a =GIT_WORK_TREE= or bare-repo edge case for =rev-parse --show-toplevel= to disagree with the cwd at all.
@@ -76,7 +89,28 @@ Craig approved 2026-07-23. Applied to =claude-templates/.ai/notes.org= and synce
CLOSED: [2026-07-23 Thu]
Craig approved 2026-07-23. Added =*** Clear temp/= to =wrap-it-up.org= Step 3, after the archive pass, and synced to the mirror. Two guards: confirm before deleting anything that reads as in-progress rather than throwaway (that belongs in =working/=, so move it there), and skip entirely where =temp/= isn't gitignored, since that means the project uses the directory for something else. This closes the last open clause of his 2026-07-20 working/temp capture.
+** TODO [#B] No two language bundles compose any more — polyglot is now refused :feature:spec:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-23
+:END:
+Completing the python and typescript bundles (the [#A] above, fixed 2026-07-23) had a consequence worth deciding on deliberately: all five bundles now ship =claude/settings.json= and =githooks/pre-commit=, so *every* pair collides and =install-lang= refuses the second without =FORCE=1=.
+
+This reopens a decision that was closed on a premise that turned out to be a bug. On 2026-07-17 the call was "polyglot: case-by-case, no option-2 machinery — bundles already compose; only coverage-makefile.txt collides, and it's a manual paste." Bundles appeared to compose only because python and typescript were missing the two files everything else collides on. The composition was the defect, not a design property.
+
+Live impact: =clock-panel= is a python + typescript project. It can install one bundle's hooks or the other's, not both. Today it has neither, so nothing regressed underneath it, but the polyglot path it would have wanted is now closed. =work= is python-only and unaffected.
+
+The real question is what a polyglot project should get. Both files are single-owner by construction: =settings.json= would need its =PostToolUse= arrays merged, and =pre-commit= would need each language's checks concatenated behind one secret scan (which is already identical across all five). Neither merge is hard; the reason it was never built is that nobody had a project that needed it. Now one does.
+
+Options, roughly: (1) build the merge — settings arrays union, pre-commit composes per-language phases; (2) split the shared half out, so the secret scan is one file every bundle sources and only the language phase is per-bundle; (3) leave =FORCE=1= as the polyglot answer and document that the last install wins; (4) do nothing, since only one project is affected. Option 2 is the one that would also have prevented the [#A] — a shared secret scan can't go missing from a bundle that never had its own copy.
+
+Not [#A] because nothing is currently broken: no project is running with a bundle it lost. Not [#D] because a live project wants it and the decision is now forced rather than hypothetical.
+
+Pinned by =scripts/tests/install-lang-collision.bats= so the trade can't drift back unnoticed.
+
** TODO [#B] inbox-send leaves a phantom empty handoff in the target inbox :bug:solo:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-23
+:END:
=inbox-send.py= writes straight to the destination path in the target project's =inbox/=. =Path.write_text= opens with mode =w=, which creates and truncates before any content is written, so *any* failure between opening and finishing leaves a zero-byte =.org= file sitting in another project's inbox.
That file is not inert. =inbox-status= counts it as a pending handoff, so it trips the receiving project's =inbox-boundary-check= Stop hook and blocks a turn there. The receiving agent then has to resolve a handoff with no content and no sender context, which it cannot do from the file. Meanwhile the sender saw an error and will most likely retry, so the target gets a second file too.
@@ -90,6 +124,9 @@ Grading: Major severity (a phantom handoff in a *different* project's inbox, unr
Fix direction: write to a temp file in the destination directory and =os.replace= into place, so the inbox only ever sees a complete file. Pin =encoding="utf-8"= on both =write_text= and the =read_text= in =resolve_roots=. Same treatment for =send_file=, whose =shutil.copy2= has the same shape. Test by forcing a mid-write failure and asserting the destination directory is unchanged.
** TODO [#C] Two Signal-channel gaps: unbounded send, second account never received :bug:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-23
+:END:
Both found 2026-07-23 reading =claude-templates/bin/agent-text= and =scripts/signal-receive.sh=. Shellcheck is clean on all four =claude-templates/bin/= scripts; these are logic gaps, not style.
1. *The direct send has no timeout.* =agent-text='s relay path passes =-o ConnectTimeout=10= to ssh, but the local-account path calls =signal-cli send= with no bound at all. A stalled network or a lock contended by the receive timer hangs the call indefinitely. That matters more than it looks: agent-text is invoked *by agents*, so an unbounded call blocks the calling turn with no output, and the caller can't distinguish a hang from a slow send. The receive path already takes ~16s of wall clock every cadence, so the two contending on the same account is not hypothetical. Fix: wrap the direct send in =timeout=, matching the relay's bound, and let the existing non-zero path print the desktop-fallback message.
@@ -101,6 +138,9 @@ Both found 2026-07-23 reading =claude-templates/bin/agent-text= and =scripts/sig
Grading: Minor severity for both (one is a latent hang in a tool with a documented fallback, the other a warning on an account nothing currently depends on) x most users, frequently (the stale warning is a standing condition on this host; the hang needs a stall) = P3 = [#C].
** TODO [#C] Two smaller inbox-send defects: uncaught traceback, duplicate roots :bug:quick:solo:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-23
+:END:
Both verified 2026-07-23, both in =.ai/scripts/inbox-send.py=, both low-harm. Grouped because they're the same file and the same fix session.
1. *Uncaught =PermissionError=.* =main= catches =(ValueError, FileNotFoundError)= around the send, but =send_file= reaches =shutil.copy2=, which raises =PermissionError= on an unreadable source. That is an =OSError=, not caught, so the user gets a raw Python traceback instead of the clean =inbox-send: <message>= error every other failure path produces. Reproduced with a =chmod 000= source. No partial file is left in this case. Fix: catch =OSError= alongside the other two.
@@ -110,6 +150,9 @@ Both verified 2026-07-23, both in =.ai/scripts/inbox-send.py=, both low-harm. Gr
Grading: Minor severity (one is cosmetic output noise, the other an ugly but accurate failure message; neither loses or misroutes a message) x rare edge case (an unreadable source file, or a roots config with an overlap) = P4... graded up to [#C] rather than [#D] because both fixes are one line each and sit in a script every project depends on for cross-project messaging.
** TODO [#C] lint-org todo-format checkers fire on spec files :bug:solo:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-23
+:END:
=level2-done-without-closed= and =level-2-dated-header= encode =todo.org= completion conventions, but they run against every org file, including =docs/specs/=. A spec's Decisions section legitimately uses =** DONE <decision>= with no =CLOSED:= cookie, and its Review-and-iteration-history section legitimately uses =** <dated> — <who> — <role>= headings. Neither is a completion defect there.
Reproduced 2026-07-23 across rulesets' own specs: 100 findings over 7 of the 9 files in =docs/specs/= (docs-lifecycle 27, sentry-workflow 25, autonomous-batch 11, wrapup-routing 11, inbox-consolidation 10, agent-kb 8, encourage-kb 8; the two 2026-07-20 specs are clean). Every project carrying =docs/specs/= inherits the same noise on every sweep. Reported independently by takuzu's first sentry dogfood run.
@@ -119,6 +162,9 @@ Grading: Minor severity (judgment-kind output, so nothing mutates; the cost is n
Fix direction: scope both checkers away from =docs/specs/=, or gate them on the file carrying a =todo.org=-shaped structure. Note (established 2026-07-23) that these are *org-lint's own* checkers, not ones lint-org.el implements, so the fix filters upstream output by file path rather than editing a checker. Takuzu's alternative was to bless =CLOSED:= dates in =spec-create=, which fixes the symptom in new specs and leaves the nine existing ones noisy.
** TODO [#C] notes.org template trips four lint-org flags in every project :bug:quick:solo:
+:PROPERTIES:
+:LAST_REVIEWED: 2026-07-23
+:END:
The synced =claude-templates/.ai/notes.org= carries two boilerplate lines that open with markdown-style bold at column 0 (=**Session history is NOT in this file.**=, =**For protocols and conventions, see:**=). Org parses a line-initial =**= as a level-2 heading, so =misplaced-heading= flags both, and the =#+begin_example= block in the Pending Decisions instructions trips =invalid-block= twice. Every project inherits all four on every sweep.
Reproduced 2026-07-23. Confirms smoke's proposal. Additional finding from the same run: these two are =mechanical-fixed=, not judgment, so =lint-org --fix= silently rewrites the template's =**bold**= to org =*bold*=. The rewrite is correct org, but it lands on a synced template, so whichever project runs =--fix= first creates drift against canonical.