diff --git a/agents/conductors/intake/AGENTS.md b/agents/conductors/intake/AGENTS.md
index 88937b3..dd09631 100644
--- a/agents/conductors/intake/AGENTS.md
+++ b/agents/conductors/intake/AGENTS.md
@@ -74,13 +74,18 @@ schema — light structure over free-form prose.
| **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 **backlog** page; `--apply` writes `PyAutoMind/dashboard.md` |
+| **formalise** | `intake formalise [prefix]` | retroactively header the prompts census flags — derive the missing fields, insert in place, prose untouched; `--apply` writes |
Census/dashboard are the Mind *backlog* view — deliberately distinct from
Heart's `/health status` health view (see "must never do"). The prompt-taxonomy
folder is authoritative for a prompt's work-type/target; header fields are
display metadata, and headerless legacy prompts surface as hygiene flags rather
-than errors. `repair` (fixing those flags in place) is the **planned follow-up**
-— not in this cut.
+than errors. Formalise closes those flags (once codenamed `repair`, renamed
+because raw prompts are intended word-vomit awaiting conception, not defects):
+it derives Difficulty/Autonomy/Priority via the sizing faculty, writes
+Type/Target from the folder, keeps every existing field value and every line of
+prose, and turns work-type disagreements into **re-home suggestions** — it never
+moves or deletes a file.
## Run
@@ -93,6 +98,8 @@ bin/pyauto-brain intake --apply ideas # write them
bin/pyauto-brain intake census # backlog inventory (read-only)
bin/pyauto-brain intake dashboard # backlog page to stdout (dry-run)
bin/pyauto-brain intake --apply dashboard # write PyAutoMind/dashboard.md
+bin/pyauto-brain intake formalise # propose retroactive headers (dry-run)
+bin/pyauto-brain intake --apply formalise bug/ # write them, only under bug/
```
**Writes only under `--apply`; dry-run is the default.** Exit codes: `0` produced
diff --git a/agents/conductors/intake/INTAKE_TAXONOMY.md b/agents/conductors/intake/INTAKE_TAXONOMY.md
index db7609f..e26b81b 100644
--- a/agents/conductors/intake/INTAKE_TAXONOMY.md
+++ b/agents/conductors/intake/INTAKE_TAXONOMY.md
@@ -109,6 +109,18 @@ itemised.
*backlog* page (never health; that is Heart's). Census is always read-only;
dashboard writes only under `--apply`.
+**formalise** closes the hygiene flags retroactively (once codenamed `repair`;
+renamed — raw prompts are intended word-vomit awaiting conception, not defects).
+Per flagged prompt it derives the missing fields (Type/Target from the folder,
+Difficulty/Autonomy/Priority via the same sizing-faculty path conception uses)
+and inserts only those lines: after the last field of a partial header block,
+below an existing `# heading`, or under a derived `#
` when there is
+neither — every existing line survives verbatim, plus a retroactive-provenance
+comment. Where the body classifier disagrees with the folder at medium/high
+confidence it emits a *re-home suggestion*; it never moves or deletes a file.
+Optional path-prefix argument scopes the run (`intake formalise bug/`); writes
+only under `--apply` and re-runs are no-ops (nothing left missing).
+
## 7. What intake does NOT own
- The difficulty heuristic, prompt parsing, repo sets → the **sizing faculty**.
diff --git a/agents/conductors/intake/_intake.py b/agents/conductors/intake/_intake.py
index 28a456d..7a9af21 100755
--- a/agents/conductors/intake/_intake.py
+++ b/agents/conductors/intake/_intake.py
@@ -466,6 +466,127 @@ def _fmt(counts, top=None):
"header (--json lists them)")
+# --- formalise (retroactive conception) ----------------------------------------
+# The backlog's raw prompts are intended word-vomit — conception deferred, not
+# defects (hence *formalise*, not "repair"). Formalise derives the missing header
+# fields and inserts them without touching a single existing line of prose.
+_FIELD_LINE = re.compile(
+ r"(Type|Target|Difficulty|Autonomy|Priority|Status):\s*\S")
+
+
+def _derive_fields(text: str, work_type: str, target: str) -> dict:
+ """Derive a full header for a prompt body, folder identity authoritative.
+
+ Type/Target come from the taxonomy folder; Difficulty/Autonomy/Priority run
+ the same sizing-faculty path `analyse` uses at conception time.
+ """
+ repos = _repos_in(text)
+ tgt = normalise_repo(target) if target != "-" else "?"
+ if tgt in KNOWN_REPOS and tgt not in repos:
+ repos = sorted(set(repos) | {tgt})
+ p = {"text": text, "repos": repos, "words": len(text.split()),
+ "target": target, "work_type": work_type}
+ level, _score, factors = estimate_difficulty(p)
+ return {
+ "type": work_type,
+ "target": REPO_DISPLAY.get(tgt, target if target != "-" else "?"),
+ "difficulty": level,
+ "autonomy": infer_autonomy(level, factors),
+ "priority": infer_priority(text),
+ "status": "formalised",
+ }
+
+
+def _insert_fields(text: str, add: dict, has_header: bool, title: str) -> str:
+ """Insert the missing `Field: value` lines, preserving every existing line.
+
+ Partial header -> append after the last recognised field line in the leading
+ block (non-field lines like `Repos:` / `Milestone:` stay put). No header but
+ a leading `# heading` -> insert below it. Neither -> prepend a derived
+ `# ` so the file lands on the blessed shape.
+ """
+ lines = text.splitlines()
+ field_lines = [f"{f.capitalize()}: {add[f]}" for f in HEADER_FIELDS if f in add]
+ if has_header:
+ last = max(i for i, ln in enumerate(lines[:30])
+ if _FIELD_LINE.match(ln.strip()))
+ lines[last + 1:last + 1] = field_lines
+ else:
+ first = next((i for i, ln in enumerate(lines) if ln.strip()), 0)
+ if lines and lines[first].lstrip().startswith("#"):
+ lines[first + 1:first + 1] = [""] + field_lines
+ else:
+ lines[:0] = [f"# {title}", ""] + field_lines + [""]
+ # Preserve the file's own line endings — "verbatim" includes bytes, and a
+ # CRLF prompt must not come back LF-normalised with every line rewritten.
+ nl = "\r\n" if "\r\n" in text else "\n"
+ return nl.join(lines) + (nl if text.endswith("\n") else "")
+
+
+def formalise(mind: Path, prefix: str = "", apply: bool = False) -> dict:
+ """Retroactively formalise headerless / incomplete backlog prompts in place.
+
+ Reuses the census to select records with missing fields; writes ONLY under
+ --apply. Never moves or deletes a file — a work-type disagreement between
+ the body classifier and the taxonomy folder becomes a re-home *suggestion*
+ for a human, because the folder is authoritative.
+ """
+ c = census(mind)
+ proposals, suggestions = [], []
+ for r in c["records"]:
+ if prefix and not r["path"].startswith(prefix):
+ continue
+ if not r["missing"]:
+ continue
+ path = mind / r["path"]
+ # newline="" keeps \r\n intact — read_text's universal-newline mode
+ # would silently translate it and defeat the verbatim write-back.
+ with path.open(encoding="utf-8", errors="replace", newline="") as fh:
+ text = fh.read()
+ derived = _derive_fields(text, r["work_type"], r["target"])
+ add = {f: derived[f] for f in r["missing"]}
+ proposals.append({"path": r["path"], "title": r["title"],
+ "add": add, "keep": r["header"]})
+ if apply:
+ new = _insert_fields(text, add, bool(r["header"]), r["title"])
+ stamp = (f"")
+ if "formalised retroactively" not in new:
+ nl = "\r\n" if "\r\n" in new else "\n"
+ new = new.rstrip("\r\n") + nl + nl + stamp + nl
+ path.write_text(new, encoding="utf-8", newline="")
+ wt_guess, conf, _hits_ = classify_work_type(text)
+ if (conf != "low" and wt_guess != r["work_type"]
+ and r["work_type"] != "triage"):
+ suggestions.append(f"{r['path']} — classifier reads as {wt_guess} "
+ f"({conf}); filed under {r['work_type']}/")
+ return {
+ "generated": _dt.date.today().isoformat(),
+ "scanned": c["total"],
+ "formalised": len(proposals),
+ "applied": bool(apply),
+ "proposals": proposals,
+ "rehome_suggestions": suggestions,
+ }
+
+
+def emit_formalise(res: dict):
+ verb = "formalised" if res["applied"] else "to formalise"
+ print(f"== Intake formalise: {res['formalised']} prompt(s) {verb} "
+ f"(of {res['scanned']} scanned) ==")
+ for p in res["proposals"]:
+ adds = " · ".join(f"{f.capitalize()}: {v}" for f, v in p["add"].items())
+ print(f" {p['path']}")
+ print(f" + {adds}")
+ if res["rehome_suggestions"]:
+ print(f"Re-home suggestions ({len(res['rehome_suggestions'])}) — "
+ "folder stays authoritative; move by hand if the classifier is right:")
+ for s in res["rehome_suggestions"]:
+ print(f" - {s}")
+ if not res["applied"]:
+ print("\n(dry-run — re-run `intake --apply formalise` to write the headers)")
+
+
# --- ideas.md scanning --------------------------------------------------------
def scan_ideas(mind: Path):
"""Yield (bullet_text, context_header) for substantive ideas.md lines."""
@@ -552,9 +673,20 @@ def main(argv=None):
sub.add_parser("dashboard", help="render the census as the Mind backlog page; "
"--apply writes dashboard.md")
+ fm = sub.add_parser("formalise", help="retroactively header the backlog "
+ "prompts census flags; --apply writes")
+ fm.add_argument("prefix", nargs="?", default="",
+ help="only formalise prompts under this path prefix "
+ "(e.g. bug/)")
+
a = ap.parse_args(argv)
mind = Path(a.mind)
+ if a.cmd == "formalise":
+ res = formalise(mind, prefix=a.prefix, apply=a.apply)
+ print(json.dumps(res, indent=2)) if a.as_json else emit_formalise(res)
+ return 0
+
if a.cmd == "census":
c = census(mind)
print(json.dumps(c, indent=2)) if a.as_json else emit_census(c)
diff --git a/agents/conductors/intake/intake.sh b/agents/conductors/intake/intake.sh
index d682ae6..11ac09d 100755
--- a/agents/conductors/intake/intake.sh
+++ b/agents/conductors/intake/intake.sh
@@ -21,6 +21,9 @@
# intake census inventory all filed prompts (always read-only)
# intake dashboard render the census as the Mind backlog page;
# --apply writes PyAutoMind/dashboard.md
+# intake formalise [prefix] retroactively header the backlog prompts the
+# census flags (word-vomit is intent, not defect);
+# --apply writes the headers in place
#
# Flags (place before the subcommand; both default OFF):
# --apply write the formal prompt file(s) / dashboard.md; else dry-run only
@@ -42,7 +45,7 @@ apply=0
forward=()
while [[ $# -gt 0 ]]; do
case "$1" in
- -h|--help) sed -n '2,33p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;;
+ -h|--help) sed -n '2,36p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;;
--json) as_json=1; shift ;;
--apply) apply=1; shift ;;
*) forward+=("$1"); shift ;;
@@ -58,7 +61,7 @@ fi
# A bare first token that is not a known subcommand -> classify mode on the rest,
# so `intake "raw idea"` and `intake --file p.md` both work as the front door.
case "${forward[0]}" in
- classify|ideas|census|dashboard) ;;
+ classify|ideas|census|dashboard|formalise) ;;
*) forward=(classify "${forward[@]}") ;;
esac
diff --git a/skills/intake/intake.md b/skills/intake/intake.md
index a41ed41..9c6b981 100644
--- a/skills/intake/intake.md
+++ b/skills/intake/intake.md
@@ -33,6 +33,11 @@ dev workflow (issue, branch, plan). Do not bypass the Brain.
**backlog** page; dry-run prints it, `--apply` writes
`PyAutoMind/dashboard.md` (commit via `prompt_sync_push`). Backlog only —
organism *health* is `/health`, not this page.
+- `bin/pyauto-brain intake formalise [prefix]` — retroactively headers the
+ prompts census flags (word-vomit is intent, not defect): derives the missing
+ fields, inserts them in place with all prose verbatim, reports re-home
+ suggestions instead of ever moving files. Dry-run proposes; `--apply` writes
+ (then regenerate the dashboard).
## Boundary