Hello,
Disclosure up front: the patch and this message were generated by an LLM and
edited by me.
The agenda skip functions silently ignore TODO keywords that begin
or end with a character that is not a word constituent, such as
"[ ]" and "[X]".
MWE: in emacs -Q with Org 9.8.8 (also present on current bugfix,
c75ffe4):
(let ((org-todo-keywords '((sequence "[ ]" "|" "[X]"))))
(with-temp-buffer
(insert "* [ ] hello\n")
(org-mode)
(goto-char (point-min))
(list (org-get-todo-state)
(org-agenda-skip-entry-if 'todo '("[ ]")))))
=> ("[ ]" nil)
Expected ("[ ]" 13).
Org recognized the keyword, but the agenda skip function did not. The same holds
for 'nottodo, 'todo-unblocked and 'nottodo-unblocked, and for the 'todo / 'done
class symbols when every keyword is bracketed.
The cause is in `org-agenda-skip-if-todo', which matches with
(regexp-opt keywords 'words)
`words' wraps the alternation in "\\<...\\>", and "\\<" cannot match in front
of "[", so the search never succeeds. Consequently the 'todo and 'nottodo
conditions degenerate into constants: the former never matches and nothing is
skipped, the latter always matches, so everything is skipped.
The word boundary seems to stand in for a delimiter check rather than a real
constraint on keywords: without the trailing "\\>", a headline such as "* TODOs
are not a keyword" would match "TODO". Org expresses that check elsewhere with
an explicit delimiter. More precisely, org-element matches the keyword as
`org-todo-regexp' followed by "\\(?: \\|$\\)" (org-element.el:1398) -- a space
or end of line, with no notion of word constituents. That's why Org's parser is
perfectly happy about keywords starting or ending with punctuation characters.
The patch does the same here, keeping the guard while fixing bracketed
keywords. The resulting regexp essentially mirrors the parser's: it is a
tailored `org-todo-regexp' produced with `regexp-opt' with the same grouping
argument, only over the subset of keywords the caller asked about, which is
followed by the same space or end-of-line delimiter. When the caller asks about
the whole keyword set, the two alternations are byte-identical: "\\(\\[\\(?:[
X]]\\)\\)".
One other case changes behaviour: a keyword followed by a tab, as in
"* TODO<TAB>hello". "\\>" matched after the final "O", so the old regexp treated
that as a keyword; org-element does not, since it requires a literal space or
end of line. The new delimiter agrees with the parser and stops matching it.
The patch leaves the existing "^\\*+[ \t]+" after the stars alone. That
tolerance is not redundant: `org-outline-regexp' is "\\*+ ", so a tab directly
after the stars never starts a headline, but "* <TAB>TODO hello" does, and
org-element recognizes the keyword there (it does `skip-chars-forward " \t"' at
org-element.el:1395, immediately before the match). Keeping "[ \t]+" keeps the
two in agreement.
On a side note, "\\<...\\>" has been there since the function was introduced in
57148dd8a (2010); the 2017 refactor in 1168d085d carried it over as `words'.
Grepping lisp/ finds one other such call, on tag group keys in
`org-tags-expand' (org.el:12089). That one is only partly protected: the
replacement runs under `org-mode-tags-syntax-table', which makes "@" and "_"
word constituents, but not "#" or "%", which the manual also lists as valid tag
characters. A group key of "@desk" expands; one of "#gtd" is silently left
alone. I have not touched it here -- happy to send a separate patch if you'd
like.
`test-org-agenda/skip-if' only exercised the "*" wildcard and carried a
"TODO: Test for specific TODO keywords" note; the patch adds a test with real
keywords and drops the note.
The patch is against bugfix. make compile and make test pass; the lisp/ change
is 2
insertions, 1 deletion, the rest being tests and comments, 13
insertions, 1 deletion in total, so:
TINYCHANGE
Thanks,
Mykhailo Shevchuk
>From 41e6f0c601e30448b90414532fbc349f1c16dcbf Mon Sep 17 00:00:00 2001
From: Mykhailo Shevchuk <[email protected]>
Date: Wed, 5 Aug 2026 20:30:13 +0200
Subject: [PATCH] org-agenda.el: Fix TODO skip for keywords with non-word
characters
* lisp/org-agenda.el (org-agenda-skip-if-todo): Delimit the keyword
with a space or end of line instead of a word boundary.
* testing/lisp/test-org-agenda.el (test-org-agenda/skip-if): Remove
the note asking for a test with specific TODO keywords.
(test-org-agenda/skip-if-todo-keyword): New test.
`regexp-opt' called with `words' wraps its alternation in "\\<...\\>".
A keyword that begins or ends with a character that is not a word
constituent, such as "[ ]" or "[X]", can therefore never match, and
the `todo' and `nottodo' conditions degenerate into constants: the
former never matches, the latter always does. Org itself places no
word constraint on keywords. org-element matches one as
`org-todo-regexp' followed by "\\(?: \\|$\\)", and this change builds
the same shape from the keywords the caller asked about. The
delimiter also preserves the guard the word boundary provided, so a
headline such as "* TODOs are not a keyword" still does not match the
keyword "TODO". A keyword followed by a tab rather than a space no
longer matches either; org-element does not recognize one there.
TINYCHANGE
---
lisp/org-agenda.el | 3 ++-
testing/lisp/test-org-agenda.el | 12 +++++++++++-
2 files changed, 13 insertions(+), 2 deletions(-)
diff --git a/lisp/org-agenda.el b/lisp/org-agenda.el
index d727ab4ce..df5aa7660 100644
--- a/lisp/org-agenda.el
+++ b/lisp/org-agenda.el
@@ -5272,7 +5272,8 @@ a list of TODO keywords, or a state symbol `todo' or `done' or
(error "Invalid TODO class or type: %S" args))
(`(,_ ,(pred (member "*"))) org-todo-keywords-1)
(`(,_ ,todo-list) todo-list))
- 'words))))
+ t)
+ "\\(?: \\|$\\)")))
(pcase args
(`(todo . ,_)
(let (case-fold-search) (re-search-forward todo-re end t)))
diff --git a/testing/lisp/test-org-agenda.el b/testing/lisp/test-org-agenda.el
index 312104ab1..f5d3d020a 100644
--- a/testing/lisp/test-org-agenda.el
+++ b/testing/lisp/test-org-agenda.el
@@ -489,7 +489,6 @@ See https://list.orgmode.org/[email protected]"
(deadline) (notdeadline)
(timestamp) (nottimestamp)
(regexp "hello") (notregexp "hello")
- ;; TODO: Test for specific TODO keywords
(todo ("*")) (nottodo ("*"))))
(should
(equal
@@ -543,6 +542,17 @@ DEADLINE: <2023-07-15 Sat>"
"* TODO write better tests"
(org-agenda-skip-if nil options))))))
+(ert-deftest test-org-agenda/skip-if-todo-keyword ()
+ "Test `org-agenda-skip-if' with explicit TODO keywords."
+ (let ((org-todo-keywords '((sequence "TODO" "|" "DONE")
+ (sequence "[ ]" "|" "[X]"))))
+ (should (org-test-with-temp-text "* [ ] hello"
+ (org-agenda-skip-if nil '(todo ("[ ]")))))
+ (should-not (org-test-with-temp-text "* [X] hello"
+ (org-agenda-skip-if nil '(todo ("[ ]")))))
+ (should-not (org-test-with-temp-text "* TODOs are not a keyword"
+ (org-agenda-skip-if nil '(todo ("TODO")))))))
+
(ert-deftest test-org-agenda/timestamp-ignore-todo-item ()
"Test if `org-agenda' ignores a todo item with a timestamp.
--
2.55.0