|
| 1 | +"""A planning registry whose kinds can actually read — ROADMAP §12.1's sequel. |
| 2 | +
|
| 3 | +`plan_incident` proves the governance; its node bodies are stubs, and a goal |
| 4 | +like "summarise the docs in this workspace" gets either an honest negative or |
| 5 | +a hollow success, depending on how vague the goal is. This registry closes |
| 6 | +that gap for one bounded job: **reading documentation under the current |
| 7 | +working directory and reporting what is there.** Three kinds: |
| 8 | +
|
| 9 | + survey list the documentation files under cwd |
| 10 | + read read them and record an excerpt of each |
| 11 | + summarise one note distilling title + first paragraph per file |
| 12 | +
|
| 13 | +Everything is deterministic operator code. The model still does the |
| 14 | +*planning* — which kinds, in what order, replanning on refusal — but no node |
| 15 | +body contains a model call, so a run with a scripted planner produces real |
| 16 | +file content and a run with `--model` spends tokens only on rounds. |
| 17 | +
|
| 18 | +The confinement matters more than the capability: bodies read, never write, |
| 19 | +and only under `Path.cwd()` — which, launched from the Slack bot, is the |
| 20 | +bot's working directory. A proposal cannot steer them elsewhere because a |
| 21 | +proposal names kinds and carries no arguments; there is no path field to |
| 22 | +inject (the gap issue #10 tracks does not open here because these bodies |
| 23 | +take no arguments at all). |
| 24 | +""" |
| 25 | + |
| 26 | +from __future__ import annotations |
| 27 | + |
| 28 | +from pathlib import Path |
| 29 | +from typing import Any |
| 30 | + |
| 31 | +from pydantic import BaseModel |
| 32 | + |
| 33 | +from grapharc.harness.permissions import Decision |
| 34 | +from grapharc.planner import CostEstimate, EdgePolicy, EdgeRule, NodeRegistry, NodeSpec |
| 35 | + |
| 36 | +#: Read at most this many files, this much of each. Documentation, not a dump. |
| 37 | +MAX_FILES = 20 |
| 38 | +MAX_CHARS = 40_000 |
| 39 | +_SUFFIXES = (".md", ".txt", ".rst") |
| 40 | +_SKIP_DIRS = {".git", ".grapharc", ".venv", "__pycache__", "node_modules"} |
| 41 | + |
| 42 | + |
| 43 | +class DocsState(BaseModel): |
| 44 | + """`notes` is deliberately the whole record: the loop's goal check reads it.""" |
| 45 | + |
| 46 | + goal: str = "" |
| 47 | + notes: list[str] = [] |
| 48 | + |
| 49 | + |
| 50 | +def _docs_files(root: Path) -> list[Path]: |
| 51 | + """Every documentation file under `root`, and nothing outside it.""" |
| 52 | + found = [] |
| 53 | + for path in sorted(root.rglob("*")): |
| 54 | + if len(found) >= MAX_FILES: |
| 55 | + break |
| 56 | + if not path.is_file() or path.suffix.lower() not in _SUFFIXES: |
| 57 | + continue |
| 58 | + if any(part in _SKIP_DIRS for part in path.parts): |
| 59 | + continue |
| 60 | + # Belt and braces: rglob cannot leave root, but a symlink can point |
| 61 | + # anywhere. Resolve and check before a single byte is read. |
| 62 | + if not path.resolve().is_relative_to(root): |
| 63 | + continue |
| 64 | + found.append(path) |
| 65 | + return found |
| 66 | + |
| 67 | + |
| 68 | +def _excerpt(path: Path, root: Path) -> str: |
| 69 | + text = path.read_text(encoding="utf-8", errors="replace")[:MAX_CHARS] |
| 70 | + lines = [line.strip() for line in text.splitlines()] |
| 71 | + title = next((line.lstrip("# ") for line in lines if line), path.name) |
| 72 | + body = next((line for line in lines if line and not line.startswith("#")), "") |
| 73 | + return f"{path.relative_to(root)}: {title}" + (f" — {body[:160]}" if body else "") |
| 74 | + |
| 75 | + |
| 76 | +def _survey_body(state: DocsState) -> dict: |
| 77 | + root = Path.cwd().resolve() |
| 78 | + files = _docs_files(root) |
| 79 | + listing = ", ".join(str(f.relative_to(root)) for f in files) or "none found" |
| 80 | + return {"notes": [*state.notes, f"survey: {len(files)} documentation file(s): {listing}"]} |
| 81 | + |
| 82 | + |
| 83 | +def _read_body(state: DocsState) -> dict: |
| 84 | + root = Path.cwd().resolve() |
| 85 | + notes = [f"read {f.relative_to(root)}: {_excerpt(f, root)}" for f in _docs_files(root)] |
| 86 | + return {"notes": [*state.notes, *(notes or ["read: nothing to read"])]} |
| 87 | + |
| 88 | + |
| 89 | +def _summarise_body(state: DocsState) -> dict: |
| 90 | + root = Path.cwd().resolve() |
| 91 | + files = _docs_files(root) |
| 92 | + if not files: |
| 93 | + summary = "summary: no documentation files under the working directory" |
| 94 | + else: |
| 95 | + parts = "; ".join(_excerpt(f, root) for f in files[:10]) |
| 96 | + summary = f"summary of {len(files)} file(s): {parts}" |
| 97 | + return {"notes": [*state.notes, summary]} |
| 98 | + |
| 99 | + |
| 100 | +_BODIES = {"survey": _survey_body, "read": _read_body, "summarise": _summarise_body} |
| 101 | + |
| 102 | + |
| 103 | +def _factory(build: Any) -> Any: |
| 104 | + # `build` is the materialiser's NodeBuild: `name` is the instance the |
| 105 | + # planner chose ("readme_survey"), `kind` is what the registry licensed. |
| 106 | + # Behaviour keys on the kind; the name is the planner's business. |
| 107 | + body = _BODIES[build.kind] |
| 108 | + body.writes = {"notes"} |
| 109 | + return body |
| 110 | + |
| 111 | + |
| 112 | +WRITES: dict[str, set[str]] = {kind: {"notes"} for kind in _BODIES} |
| 113 | + |
| 114 | + |
| 115 | +def build_registry() -> NodeRegistry: |
| 116 | + """Three read-only kinds. Absence is refusal; there is no write kind to deny.""" |
| 117 | + return NodeRegistry( |
| 118 | + [ |
| 119 | + NodeSpec( |
| 120 | + name="survey", |
| 121 | + description="list the documentation files under the working directory", |
| 122 | + factory=_factory, |
| 123 | + worst_case=CostEstimate(iterations=1, tokens=300), |
| 124 | + ), |
| 125 | + NodeSpec( |
| 126 | + name="read", |
| 127 | + description="read each documentation file and record an excerpt", |
| 128 | + factory=_factory, |
| 129 | + worst_case=CostEstimate(iterations=1, tokens=1500), |
| 130 | + ), |
| 131 | + NodeSpec( |
| 132 | + name="summarise", |
| 133 | + description="distil what was read into one summary note", |
| 134 | + factory=_factory, |
| 135 | + worst_case=CostEstimate(iterations=1, tokens=800), |
| 136 | + ), |
| 137 | + ] |
| 138 | + ) |
| 139 | + |
| 140 | + |
| 141 | +def default_edge_policy() -> EdgePolicy: |
| 142 | + """Allow every transition: nothing here mutates, so nothing needs denying.""" |
| 143 | + return EdgePolicy(rules=(EdgeRule(action=Decision.ALLOW),)) |
| 144 | + |
| 145 | + |
| 146 | +def scripted_planner_replies() -> list[str]: |
| 147 | + """One reply: survey → read → summarise. Read by `grapharc plan` when no |
| 148 | + `--model` is given, so the free path exercises the same registry the paid |
| 149 | + one does. In an empty directory the chain still yields three notes, so the |
| 150 | + loop's goal check is satisfied either way.""" |
| 151 | + import json |
| 152 | + |
| 153 | + from grapharc.runtime.graph import END, START |
| 154 | + |
| 155 | + chain = ["survey", "read", "summarise"] |
| 156 | + endpoints = [START, *chain, END] |
| 157 | + return [ |
| 158 | + json.dumps( |
| 159 | + { |
| 160 | + "nodes": [{"name": kind} for kind in chain], |
| 161 | + "edges": [ |
| 162 | + {"source": a, "target": b} |
| 163 | + for a, b in zip(endpoints, endpoints[1:], strict=False) |
| 164 | + ], |
| 165 | + } |
| 166 | + ) |
| 167 | + ] |
| 168 | + |
| 169 | + |
| 170 | +STATE_SCHEMA = DocsState |
| 171 | + |
| 172 | +#: Nothing in this registry writes outside run state, so the policy generator |
| 173 | +#: has nothing to deny — and saying so explicitly beats being defaulted. |
| 174 | +MUTATING_KINDS: tuple[str, ...] = () |
| 175 | + |
| 176 | +__all__ = [ |
| 177 | + "MUTATING_KINDS", |
| 178 | + "STATE_SCHEMA", |
| 179 | + "WRITES", |
| 180 | + "DocsState", |
| 181 | + "build_registry", |
| 182 | + "default_edge_policy", |
| 183 | + "scripted_planner_replies", |
| 184 | +] |
0 commit comments