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())

Reply via email to