Skip to main content

Command Palette

Search for a command to run...

A decision-log checker for AI tasks that were declined or deferred

One small Python checker, three versions of the same log, and what the checker can't see

Updated
•10 min read•View as Markdown

When an AI-assisted task stops before any code changes, the reason usually ends up in one comment: "blocked, no access." The next person who picks the task up repeats the same investigation, because the comment doesn't say which access, who can grant it, or what was already tried.

Teams that write a proper decision log fix most of that. The trouble I keep running into is that the log is free text, so its quality depends on how tired the runner was when they wrote it. This field note is about making the log checkable. It has one artifact: a small Python checker that reads a declined or deferred task log and refuses the ones that will waste the next person's time.

The example task below is made up. The checker, the three log files and the output are real, and you can run them with Python 3.11 or newer and no dependencies.

The log is a TOML file

Free text is hard to check, so the log is a short TOML file that sits on the task. These are the fields the checker cares about:

  • decision: declined (the task shouldn't go ahead as written) or deferred (it should, but not now or not by this person).
  • missing_access: the resource, the access the runner has, the access the task needs, and who can grant it.
  • unclear_scope: the sentence from the brief, quoted, with at least two readings, and who answers it.
  • alternate_owner: a team, a person if known, and whether the runner is handing the task over or only pointing at them.
  • changed: what was touched, or "nothing".
  • For a deferred task, unblock_when and revisit. For a declined task, reason_for_author.

The checker

"""Check a declined/deferred task log before it goes on the ticket.

Usage: python3 check_decision_log.py logs/*.toml
Exits 1 if any file has an error. Stdlib only, Python 3.11+.
"""
import re
import sys
import tomllib

SECRETS = [
    (r"gh[pousr]_[A-Za-z0-9]{20,}", "GitHub token"),
    (r"github_pat_\w{20,}", "GitHub fine-grained token"),
    (r"AKIA[0-9A-Z]{16}", "AWS access key id"),
    (r"xox[abprs]-[A-Za-z0-9-]{10,}", "Slack token"),
    (r"sk-[\w-]{20,}", "API secret key"),
    (r"(?i)bearer\s+[\w.-]{20,}", "bearer token"),
    (r"(?i)(password|passwd|secret)\s*[:=]\s*\S+",
     "password or secret assignment"),
]
SCOPE_ANSWERERS = {"author", "reviewer"}
HANDOVER = {"handing over", "pointing only"}


def check(path):
    raw = open(path, encoding="utf-8").read()
    errors, warnings = [], []

    for pattern, label in SECRETS:
        if re.search(pattern, raw):
            errors.append(f"looks like a {label} is pasted"
                          " in the log; remove it and rotate it")

    try:
        log = tomllib.loads(raw)
    except tomllib.TOMLDecodeError as e:
        return [f"not valid TOML: {e}"], []

    decision = log.get("decision")
    if decision not in ("declined", "deferred"):
        errors.append(f"decision is {decision!r};"
                      ' use "declined" or "deferred"')

    for i, a in enumerate(log.get("missing_access", []), 1):
        for key in ("resource", "have", "need", "who_can_grant"):
            if not a.get(key):
                errors.append(f"missing_access #{i}: no {key}")
        if a.get("have") and a.get("have") == a.get("need"):
            warnings.append(f"missing_access #{i}: have and"
                            f" need are both {a['have']!r}")

    for i, s in enumerate(log.get("unclear_scope", []), 1):
        if not s.get("quote"):
            errors.append(f"unclear_scope #{i}: quote the"
                          " sentence from the brief")
        if len(s.get("readings", [])) < 2:
            errors.append(f"unclear_scope #{i}: list at least"
                          " two readings")
        who = s.get("answered_by")
        if who not in SCOPE_ANSWERERS:
            errors.append(f"unclear_scope #{i}: answered_by must"
                          f" be author or reviewer, not {who!r}")

    owner = log.get("alternate_owner")
    if owner is not None:
        if not owner.get("team"):
            errors.append("alternate_owner: no team")
        if owner.get("handover") not in HANDOVER:
            errors.append('alternate_owner: handover must be'
                          ' "handing over" or "pointing only"')
        if not owner.get("person"):
            warnings.append("alternate_owner: no person named")

    if "changed" not in log:
        errors.append('no "changed" line; write "nothing"'
                      " if nothing was touched")

    if decision == "deferred":
        if not log.get("unblock_when"):
            errors.append("deferred but no unblock_when conditions")
        if not log.get("revisit"):
            errors.append("deferred but no revisit date")
        elif log.get("date") and log["revisit"] <= log["date"]:
            errors.append("revisit is not after the log date")
    if decision == "declined" and not log.get("reason_for_author"):
        errors.append("declined but no reason_for_author")

    specific = (log.get("missing_access")
                or log.get("unclear_scope") or owner)
    if not specific:
        warnings.append("no missing_access, unclear_scope or"
                        " alternate_owner; is the reason specific?")

    return errors, warnings


def main(paths):
    failed = False
    for path in paths:
        errors, warnings = check(path)
        status = "FAIL" if errors else "ok"
        print(f"{path}: {status} ({len(errors)} errors,"
              f" {len(warnings)} warnings)")
        for e in errors:
            print(f"  error:   {e}")
        for w in warnings:
            print(f"  warning: {w}")
        failed |= bool(errors)
    return 1 if failed else 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))

One task, three versions of the log

The task: "Regenerate the missing September invoice PDFs." Lilit claims it and opens it with a coding agent. Before generating anything, she checks where the PDFs live, what she can write there, and what "regenerate" means here. Then she stops and writes the log.

Version 1: the comment everyone writes

task = "Regenerate the missing September invoice PDFs"
decision = "blocked"
runner = "Lilit"
date = 2026-10-15
notes = "no access to prod storage, not sure which template they want. ask billing?"
logs/1-first-note.toml: FAIL (2 errors, 1 warnings)
  error:   decision is 'blocked'; use "declined" or "deferred"
  error:   no "changed" line; write "nothing" if nothing was touched
  warning: no missing_access, unclear_scope or alternate_owner; is the reason specific?

All the information is in the note somewhere, but none of it can be acted on. "Blocked" doesn't say whether the task should come back. "Ask billing?" doesn't say who, or whether Billing is meant to take the task.

Version 2: structured, with two real problems

task = "Regenerate the missing September invoice PDFs"
decision = "deferred"
runner = "Lilit (with coding agent)"
date = 2026-10-15
tried = """
Listed the bucket with the read token from the deploy job
(ghp_EXAMPLEONLYexampleonlyEXAMPLEONLY0000) to count missing files: 212.
"""
changed = "nothing"

[[missing_access]]
resource = "invoices-prod bucket"
have = "read"
need = "write"
who_can_grant = "Billing ops lead"

[[unclear_scope]]
quote = "regenerate the missing invoices"
readings = ["use the September template", "use the current template"]
answered_by = "agent"

[alternate_owner]
team = "Billing ops"

(The token is a fake placeholder.)

logs/2-second-pass.toml: FAIL (5 errors, 1 warnings)
  error:   looks like a GitHub token is pasted in the log; remove it and rotate it
  error:   unclear_scope #1: answered_by must be author or reviewer, not 'agent'
  error:   alternate_owner: handover must be "handing over" or "pointing only"
  error:   deferred but no unblock_when conditions
  error:   deferred but no revisit date
  warning: alternate_owner: no person named

The first two errors are the ones I wrote the checker for.

The pasted token is the worse one, and the log hides a second problem behind it. The count of 212 came from a deploy job's token, not from Lilit's own access. Her own have = "read" was also wrong: she had no access to the bucket at all. If the access isn't yours, the fix is to ask the person in who_can_grant or hand the task over, not to borrow a credential that happens to work. Once that token was out of the log, the honest count had to come from somewhere else.

The second error is about who answers the scope question. The agent offered a reading and it was a reasonable one, but which template goes on a customer invoice is a billing and legal decision. The checker only accepts the task author or the reviewer.

Version 3: what went on the ticket

task = "Regenerate the missing September invoice PDFs"
decision = "deferred"
runner = "Lilit (with coding agent)"
date = 2026-10-15
tried = """
Counted missing PDFs from the export job's own failure report: 212.
Ran the generator locally on 3 sample orders with the current template.
"""
changed = "nothing; local test output deleted, no branch pushed"
unblock_when = [
  "the author picks a template for the 212 invoices",
  "Billing ops takes the task, or grants Lilit write on invoices-prod",
]
revisit = 2026-10-17

[[missing_access]]
resource = "invoices-prod bucket"
have = "none (the count above came from the job report, not the bucket)"
need = "write"
who_can_grant = "Billing ops lead"

[[unclear_scope]]
quote = "regenerate the missing invoices"
readings = [
  "September template, so totals match what customers were charged",
  "current template, which adds the new tax line from October 1",
]
answered_by = "author"

[alternate_owner]
team = "Billing ops"
person = "Mariam"
handover = "pointing only"
logs/3-final.toml: ok (0 errors, 0 warnings)

The readings now explain why the choice matters, which is what gets the author to answer quickly. pointing only tells Mariam she is not being handed a task she never accepted.

Who writes and who decides

The checker enforces a split of roles that is easy to blur when an agent is in the loop.

  • The task author prepares the work. They wrote "regenerate the missing invoices", so they answer which template. If the task is declined, reason_for_author is addressed to them.
  • The runner executes with their own authorized tools. Lilit claims the task, does the readiness check with her own access, and writes the log. The agent can draft entries, but she confirms them, because only she knows what was actually tried. If her access isn't enough, the log names who can grant more. It never records a borrowed credential as a workaround.
  • The reviewer judges. For a deferred task, they decide whether the reason holds and what happens next: wait for the author, or reopen the task for Billing ops.

That split is what keeps a stalled task as transferable AI work. Whoever picks it up next gets the question, the answer owner, and the access path, not just the word "blocked".

Where the log goes

On the task itself, not in chat. In Wagglet's request-to-delivery workflow, a delivery report is allowed to say that an approach was tried, it failed, and why. A claim that is released or reopened also stays in the task's attempt history. So the log has an obvious home: the same ticket, attached to the attempt that stopped. A reviewer can then reopen the task for a new runner without anyone opening a second ticket.

I run the checker as a pre-commit-style step before pasting the log in. Anything that exits 1 doesn't go on the ticket.

Limits

  • It checks structure, not truth. Version 2 claimed have = "read" when Lilit had no access. The checker passed that field. Only the runner knows what's true.
  • The secret scan only catches what it knows. I tested a log with a database URL that had a password in it (postgres://user:password@host/db). It passed with 0 errors. The patterns cover common token prefixes and password= assignments, nothing more. Your real secret scanner should still run on anything posted to a ticket.
  • answered_by = "author" is a promise, not a fact. The checker can't tell whether the author has actually answered.
  • TOML is friction. Engineers are fine with it. A product manager filing a declined request will probably skip it, so for non-code tasks a form with the same fields works better.
  • It's too heavy for small tasks. On a five-minute task, one line saying who should take it is enough.

Drafted with AI assistance. The checker, the three logs and the output above come from running it locally; the invoice task is invented.