#!/usr/bin/env python3 """Promote a routed intake work record into a workplan, task, decision, or engagement — CUST-WP-0061-T03, the promotion transition named in canon/standards/work-record-types_v0.1.md: "Promotion is a first-class transition: intake.routed -> workplan | task | decision | engagement, executed by one CLI/MCP call that writes both artefacts, sets promoted_to/origin back-links, and re-syncs. Manual transcription of an intake item into other kinds is a process defect." This is the mechanism AWQ-010 needed and didn't have: a human/agent had to notice, transcribe, and re-register it by hand (binky-control, 2026-07-19). One call does what that manual pass did. Kinds: workplan — new ADR-001 file at workplans/{ID}-{slug}.md, registered against the hub the same way C-06 registers workplans. task — appended as a ```task``` block to an existing workplan file (--workplan-file), registered via POST /tasks. decision — appended as a ```yaml``` block with a fresh {PREFIX}-DEC- {YYYY}-{NNN} id to --target-file, registered the same way C-32 registers decisions. engagement — appended as a ```yaml``` block with a fresh {PREFIX}-ENG- {YYYY}-{NNN} id to --target-file. File-only: no hub entity exists for engagements yet (CUST-WP-0061 stage-3 seed) — this is reported, not silently skipped. In every case: the intake is closed with outcome=promoted and promoted_to=; the new record's file carries an `origin: intake:` back-link. """ from __future__ import annotations import argparse import re import sys from datetime import date, datetime, timezone from pathlib import Path sys.path.insert(0, str(Path(__file__).parent)) from consistency_check import ( # noqa: E402 _api_get, _api_patch, _api_post, _inject_task_id_into_block, _inject_yaml_block_field, infer_wp_prefix, resolve_topic_domain_slug, ) _SKIP_DIRS = frozenset({".git", "node_modules", ".venv", "history", "agents_backup"}) class PromotionError(Exception): pass def _slugify(title: str) -> str: slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") return slug[:60].rstrip("-") or "untitled" def _next_number(repo_dir: Path, id_regex: re.Pattern, *, width: int = 4) -> int: """Scan every .md file for ids matching id_regex (capture group 1 = the numeric part); return the next free sequence number.""" max_n = 0 for md in repo_dir.rglob("*.md"): if any(part in _SKIP_DIRS for part in md.parts): continue try: text = md.read_text(errors="replace") except OSError: continue for m in id_regex.finditer(text): try: max_n = max(max_n, int(m.group(1))) except (ValueError, IndexError): continue return max_n + 1 def _fetch_intake(api_base: str, intake_id: str) -> dict: intake = _api_get(api_base, f"/intakes/{intake_id}") if not intake or (isinstance(intake, dict) and "_error" in intake): raise PromotionError(f"could not fetch intake {intake_id}: {intake}") if intake.get("status") != "routed": raise PromotionError( f"intake {intake_id} is '{intake.get('status')}', not 'routed' — " f"route it first (POST /intakes/{intake_id}/route)" ) return intake def _close_intake(api_base: str, intake_id: str, canonical_id: str, to_kind: str) -> dict: result = _api_post(api_base, f"/intakes/{intake_id}/close", { "outcome": "promoted", "promoted_to": canonical_id, "note": f"promoted to {to_kind} {canonical_id}", }) if not result or (isinstance(result, dict) and "_error" in result): raise PromotionError(f"created {canonical_id} but failed to close intake: {result}") return result def _origin_line(intake_id: str) -> str: return f'origin: "intake:{intake_id}"' # --------------------------------------------------------------------------- # workplan # --------------------------------------------------------------------------- def _promote_to_workplan( api_base: str, repo_dir: Path, repo_slug: str, domain: str, intake: dict, ) -> tuple[str, Path]: prefix = infer_wp_prefix(repo_dir, repo_slug) id_re = re.compile(rf"\b{re.escape(prefix)}-(\d+)\b") number = _next_number(repo_dir, id_re) new_id = f"{prefix}-{str(number).zfill(4)}" slug = _slugify(intake["title"]) workplans_dir = repo_dir / "workplans" workplans_dir.mkdir(exist_ok=True) new_file = workplans_dir / f"{new_id}-{slug}.md" if new_file.exists(): raise PromotionError(f"target file already exists: {new_file}") today = date.today().isoformat() body = intake.get("description") or intake["title"] content = ( f"---\n" f'id: {new_id}\n' f"type: workplan\n" f'title: "{intake["title"]}"\n' f"domain: {domain}\n" f"repo: {repo_slug}\n" f"status: proposed\n" f"owner: codex\n" f'created: "{today}"\n' f'updated: "{today}"\n' f"---\n\n" f'# {intake["title"]}\n\n' f"{body}\n\n" f"**Promoted from intake** `{intake['id']}` — {_origin_line(intake['id'])}\n" ) new_file.write_text(content, encoding="utf-8") topics = _api_get(api_base, "/topics") topic_domain = resolve_topic_domain_slug(domain, repo_market_domain=domain or None) topic_id = None if isinstance(topics, list): for t in topics: if t.get("domain_slug") == topic_domain: topic_id = t["id"] break if topic_id is None: raise PromotionError( f"wrote {new_file.name} but no topic found for domain '{topic_domain}' " f"— could not register the workstream; run fix-consistency to finish registration" ) repo_record = _api_get(api_base, f"/repos/{repo_slug}") if not repo_record or (isinstance(repo_record, dict) and "_error" in repo_record): raise PromotionError( f"wrote {new_file.name} but could not look up repo '{repo_slug}' " f"({repo_record}) — could not register the workstream; " f"run fix-consistency to finish registration" ) ws = _api_post(api_base, "/workplans", { "topic_id": topic_id, "repo_id": repo_record["id"], "title": intake["title"], "slug": f"{repo_slug}-{slug}", "status": "active", "owner": "codex", }) if not ws or (isinstance(ws, dict) and "_error" in ws): raise PromotionError( f"wrote {new_file.name} but workstream registration failed: {ws} " f"— run fix-consistency to finish registration (C-06 will pick it up)" ) _inject_field_in_frontmatter(new_file, "state_hub_workstream_id", ws["id"]) return new_id, new_file def _inject_field_in_frontmatter(file_path: Path, field: str, value: str) -> None: text = file_path.read_text(encoding="utf-8") parts = text.split("---", 2) if len(parts) < 3: return parts[1] = parts[1].rstrip() + f'\n{field}: "{value}"\n' file_path.write_text("---".join(parts), encoding="utf-8") # --------------------------------------------------------------------------- # task # --------------------------------------------------------------------------- def _promote_to_task(api_base: str, workplan_file: Path, intake: dict) -> str: if not workplan_file.is_file(): raise PromotionError(f"--workplan-file not found: {workplan_file}") text = workplan_file.read_text(encoding="utf-8") wp_id_match = re.search(r"^id:\s*(\S+)", text, re.MULTILINE) if not wp_id_match: raise PromotionError(f"could not find workplan id in {workplan_file}") wp_id = wp_id_match.group(1).strip().strip('"') ws_id_match = re.search(r'^state_hub_workstream_id:\s*"?([\w-]+)"?', text, re.MULTILINE) if not ws_id_match: raise PromotionError( f"{workplan_file} has no state_hub_workstream_id — run fix-consistency on it first" ) ws_id = ws_id_match.group(1) existing_t_re = re.compile(rf"{re.escape(wp_id)}-T(\d+)") number = _next_number(workplan_file.parent.parent, existing_t_re) t_id = f"{wp_id}-T{str(number).zfill(2)}" body = intake.get("description") or intake["title"] section = ( f'\n## Task: {intake["title"]}\n\n' f"{body}\n\n" f"Promoted from intake `{intake['id']}`.\n\n" f"```task\n" f"id: {t_id}\n" f"status: todo\n" f"priority: medium\n" f"```\n" ) workplan_file.write_text(text.rstrip() + "\n" + section, encoding="utf-8") task = _api_post(api_base, "/tasks", { "workplan_id": ws_id, "title": intake["title"], "description": intake.get("description"), "status": "todo", "priority": "medium", }) if task and "_error" not in task: _inject_task_id_into_block(workplan_file, "state_hub_task_id", task["id"], t_id) return t_id # --------------------------------------------------------------------------- # decision / engagement — share the "yaml block appended to a target file" # shape; decision has a hub entity (C-32-style registration), engagement # does not yet. # --------------------------------------------------------------------------- def _next_dec_or_eng_id(repo_dir: Path, prefix: str, kind_infix: str) -> str: year = date.today().year id_re = re.compile(rf"\b{re.escape(prefix)}-{kind_infix}-{year}-(\d+)\b") number = _next_number(repo_dir, id_re, width=3) return f"{prefix}-{kind_infix}-{year}-{str(number).zfill(3)}" def _promote_to_decision( api_base: str, repo_dir: Path, repo_slug: str, domain: str, target_file: Path, intake: dict, ) -> str: prefix = infer_wp_prefix(repo_dir, repo_slug).replace("-WP", "") new_id = _next_dec_or_eng_id(repo_dir, prefix, "DEC") _append_yaml_block(target_file, { "id": new_id, "title": intake["title"], "status": "prepared", "lane": intake.get("lane", "yellow"), "agent_recommendation": intake.get("description") or "(fill in before founder review)", "fallback_if_no_response": "no degradation — remains pending", "created_at": date.today().isoformat(), }, origin_intake_id=intake["id"]) topics = _api_get(api_base, "/topics") topic_domain = resolve_topic_domain_slug(domain, repo_market_domain=domain or None) topic_id = None if isinstance(topics, list): for t in topics: if t.get("domain_slug") == topic_domain: topic_id = t["id"] break if topic_id is None: raise PromotionError( f"wrote {new_id} to {target_file.name} but no topic found for domain " f"'{topic_domain}' — run fix-consistency to finish registration" ) decision = _api_post(api_base, "/decisions", { "title": intake["title"], "topic_id": topic_id, "decision_type": "pending", "description": intake.get("description"), }) if decision and "_error" not in decision: _inject_yaml_block_field(target_file, "state_hub_decision_id", decision["id"], new_id) return new_id def _promote_to_engagement(repo_dir: Path, repo_slug: str, target_file: Path, intake: dict) -> str: prefix = infer_wp_prefix(repo_dir, repo_slug).replace("-WP", "") new_id = _next_dec_or_eng_id(repo_dir, prefix, "ENG") _append_yaml_block(target_file, { "id": new_id, "title": intake["title"], "status": "queued", "counterparty": "(fill in)", "prepared_material": [], "deadline_pressure": "none", }, origin_intake_id=intake["id"]) return new_id def _append_yaml_block(target_file: Path, fields: dict, *, origin_intake_id: str) -> None: lines = ["```yaml"] for k, v in fields.items(): if isinstance(v, str): lines.append(f'{k}: "{v}"') elif isinstance(v, list): lines.append(f"{k}: {v!r}") else: lines.append(f"{k}: {v}") lines.append(f'origin: "intake:{origin_intake_id}"') lines.append("```") block = "\n".join(lines) + "\n" if target_file.exists(): existing = target_file.read_text(encoding="utf-8") target_file.write_text(existing.rstrip() + "\n\n" + block, encoding="utf-8") else: target_file.parent.mkdir(parents=True, exist_ok=True) target_file.write_text(block, encoding="utf-8") # --------------------------------------------------------------------------- # entry point # --------------------------------------------------------------------------- def _soft_warn_missing_doc(intake: dict) -> None: """STATE-WP-0077: warn (do not block) if DoC-Ok is not recorded.""" try: from quality_assessment import intake_has_doc_ok except ImportError: sys.path.insert(0, str(Path(__file__).resolve().parent)) from quality_assessment import intake_has_doc_ok # type: ignore if intake_has_doc_ok(intake): return print( "WARNING: intake has no DoC-Ok recorded (quality_doc / notes). " "Promotion continues (soft only). Assess Definition of Comprehension " "before confident promote — docs/work-record-quality-gates.md", file=sys.stderr, ) def promote_intake( api_base: str, intake_id: str, to_kind: str, repo_dir: Path, repo_slug: str, domain: str, *, target_file: str | None = None, workplan_file: str | None = None, ) -> dict: intake = _fetch_intake(api_base, intake_id) _soft_warn_missing_doc(intake) if to_kind == "workplan": canonical_id, new_file = _promote_to_workplan(api_base, repo_dir, repo_slug, domain, intake) location = str(new_file.relative_to(repo_dir)) elif to_kind == "task": if not workplan_file: raise PromotionError("--to task requires --workplan-file") wp_path = repo_dir / workplan_file canonical_id = _promote_to_task(api_base, wp_path, intake) location = workplan_file elif to_kind == "decision": if not target_file: raise PromotionError("--to decision requires --target-file") canonical_id = _promote_to_decision( api_base, repo_dir, repo_slug, domain, repo_dir / target_file, intake ) location = target_file elif to_kind == "engagement": if not target_file: raise PromotionError("--to engagement requires --target-file") canonical_id = _promote_to_engagement(repo_dir, repo_slug, repo_dir / target_file, intake) location = target_file else: raise PromotionError(f"unsupported --to kind: {to_kind!r} (workplan|task|decision|engagement)") _close_intake(api_base, intake_id, canonical_id, to_kind) return { "intake_id": intake_id, "to_kind": to_kind, "canonical_id": canonical_id, "location": location, } def main() -> None: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("intake_id") ap.add_argument("--to", required=True, choices=["workplan", "task", "decision", "engagement"]) ap.add_argument("--repo-path", default=".", help="local checkout path of the target repo") ap.add_argument("--repo-slug", required=True) ap.add_argument("--domain", required=True, help="market domain slug, e.g. infotech") ap.add_argument("--target-file", default=None, help="repo-relative path (decision/engagement)") ap.add_argument("--workplan-file", default=None, help="repo-relative workplan file path (task)") ap.add_argument("--api-base", default="http://127.0.0.1:8000") args = ap.parse_args() try: result = promote_intake( args.api_base, args.intake_id, args.to, Path(args.repo_path).resolve(), args.repo_slug, args.domain, target_file=args.target_file, workplan_file=args.workplan_file, ) except PromotionError as e: print(f"ERROR: {e}", file=sys.stderr) sys.exit(1) print(f"Promoted intake {result['intake_id']} -> {result['to_kind']} {result['canonical_id']}") print(f" file: {result['location']}") print(" Review, commit, and push the change — promotion writes files but does not commit.") if __name__ == "__main__": main()