From c8fd8b8e4adc9c11f149eaf4176328298bcadae8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 18:45:16 +0000 Subject: [PATCH] =?UTF-8?q?feat(intake):=20add=20`human=5Freview`=20?= =?UTF-8?q?=E2=80=94=20the=20declaration-only=20work-type?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A completed task sometimes needs a person to read it before it counts as done. Nothing in the workflow held that: `complete/` means shipped, and a prompt that shipped without anyone checking it is indistinguishable from one that was checked. `human_review/` is a work-type in the ordinary layout (`draft/human_review//.md`, same light header) that is unlike every other one in four ways, all of them following from "a human decided this, no classifier could": - **Never inferred.** It is filtered out of the classifier's signal sets (`MANUAL_ONLY_WORK_TYPES`, sizing faculty), so only an explicit `Type: human review` reaches it. Prose about reviewing shipped work still classifies as ordinary work. Review is opt-in, not a lifecycle stage โ€” no close-out path files one. - **Never demoted.** An unresolved target sends an ordinary prompt to `triage/` ("nobody classified this"); here somebody did, and a review's subject may only be named in a completion record, so it files flat. - **Never drift.** A merged PR in the body, or a `Status:` saying shipped, is the premise of a review rather than a stalled lifecycle. - **Never backlog.** Kept out of `census()["records"]` entirely, which keeps it out of the backlog count, the pick lists, the work-type sections, the bundler and the epics in one move. It renders as its own **Human review** section on both dashboard pages, directly under *In flight* โ€” both are live obligations, and a review sunk below 140 backlog prompts would never be read. Its ๐Ÿ“‹ hands out a read-and-report prompt rather than a `/start_dev` (there is nothing to start) that names both exits, sign off or file the follow-up, because a review that stops at "looks fine" leaves the row on the board forever. `human_review` is also the first work-type whose name is two words, so the declaration readers now normalise `human review` / `human-review` / `human_review` to the folder key, and `strip_declarations` no longer strands a leading declaration's punctuation in the derived title (`Type: human review. Check the fit quality.` titled as `. Check the fit quality`). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0139dGR6XPDoo4Wrq9eDevTw --- agents/conductors/intake/AGENTS.md | 10 +- agents/conductors/intake/INTAKE_TAXONOMY.md | 42 ++++- agents/conductors/intake/_intake.py | 160 ++++++++++++++++++-- agents/faculties/sizing/_sizing.py | 44 +++++- board/_theme.py | 7 +- skills/intake/SKILL.md | 2 +- skills/intake/intake.md | 13 ++ skills/start_dev/start_dev.md | 11 +- tests/test_declared_header_precedence.py | 25 +++ tests/test_intake_dashboard.py | 126 +++++++++++++++ 10 files changed, 414 insertions(+), 26 deletions(-) diff --git a/agents/conductors/intake/AGENTS.md b/agents/conductors/intake/AGENTS.md index 77018de..0cb2c51 100644 --- a/agents/conductors/intake/AGENTS.md +++ b/agents/conductors/intake/AGENTS.md @@ -34,6 +34,7 @@ must not duplicate: |-------|------|-------------------| | `/route` | infer work-type, **dispatch** (starts dev now) | intake **files a prompt**, defers | | `triage/` | the unclassified-prompt bucket | intake **writes into** it on low confidence โ€” reuse, don't reinvent | +| `human_review/` | shipped work a human asked to sign off | intake files it **only when declared**; it is never inferred, never demoted to `triage/`, and never counted as backlog | | `create_issue` | prompt โ†’ GitHub issue + registry | intake runs strictly **before** it | ## What it decides (the IntakeDecision) @@ -48,7 +49,12 @@ Proposed path ยท Header (the block written verbatim) ยท Risks ยท Next action - **Work-type** โ€” scored by keyword signal into `feature/bug/refactor/docs/test/ release/maintenance/research/experiment`; a tie across dissimilar types or zero - signal โ†’ `triage`. + signal โ†’ `triage`. `human_review` is the one type the classifier may never + select: it means *a human has decided this shipped work needs their eyes*, + which no keyword carries, so only an explicit `Type: human review` declaration + reaches it (`MANUAL_ONLY_WORK_TYPES` in the sizing faculty). Nothing files a + review automatically and no workflow requires one โ€” review is opt-in, not a + lifecycle stage. - **Target** โ€” resolved from `@RepoName` mentions *and* bare repo names, incl. the **organism repos** (`pyautomind/pyautobrain/pyautoheart/pyautobuild/pyautomemory`) โ€” the gap that made the Feature Agent mis-route a `pyautobrain` target. @@ -100,7 +106,7 @@ the page's Hygiene section), and an un-themed prompt falls back to `Target:`. | **classify** | `intake ""` / `intake classify --file P` | classify one raw input; `--apply` writes the prompt | | **ideas** | `intake ideas` | scan `ideas.md`, propose one prompt per bullet; `--apply` writes them | | **census** | `intake census` | inventory every filed prompt (work-type/target/difficulty/status + hygiene flags); always read-only | -| **dashboard** | `intake dashboard` | render the census as the Mind **task** page โ€” picks, in flight, parked, planned, backlog, bundles, recent, epics; `--apply` writes `PyAutoMind/dashboard.md`, `--check` exits 1 on drift | +| **dashboard** | `intake dashboard` | render the census as the Mind **task** page โ€” picks, in flight, human review, parked, planned, backlog, bundles, recent, epics; `--apply` writes `PyAutoMind/dashboard.md`, `--check` exits 1 on drift | | **formalise** | `intake formalise [prefix]` | retroactively header the prompts census flags โ€” derive the missing fields, insert in place, prose untouched; `--apply` writes | | **reconcile** | `intake reconcile [prefix]` | rank backlog prompts that look already-shipped (vs the `complete/` records / `active/`), and pair live prompts that look like the same work filed twice; always read-only โ€” retiring stays human | | **reconcile --repo** | `intake reconcile --repo [prefix]` | **also** read the target repo's source: identifiers the prompts name that exist upstream, and lines they quote that are **gone** โ€” the two signals that see a prompt with no Mind-side trace. Opt-in; the default path is offline | diff --git a/agents/conductors/intake/INTAKE_TAXONOMY.md b/agents/conductors/intake/INTAKE_TAXONOMY.md index 48321a5..8b01a3e 100644 --- a/agents/conductors/intake/INTAKE_TAXONOMY.md +++ b/agents/conductors/intake/INTAKE_TAXONOMY.md @@ -25,6 +25,12 @@ prefix match) and picks the strongest: | `experiment` | spike, proof of concept, prototype, sandbox | | `feature` | add, implement, support, introduce, enable, extend (the default verb set) | +`human_review` is deliberately absent from that table, and is filtered out of +the signal sets rather than merely left out of them. It means *a human has +decided this shipped work needs their eyes* โ€” a judgement no keyword carries โ€” +so it is reachable only through an explicit declaration and can never be +inferred. See ยง7 below. + **Confidence & triage.** `high` when the winner has โ‰ฅ2 signals, `medium` at 1. A tie across *dissimilar* non-feature types, or zero signal, is genuinely ambiguous โ†’ `triage` (low confidence), written to `triage/.md` for a human @@ -134,7 +140,41 @@ suspects high/medium/low with the evidence shown. Always read-only: the final verification (target repo git log / merged PRs) and the retirement to `issued/` stay human. -## 7. What intake does NOT own +## 7. Human review โ€” the one type only a human may set + +`human_review/` holds prompts about work that has already **shipped**: a +completed task a human wants to read and sign off before calling it done. It is +a work-type like any other in the layout (`draft/human_review//.md`, +same light header) and unlike any other in three ways: + +- **Never inferred.** It is in `MANUAL_ONLY_WORK_TYPES` (sizing faculty), so the + classifier skips it entirely. The only route in is a declaration โ€” + `Type: human review`, `human-review` or `human_review`, in a header line or + mid-sentence; `norm_work_type` folds all three to the folder key. Prose *about* + reviewing shipped work classifies as ordinary work, as it should. +- **Never demoted.** An unresolved target sends an ordinary prompt to `triage/` + ("nobody classified this"). Here somebody did, and a review's subject is + shipped work whose repo may live in a completion record rather than in an + `@RepoName` the body repeats โ€” so it files flat at + `draft/human_review/.md` instead. +- **Never drift.** For every other draft prompt a merged PR in the body, or a + `Status:` saying shipped, means the lifecycle stalled. For a review both are + the premise, so the drift checks skip it. + +Nothing puts a task here automatically and no workflow requires it: review is +opt-in, not a lifecycle stage. An empty section means nothing has been flagged, +not that nothing shipped. + +**On the dashboard** it is its own top-level section, directly under *In flight* +โ€” both are live obligations, and a review sunk below a 140-prompt backlog would +never be read. It is kept out of `census()["records"]` entirely, which keeps it +out of the backlog count, the pick lists, the work-type sections, the bundler and +the epics in one move. Its ๐Ÿ“‹ hands out a read-and-report prompt rather than a +`/start_dev` (there is nothing to start) that ends by naming both exits โ€” sign +off and retire the prompt, or file the follow-up with `/intake` โ€” because a +review that stops at "looks fine" leaves the row on the board forever. + +## 8. What intake does NOT own - The difficulty heuristic, prompt parsing, repo sets โ†’ the **sizing faculty**. - The work-type โ†’ agent routing โ†’ `PyAutoMind/ROUTING.md` + the conductors. diff --git a/agents/conductors/intake/_intake.py b/agents/conductors/intake/_intake.py index bd1ae90..0e60b89 100755 --- a/agents/conductors/intake/_intake.py +++ b/agents/conductors/intake/_intake.py @@ -38,7 +38,8 @@ # the Feature Agent and this agent consult it โ€” one source of truth for sizing. sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "faculties" / "sizing")) from _sizing import ( # noqa: E402 - WORK_TYPES, LIBRARY_REPOS, WORKSPACE_REPOS, ORGANISM_REPOS, KNOWN_REPOS, + WORK_TYPES, HUMAN_REVIEW, MANUAL_ONLY_WORK_TYPES, + LIBRARY_REPOS, WORKSPACE_REPOS, ORGANISM_REPOS, KNOWN_REPOS, RISK_KEYWORDS, AMBIGUITY_KEYWORDS, normalise_repo, declared_header, declared_inline, effective_difficulty, strip_declarations, _hits, policy as _sizing_policy, BODY_MAP_PATH, @@ -196,9 +197,17 @@ def _repos_in(text: str) -> list: def classify_work_type(text: str): - """Return (work_type, confidence, per_type_hits).""" + """Return (work_type, confidence, per_type_hits). + + `MANUAL_ONLY_WORK_TYPES` (today: `human_review`) can never come out of here. + They are not a reading of the prose โ€” they are a human saying "this needs my + eyes" โ€” so they are filtered out of the signal sets rather than merely + absent from them, and only a `Type:` declaration reaches them. + """ scores = {} for wt, sigs in WORK_TYPE_SIGNALS.items(): + if wt in MANUAL_ONLY_WORK_TYPES: + continue # Word-boundary prefix match (shared _hits) โ€” not plain substring, so # "add" does not fire on "address" and "test" not on "latest". hits = _hits(text, sigs) @@ -318,7 +327,15 @@ def analyse(text: str, source: str, themes=None): title = _title(strip_declarations(text, decl_spans)) slug = _slug(title) folder = work_type if confidence != "low" else "triage" - if folder == "triage": + if folder == HUMAN_REVIEW: + # A declared human review is never demoted. `triage/` means "nobody has + # classified this"; here a human has, and the thing being reviewed is + # shipped work whose target may live in a completion record rather than + # in an @RepoName the body happens to repeat. Unresolved target files + # flat under the work-type, the way triage/ does. + proposed = (f"draft/{folder}/{target}/{slug}.md" if target != "?" + else f"draft/{folder}/{slug}.md") + elif folder == "triage": proposed = f"draft/triage/{slug}.md" elif target != "?": proposed = f"draft/{folder}/{target}/{slug}.md" @@ -360,7 +377,7 @@ def analyse(text: str, source: str, themes=None): "proposed_path": proposed, "header": header, "risks": _risks(level, factors, confidence, target, declared, estimated), - "next_action": _next_action(proposed, confidence), + "next_action": _next_action(proposed, confidence, folder), } @@ -396,7 +413,10 @@ def _risks(level, factors, confidence, target, declared=None, estimated=None): if confidence == "low": out.append("Low classification confidence โ€” filed to triage/ for a human " "to re-home once the work type is clear.") - if target == "?": + if declared.get("type") in MANUAL_ONLY_WORK_TYPES: + out.append(f"Type {declared['type']} declared โ€” it is never inferred, so " + "this filing exists because a human asked for it.") + if target == "?" and declared.get("type") != HUMAN_REVIEW: out.append("No target repo resolved โ€” add an @RepoName reference or set " "Target: before start_dev.") if factors["architectural_risk"]: @@ -408,10 +428,17 @@ def _risks(level, factors, confidence, target, declared=None, estimated=None): return out -def _next_action(proposed, confidence): +def _next_action(proposed, confidence, work_type=None): if confidence == "low": return (f"Re-run with a clearer description or --apply to file {proposed} " "in triage/ for manual re-homing.") + if work_type == HUMAN_REVIEW: + # A human review has no dev leg to route: the work already shipped. + # It waits on the Human review section of the Mind dashboard until a + # person reads it and signs it off. + return (f"Review the header, then `--apply` to write {proposed}; " + "afterwards it stands on the dashboard's Human review section " + "until you sign it off.") return (f"Review the header, then `--apply` to write {proposed}; " "afterwards `/start_dev {}` routes it.".format(proposed)) @@ -1262,7 +1289,7 @@ def _clip(text: str, limit: int = 130) -> str: # is something that already happened. EVENT_LABEL = {"issued": "issued", "registered": "issued", "started": "started", "planned": "planned", "filed": "filed", "parked": "parked", - "found": "found"} + "found": "found", "review": "flagged for review"} def _anchor(heading: str) -> str: @@ -1297,6 +1324,13 @@ def recent_events(c: dict, limit: int = RECENT_MAX) -> list: events.append({"date": r["date"], "event": "filed", "title": r["title"], "path": r["path"], "payload": f"/start_dev {r['path']}"}) + # Flagging a task for review IS an event on the work in hand, and it hands + # out its own payload rather than a `/start_dev` (there is nothing to start). + for r in c.get("human_review") or []: + if r.get("date"): + events.append({"date": r["date"], "event": "review", + "title": r["title"], "path": r["path"], + "payload": _review_payload(r)}) for key, verb in (("parked", "resume"), ("planned", "start")): for e in c.get(key) or []: if e.get("date"): @@ -1324,6 +1358,11 @@ def census(mind: Path) -> dict: *work* view โ€” health belongs to the Heart, never here. """ records, hygiene, drift, theme_flags = [], [], [], [] + # `human_review/` prompts are collected apart from the backlog: they are not + # work to pick up, they are shipped work waiting on a person. Keeping them + # out of `records` keeps them out of the pick lists, the work-type sections, + # the bundler, the epics and the backlog count in one move. + reviews = [] vocab = parse_themes(mind) for wt in WORK_TYPES: folder = mind / "draft" / wt @@ -1342,9 +1381,12 @@ def census(mind: Path) -> dict: phase = int(header.get("phase", "")) except ValueError: phase = None - records.append({ + (reviews if wt == HUMAN_REVIEW else records).append({ "path": str(rel), "work_type": wt, + # Only human_review rows read this; for a backlog prompt the + # date's event is "filed", which `recent_events` supplies. + "event": "review" if wt == HUMAN_REVIEW else "", # Folder after the work-type = target repo/domain (authoritative # โ€” a header Target: is free prose and must not override the # taxonomy). rel is draft///.md. @@ -1374,6 +1416,12 @@ def census(mind: Path) -> dict: if stray: theme_flags.append(f"{rel} โ€” unknown theme keyword(s): " + ", ".join(stray)) + if wt == HUMAN_REVIEW: + # The drift checks below read "this body names a merged PR / + # calls itself shipped, so the prompt should have advanced". + # For a human review both are the premise, not drift: the work + # shipped, and the prompt exists to have someone check it. + continue if _FIX_PR_RE.search(text): drift.append(f"{rel} โ€” body records a fix PR, but the prompt " "never left draft/ (reconcile its lifecycle)") @@ -1445,6 +1493,9 @@ def _count(key): "by_difficulty": _count("difficulty"), "by_priority": _count("priority"), "records": records, + # Shipped work a human asked to check โ€” never part of `records`, so + # never part of the backlog count above (see the collection loop). + "human_review": sorted(reviews, key=_pick_key), "in_flight": in_flight, "epics": parse_epics(mind / "epics.md"), "bundles": parse_bundles(mind / "bundles.md"), @@ -1538,6 +1589,57 @@ def _pick_key(r: dict) -> tuple: r["target"], r["path"]) +# --- human review ------------------------------------------------------------- +# `human_review/` prompts are the one section of the page that is not work to +# start. The task already shipped; what is outstanding is a person reading it +# and saying it is sound. So its ๐Ÿ“‹ hands out a read-and-report prompt rather +# than a `/start_dev`, and it ends by naming both exits โ€” sign off (retire the +# prompt) or don't (file the follow-up) โ€” because a review that stops at +# "looks fine" leaves the row on the board forever. +HUMAN_REVIEW_BLURB = ( + "Shipped work waiting on **you** โ€” tasks a human asked to check before " + "calling them done. Nothing lands here on its own: a task only gets a " + "review row when someone files one (`/intake` with `Type: human review`), " + "so an empty section means nothing has been flagged, not that nothing " + "shipped." +) + +HUMAN_REVIEW_PAYLOAD = """\ +Walk me through the completed work described in `{path}` so I can sign it off. + +1. Read the prompt: what was asked, and what it claims shipped. +2. Find the evidence โ€” the merged PR(s), the commits, the `complete/` record โ€” + and read the actual diff, not the description of it. +3. Report what changed, what it does NOT cover, and anything you would have + done differently. Call out behaviour changes and test gaps explicitly. + +Change nothing while reviewing. When I sign it off, retire the prompt from the +PyAutoMind checkout (`python3 scripts/lifecycle.py record --date + --from-file --apply`, then `git rm` the prompt) and +regenerate the dashboard. If I do not sign it off, file the follow-up with +`/intake` instead. +""" + + +def _review_payload(r: dict) -> str: + return HUMAN_REVIEW_PAYLOAD.format(path=r["path"]) + + +def _review_head(r: dict) -> str: + """One human-review row's text โ€” `_bullet`'s shape, minus difficulty. + + Difficulty sizes the work of BUILDING a thing; nothing was built here, so + it would be noise. The date takes its place: how long a review has been + waiting is exactly what a human reading this section wants to know. + """ + facets = " ยท ".join(_summary_label(x) for x in + (r["target"], r["autonomy"], r["priority"]) if x != "-") + head = f"{_summary_label(r['title'])}" + if facets: + head += f" โ€” {facets}" + return head + _dated(r) + + # --- freshness banner ---------------------------------------------------------- # The page is only as current as the files it is generated from, and the way it # goes wrong is asymmetric: `dashboard_refresh.yml` self-heals a stale *render* @@ -1782,6 +1884,8 @@ def render_dashboard(c: dict) -> str: "| Where | Count |", "|-------|------:|", f"| [In flight](#in-flight) (`active/`) | {c['issued_count']} |", + f"| [Human review](#human-review) (`draft/human_review/`) | " + f"{len(c.get('human_review') or [])} |", f"| [Parked](#parked) (`parked.md`) | {len(c['parked'])} |", f"| [Planned](#planned) (`planned.md`) | {len(c['planned'])} |", f"| [Backlog](#backlog) (`draft/`) | {c['total']} |", @@ -1827,6 +1931,14 @@ def render_dashboard(c: dict) -> str: L += _items(flight) or ["- _(nothing in flight)_"] L += [""] + # Directly under In flight: both are live obligations, and a review that + # sank below the 140-prompt backlog would never be read. + reviews = c.get("human_review") or [] + L += ["## Human review", "", HUMAN_REVIEW_BLURB, ""] + L += _items([_task_row(_review_head(r), _review_payload(r)) + for r in reviews]) or ["- _(nothing awaiting review)_"] + L += [""] + for key, heading, verb, blurb in ( ("parked", "Parked", "resume", "Started or scoped, not currently in flight โ€” " @@ -1983,11 +2095,13 @@ def _md_inline(text: str) -> str: """Render the inline markdown this module authors (`code` spans only) as HTML. The freshness blurb is written once and rendered on both pages; the markdown - page takes it verbatim, this turns its backticks into `` so the HTML - twin does not print them literally. Deliberately not a markdown parser โ€” - it handles exactly the one construct the blurb uses. + page takes it verbatim, this turns its backticks into `` and its + `**bold**` into `` so the HTML twin does not print them literally. + Deliberately not a markdown parser โ€” it handles exactly the two constructs + the blurbs this module authors actually use. """ - return re.sub(r"`([^`]+)`", r"\1", text) + text = re.sub(r"`([^`]+)`", r"\1", text) + return re.sub(r"\*\*([^*]+)\*\*", r"\1", text) def _attr(value: str) -> str: @@ -2049,7 +2163,9 @@ def record_row(r): 'Recent is the same work by date โ€” what has ' "been happening rather than what to do next."), # The four numbers a human wants before reading a single row. - stats((c["issued_count"], "In flight"), (len(c["parked"]), "Parked"), + stats((c["issued_count"], "In flight"), + (len(c.get("human_review") or []), "Human review"), + (len(c["parked"]), "Parked"), (len(c["planned"]), "Planned"), (c["total"], "Backlog")), ] H += [f'

Last updated {c["generated"]}. ' @@ -2105,6 +2221,22 @@ def h2(title, src): if not c["in_flight"]: H.append('

(nothing in flight)

') + reviews = c.get("human_review") or [] + H += ['' + + h2("Human review", "draft/human_review").replace( + "/blob/main/draft", "/tree/main/draft"), + f'

{_md_inline(HUMAN_REVIEW_BLURB)}

'] + for r in reviews: + text = link(r["path"], _summary_label(r["title"])) + text += pills(*(_summary_label(x) for x in + (r["target"], r["autonomy"], r["priority"])), + work_type=HUMAN_REVIEW) + if r.get("date"): + text += f'{_dated(r)}' + H.append(_html_task(text, _review_payload(r))) + if not reviews: + H.append('

(nothing awaiting review)

') + for key, heading, verb in (("parked", "Parked", "resume"), ("planned", "Planned", "start")): rows = c[key] @@ -2231,6 +2363,8 @@ def _fmt(counts, top=None): print(f"Filed prompts: {c['total']} (already issued: {c['issued_count']})") print(f"Registry: in flight {c['issued_count']} ยท parked " f"{len(c['parked'])} ยท planned {len(c['planned'])}") + print(f"Human review: {len(c.get('human_review') or [])} shipped task(s) " + "flagged for a human to sign off") print(f"By work-type: {_fmt(c['by_work_type'])}") print(f"By target: {_fmt(c['by_target'], top=8)}") print(f"By difficulty: {_fmt(c['by_difficulty'])}") diff --git a/agents/faculties/sizing/_sizing.py b/agents/faculties/sizing/_sizing.py index f5a63c3..93a110b 100755 --- a/agents/faculties/sizing/_sizing.py +++ b/agents/faculties/sizing/_sizing.py @@ -53,9 +53,33 @@ "maintenance": "dependency updates, hygiene, small tech debt", "research": "exploratory scientific/algorithmic investigation", "experiment": "prototype, spike, proof-of-concept", + "human_review": "shipped work a human must read and sign off", "triage": "classification still unclear", } +# `human_review` is the one work-type nothing may INFER. Every other type is a +# guess a classifier is allowed to make from prose; this one says "a human has +# decided this shipped work needs their eyes", which is a judgement no keyword +# carries. It is therefore reachable only through an explicit `Type:` +# declaration (see `MANUAL_ONLY_WORK_TYPES` below and the intake classifier), +# and a task never acquires it by default โ€” review is opt-in, not a stage. +HUMAN_REVIEW = "human_review" + +# Work-types a classifier must never select on its own; only a declaration wins +# them. Kept beside the vocabulary so every conductor reads the same rule. +MANUAL_ONLY_WORK_TYPES = frozenset({HUMAN_REVIEW}) + + +def norm_work_type(value: str) -> str: + """"Human Review" / "human-review" -> the canonical folder key. + + Work-type folders are single lowercase tokens, so the only normalisation a + multi-word type needs is separator collapsing โ€” but it needs it in both + readers (`Type: human review` in a header, "type: human-review" in prose) + and in the folder name, so it is defined once here. + """ + return re.sub(r"[\s-]+", "_", value.strip().lower()) + # --- policy + body-map loaders (the extraction seam, PyAutoBrain#75) --------- # Vocabulary lives in PyAutoBrain/config/policy.yaml (a declared config # surface an adopting fork replaces); repo IDENTITY derives from the body map @@ -441,7 +465,7 @@ def declared_header(text: str) -> dict: if v in DIFFICULTY_LEVELS and out["declared_difficulty"] is None: out["declared_difficulty"] = v elif key == "type": - v = value.lower() + v = norm_work_type(value) if v in WORK_TYPES and out["declared_type"] is None: out["declared_type"] = v elif key == "autonomy": @@ -469,7 +493,11 @@ def declared_header(text: str) -> dict: _DIFFICULTY_ALT = r"too[-\s]large|small|medium|large" _AUTONOMY_ALT = r"human[-\s]required|supervised|safe" _PRIORITY_ALT = r"high|normal|low" -_TYPE_ALT = "|".join(sorted(WORK_TYPES, key=len, reverse=True)) +# A multi-word type is written "human review" or "human-review" as often as +# "human_review", so every separator is accepted and `norm_work_type` folds the +# match back to the folder key. +_TYPE_ALT = "|".join(t.replace("_", r"[\s_-]") + for t in sorted(WORK_TYPES, key=len, reverse=True)) # Between key and value: a colon/equals, "is", or nothing ("Difficulty large"). _DECL_SEP = r"\s*(?::|=|\bis\b)?\s*" _DECLARATION = re.compile( @@ -511,7 +539,7 @@ def declared_inline(text: str): if priority: fields.setdefault("priority", _norm_level(priority)) if work_type: - fields.setdefault("type", work_type.lower()) + fields.setdefault("type", norm_work_type(work_type)) spans.append(m.span()) return fields, spans @@ -533,6 +561,10 @@ def strip_declarations(text: str, spans: list) -> str: out = re.sub(r"[ \t]+", " ", out) out = re.sub(r"\s+([.,;:])", r"\1", out) out = re.sub(r"([.,;:])(\s*[.,;:])+", r"\1", out) + # A declaration that OPENED the line ("Type: human review. Check the โ€ฆ") + # leaves its terminator stranded at the front; without this the derived + # title โ€” and the filename โ€” start with a bare full stop. + out = re.sub(r"^[ \t]*[.,;:]+[ \t]*", "", out, flags=re.M) return out if re.search(r"\w", out) else text @@ -580,9 +612,9 @@ def parse_prompt(path: Path, mind: Path): # Formalised prompts carry Type:/Target: header lines โ€” authoritative # whenever the path yields no valid taxonomy (active/, stray layouts). if work_type not in WORK_TYPES: - m = re.search(r"^Type:\s*([a-z_]+)\s*$", text, re.M) - if m and m.group(1) in WORK_TYPES: - work_type = m.group(1) + m = re.search(r"^Type:\s*([A-Za-z][A-Za-z _-]*?)\s*$", text, re.M) + if m and norm_work_type(m.group(1)) in WORK_TYPES: + work_type = norm_work_type(m.group(1)) if target == "?": m = re.search(r"^Target:\s*(\S+)\s*$", text, re.M) if m: diff --git a/board/_theme.py b/board/_theme.py index 161a3f6..0649c85 100644 --- a/board/_theme.py +++ b/board/_theme.py @@ -568,7 +568,7 @@ def hero(key, kind, lede_html=""): "feature": "โœจ", "bug": "\U0001f41b", "refactor": "โ™ป๏ธ", "docs": "\U0001f4d6", "test": "\U0001f9ea", "release": "\U0001f680", "maintenance": "\U0001f9f9", "research": "\U0001f52c", - "experiment": "โš—๏ธ", "triage": "โ“", + "experiment": "โš—๏ธ", "human_review": "\U0001f440", "triage": "โ“", } @@ -595,7 +595,10 @@ def pills(*values, work_type=None): out = [] if work_type and work_type != "-": glyph = WORK_TYPE_GLYPHS.get(work_type, "") - tone = " y" if work_type == "triage" else "" + # Both of these are a call to action rather than a category: `triage` + # means nobody has classified this, `human_review` means someone is + # waiting on a person to read it. + tone = " y" if work_type in ("triage", "human_review") else "" out.append(f'{glyph} {_esc(work_type)}' f'') first = True diff --git a/skills/intake/SKILL.md b/skills/intake/SKILL.md index 806b6d5..525d4fe 100644 --- a/skills/intake/SKILL.md +++ b/skills/intake/SKILL.md @@ -1,6 +1,6 @@ --- name: intake -description: Turn raw PyAutoLabs ideas, bug reports, or loose requirements into classified and sized PyAutoMind prompt files through the PyAutoBrain Intake Agent, regenerating the Mind dashboard in the same commit. Use before start-dev when intent is not yet formalized. +description: Turn raw PyAutoLabs ideas, bug reports, or loose requirements into classified and sized PyAutoMind prompt files through the PyAutoBrain Intake Agent, regenerating the Mind dashboard in the same commit. Also the door for flagging already-shipped work for human review (Type: human review), which is only ever filed when explicitly asked for. Use before start-dev when intent is not yet formalized. --- # Intake diff --git a/skills/intake/intake.md b/skills/intake/intake.md index b9cefe4..c6b1e6c 100644 --- a/skills/intake/intake.md +++ b/skills/intake/intake.md @@ -22,6 +22,15 @@ Shared routing context: `PyAutoBrain/skills/COMMANDS.md`. 3. When it looks right, re-run with **`--apply`** to write the prompt file into PyAutoMind under `draft///.md` (or `draft/triage/` if the classification is genuinely unclear). + + **Asking for a human review.** To flag a task that has already *shipped* as + needing your eyes before it counts as done, declare it โ€” `Type: human review` + in the input, anywhere. It files to `draft/human_review//.md` and + stands in the dashboard's **Human review** section (with a ๐Ÿ“‹ that hands out a + read-and-report prompt, not a `/start_dev`) until you sign it off. Never infer + this: no amount of "someone should check this" in the prose selects it, and no + completed task acquires it by default. If the user has not asked for a review, + there is no review. 4. **Regenerate the dashboard and commit both together.** `--apply` writes the prompt file and nothing else โ€” it runs no git โ€” so the filing is not finished until the page that offers the task is rebuilt: @@ -94,6 +103,10 @@ dev workflow (issue, branch, plan). Do not bypass the Brain. infers a work-type and *files a prompt* (defers). - Low-confidence classification lands in `triage/` โ€” the existing unclassified bucket, reused. +- **`human_review/` is declaration-only.** It is the sole work-type intake may + not infer, and a review is never a required step โ€” the user asks for one, or + there isn't one. A declared review is also never demoted to `triage/`: with no + target resolved it files flat at `draft/human_review/.md`. - Writes happen **only** under `--apply`; the default is a read-only dry run. - **Machine sources** (scholar research runs, Heart findings, profiling results) stage as provenance-tagged `ideas.md` bullets โ€” `- [from: ] diff --git a/skills/start_dev/start_dev.md b/skills/start_dev/start_dev.md index 7327a2b..c1a0465 100644 --- a/skills/start_dev/start_dev.md +++ b/skills/start_dev/start_dev.md @@ -34,7 +34,16 @@ Examples: `bug/autofit/factor_graph_instance_iteration.md`, If the user gives a development task with **no** prompt path, first write a concise prompt file under the right `PyAutoMind///` folder -(include the original request verbatim), then continue with that path. +(include the original request verbatim), then continue with that path. Never +write one under `human_review/` โ€” that folder is shipped work awaiting a human's +sign-off, not work to start, and it is filed only by an explicit `/intake` +declaration. + +A `draft/human_review/โ€ฆ` path handed to `/start_dev` is almost certainly a +mistake: the work it names already shipped. Say so and offer the review (read +the prompt, find the merged PR and the `complete/` record, report back) instead +of opening a worktree โ€” unless the user confirms they want new development, in +which case file that as its own ordinary prompt. ## --auto mode diff --git a/tests/test_declared_header_precedence.py b/tests/test_declared_header_precedence.py index e1d93be..5d295e4 100644 --- a/tests/test_declared_header_precedence.py +++ b/tests/test_declared_header_precedence.py @@ -167,3 +167,28 @@ def test_bug_decision_reports_the_declared_difficulty(): assert d["difficulty_declared"] == "small" assert d["difficulty_derived"] != "small" assert d["difficulty_disagreement"] is True + + +def test_a_multi_word_work_type_is_read_however_a_human_spells_it(): + """`human_review` is the first work-type whose name is two words. + + A human writes "human review" in a header and "human-review" mid-sentence; + the folder key is `human_review`. All three must resolve, in both readers, + or the one work-type that can ONLY be declared becomes undeclarable. + """ + for spelling in ("human review", "Human Review", "human-review", + "human_review"): + assert declared_header(f"Type: {spelling}\n")["declared_type"] == \ + "human_review", spelling + assert declared_inline(f"file this as type: {spelling} please")[0] \ + .get("type") == "human_review", spelling + # An unknown type is still no declaration at all. + assert declared_header("Type: vibes\n")["declared_type"] is None + + +def test_a_leading_declaration_does_not_strand_its_punctuation_in_the_title(): + """"Type: human review. Check the thing." must title as "Check the thing", + not ". Check the thing" โ€” the title names the file.""" + text = "Type: human review. Check the fit quality." + _fields, spans = declared_inline(text) + assert strip_declarations(text, spans).strip() == "Check the fit quality." diff --git a/tests/test_intake_dashboard.py b/tests/test_intake_dashboard.py index f74507c..97c19e8 100644 --- a/tests/test_intake_dashboard.py +++ b/tests/test_intake_dashboard.py @@ -1430,3 +1430,129 @@ def test_formalising_writes_themes_under_repos_and_never_waits_for_one(tmp_path) # A pasted header block that already carries the list keeps it. pasted = _intake.analyse(_themed("Pasted", "mge"), "test") assert pasted["themes"] == ["mge"] + + +# --------------------------------------------------------------------------- # +# human review โ€” the manual-only work-type (a complete task a human must check) +# --------------------------------------------------------------------------- # +def _review(title, target="widgets", priority="normal", date="2026-08-29"): + return (f"# {title}\n\nType: human_review\nTarget: {target}\n" + f"Difficulty: small\nAutonomy: human-required\n" + f"Priority: {priority}\nStatus: formalised\nFiled: {date}\n\n" + "Shipped in PR #99. Wanted eyes on it before calling it done.\n") + + +def test_human_review_is_never_inferred_only_declared(): + """The one work-type no classifier may reach. + + Every other type is a reading of the prose; this one is a human saying + "my eyes are needed", which no keyword carries. Prose that talks about + reviewing shipped work still classifies as ordinary work. + """ + prose = ("Someone should review and assess the finished work on the " + "widget pipeline and check it is ok before we call it done.") + assert _intake.classify_work_type(prose)[0] != "human_review" + assert _intake.analyse(prose, "test")["work_type"] != "human_review" + for declaration in ("Type: human review", "Type: human-review", + "Type: human_review"): + d = _intake.analyse(f"{declaration}\n\nCheck the @PyAutoMind widget " + "work shipped in PR #99.", "test") + assert d["work_type"] == "human_review", declaration + assert d["work_type_source"] == "declared" + assert d["proposed_path"].startswith("draft/human_review/") + + +def test_declared_human_review_is_never_demoted_to_triage(): + """`triage/` means nobody classified this; here somebody did. + + A review's subject is shipped work whose repo may only be named in a + completion record, so an unresolved target must not send it to triage the + way it would an ordinary prompt. + """ + d = _intake.analyse("Type: human review\n\nCheck last week's thing.", "test") + assert d["work_type"] == "human_review" + assert d["proposed_path"] == "draft/human_review/check_last_week_s_thing.md" + assert "triage" not in d["proposed_path"] + assert not any("No target repo resolved" in r for r in d["risks"]) + assert "start_dev" not in d["next_action"] + # The same input WITHOUT the declaration is the ordinary triage filing. + assert _intake.analyse("Check last week's thing.", + "test")["proposed_path"].startswith("draft/triage/") + + +def test_declaring_a_type_does_not_leak_into_the_derived_title(tmp_path): + """A declaration that opens the input must not name the file after itself.""" + d = _intake.analyse("Type: human review. Check the widget fit quality.", + "test") + assert d["title"] == "Check the widget fit quality" + assert d["proposed_path"].endswith("check_the_widget_fit_quality.md") + + +def test_human_review_is_its_own_section_not_backlog(tmp_path): + """Shipped work waiting on a person is not work to pick up. + + It must not inflate the backlog count, appear in the pick lists, or sink + into a work-type section under 140 other prompts. + """ + mind = _mind(tmp_path, drafts={ + "feature/widgets/a.md": _prompt("Widget A", priority="high"), + "human_review/widgets/checked.md": _review("Check the widget rollout", + priority="high")}) + c = _intake.census(mind) + assert c["total"] == 1 + assert [r["path"] for r in c["human_review"]] == [ + "draft/human_review/widgets/checked.md"] + assert "human_review" not in c["by_work_type"] + assert all(r["work_type"] != "human_review" for r in c["records"]) + + page = _page(mind) + section = page.split("## Human review")[1].split("## Parked")[0] + assert "Check the widget rollout" in section + assert "Widget A" not in section + assert "| [Backlog](#backlog) (`draft/`) | 1 |" in page + assert "| [Human review](#human-review) (`draft/human_review/`) | 1 |" in page + # The row hands out a review prompt, never a /start_dev. + assert "/start_dev draft/human_review" not in page + assert "so I can sign it off" in section + # Highest priority is a pick list; a review is not pickable work. + assert "Check the widget rollout" not in page.split("## In flight")[0] + + +def test_human_review_section_renders_empty_rather_than_vanishing(tmp_path): + """An absent section reads as "nothing to review"; so must an empty one โ€” + but only the section says which, so it is always drawn.""" + page = _page(_mind(tmp_path, drafts={"feature/widgets/a.md": _prompt("A")})) + section = page.split("## Human review")[1].split("## Parked")[0] + assert "_(nothing awaiting review)_" in section + assert "nothing has been flagged, not that nothing shipped" in section + + +def test_human_review_body_may_name_its_shipped_pr_without_reading_as_drift( + tmp_path): + """For every other prompt a merged PR in the body means the lifecycle + stalled. For a review it is the premise.""" + mind = _mind(tmp_path, drafts={ + "human_review/widgets/checked.md": _review("Check it"), + "feature/widgets/stalled.md": _prompt("Stalled") + "\nFix: PR #12\n"}) + drift = _intake.census(mind)["drift"] + assert any("stalled.md" in d for d in drift) + assert not any("human_review" in d for d in drift) + + +def test_human_review_appears_in_the_recent_feed_as_its_own_event(tmp_path): + mind = _mind(tmp_path, drafts={ + "human_review/widgets/checked.md": _review("Check it")}) + row = _intake.census(mind)["recent"][0] + assert row["event"] == "flagged for review" + assert row["payload"].startswith("Walk me through the completed work") + + +def test_human_review_renders_on_the_html_twin(tmp_path): + mind = _mind(tmp_path, drafts={ + "human_review/widgets/checked.md": _review("Check the widget rollout")}) + html = _intake.render_dashboard_html(_intake.census(mind)) + section = html.split('')[1].split("

")[1] + assert "Check the widget rollout" in section + assert "so I can sign it off" in section + # The blurb's markdown must not print literally on a page that renders HTML. + assert "**you**" not in html and "you" in html