Google measured what the comments in your repository cost your coding agent. Across Google's own sessions, 13% exposed the agent to an inline TODO(bug/...) or FIXME(bug/...) reference, and the detours that followed added 70% more steps, 77% more tokens and 105% more wait time [1].
TODO(bug/...)
FIXME(bug/...)
This post gives you a scanner that finds the markers agents latch onto, two recipes for turning the findings into a ranked work list, a presubmit rule that stops new ones, and the error bodies you will hit while building it. Every command below was run while writing this, and the output shown is the output that came back.
A person skims a file and reads // TODO(bug/4821) as archaeology. An agent reads the same line during the planning and research phase, when it is deciding what the task contains, and a bug reference reads as part of the task. Google's post describes the mechanism plainly: what is easy for a human to skim past, an agent treats as a call to action, and obsolete specs, long resolved bugs and lagging documentation arrive as context rather than as history [1].
// TODO(bug/4821)
The measurement is the interesting part, because nobody can review millions of sessions by hand. Google used the bug reference inside the comment as an objective proxy and cross-referenced it against live ticket status. In 6% of the sessions the agent took a follow-up action on that bug id, and 83% of those actions were unprompted: the developer never asked for the bug. That choice of proxy matters for anyone copying the method, because a plain TODO with no ticket behind it is not what they measured, so a cleanup that deletes every TODO is measuring nothing.
TODO
Two limits apply before you quote those figures in a meeting. They describe Google's codebase and Google's sessions, not yours, so treat them as a reason to measure rather than as your own baseline. Google also reports that higher capability models show higher distraction rates, so moving to a stronger model makes the problem louder, not quieter.
Save this as find_stale_markers.py at the root of the repository you want to check and run it. It reads comment markers only, so prose about TODOs and words like hacker stay out of the results, and it reports the oldest files first because an old file is where an obsolete marker survives.
find_stale_markers.py
hacker
#!/usr/bin/env python3 """Find the comments that send coding agents on side quests. python3 find_stale_markers.py --root . --min-age 90 python3 find_stale_markers.py --root . --json findings.json python3 find_stale_markers.py --root . --fail-over 0 # exit 1 for CI """ from __future__ import annotations import argparse import json import os import re import subprocess import sys from datetime import date from pathlib import Path # A marker only counts inside a comment, so "hacker" is not a HACK and prose # about TODOs is not a marker. MARKER_RE = re.compile( r"(?P<comment>//+|#|/\*+|\*|<!--|--)\s*" r"(?P<marker>TODO|FIXME|HACK|XXX)\b" r"(?:\s*\((?P<ref>[^)]{2,60})\))?" r"\s*:?\s*(?P<text>.{0,120})", re.IGNORECASE, ) SKIP_DIRS = {".git", "node_modules", "dist", "build", ".venv", "__pycache__", ".next", "vendor"} CODE_SUFFIXES = { ".py", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".java", ".go", ".rs", ".rb", ".php", ".c", ".h", ".cc", ".cpp", ".cs", ".kt", ".swift", ".sql", ".sh", } PROSE_SUFFIXES = {".md", ".mdx", ".rst", ".txt"} def git_env() -> dict[str, str]: """A git environment with injected GIT_CONFIG_* keys removed. When GIT_CONFIG_COUNT is set without a matching GIT_CONFIG_KEY_0, git exits 128 with "error: missing config key GIT_CONFIG_KEY_0". """ return {k: v for k, v in os.environ.items() if not k.startswith("GIT_CONFIG_")} def file_added(path: Path, root: Path) -> str: """The date the file first entered history, which bounds marker age.""" result = subprocess.run( ["git", "-C", str(root), "log", "--diff-filter=A", "--format=%cs", "-1", "--", str(path.relative_to(root))], capture_output=True, text=True, timeout=20, env=git_env(), ) if result.returncode != 0: return "" lines = [line for line in result.stdout.strip().splitlines() if line.strip()] return lines[-1] if lines else "" def age_in_days(stamp: str) -> int | None: if not stamp: return None try: year, month, day = (int(part) for part in stamp.split("-")) return (date.today() - date(year, month, day)).days except ValueError: return None def scan_file(path: Path, root: Path) -> list[dict]: try: text = path.read_text(encoding="utf-8", errors="replace") except OSError: return [] added = file_added(path, root) age = age_in_days(added) found = [] for number, line in enumerate(text.splitlines(), start=1): match = MARKER_RE.search(line) if not match: continue found.append({ "file": str(path.relative_to(root)), "line": number, "marker": match.group("marker").upper(), "ref": (match.group("ref") or "").strip(), "text": match.group("text").strip(), "file_added": added, "file_age_days": age, }) return found def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--root", type=Path, default=Path(".")) parser.add_argument("--min-age", type=int, default=0) parser.add_argument("--include-prose", action="store_true", help="also scan markdown, where markers are usually examples") parser.add_argument("--json", type=Path) parser.add_argument("--fail-over", type=int, default=-1, help="exit 1 when more than this many markers are found; 0 is valid") args = parser.parse_args() root = args.root.resolve() if not root.is_dir(): print(f"root is not a directory: {root}", file=sys.stderr) return 2 suffixes = CODE_SUFFIXES | (PROSE_SUFFIXES if args.include_prose else set()) findings: list[dict] = [] for path in sorted(root.rglob("*")): if not path.is_file() or path.suffix.lower() not in suffixes: continue if SKIP_DIRS & set(path.relative_to(root).parts): continue findings.extend(scan_file(path, root)) if args.min_age: findings = [item for item in findings if (item["file_age_days"] or 0) >= args.min_age] findings.sort(key=lambda item: (-(item["file_age_days"] or 0), item["file"], item["line"])) if args.json: args.json.write_text(json.dumps(findings, indent=2) + "\n", encoding="utf-8") referenced = [item for item in findings if item["ref"]] print(f"markers found: {len(findings)} in {len({item['file'] for item in findings})} files") print(f"markers carrying a bug reference: {len(referenced)}") for item in findings[:12]: age = f"{item['file_age_days']}d" if item["file_age_days"] is not None else "unknown" ref = f"({item['ref']})" if item["ref"] else "" print(f" {age:>7} {item['file']}:{item['line']} {item['marker']}{ref} {item['text'][:56]}") if args.fail_over >= 0 and len(findings) > args.fail_over: print(f"FAIL: {len(findings)} markers, over the {args.fail_over} allowed", file=sys.stderr) return 1 return 0 if __name__ == "__main__": sys.exit(main())
Run against a small production repository, the first version reported four markers in one file and no bug references:
$ python3 find_stale_markers.py --root . markers found: 4 in 1 files markers carrying a bug reference: 0 77d scripts/new-post.sh:45 TODO the mechanism 77d scripts/new-post.sh:47 TODO what we do about it 77d scripts/new-post.sh:49 TODO the honest boundary 77d scripts/new-post.sh:51 TODO one next step
Add --include-prose when you also want the markdown, and be ready for a longer list. The same repository returned 13 markers across 10 files that way, nearly all of them example text inside documentation.
--include-prose
If your repository lives in a git worktree created by a harness that injects configuration, the history lookup is where this breaks. Git reads GIT_CONFIG_COUNT and then looks for the numbered keys beside it; when a counter survives without its keys, every git call fails, and a scanner that swallows the failure reports unknown ages for everything:
GIT_CONFIG_COUNT
unknown
$ git log --format=%cs -1 -- src/cart.ts error: missing config key GIT_CONFIG_KEY_0 fatal: unable to parse command-line config $ echo $? 128
The fix is in the script already, and it is worth copying into any tool that shells out to git:
import os def git_env() -> dict[str, str]: """Strip the injected GIT_CONFIG_* family before calling git.""" return {k: v for k, v in os.environ.items() if not k.startswith("GIT_CONFIG_")} # subprocess.run(["git", ...], env=git_env())
Bounds from git history are enough to rank the work, but they are weak evidence about a single comment: a file added five years ago can hold a marker written last Tuesday. Use the file age to order the sweep, then read the ticket before deleting anything, which is the part Google's own cleaner got right. Their tool had to look at the history of the bug, the file and the changes to the codebase, sometimes spanning years, before proposing a deletion, and the proposals were reviewed by engineers. Over six months that produced thousands of confirmed obsolete references with more than 95% acceptance [1].
The scanner answers what exists. The next question is which of those markers still point at real work, and that requires the ticket status.
# One marker, one ticket, no auth for a public repository. gh api repos/cloudflare/security-audit-skill/issues/1 \ --jq '{number, title, state, state_reason, closed_at}'
The response shape is what the ranking step consumes. Against a live public repository the call returned a closed ticket with a completion reason, which is exactly the case that misleads an agent:
{"number": 1, "title": "Repo layout incompatible with npx skills add", "state": "closed", "state_reason": "completed", "created_at": "2026-06-24T19:57:18Z", "closed_at": "2026-06-29T13:48:07Z"}
Wire that into a batch step so each referenced marker gets a status, and bucket the output. The script below reads the JSON the scanner wrote and emits three buckets: closed (delete or rewrite), open (keep, and say so in the comment), and unknown (the ticket lookup failed, so leave it alone).
#!/usr/bin/env python3 """Bucket scanner findings by ticket state. python3 bucket_refs.py findings.json --field state --closed closed \ --open open --out worklist.json """ import argparse, json, pathlib parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("findings") parser.add_argument("--field", default="state") parser.add_argument("--closed", default="closed") parser.add_argument("--open", default="open") parser.add_argument("--out", type=pathlib.Path) args = parser.parse_args() rows = json.loads(pathlib.Path(args.findings).read_text()) buckets = {"closed": [], "open": [], "unknown": [], "no_reference": []} for row in rows: ref, status = row.get("ref", ""), row.get("ticket_status") if not ref: buckets["no_reference"].append(row) elif status is None or status == "": buckets["unknown"].append(row) elif status == args.closed: buckets["closed"].append(row) elif status == args.open: buckets["open"].append(row) else: buckets["unknown"].append(row) for name, items in buckets.items(): print(f"{name}: {len(items)}") if args.out: args.out.write_text(json.dumps(buckets, indent=2) + "\n")
The bucket names are the whole point. A plain TODO is not a bug reference, an unreachable ticket is not a closed ticket, and treating either as safe to delete is how a cleanup removes work someone still intends to do. In our own run the work list was short and honest: four markers, none carrying a ticket reference, which is a realistic result for a young repository and a reason to run the scan before assuming you have a problem. If you want the same discipline applied to the agent's own output rather than to the repository, the trajectory scorer in our plugin evaluation guide grades every step instead of the final message.
Deleting a stale reference and confirming the repository is clean takes about a minute, and the exit code is the evidence. Start from a repository with one marker that points at a closed ticket:
$ python3 find_stale_markers.py --root . --json findings.json markers found: 1 in 1 files markers carrying a bug reference: 1 0d src/cart.ts:2 TODO(bug/4821) rounding breaks on multi-currency carts $ cat findings.json [ { "file": "src/cart.ts", "line": 2, "marker": "TODO", "ref": "bug/4821", "text": "rounding breaks on multi-currency carts", "file_added": "2026-11-08", "file_age_days": 0 } ]
Now remove the comment, keeping the code it described, and re-run the scan in CI mode. The threshold of zero is a real threshold, which is worth saying because an early version of this script used zero as a sentinel and could never fail:
$ python3 find_stale_markers.py --root . --fail-over 0 markers found: 0 in 0 files markers carrying a bug reference: 0 $ echo $? 0
Before the edit, the same command returned exit 1 and printed the failure to stderr. That pair of runs is the measurement you report, because it is a number rather than an impression:
FAIL: 4 markers, over the 0 allowed
Two more habits belong in this recipe. Rewrite rather than delete when the comment still carries information a reader needs, because an agent reads the rewritten sentence too, and prefer a positive statement of the current behaviour to a note about what changed. Then re-run the scan on the pull request, not only on your branch, so a marker that arrives with a merge is caught by the same gate.
A gate makes the cleanup durable. This hook rejects a new bug reference that points at a closed ticket, and it works with a plain bash and gh:
bash
gh
#!/usr/bin/env bash # .githooks/pre-commit: refuse to commit a TODO that points at a closed ticket. set -euo pipefail staged=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(py|ts|tsx|js|jsx|java|go|rs)$' || true) [ -z "$staged" ] && exit 0 fail=0 for file in $staged; do while IFS= read -r line; do ref=$(printf '%s' "$line" | grep -oE 'TODO\(([^)]+)\)|FIXME\(([^)]+)\)' | head -1 | sed -E 's/(TODO|FIXME)\(([^)]+)\)/\2/') [ -z "$ref" ] && continue state=$(gh api "repos/:owner/:repo/issues/${ref##*/}" --jq .state 2>/dev/null || echo unknown) if [ "$state" = "closed" ]; then echo "blocked: $file references closed ticket $ref" >&2 echo " remove the marker or point it at open work" >&2 fail=1 fi done < <(grep -nE 'TODO\(|FIXME\(' "$file" || true) done exit $fail
A hook only protects the machines that install it, so pair it with a check that runs for everyone:
# .github/workflows/stale-markers.yml name: stale-markers on: pull_request: schedule: - cron: "0 6 * * 1" # Monday, 06:00 UTC jobs: scan: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 # the scanner reads first-commit dates - uses: actions/setup-python@v5 with: python-version: "3.12" - name: Scan for stale markers run: python3 scripts/find_stale_markers.py --root . --json findings.json --fail-over 20 - uses: actions/upload-artifact@v4 if: always() with: name: stale-markers path: findings.json
The threshold in CI is a budget rather than a target. Set it to the number you have today, then lower it as the sweep removes markers, so the gate ratchets down instead of blocking every unrelated pull request on day one.
The code is half of what an agent reads. Instruction files carry the same stale claims, and they are read on every single session, so an obsolete line there costs more than an obsolete comment. This block goes in AGENTS.md, CLAUDE.md or the equivalent file your harness reads:
AGENTS.md
CLAUDE.md
## Working in this repository - The task is the task. If a comment references a ticket, that reference is history, not scope. Do not look up, open or fix a referenced ticket unless the request names it. - When you find `TODO(bug/...)` or `FIXME(bug/...)` in a file you are reading, mention it in your summary under "Noticed, not touched" and continue. - When your work makes a marker obsolete, delete the marker in the same change. Do not leave a note explaining what used to be true. - Prefer a statement of current behaviour to a note about intent. "Rounding uses banker's rounding" ages better than "TODO: decide rounding".
The first two rules do most of the work, because they give the agent a place to put the finding that is not the middle of your task. The last two stop the repository from generating tomorrow's stale markers, which is the failure mode a one-off cleanup leaves behind.
Two things make this class of instruction easier to adopt. A local audit of what the agent actually did, in the style of the open-source Stroq project, turns the same evidence into a reviewable record [2], and a skill such as Cloudflare's security audit skill shows the pattern of a machine-readable finding with independent verification [3]. Claude Code's own changelog points the same direction from the vendor side, with entries such as denying a tool call that would otherwise wait on a permission prompt [4]. If you already keep agents inside a permission boundary, which is what we argued for in the human approval piece and again in the guide to gating risky tool calls, the marker sweep is the cheap half of the same discipline.
Each of these came from a real run while writing this post, and each has a specific fix.
root is not a directory, exit code 2. The path you passed to --root does not exist, usually because a CI job runs the script before checkout finishes or from the wrong working directory. Print the resolved path (the script does) and check the checkout step.
root is not a directory
--root
$ python3 find_stale_markers.py --root ./does-not-exist root is not a directory: /home/user/project/does-not-exist $ echo $? 2
re.error: missing ), unterminated subpattern at position 0. A pattern was edited and a group was left open, which is easy to do when you extend the marker list. The traceback names the position, so the fix is at that offset in your pattern:
re.error: missing ), unterminated subpattern at position 0
File "/usr/lib/python3.12/re/_parser.py", line 864, in _parse raise source.error("missing ), unterminated subpattern", re.error: missing ), unterminated subpattern at position 0
Everything reports unknown for the file age. Git history lookup failed. Run the lookup by hand with the environment stripped, as above, and check the exit code rather than the printed output, because a scanner that ignores the code will happily print empty dates.
A threshold of zero that never fires. If your script treats zero as "no limit", --fail-over 0 silently passes and the gate you rely on does nothing. Our first version did exactly that, which is why the argument now defaults to -1 and the comparison is >= 0. Add one test that asserts the zero case exits non-zero.
--fail-over 0
>= 0
Does this apply to every coding agent? The measurement is Google's, on Google's internal agents. The mechanism applies to any agent that reads files during planning, and the reader reports in r/ChatGPTCoding about review effort and resource use suggest the same shape elsewhere [5]. Treat the mechanism as shared and the percentages as Google's.
Should I delete every TODO in the repository? No. The measured problem is a bug reference that no longer points at live work. A TODO that states current intent is useful context, and deleting it removes information the next reader needs.
How often should the scan run? Weekly on the whole repository, and on every pull request with a threshold. The weekly run finds the markers that age; the pull-request run stops new ones, and the second one is what keeps the count falling.
What if the ticket system is not reachable from CI? Leave those markers in the unknown bucket and review them by hand. An unreachable ticket is not a closed ticket, and the deletion should wait for a human who can see the tracker. If your agents run inside a client's systems, this is the same boundary we described in the shadow agents piece: the decision stays with a person.
Run the scanner on the repository your agents read most, and read the list before you change anything. Rank the bug-referencing markers by file age, check each ticket, and delete only the references the ticket proves obsolete. Then add the presubmit rule and the instruction block, so the count you removed does not come back.
For a team starting with agents, the order matters more than the tooling: an agent-friendly repository first, an agent with a bounded permission set second. If you are still choosing the harness itself, our Jev complete guide covers the setup we use. If you want the second half done for you, and you work in a codebase where agents already touch production paths, send a written intake and describe the repository. Maxpertise builds AI workflows you own, live in about ten days, and we publish what we learn in guides like this one.
[1] Google Developers Blog, "Agent Distraction", 2026-11-06, Sam Saccone and Madhuri Garikapati. Every figure quoted: the 13% exposure rate, the 6% follow-up rate, the 83% unprompted share, the 41% and 57% split, and the 70%, 77% and 105% overhead with p<0.0001.
[2] Stroq, a local audit of what your coding agent did and why, open source, TypeScript, last push 2026-11-07.
[3] Cloudflare, security-audit-skill, a coding-agent skill for multi-phase security audits with independently verified findings.
[4] Claude Code CHANGELOG, release 2.1.319, file last committed 2026-11-06. The changelog carries no per-release dates.
[5] r/ChatGPTCoding, top posts of the week, the review-burden and session-cost threads from 2026-11-04 and 2026-11-05. Individual permalinks returned an empty body to this machine, so the feed is the citable source.
Maxpertise is an AI-native engineering company. We embed native AI engineers inside your team, live in about 10 days. Please enable JavaScript to view the site, or email contact@maxpertise.net.