feat: tick in-body checkboxes from a domain script

A checkbox is the one part of a body that is state and not prose.
Everything else is written once; boxes get ticked as the work goes, and
until now the only ways to tick one were a human with an editor or a
model rewriting the whole body. The second is worse: the rewrite re-flows
lines and re-words sentences, so the issue's diff swells around a change
that means one character. Progress was invisible too — issue_index.py
builds INDEX.md from metadata and never looked inside a body, so "3 of 7
done" required opening the file.

All three pieces are domain: a checkbox is body syntax, which is part of
the answer to "what is an issue". The parser goes in issue.py so the sync
layer can reuse it instead of redefining the format on its own side.

issue.py gains checkboxes(text) -> [Checkbox(index, line, end_line,
checked, text, section)], plus set_checkbox(text, item, checked) and
checkbox_progress(text). All pure, no I/O, importable from another layer.
The scan covers the whole text, in any section: the type/feature template
keeps child issues as checkboxes under `## Issues`, so binding the parser
to `## Acceptance criteria` would silently lose half of them; the heading
is recorded, never required. Only a marker line opens an item, so a
wrapped continuation line belongs to the item above it rather than
counting as one of its own. A `- [ ]` inside a code fence is an example
of the markup and is skipped. Line numbers are relative to the text
given, which is what lets a caller work on a body or on a whole file.

issue_ac.py lists the items numbered, grouped by heading, and ticks one
by number or by substring. An ambiguous substring is an error that prints
the matches — a coin flip would tick the wrong box and look like it
worked. It patches the file rather than round-tripping through
Issue.to_text(), so exactly one character changes: metadata order,
wording, wrapping, trailing whitespace and CRLF endings all come back
byte for byte, proven by a diff in the tests.

INDEX.md gains a progress column: `3/7` for an issue with checkboxes,
blank for one without. Counted off the body at build time and stored in
no field — a second copy of the state would be wrong by the next edit.

issue_check.py is unchanged and stays that way on purpose: an unticked
box is work not done yet, not a malformed issue, and validate() carries a
comment saying so.

Delivering a tick to the tracker is out of scope — that is push.py
--update in /tea:sync.

format.md gets one clarifying bullet. It said acceptance criteria are
checkboxes but never said what a checkbox is, so the parser had to settle
questions the format left open: any section, wrapped items, fenced
examples. Those rules are now written down where the parser and the sync
layer can both point at them.

tests/ is new, and is the convention: plain stdlib unittest, no pytest
and no third-party deps, since the code under test may not have
dependencies either. Scripts are imported via sys.path.insert and every
fixture is built in a TemporaryDirectory, never in tmp/.

    python3 -m unittest discover -s tests -v     32 tests, OK

skills/issue/scripts/ still imports stdlib only, with no subprocess.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
naudachu
2026-08-10 15:41:15 +05:00
parent d8bd927f1d
commit 62c8ff976d
9 changed files with 770 additions and 13 deletions
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""
issue_ac.py — list and tick the checkboxes in an issue's body. Offline.
issue_ac.py wire-sqlc-appclick numbered list with state
issue_ac.py wire-sqlc-appclick --check 3 by number
issue_ac.py wire-sqlc-appclick --check регресс by substring
issue_ac.py wire-sqlc-appclick --uncheck 3
A checkbox is the one part of a body that is *state* and not prose. Everything
else is written once; boxes get ticked as the work goes, and until now the only
ways to tick one were a human with an editor or a model rewriting the whole
body — the second worse than the first, because the rewrite re-flows the text
and the issue's diff swells around a change of one character. This changes that
one character and nothing else.
Named after `## Acceptance criteria`, where most boxes live, but every checkbox
in the body is listed and tickable: a type/feature keeps its children under
`## Issues`, and binding this to one heading would silently lose half of them.
A substring picks an item only when it picks exactly one. Two matches is an
error listing both — a coin flip would tick the wrong box and look like it
worked.
Delivering the changed body to a tracker is not part of this: that is
`push.py --update` in /tea:sync.
"""
import argparse
import os
import re
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import issue # noqa: E402
import issue_index # noqa: E402
NUMBER = re.compile(r'^\d+$')
def box(c):
return "[x]" if c.checked else "[ ]"
def listing(items):
"""The numbered list, grouped by the heading each item sits under."""
out, section = [], None
for c in items:
if c.section != section:
section = c.section
out.append("")
out.append(section or "(above the first heading)")
out.append(" %2d %s %s" % (c.index, box(c), c.text))
return out
def select(items, needle):
"""Resolve a --check/--uncheck argument to exactly one item, or exit."""
needle = (needle or "").strip()
if not needle:
sys.exit("issue_ac.py: empty selector — give an item number or a substring")
if NUMBER.match(needle):
n = int(needle)
if not 1 <= n <= len(items):
sys.exit("issue_ac.py: no item %d — the issue has %d" % (n, len(items)))
return items[n - 1]
hits = [c for c in items if needle.lower() in c.text.lower()]
if not hits:
sys.exit("issue_ac.py: nothing matches %r" % needle)
if len(hits) > 1:
sys.exit("\n".join(
["issue_ac.py: %r matches %d items — narrow it down, or use a number:"
% (needle, len(hits))]
+ [" %2d %s %s" % (c.index, box(c), c.text) for c in hits]))
return hits[0]
def main(argv=None):
ap = argparse.ArgumentParser(
description="List and tick an issue's checkboxes (offline)")
ap.add_argument("id", help="issue id (the slug, without .md)")
g = ap.add_mutually_exclusive_group()
g.add_argument("--check", metavar="N|TEXT", help="tick one item: number or substring")
g.add_argument("--uncheck", metavar="N|TEXT", help="untick one item: number or substring")
ap.add_argument("--out", default=issue.ISSUE_ROOT, help="store root (default: tmp/issues)")
args = ap.parse_args(argv)
path = issue.path_of(args.out, args.id)
if not os.path.exists(path):
sys.exit("issue_ac.py: no issue %r in %s" % (args.id, args.out))
# newline="": no translation in either direction. Byte-for-byte means the
# line endings too — reading a CRLF file in text mode and writing it back
# would rewrite every line while claiming to have changed one character.
with open(path, newline="") as f:
text = f.read()
# The whole file, not just the body: line numbers then point at the file,
# and the metadata block is rewritten by nobody. Round-tripping through
# Issue.to_text() would re-render metadata and re-strip the body, which is
# exactly the byte-level churn this script exists to avoid.
items = issue.checkboxes(text)
needle = args.check if args.check is not None else args.uncheck
if not items:
if needle is not None:
sys.exit("issue_ac.py: %s has no checkboxes" % args.id)
print("%s — no checkboxes" % args.id)
return 0
if needle is None:
done = sum(1 for c in items if c.checked)
print("%s%d/%d %s" % (args.id, done, len(items), path))
print("\n".join(listing(items)))
return 0
checked = args.check is not None
item = select(items, needle)
new = issue.set_checkbox(text, item, checked)
verb = "checked" if checked else "unchecked"
if new == text:
print("unchanged %2d %s %s" % (item.index, box(item), item.text))
return 0
with open(path, "w", newline="") as f:
f.write(new)
issue_index.build(args.out)
done, total = issue.checkbox_progress(new)
print("%s %2d %s %s" % (verb, item.index, "[x]" if checked else "[ ]", item.text))
print("%s%d/%d %s:%d" % (args.id, done, total, path, item.line))
return 0
if __name__ == "__main__":
sys.exit(main())