This is an automated email from the ASF dual-hosted git repository.
jamesbognar pushed a commit to branch release-manager
in repository https://gitbox.apache.org/repos/asf/juneau.git
The following commit(s) were added to refs/heads/release-manager by this push:
new 3de55b954e Add TODO-tracker scaffolding for the Release Manager
3de55b954e is described below
commit 3de55b954eea50008477d09af5d598bf73b1877b
Author: James Bognar <[email protected]>
AuthorDate: Tue Aug 18 14:46:58 2026 -0400
Add TODO-tracker scaffolding for the Release Manager
Adds the maintainer-side TODO tracking tooling adopted from Juneau:
- scripts/todo-next-id.py and scripts/todo-status-audit.py -- the paired
helpers that allocate the next TODO id and audit tracker status
consistency against the .work/todo/ tree.
- .gitignore now ignores .work/ (the tracker's on-disk working state) plus
the AI-assistant skill/agents mirror dirs (AGENTS.md, .cursor/, /agents/,
/.claude/).
- pom.xml adds apache-rat-plugin excludes for .work/**, agents/** and
.claude/** so `mvn verify` does not fail RAT on those unlicensed local
files.
The skill/agents dirs (agents/, .claude/, .cursor/, .work/) exist on disk to
drive local Cursor/Claude tooling but are intentionally left untracked --
mirroring Juneau's ASF source-hygiene convention that keeps assistant
working
files out of the released source tree.
---
.gitignore | 7 ++
pom.xml | 7 ++
scripts/todo-next-id.py | 214 ++++++++++++++++++++++++++++++++++
scripts/todo-status-audit.py | 266 +++++++++++++++++++++++++++++++++++++++++++
4 files changed, 494 insertions(+)
diff --git a/.gitignore b/.gitignore
index 65096e8747..07e9e8e467 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,3 +11,10 @@ e2e/node_modules/
e2e/test-results/
e2e/playwright-report/
e2e/.playwright/
+
+# Local Cursor / AI assistant working files (not part of the Apache source
tree)
+AGENTS.md
+.cursor/
+/agents/
+/.claude/
+/.work/
diff --git a/pom.xml b/pom.xml
index 2cfb48f198..922dd80f7d 100644
--- a/pom.xml
+++ b/pom.xml
@@ -143,6 +143,13 @@
<!-- Build output / local run-state -->
<exclude>target/**</exclude>
+
+ <!-- Local Cursor / AI assistant working files
(gitignored; not part of the source tree).
+ .work/ currently holds only markdown, but will
accumulate logs/.txt/.json captures;
+ excluding it now keeps `mvn verify` from failing
on unlicensed local files. -->
+ <exclude>.work/**</exclude>
+ <exclude>agents/**</exclude>
+ <exclude>.claude/**</exclude>
</excludes>
</configuration>
<executions>
diff --git a/scripts/todo-next-id.py b/scripts/todo-next-id.py
new file mode 100755
index 0000000000..08554c3326
--- /dev/null
+++ b/scripts/todo-next-id.py
@@ -0,0 +1,214 @@
+#!/usr/bin/env python3
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""
+Next-free TODO-id calculator for this repository's .work/todo/ tracker.
+
+Repo-agnostic: the repository root is derived from this file's own location
+(<root>/scripts/todo-next-id.py -> <root>), never hardcoded, so the same body
works in
+every repository that adopts the convention. Only the REPO_LABEL / SKILL_NAME
constants
+below and the license header differ between copies.
+
+**Ids are per-repository and start at 1 in each.** This script only ever scans
the tree it
+lives in. It prints the resolved root to stderr on every run precisely so that
a run made
+from the wrong working tree is visible rather than silent.
+
+Mirrors the exact scan scope documented in this repo's TODO-management skill,
in its
+"Adding a new item" and "MAYBE Numbering" sections:
+
+ 1. Every "[TODO-n]" and bare "TODO-n" token in .work/todo/TODO.md (a
trailing lowercase
+ letter suffix, e.g. "TODO-174a", is stripped -- only the numeric part
counts). A
+ qualified cross-repo citation such as "juneau:TODO-42" is NOT counted; it
names an id in
+ another repo's tracker.
+
+ Note that this scan cannot distinguish an illustrative id from a live
one: writing
+ "for example, TODO-5" anywhere in TODO.md permanently consumes id 5.
Write "TODO-<n>" in
+ prose.
+ 2. Every
"TODO-"/"READY-"/"MAYBE-"/"FINISHED-"/"CANCELLED-<n>[<letter>]-*.md" filename
+ directly under .work/todo/ and .work/todo/finished/.
+
+next = 1 + max(all numeric ids found). A child's letter suffix (TODO-174a,
FINISHED-337f, ...)
+is ignored for this computation -- only its numeric part counts, per the
skill's "Numbering"
+rule -- so promoting/splitting a lettered child never consumes a new
sequential id.
+
+.work/ is gitignored, so this is pure filesystem/text scanning; no git needed.
+
+A MISSING .work/todo/ directory is a hard error (exit 2), not an empty scan.
Silently
+returning "1" from a tree that has no tracker is how ids get reused: it is the
correct answer
+in a freshly-seeded repo and a catastrophic one in a repo whose tracker you
failed to find.
+Pass --allow-missing if you genuinely want the empty-tree answer.
+
+Usage:
+ ./scripts/todo-next-id.py
+ ./scripts/todo-next-id.py --list
+ ./scripts/todo-next-id.py --check 12
+ ./scripts/todo-next-id.py --root /path/to/other/repo
+
+Options:
+ --root <path> Repository root to scan (default: the parent of this
script's directory).
+ --list Print every id currently in use (letter suffixes
preserved), one per
+ line, sorted numerically then by letter, instead of the
next free id.
+ --check <id> Exit 1 with a message if <id> (e.g. "12" or "7a"; a
leading "TODO-"
+ is tolerated) is already in use; exit 0 with a message if
it's free.
+ --allow-missing Treat an absent .work/todo/ as an empty tracker instead of
an error.
+ --help, -h Show this help message.
+
+Exit status:
+ 0 Success (or --check found the id free).
+ 1 --check found the id already taken, or a malformed --check argument.
+ 2 .work/todo/ does not exist under the resolved root (and
--allow-missing was not given).
+"""
+
+from __future__ import annotations
+
+import argparse
+import re
+import sys
+from pathlib import Path
+
+#
---------------------------------------------------------------------------------------
+# The ONLY repo-specific values in this file. Everything below is identical
across every
+# copy of this script; keep it that way so a fix lands once and is copied
verbatim.
+#
---------------------------------------------------------------------------------------
+REPO_LABEL = "Juneau Release Manager"
+SKILL_NAME = "release-manager-todo-management"
+
+DEFAULT_REPO_ROOT = Path(__file__).resolve().parent.parent
+
+# "[TODO-42]" or bare "TODO-42" in TODO.md prose. A trailing run of lowercase
letters (the
+# child-letter suffix, possibly more than one for grandchildren like "17fa")
is captured
+# separately so it can be preserved for --list/--check but ignored for
numbering.
+#
+# The leading lookbehind rejects a qualified cross-repo citation --
"juneau:TODO-42",
+# "support-console:TODO-42" -- which names an id in ANOTHER repo's tracker and
must not
+# consume one here. Ids are bare and per-repository, so without this the act
of writing down
+# that another repo's item blocks you would silently burn a local id.
+TODO_TOKEN_RE = re.compile(r"(?<![\w:])TODO-(\d+)([a-z]*)\b")
+
+# Every lifecycle-state filename directly under .work/todo/ or
.work/todo/finished/.
+FILENAME_RE =
re.compile(r"^(?:TODO|READY|MAYBE|FINISHED|CANCELLED)-(\d+)([a-z]*)-.*\.md$")
+
+
+def collect_ids(todo_dir: Path) -> tuple[set, set]:
+ """
+ Scan every source described in the module docstring.
+
+ Returns (raw_ids, numeric_ids):
+ - raw_ids: every distinct id token as it actually appears (e.g.
"17", "17a",
+ "12f"), for --list / --check.
+ - numeric_ids: just the base integer part of each id (letter suffix
stripped), for
+ computing the next free id.
+ """
+ raw_ids = set()
+ numeric_ids = set()
+
+ todo_md = todo_dir / "TODO.md"
+ if todo_md.is_file():
+ text = todo_md.read_text(encoding="utf-8")
+ for m in TODO_TOKEN_RE.finditer(text):
+ raw_ids.add(m.group(1) + m.group(2))
+ numeric_ids.add(int(m.group(1)))
+
+ for directory in (todo_dir, todo_dir / "finished"):
+ if not directory.is_dir():
+ continue
+ for entry in directory.iterdir():
+ if not entry.is_file():
+ continue
+ m = FILENAME_RE.match(entry.name)
+ if not m:
+ continue
+ raw_ids.add(m.group(1) + m.group(2))
+ numeric_ids.add(int(m.group(1)))
+
+ return raw_ids, numeric_ids
+
+
+def sort_key(raw_id: str):
+ """Sort key for a raw id string: numeric part first, then its letter
suffix."""
+ m = re.match(r"^(\d+)([a-z]*)$", raw_id)
+ if not m:
+ return (0, raw_id)
+ return (int(m.group(1)), m.group(2))
+
+
+def normalize_check_id(raw: str) -> str | None:
+ """Normalize a --check argument (optionally "TODO-"-prefixed) to a bare
"<digits><letters>" id, or None if malformed."""
+ candidate = raw.strip()
+ if candidate.upper().startswith("TODO-"):
+ candidate = candidate[len("TODO-"):]
+ m = re.match(r"^(\d+)([a-zA-Z]*)$", candidate)
+ if not m:
+ return None
+ return m.group(1) + m.group(2).lower()
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ description=f"Compute the next free .work/todo/ TODO id for
{REPO_LABEL} (see @{SKILL_NAME}).",
+ epilog=__doc__,
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ )
+ parser.add_argument("--root", metavar="PATH", help="Repository root to
scan (default: parent of this script's directory).")
+ parser.add_argument("--list", action="store_true", help="Print every id
currently in use, sorted.")
+ parser.add_argument("--check", metavar="ID", help="Exit non-zero if ID is
already taken.")
+ parser.add_argument("--allow-missing", action="store_true", help="Treat an
absent .work/todo/ as empty instead of an error.")
+ args = parser.parse_args()
+
+ repo_root = Path(args.root).resolve() if args.root else DEFAULT_REPO_ROOT
+ todo_dir = repo_root / ".work" / "todo"
+
+ # Always announce the tree actually scanned. Ids are per-repository and
bare, so
+ # "TODO-5" is a different item in each repo; a wrong-tree run must not
look identical
+ # to a right-tree one. stderr, so `NEXT=$(./scripts/todo-next-id.py)`
still works.
+ print(f"[{REPO_LABEL}] scanning {todo_dir}", file=sys.stderr)
+
+ if not todo_dir.is_dir():
+ if not args.allow_missing:
+ print(
+ f"ERROR: {todo_dir} does not exist.\n"
+ f" Ids are per-repository, so an unfound tracker must
not be reported as an empty\n"
+ f" one -- that silently hands out id 1 and reuses live
ids. Check you are in the\n"
+ f" right working tree, or pass --allow-missing if this
repo genuinely has no tracker yet.",
+ file=sys.stderr,
+ )
+ return 2
+ print(f"WARNING: {todo_dir} does not exist; treating as empty
(--allow-missing).", file=sys.stderr)
+
+ raw_ids, numeric_ids = collect_ids(todo_dir)
+
+ if args.check is not None:
+ normalized = normalize_check_id(args.check)
+ if normalized is None:
+ print(f"ERROR: '{args.check}' is not a valid id (expected e.g.
'12' or '7a').", file=sys.stderr)
+ return 1
+ if normalized in raw_ids:
+ print(f"TAKEN: {normalized} is already in use.")
+ return 1
+ print(f"FREE: {normalized} is not currently in use.")
+ return 0
+
+ if args.list:
+ for raw_id in sorted(raw_ids, key=sort_key):
+ print(raw_id)
+ return 0
+
+ next_id = 1 + max(numeric_ids, default=0)
+ print(next_id)
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/todo-status-audit.py b/scripts/todo-status-audit.py
new file mode 100755
index 0000000000..74452ea123
--- /dev/null
+++ b/scripts/todo-status-audit.py
@@ -0,0 +1,266 @@
+#!/usr/bin/env python3
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""
+Best-effort status/header consistency pre-filter for this repository's
.work/todo/ plan files.
+
+Repo-agnostic: the repository root is derived from this file's own location
+(<root>/scripts/todo-status-audit.py -> <root>), never hardcoded, so the same
body works in
+every repository that adopts the convention. Only the REPO_LABEL / SKILL_NAME
constants
+below and the license header differ between copies.
+
+Checks every TODO-<id>-*.md / READY-<id>-*.md / MAYBE-<id>-*.md file directly
under .work/todo/
+(FINISHED-/CANCELLED-*.md archives are explicitly out of scope -- per this
repo's
+TODO-management skill, "status line is not required in FINISHED archives")
against that
+skill's "Per-file `Current status:` and `Complexity:` header" rules, and flags
candidate
+inconsistencies. This is a PRE-FILTER, not a validator: it flags candidates
for a human
+(or agent) to look at, and will not catch everything on format-drifted files
-- tolerant,
+best-effort markdown-header parsing throughout.
+
+Checks performed (each file may accumulate multiple flags):
+ - missing_status_header No "Current status:" line found anywhere in
the file.
+ - missing_complexity_header No "Complexity:" line found anywhere in the
file.
+ - status_header_misplaced "Current status:" appears at or after the
first "## " section
+ heading (it must come before it, per the
skill's "Placement" rule).
+ - unrecognized_status_phrase The status text doesn't start with one of the
skill's documented
+ phrases for this file's prefix (TODO/READY:
"Waiting for user
+ input on open questions.", "Ready to
execute.", "In progress.";
+ MAYBE: must start with "Parked"). Free-form
variants that legitimately
+ extend a recognized prefix (e.g. "Ready to
execute (all items
+ independently actionable)." ) are NOT flagged
-- only prefix
+ mismatches are.
+ - ready_but_has_open_questions
+ Status starts with "Ready to execute" but the
file still has a
+ "## Open questions" section containing at
least one numbered item
+ whose text doesn't look answered/resolved.
+ - ready_prefix_waiting_status A READY-*.md file whose status still says
"Waiting for user input"
+ (READY files should have no unresolved open
questions left).
+ - todo_prefix_marked_ready A TODO-*.md file (not yet renamed) whose
status already says "Ready
+ to execute" -- a likely-missed rename to
READY-*.md (see the skill's
+ "OQA lifecycle -> status transitions").
+ - parked_status_wrong_prefix A TODO-*.md/READY-*.md file whose status
starts with "Parked" (that
+ wording is reserved for MAYBE-*.md files).
+ - maybe_prefix_non_parked A MAYBE-*.md file whose status does NOT start
with "Parked".
+
+A MISSING scan directory is a hard error (exit 2). An EMPTY-but-present one is
a clean pass
+(exit 0). The original version conflated the two and returned 0 for both, so
pointing the
+script at a tree with no tracker produced a reassuring "nothing to flag" --
the same silent-zero
+trap as running `rg` over the gitignored .work/ without --no-ignore.
+
+Usage:
+ ./scripts/todo-status-audit.py
+ ./scripts/todo-status-audit.py --verbose
+ ./scripts/todo-status-audit.py --root /path/to/other/repo
+ ./scripts/todo-status-audit.py --dir /path/to/alternate/todo/dir
+
+Options:
+ --root <path> Repository root; scans <root>/.work/todo/ (default: parent
of this
+ script's directory). Ignored if --dir is given.
+ --dir <path> Exact directory to scan, overriding --root. Non-recursive
-- only *.md
+ files directly in this directory are considered.
+ --verbose, -v Also print files that passed every check (default: only
print flagged files).
+ --help, -h Show this help message.
+
+Exit status:
+ 0 No inconsistencies flagged (including the legitimately-empty-tracker
case).
+ 1 At least one file was flagged.
+ 2 The scan directory does not exist.
+"""
+
+from __future__ import annotations
+
+import argparse
+import re
+import sys
+from pathlib import Path
+
+#
---------------------------------------------------------------------------------------
+# The ONLY repo-specific values in this file. Everything below is identical
across every
+# copy of this script; keep it that way so a fix lands once and is copied
verbatim.
+#
---------------------------------------------------------------------------------------
+REPO_LABEL = "Juneau Release Manager"
+SKILL_NAME = "release-manager-todo-management"
+
+DEFAULT_REPO_ROOT = Path(__file__).resolve().parent.parent
+
+FILENAME_RE = re.compile(r"^(TODO|READY|MAYBE)-\d+[a-z]*-.*\.md$")
+
+STATUS_LINE_RE = re.compile(r"^\s*Current status:\s*(.*)$", re.IGNORECASE |
re.MULTILINE)
+COMPLEXITY_LINE_RE = re.compile(r"^\s*Complexity:\s*(.*)$", re.IGNORECASE |
re.MULTILINE)
+SECTION_HEADING_RE = re.compile(r"^##\s+(.*)$", re.MULTILINE)
+NUMBERED_ITEM_RE = re.compile(r"^\s*\d+[.)]\s+(.*)$", re.MULTILINE)
+
+# Matched on word boundaries. A bare substring test reads "unanswered" /
"unresolved" -- the
+# most natural wording for an OPEN question -- as containing "answered" /
"resolved", which
+# silently disables the ready_but_has_open_questions check for exactly the
case it exists to
+# catch. Still best-effort: an explicit negation like "not resolved" reads as
resolved.
+RESOLVED_MARKER_RE = re.compile(r"\b(?:resolved|answered|decided)\b")
+
+# Recognized status prefixes (case-insensitive, checked with str.startswith
after lowercasing) per
+# the skill's "Status wording rules" -- kept separate from TODO/READY vs MAYBE
since the two file
+# families use disjoint wording.
+TODO_READY_STATUS_PREFIXES = (
+ "waiting for user input on open questions",
+ "ready to execute",
+ "in progress",
+)
+MAYBE_STATUS_PREFIX = "parked"
+
+
+def find_plan_files(todo_dir: Path) -> list:
+ """Every TODO-/READY-/MAYBE-<id>[<letter>]-*.md file directly under
todo_dir, sorted by name."""
+ return sorted(p for p in todo_dir.glob("*.md") if
FILENAME_RE.match(p.name))
+
+
+def file_prefix(path: Path) -> str:
+ """"TODO", "READY", or "MAYBE" (the filename already matched
FILENAME_RE)."""
+ return path.name.split("-", 1)[0]
+
+
+def extract_section(text: str, heading_text: str) -> str | None:
+ """
+ Return the body text of the first "## <heading_text>" section
(case-insensitive substring match
+ on the heading line), from just after that heading line up to (not
including) the next "## "
+ heading or end of file. Returns None if no matching heading exists.
+ """
+ headings = list(SECTION_HEADING_RE.finditer(text))
+ for i, m in enumerate(headings):
+ if heading_text.lower() in m.group(1).strip().lower():
+ start = m.end()
+ end = headings[i + 1].start() if i + 1 < len(headings) else
len(text)
+ return text[start:end]
+ return None
+
+
+def open_questions_are_unresolved(section_body: str) -> bool:
+ """True if the "## Open questions" section has at least one numbered item
that doesn't look answered/resolved."""
+ items = list(NUMBERED_ITEM_RE.finditer(section_body))
+ if not items:
+ # No numbered items at all (empty placeholder, or prose-only) --
nothing concretely
+ # "open" to flag; conservative by design (avoid false positives on
trivial sections).
+ return False
+ for i, m in enumerate(items):
+ start = m.start()
+ end = items[i + 1].start() if i + 1 < len(items) else len(section_body)
+ block = section_body[start:end].lower()
+ if not RESOLVED_MARKER_RE.search(block):
+ return True
+ return False
+
+
+def status_prefix_ok(prefix: str, status: str) -> bool:
+ """True if status's wording matches one of the recognized phrases for this
file's TODO/READY/MAYBE prefix."""
+ normalized = status.strip().lower()
+ if prefix == "MAYBE":
+ return normalized.startswith(MAYBE_STATUS_PREFIX)
+ return any(normalized.startswith(p) for p in TODO_READY_STATUS_PREFIXES)
+
+
+def audit_file(path: Path) -> list:
+ """Return a list of (reason_code, detail) tuples for this plan file. Empty
list means no flags."""
+ text = path.read_text(encoding="utf-8")
+ prefix = file_prefix(path)
+ flags = []
+
+ status_match = STATUS_LINE_RE.search(text)
+ complexity_match = COMPLEXITY_LINE_RE.search(text)
+
+ if status_match is None:
+ flags.append(("missing_status_header", "No 'Current status:' line
found."))
+ if complexity_match is None:
+ flags.append(("missing_complexity_header", "No 'Complexity:' line
found."))
+
+ if status_match is not None:
+ status = status_match.group(1).strip()
+ first_heading = SECTION_HEADING_RE.search(text)
+ if first_heading is not None and status_match.start() >=
first_heading.start():
+ flags.append(("status_header_misplaced", "'Current status:'
appears at/after the first '##' section heading."))
+
+ if not status_prefix_ok(prefix, status):
+ flags.append(("unrecognized_status_phrase", f"Status text doesn't
match the {prefix} wording rules: '{status}'"))
+
+ normalized = status.lower()
+ if prefix in ("TODO", "READY") and normalized.startswith("ready to
execute"):
+ oq_section = extract_section(text, "Open questions")
+ if oq_section is not None and
open_questions_are_unresolved(oq_section):
+ flags.append(("ready_but_has_open_questions", "Status says
'Ready to execute' but '## Open questions' still has unresolved item(s)."))
+
+ if prefix == "READY" and normalized.startswith("waiting for user
input"):
+ flags.append(("ready_prefix_waiting_status", "READY-prefixed file
but status still says 'Waiting for user input'."))
+
+ if prefix == "TODO" and normalized.startswith("ready to execute"):
+ flags.append(("todo_prefix_marked_ready", "TODO-prefixed file
already marked 'Ready to execute' -- possible missed rename to READY-*.md."))
+
+ if prefix in ("TODO", "READY") and normalized.startswith("parked"):
+ flags.append(("parked_status_wrong_prefix", "Status says
'Parked...' but filename is not MAYBE-prefixed."))
+
+ if prefix == "MAYBE" and not normalized.startswith("parked"):
+ flags.append(("maybe_prefix_non_parked", f"MAYBE-prefixed file but
status doesn't start with 'Parked': '{status}'"))
+
+ return flags
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(
+ description=f"Best-effort pre-filter for {REPO_LABEL}'s .work/todo/
header inconsistencies (see @{SKILL_NAME}).",
+ epilog=__doc__,
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ )
+ parser.add_argument("--root", metavar="PATH", help="Repository root; scans
<root>/.work/todo/ (default: parent of this script's directory).")
+ parser.add_argument("--dir", metavar="PATH", help="Exact directory to
scan, overriding --root.")
+ parser.add_argument("--verbose", "-v", action="store_true", help="Also
print files that passed every check.")
+ args = parser.parse_args()
+
+ if args.dir:
+ todo_dir = Path(args.dir).resolve()
+ else:
+ repo_root = Path(args.root).resolve() if args.root else
DEFAULT_REPO_ROOT
+ todo_dir = repo_root / ".work" / "todo"
+
+ # Always announce the tree actually scanned, for the same reason
todo-next-id.py does:
+ # bare per-repository ids make a wrong-tree run indistinguishable from a
right-tree one.
+ print(f"[{REPO_LABEL}] scanning {todo_dir}", file=sys.stderr)
+
+ # A missing directory and an empty one are NOT the same answer. Missing
means the caller
+ # is looking at the wrong tree (or the scaffolding was never installed)
and must be told;
+ # empty means a genuinely clean tracker and is a legitimate pass.
+ if not todo_dir.is_dir():
+ print(f"ERROR: {todo_dir} does not exist -- nothing was scanned. Check
the working tree, or pass --dir.", file=sys.stderr)
+ return 2
+
+ files = find_plan_files(todo_dir)
+
+ if not files:
+ print(f"No TODO-/READY-/MAYBE-*.md files found under {todo_dir}
(directory exists and is empty of plan files).")
+ return 0
+
+ flagged_count = 0
+ for path in files:
+ flags = audit_file(path)
+ if flags:
+ flagged_count += 1
+ print(f"{path.name}")
+ for reason, detail in flags:
+ print(f" [{reason}] {detail}")
+ print()
+ elif args.verbose:
+ print(f"{path.name} OK")
+
+ print(f"Scanned {len(files)} file(s); {flagged_count} flagged.")
+ return 1 if flagged_count else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())