merge: tick in-body checkboxes from a domain script

# Conflicts:
#	AGENTS.md
This commit is contained in:
naudachu
2026-08-10 15:42:38 +05:00
9 changed files with 762 additions and 13 deletions
+39 -3
View File
@@ -36,6 +36,7 @@ All offline, all in `<skill-base-dir>/scripts/`.
|---|---|
| `issue_new.py --type T --title "…"` | create `tmp/issues/<slug>.md` from the type's template |
| `issue_check.py [id…]` | validate against the canonical format; exit 1 on errors |
| `issue_ac.py <id> [--check N\|TEXT]` | list the body's checkboxes; tick or untick one |
| `issue_tree.py [id…]` | draw the dependency graph from `depends:` |
| `issue_index.py` | rebuild `tmp/issues/INDEX.md` |
| `issue.py` | the domain module the others import — not a command |
@@ -110,13 +111,48 @@ decision — `/tea:sync` — and does not change the file's status here.
## Editing an issue
Edit the file. Change `state:` to close it, edit `labels:`, tick checkboxes in
`## Acceptance criteria`, add ids to `depends:`. Re-run `issue_check.py`
afterwards, and `issue_index.py` to refresh the table.
Edit the file. Change `state:` to close it, edit `labels:`, add ids to
`depends:`. Re-run `issue_check.py` afterwards, and `issue_index.py` to refresh
the table. Checkboxes are the exception — use `issue_ac.py`, below.
If the issue is synced (`origin: gitea`), your edit is local until you run
`push.py --update` from `/tea:sync`. Nothing tracks that drift automatically.
## Ticking checkboxes
A checkbox is the one part of a body that is **state** and not prose, so it has
a command of its own. Never rewrite a body just to tick a box: the rewrite
re-flows lines and re-words sentences, and the issue's diff swells around a
change that means one character.
```bash
python3 <skill-base-dir>/scripts/issue_ac.py wire-sqlc-appclick
python3 <skill-base-dir>/scripts/issue_ac.py wire-sqlc-appclick --check 3
python3 <skill-base-dir>/scripts/issue_ac.py wire-sqlc-appclick --check "регресс"
python3 <skill-base-dir>/scripts/issue_ac.py wire-sqlc-appclick --uncheck 3
```
With no flag it prints the numbered list with each item's state, grouped by the
heading the item sits under. `--check` / `--uncheck` take that number or a
substring of the item's text (case-insensitive).
- **Every checkbox in the body counts, not just `## Acceptance criteria`.** A
`type/feature` keeps its children as checkboxes under `## Issues`, and they
are numbered in the same list. The script is named after the section most
boxes live in, nothing more.
- **A substring must match exactly one item.** Two matches is an error that
lists them; pick by number instead. It never guesses.
- **Exactly one character of the file changes.** Metadata, wording, wrapping
and trailing whitespace all come back byte for byte, so `git diff` and the
tracker's diff show the tick and nothing else.
- Examples inside a ``` fence are markup, not state — they are skipped.
- `INDEX.md` gains a `progress` column (`3/7`, blank when the issue has no
boxes), recomputed from the body on every build and stored in no field.
`issue_ac.py` rebuilds the index after a successful tick.
Getting the tick to the tracker is a separate step — `push.py --update` in
`/tea:sync`.
## Writing a proper description
Issues get filed on the run — "comments aren't pulled", "the guard broke".
+7
View File
@@ -167,6 +167,13 @@ grep -ln 'depends:.*migrate-schema' tmp/issues/*.md
valid answer.
- Acceptance criteria are `- [ ]` checkboxes; each item is an objectively
checkable condition, not an aspiration.
- A checkbox is **item markup, not a property of one section**: `- [ ]`
unticked, `- [x]` ticked, and it means the same under `## Issues` as under
`## Acceptance criteria`. An item that wraps continues on an indented line
and is still one item. A `- [ ]` inside a ``` code fence is an example of the
markup, not state. Tick them with `issue_ac.py`, which reads the whole body
on exactly these rules and rewrites one character; progress (`3/7`) is
counted off the body and is never a metadata field.
- Code references use the `path/file.ext:line` form; related issues by id.
- Screenshots are allowed but their content must be duplicated as text — an
LLM reading these files cannot see images.
+126
View File
@@ -45,6 +45,7 @@ without a parser:
grep -l 'labels:.*type/bug' tmp/issues/*.md
grep -ln 'depends:.*migrate-schema' tmp/issues/*.md # who depends on it
"""
import collections
import os
import re
@@ -365,6 +366,127 @@ def body_dep_refs(body):
return [ref for _, ref in body_dep_ref_sections(body)]
# --------------------------------------------------------------------------
# checkboxes
# --------------------------------------------------------------------------
# A checkbox is the one part of a body that is *state* and not prose, so the
# format gives it markup of its own (references/format.md:163-164). It is item
# markup, not a property of one section: `## Acceptance criteria` is the usual
# home, but a type/feature keeps its children as checkboxes under `## Issues`
# (format.md:275-277). The scan is therefore over the whole text and the
# heading is only recorded, never required.
CHECKBOX_RE = re.compile(
r'^(?P<indent>[ \t]*)(?P<marker>[-*+]|\d+[.)])[ \t]+'
r'\[(?P<box>[ xX])\](?=[ \t]|$)(?P<text>.*)$')
# Any list item — a sibling ends the item above it, checkbox or not.
LIST_ITEM_RE = re.compile(r'^[ \t]*([-*+]|\d+[.)])([ \t]|$)')
FENCE_RE = re.compile(r'^[ \t]{0,3}(`{3,}|~{3,})')
Checkbox = collections.namedtuple(
"Checkbox", "index line end_line checked text section")
def checkboxes(text):
"""Every checkbox item in `text`, in document order.
A pure function of the string it is given — no I/O, no store, no tracker.
Pass an issue body (`Issue.body`) to get body-relative line numbers, or a
whole file to get file-relative ones; nothing else changes.
Returns a list of `Checkbox` namedtuples:
index 1-based position in this list — what a user types to pick it
line 1-based line of the `- [ ]` marker, in the text given
end_line 1-based last line of the item, continuation lines included
checked True for `[x]` / `[X]`, False for `[ ]`
text the item's text; continuation lines joined with one space
section nearest preceding `## ` heading, "" above the first one
Rules:
- Only a line matching CHECKBOX_RE opens an item. A wrapped ("continuation")
line is part of the item above it, never an item of its own; the item
runs to the next blank line, heading, code fence, or list marker.
- Fenced code blocks are skipped whole: `- [ ]` inside a ``` fence is an
example of the markup, not a box anybody may tick.
- `-`, `*`, `+` and `1.` markers all count, at any indentation, so nested
lists are seen too.
"""
lines = (text or "").splitlines()
items, section, fence = [], "", ""
for n, line in enumerate(lines, 1):
m = FENCE_RE.match(line)
if m:
tok = m.group(1)
if not fence:
fence = tok
elif tok[0] == fence[0] and len(tok) >= len(fence):
fence = ""
continue
if fence:
continue
if line.startswith("## "):
section = line.strip()
continue
if line.startswith("# "):
section = ""
continue
m = CHECKBOX_RE.match(line)
if not m:
continue
end, parts = n, [m.group("text").strip()]
for k in range(n, len(lines)): # lines[k] is line number k + 1
nxt = lines[k]
if (not nxt.strip() or nxt.startswith("#")
or FENCE_RE.match(nxt) or LIST_ITEM_RE.match(nxt)):
break
end = k + 1
parts.append(nxt.strip())
items.append(Checkbox(len(items) + 1, n, end,
m.group("box") != " ",
" ".join(p for p in parts if p), section))
return items
def set_checkbox(text, item, checked=True):
"""Return `text` with one checkbox set to `checked`.
Pure, and deliberately surgical: exactly one character of the input
changes — the one between the brackets. Everything else, including
trailing whitespace and the item's own wording, comes back byte for byte.
That is the whole point of the function: ticking a box must not produce a
diff wider than the state that changed.
`item` is a `Checkbox` from `checkboxes(text)` — the same text, or the
line number will point at the wrong line — or a 1-based line number.
Already in the requested state is a no-op: `text` is returned unchanged,
and an existing `[X]` keeps its capital.
"""
line_no = item.line if isinstance(item, Checkbox) else int(item)
off = 0
for n, raw in enumerate(text.splitlines(True), 1):
if n == line_no:
m = CHECKBOX_RE.match(raw.rstrip("\r\n"))
if not m:
raise ValueError("line %d is not a checkbox item" % line_no)
if (m.group("box") != " ") == bool(checked):
return text
box = off + m.start("box")
return text[:box] + ("x" if checked else " ") + text[box + 1:]
off += len(raw)
raise ValueError("line %d is past the end of the text" % line_no)
def checkbox_progress(text):
"""(done, total) over every checkbox in `text`; (0, 0) when it has none.
Computed on the fly, on purpose. Progress is not a metadata field: it is
the body read back, and the body is the only place the state lives."""
items = checkboxes(text)
return sum(1 for c in items if c.checked), len(items)
# --------------------------------------------------------------------------
# validation
# --------------------------------------------------------------------------
@@ -430,6 +552,10 @@ def validate(issue, known_ids=None):
warn.append("%s mentions %r but `depends:` does not list it"
% (section, ref))
# An unticked checkbox is never a finding — neither an error nor a
# warning. `- [ ]` is work not done yet, which is the normal state of a
# perfectly well-formed issue. Reading that state is issue_ac.py's job.
return err, warn
+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())
+20 -6
View File
@@ -30,6 +30,16 @@ def cell(v):
return v.replace("|", "\\|") or ""
def progress(body):
"""`3/7` for a body with checkboxes, "" for one without.
Counted from the body every time the index is built and stored nowhere —
the boxes are the state, and a second copy of it in a metadata field would
be wrong by the next edit."""
done, total = issue.checkbox_progress(body)
return "%d/%d" % (done, total) if total else ""
def build(root):
# An index of a store that is not there is not an empty index, it is a bad
# path. Raising beats writing INDEX.md into a directory nobody asked for.
@@ -42,6 +52,7 @@ def build(root):
rows.append({
"id": i,
"state": cell(iss.state),
"progress": progress(iss.body),
"type": cell(iss.type),
"labels": cell(rest),
"title": cell(iss.title),
@@ -57,13 +68,16 @@ def build(root):
"Every issue this project knows about. `origin: local` means it "
"exists nowhere else — a complete state, not a pending one. Any "
"other value names the tracker it also lives in; the handle is in "
"the file. Rebuild with `issue_index.py`.", ""]
"the file. `progress` counts the body's checkboxes, ticked over "
"total, and is blank for an issue that has none — read off the "
"body at build time, stored nowhere. Rebuild with `issue_index.py`; "
"tick a box with `issue_ac.py`.", ""]
if rows:
out += ["| id | state | type | labels | title | milestone | depends | origin |",
"|---|---|---|---|---|---|---|---|"]
out += ["| [%s](%s.md) | %s | %s | %s | %s | %s | %s | %s |" % (
r["id"], r["id"], r["state"], r["type"], r["labels"], r["title"],
r["milestone"], r["depends"], r["origin"]) for r in rows]
out += ["| id | state | progress | type | labels | title | milestone | depends | origin |",
"|---|---|---|---|---|---|---|---|---|"]
out += ["| [%s](%s.md) | %s | %s | %s | %s | %s | %s | %s | %s |" % (
r["id"], r["id"], r["state"], r["progress"], r["type"], r["labels"],
r["title"], r["milestone"], r["depends"], r["origin"]) for r in rows]
else:
out.append("_empty_")