●2.1.302 — Fork subagents are now on by default in claude -p, the Agent SDK and the desktop app. Turn them off with CLAUDE_CODE_FORK_SUBAGENT=0 (10/16)●2.1.307 — Fixes MCP failing to reconnect after sleep or a keychain lock (10/21). 2.1.306 also fixed the false claude -p login message●SONNET 4.5 — Retires on the Claude API on 11/30, 38 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…●2.1.302 — Fork subagents are now on by default in claude -p, the Agent SDK and the desktop app. Turn them off with CLAUDE_CODE_FORK_SUBAGENT=0 (10/16)●2.1.307 — Fixes MCP failing to reconnect after sleep or a keychain lock (10/21). 2.1.306 also fixed the false claude -p login message●SONNET 4.5 — Retires on the Claude API on 11/30, 38 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…
Half My Scheduled-Task Logs Were Unreadable, So I Added One Mark Line Instead of Widening the Regex
Only about half of 1,076 daily log entries yielded a result. Widening the regex recovered 35 and raised false alarms, so I wrote a fixed one-line mark per run and a morning check script.
On a Thursday night I lined up seven months of daily logs from my scheduled tasks and tried to turn them into one table.
I wanted something simple: which task, on which day, ended in SUCCESS or FAILED. I had been writing a Status: line in every log, so I assumed a single grep would do it.
Across 519 daily log files and 1,076 entries, only 526 gave up a result through the Status: line. For 515 entries, no outcome word appeared anywhere.
What follows is how I traced that, why I stopped widening the regex, and how to add one machine-readable line at the end of each run. If you run more scheduled tasks than you did a year ago, it may save you the same evening.
The unreadable half grew month by month
I counted the unreadable entries per month first.
Month
Entries
No readable result
Share
March
164
4
about 2%
May
135
50
about 37%
June
182
142
about 78%
August
94
73
about 78%
October (to the 22nd)
112
64
about 57%
March reads almost perfectly. Then June falls apart and never recovers.
The format had not been broken by accident. Every time I added a new kind of scheduled task, it started writing logs in its own shape. Of the 448 entries since June with no outcome word, 166 sit in the main daily file, 232 in the review-style files (_brushup_am and similar), and the remaining 50 in integration and troubleshooting runs.
Each log was perfectly readable to me on the day I wrote it. That is how this kind of debt works: fine for human eyes, silent trouble the first time a machine tries to read it.
The measuring script records which stage did the reading
Here is the script that produced the numbers. The useful part is that it counts not just the result but which stage managed to find it.
#!/usr/bin/env python3"""Read one result per entry from free-form daily logs (entries separated by blank lines).Try the stages in order and record which one succeeded (strict → header → body → silent)."""import re, sys, glob, collections, pathlibWORDS = r"(SUCCESS|FAILED|PENDING_PUSH|PENDING|SKIPPED_DUPLICATE|SKIPPED|SKIP)"HEAD = re.compile(r"^\[(?:\d{4}-\d\d-\d\d|\d\d:\d\d|[\w-]+ \d\d:\d\d)[^\]\n]*\]", re.M) # only a line starting with a date or time in [..] begins an entrySTRICT = re.compile(rf"^\s*[-*]?\s*Status:\s*{WORDS}", re.M) # the agreed formatINHEAD = re.compile(rf"^\[[^\]\n]+\]\s*{WORDS}", re.M) # the [task 15:00] SUCCESS styleLOOSE = re.compile(rf"\b{WORDS}\b") # the word appears anywhere in the entrydef split_entries(text): starts = [m.start() for m in HEAD.finditer(text)] if not starts: return [text] if text.strip() else [] starts.append(len(text)) return [text[a:b] for a, b in zip(starts, starts[1:])]def classify(block): for name, rx in (("strict", STRICT), ("header", INHEAD), ("body", LOOSE)): m = rx.search(block) if m: return name, m.group(1) return "silent", Nonedef main(log_dir): by_method, by_status, silent_files = collections.Counter(), collections.Counter(), [] files = sorted(glob.glob(str(pathlib.Path(log_dir) / "20??-??-??*.txt"))) for f in files: blocks = split_entries(pathlib.Path(f).read_text(encoding="utf-8", errors="ignore")) if not blocks: silent_files.append((f, "empty")); continue for b in blocks: method, status = classify(b) by_method[method] += 1 if status: by_status[status] += 1 else: silent_files.append((f, "no-status")) print(f"files={len(files)} entries={sum(by_method.values())}") print("read by stage:", dict(by_method)) print("results :", dict(by_status.most_common())) print("unreadable:", len(silent_files)) for f, why in silent_files[:5]: print(" ", pathlib.Path(f).name, why)if __name__ == "__main__": main(sys.argv[1])
The four stages are tried in order. strict is the agreed format (Status: SUCCESS), header is a result in the heading line such as [task 15:00] SUCCESS, and body is any outcome word anywhere in the entry. If none match, the entry is silent.
The silent count is the amount of log that is mute from a machine's point of view. Try it on your own logs first; it takes about three minutes.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You can now measure in about three minutes how much of your own scheduled-task logs a machine can actually read
✦You will be able to prevent false failure alarms from an incidental FAILED in the text by reading only a final mark line
✦You can now notice, with one command each morning, that a task that should have run stopped without a word
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
I expected more stages to win entries back. The header and body stages together found 11 and 24, so 35 entries, about 3% of 1,076.
Worse, the widening had a side effect. I read through the 24 entries the body stage had caught, and some matched a word that was not a result at all:
unresponsive-server check → FAILED in 2,032 ms
That line is output from a verification I ran while reviewing something. It is not the outcome of the task. But the word FAILED is in the text, so a loose pattern counts that entry as a failed task.
This ran against my expectation. The more I loosened the pattern to shrink the unreadable pile, the more false alarms I produced, because outcome words also appear in prose with other meanings.
So I changed direction. Instead of making the reader smarter, I made the writer promise one thing: a single line in a fixed shape at the very end of each run.
The writer: one function, one line
The spec for that line has four parts.
One line, tab-separated: RESULT, date, task, status, detail
Status is one of SUCCESS, SKIPPED, PENDING, FAILED. Nothing else is accepted
It goes into its own file, separate from the human-readable daily log
The date is pinned to JST
# Source this file. Call it exactly once in the last step: log_result <task> <STATUS> [detail]log_result() { local task="$1" status="$2" detail="${3:-}" day case "$status" in SUCCESS|SKIPPED|PENDING|FAILED) ;; *) echo "log_result: unknown status '$status'" >&2; return 2;; esac day="$(TZ=Asia/Tokyo date +%F)" detail="${detail//$'\t'/ }"; detail="${detail//$'\n'/ }" # flatten to one line, no tabs printf 'RESULT\t%s\t%s\t%s\t%s\n' "$day" "$task" "$status" "$detail" >> "${LOG_DIR:?LOG_DIR is not set}/_results.tsv"}
Returning 2 on an unknown status protects the line itself. If a typo like PENDINGG slips in even once, every reader has to carry a branch for unknown states forever. Rejecting it at write time means the reader only needs to know four words.
Flattening tabs and newlines in the detail field is for the same reason: a tab in the detail shifts the columns, and the reader ends up discarding the row.
Usage is a single call in the last step of the task.
Writing the line is not enough if the reader does not know which tasks to expect. A task that silently stopped leaves nothing to find. The absence of a line becomes an anomaly only once the reader knows a line should have been there. So the check script carries the list of expected tasks and the weekdays they run.
#!/usr/bin/env python3"""Morning check: for each expected task, find that day's RESULT line and judge it.Usage: morning_check_results.py <LOG_DIR> [YYYY-MM-DD] exit code 0 = all OK / 1 = needs attention"""import sys, csv, datetime, pathlib, collectionsEXPECTED = { # task name: weekdays it runs (Mon=0 … Sun=6). Every day = all seven "daily-content": set(range(7)), "premium-thu": {3},}OK_STATES = {"SUCCESS", "SKIPPED"} # SKIPPED means stopped on purpose with a reason, so it is not an anomalydef load(path): latest, bad = {}, 0 if not path.exists(): return latest, bad with path.open(encoding="utf-8") as fh: for row in csv.reader(fh, delimiter="\t"): if len(row) < 4 or row[0] != "RESULT": bad += 1; continue # count broken lines and move on _, day, task, status, *rest = row latest[(day, task)] = (status, rest[0] if rest else "") # last line of the day wins return latest, baddef main(): log_dir = pathlib.Path(sys.argv[1]) day = sys.argv[2] if len(sys.argv) > 2 else datetime.date.today().isoformat() wd = datetime.date.fromisoformat(day).weekday() latest, bad = load(log_dir / "_results.tsv") problems = [] for task, weekdays in EXPECTED.items(): if wd not in weekdays: continue status, detail = latest.get((day, task), ("MISSING", "no mark was written")) mark = "ok " if status in OK_STATES else "NG " print(f"{mark}{task:<14}{status:<8}{detail}") if status not in OK_STATES: problems.append(task) if bad: print(f"(unreadable lines: {bad} )") sys.exit(1 if problems else 0)if __name__ == "__main__": main()
I verified it against a test file of marks, not the real logs. It mixes failure and success on the same day, a broken line, and an expected task with no mark.
--- Thursday (2026-10-22)
ok daily-content SUCCESS retry ok
NG premium-thu MISSING no mark was written
(unreadable lines: 1)
exit=1
daily-content shows ok even though FAILED and SUCCESS both appear that day, because the last line wins. I did not want a day that recovered on retry counted as an anomaly. premium-thu has no mark at all, so it is MISSING and the exit code is 1.
Wire that exit code into any notification and you will notice the morning something stopped without a word.
The order I rolled it out in
Run the measuring script on the real logs and get the silent share. If it is above roughly 10%, I would say adding marks is clearly worth it
Put log_result.sh in place and add one log_result call to the last step of a single task
Let it run for about a week and confirm the mark file grows the way you expect, then extend it to the other tasks
Write the expected-task list into the check script and wire exit code 1 to a notification
I recommend trying one task before rolling it out to all of them. Doubts about status words and the detail field almost always show up on the first task.
Three pitfalls
First, last-line-wins hides mid-run failures. If the final retry succeeds, the day shows ok. To see how often retries happen, keep the raw mark file, not just the check output. I split the roles: the check is today's verdict, the mark file is the history.
Second, the expected-task list goes stale. If I stop a task and forget to remove it, MISSING appears every morning. If I add a task and forget to list it, that task is never checked. I added one line to my add/remove-a-task routine: update the list.
Third, do not backfill old logs. I was tempted to fill the 515 silent entries with guesses, and decided against it. Guessed states lined up as facts make the check meaningless. The past stays as it was, and marks start today.
My decision
I chose not to make the reader smarter, but to narrow the writer's promise to one line. The logs can stay as free-form as they like for human eyes. Only what a machine reads goes into a separate, fixed shape at the end.
Don't try to unify log formats; set aside one machine-readable line. That is the whole takeaway.
If you have a task that has been running for a while, run the measuring script and look at your silent share first. That number tells you whether adding marks is worth it. For related reading, checking the output itself before exit is covered in Logged as success, but it produced nothing, and the day a log overwrote the previous day's file is in My Unattended Task Was Overwriting the Previous Day's Log.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.