Skip to content

Commit 4228b3b

Browse files
authored
Merge pull request #119 from eduralph/feat/102-plan-seeding-sources
feat(plan): composable, configurable Plan-seeding sources
2 parents abc6f9e + aca993b commit 4228b3b

6 files changed

Lines changed: 351 additions & 3 deletions

File tree

docs/03-plan.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,25 @@ any Plan beat when `notes.json` is absent, so the planner has the comment thread
4343
without you scraping by hand. It's best-effort — a failure just falls back to the
4444
CSV / asking you.
4545

46+
### Composing several sources
47+
48+
A good brief often draws on more than the ticket — a linked design doc, an accepted
49+
proposal, a spec section, a CSV row. Declare a list of `[[plan.source]]` providers in
50+
`pdca.toml` and each contributes context into the bundle's `sources/` dir before Plan,
51+
so the planner briefs from the **full** picture, not one scrape (issue #102). Built-in
52+
types: `github` (`gh`), `gitlab` (`glab`), `csv`, `file` (a path/glob, `{id}`
53+
interpolated), and `command` (the escape hatch — exactly `notes_cmd`, run with
54+
`$PDCA_BUNDLE` / `$PDCA_SOURCES` set). Each is best-effort; the legacy `notes_cmd` still
55+
runs alongside them. For example, "the GitHub issue **and** its linked ADR":
56+
57+
```toml
58+
[[plan.source]]
59+
type = "github"
60+
[[plan.source]]
61+
type = "file"
62+
path = "docs/adr/*{id}*.md"
63+
```
64+
4665
## What a real brief looks like
4766

4867
This is the **actual** brief the planner produced for gramps issue 11589, a

template/pdca.toml.jinja

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,28 @@ issue_trailer = "Fixes #{id}"
7272
# non-fatal (Plan falls back to the CSV / asking the human). "" / unset → no fetch.
7373
# notes_cmd = "./engine/scripts/scrape-notes.sh {id}"
7474

75+
# Composable Plan-seeding sources (issue #102) — a brief often draws on MORE than the
76+
# ticket (a linked proposal / ADR / spec, a CSV row). Declare one or more providers as
77+
# [[plan.source]]; each contributes context into the bundle's `sources/` dir before the
78+
# Plan beat, and the planner briefs from ALL of them. Each is best-effort (a missing tool
79+
# / file / failing command is non-fatal). Built-in types: github (gh), gitlab (glab), csv,
80+
# file (a path/glob, {id} interpolated), command (the escape hatch — a `.format(id=)` shell
81+
# command run with $PDCA_BUNDLE / $PDCA_SOURCES set; it writes its own output). The legacy
82+
# notes_cmd above still runs, so this is purely additive. Examples (uncomment to use):
83+
# [[plan.source]]
84+
# type = "github" # gh issue view {id} --json … → sources/github-<id>.json
85+
# # repo = "owner/repo" # optional; omit to use gh's default repo
86+
# [[plan.source]]
87+
# type = "file" # a linked design doc / ADR / proposal / spec
88+
# path = "docs/adr/*{id}*.md"
89+
# [[plan.source]]
90+
# type = "csv"
91+
# path = "path/to/export.csv"
92+
# key = "id" # the id column header; omit to copy the whole export
93+
# [[plan.source]]
94+
# type = "command" # exactly today's notes_cmd, but one of several sources
95+
# cmd = "./engine/scripts/scrape-notes.sh {id}"
96+
7597
# Publish mechanics — `pdca publish` and the flow's publish-on-accept (docs 03 §Check
7698
# closing step). Branch patterns are `{id}` / `{slug}` format strings; the
7799
# repo→checkout map falls back to the sibling convention (`<project>/../<repo-last-

template/src/pdca_harness/config.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,11 @@ class Config:
7878
# retrieve a bundle's tracker thread into issue_<id>/notes.json (the planner reads it).
7979
# $PDCA_BUNDLE = the bundle dir; the command writes notes.json itself. "" ⇒ no fetch.
8080
notes_cmd: str = ""
81+
# Composable Plan-seeding sources (issue #102): a list of [[plan.source]] providers the
82+
# Plan beat runs to seed a bundle's sources/ dir, so a brief can draw on the ticket AND
83+
# a linked proposal AND a spec — not just one notes_cmd. Each: {type, ...} where type ∈
84+
# {github, gitlab, csv, file, command}; empty ⇒ only the legacy notes_cmd path.
85+
plan_sources: list[dict] = field(default_factory=list)
8186
# Publish mechanics — config-driven so the harness ships project-agnostic.
8287
# Branch patterns are .format(id=, slug=) strings; issue_trailer is .format(id=).
8388
fix_branch_pattern: str = "fix/{id}-{slug}"
@@ -160,6 +165,7 @@ def load(cls, root: Path | None = None) -> "Config":
160165

161166
paths = data.get("paths", {})
162167
tracker = data.get("tracker", {})
168+
plan_sources = list(data.get("plan", {}).get("source", [])) # [[plan.source]] (#102)
163169
publisher_cfg = data.get("publisher", {})
164170
leaves = data.get("leaves", {})
165171
gates = data.get("gates", {})
@@ -231,6 +237,7 @@ def leaf(name: str) -> LeafConfig:
231237
issue_id_example=tracker.get("issue_id_example", ""),
232238
tracker_export_csv=tracker.get("export_csv", ""),
233239
notes_cmd=tracker.get("notes_cmd", ""),
240+
plan_sources=plan_sources,
234241
fix_branch_pattern=publisher_cfg.get("fix_branch_pattern", "fix/{id}-{slug}"),
235242
feature_branch_pattern=publisher_cfg.get("feature_branch_pattern", "enhancement/{id}-{slug}"),
236243
base_remote=publisher_cfg.get("base_remote", "upstream"),

template/src/pdca_harness/leaves.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
from . import brief
4646
from . import gates
4747
from . import progress
48+
from . import sources
4849
from . import worktree
4950
from .config import Config, LeafConfig
5051

@@ -140,7 +141,7 @@ def ensure_notes(cfg: Config, d: Path) -> None:
140141
# ----------------------------------------------------------------------------
141142
def do_plan(d: Path, cfg: Config, csv: str | None = None) -> None:
142143
d.mkdir(parents=True, exist_ok=True)
143-
ensure_notes(cfg, d) # seed notes.json from the tracker scraper if configured (#65)
144+
sources.seed(cfg, d) # seed notes.json + sources/ from the configured providers (#65/#102)
144145
if cfg.planner.mode == "command":
145146
_invoke(cfg.planner, cfg.root, _plan_prompt(cfg, csv, d))
146147
return
@@ -170,6 +171,11 @@ def _plan_prompt(cfg: Config, csv: str | None, d: Path) -> str:
170171
"discussion and it is absent, ask the human to produce it with the project's "
171172
"tracker-scrape tooling, and stop. "
172173
)
174+
sources_line = (
175+
f"Also read EVERY file under {d / 'sources'} if that directory exists — the Plan "
176+
"sources (issue #102) compose the bundle's full context there (the tracker JSON, a "
177+
"linked proposal / ADR / spec, a CSV row); brief from ALL of it, not just one. "
178+
)
173179
citation_line = (
174180
"Cite the root cause against the target source with `git -C <checkout> log/show "
175181
"-- <file>` plus Read/Grep on the checkout — NEVER `cd <checkout> && git ...` "
@@ -178,7 +184,7 @@ def _plan_prompt(cfg: Config, csv: str | None, d: Path) -> str:
178184
)
179185
return (
180186
"You are the Plan leaf of a PDCA cycle. " + src_line + csv_line + notes_line
181-
+ citation_line
187+
+ sources_line + citation_line
182188
+ f"Together with the human, write brief.md in the bundle directory {d}. Default "
183189
f"to {fix_tpl} — it fits bug fixes AND ordinary new functionality. Use {geps_tpl} "
184190
"(a design proposal) ONLY for the exception: a change significant enough to "
@@ -220,7 +226,7 @@ def do_plan_batch(cfg: Config, csv: str | None = None, ids: list[str] | None = N
220226
"""
221227
cfg.bundle_root.mkdir(parents=True, exist_ok=True)
222228
for iid in ids or []:
223-
ensure_notes(cfg, cfg.bundle(iid)) # seed notes.json before the session (#65)
229+
sources.seed(cfg, cfg.bundle(iid)) # seed notes.json + sources/ per bundle (#65/#102)
224230
if cfg.planner.mode == "command":
225231
_invoke(cfg.planner, cfg.root, _plan_batch_prompt(cfg, csv, ids))
226232
return
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
"""Composable Plan-seeding sources (issue #102).
2+
3+
The Plan leaf briefs an UNPLANNED bundle from a *source*. A single ``notes_cmd`` (#65)
4+
seeds one ``notes.json`` from one command, but a good brief often draws on more than the
5+
ticket — a linked design doc / accepted proposal, a spec section, a CSV row. This module
6+
runs the **list of providers** a project declares as ``[[plan.source]]`` in pdca.toml,
7+
each contributing context into the bundle's ``sources/`` dir, so the planner briefs from
8+
the *full* picture instead of one hand-rolled scrape.
9+
10+
Built-in providers:
11+
12+
* ``github`` — ``gh issue view {id}`` JSON (title/body/comments) → ``sources/github-<id>.json``;
13+
* ``gitlab`` — ``glab issue view {id}`` → ``sources/gitlab-<id>.txt``;
14+
* ``csv`` — the issue's row (or the whole export) from a CSV → ``sources/<name>.csv``;
15+
* ``file`` — a path/glob (``{id}`` interpolated) — a linked ADR/proposal/spec — copied in;
16+
* ``command``— the escape hatch: a ``.format(id=)`` shell command (exactly today's
17+
``notes_cmd``), run with ``$PDCA_BUNDLE`` / ``$PDCA_SOURCES`` set; it writes its own output.
18+
19+
Every provider is **best-effort**: a missing tool, an absent file, or a failing command is
20+
non-fatal — that source is skipped with a note and Plan falls back to the others / the
21+
human. The legacy ``[tracker].notes_cmd`` still runs (back-compat), so a project that sets
22+
neither sees no change.
23+
"""
24+
25+
from __future__ import annotations
26+
27+
import os
28+
import shutil
29+
import subprocess
30+
import sys
31+
from pathlib import Path
32+
33+
from .config import Config
34+
35+
36+
def seed(cfg: Config, d: Path) -> None:
37+
"""Seed bundle ``d`` from every configured Plan source, plus the legacy notes_cmd.
38+
39+
Idempotent-ish and best-effort: providers that fail are skipped. New providers write
40+
into ``d/sources/``; the legacy ``notes_cmd`` still writes ``d/notes.json``. The
41+
planner reads both.
42+
"""
43+
from . import leaves # lazy: leaves imports sources via do_plan; avoid an import cycle
44+
45+
leaves.ensure_notes(cfg, d) # legacy [tracker].notes_cmd → notes.json (#65), unchanged
46+
if not cfg.plan_sources:
47+
return
48+
sources_dir = d / "sources"
49+
issue_id = d.name.removeprefix("issue_")
50+
for i, spec in enumerate(cfg.plan_sources):
51+
kind = (spec.get("type") or "").strip().lower()
52+
provider = _PROVIDERS.get(kind)
53+
if provider is None:
54+
print(f"sources: {d.name} — unknown plan.source type {kind!r}; skipping",
55+
file=sys.stderr)
56+
continue
57+
try:
58+
sources_dir.mkdir(parents=True, exist_ok=True)
59+
provider(cfg, d, sources_dir, issue_id, spec, i)
60+
except Exception as exc: # noqa: BLE001 — a failed source must never break Plan
61+
print(f"sources: {d.name}{kind} source failed ({type(exc).__name__}: {exc}); "
62+
"skipping (Plan falls back to the other sources / the human)",
63+
file=sys.stderr)
64+
65+
66+
# ----------------------------------------------------------------------------
67+
# Providers. Each: (cfg, bundle, sources_dir, issue_id, spec, index) -> None, writing its
68+
# context into sources_dir. Raising is caught by seed() (best-effort).
69+
# ----------------------------------------------------------------------------
70+
def _run_capture(cmd: list[str], cwd: Path) -> tuple[int, str]:
71+
r = subprocess.run(cmd, cwd=str(cwd), capture_output=True, text=True)
72+
return r.returncode, (r.stdout or "")
73+
74+
75+
def _github(cfg: Config, d: Path, out: Path, issue_id: str, spec: dict, i: int) -> None:
76+
"""`gh issue view <id>` as JSON. A ``repo`` in the spec scopes it; absent ⇒ gh's default."""
77+
fields = spec.get("fields", "title,body,comments,url,state,labels")
78+
cmd = ["gh", "issue", "view", issue_id, "--json", fields]
79+
if spec.get("repo"):
80+
cmd += ["--repo", str(spec["repo"])]
81+
rc, text = _run_capture(cmd, cfg.root)
82+
if rc != 0 or not text.strip():
83+
print(f"sources: {d.name} — `gh issue view {issue_id}` produced nothing (rc {rc}); "
84+
"skipping github source", file=sys.stderr)
85+
return
86+
(out / f"github-{issue_id}.json").write_text(text, encoding="utf-8")
87+
88+
89+
def _gitlab(cfg: Config, d: Path, out: Path, issue_id: str, spec: dict, i: int) -> None:
90+
cmd = ["glab", "issue", "view", issue_id]
91+
if spec.get("repo"):
92+
cmd += ["--repo", str(spec["repo"])]
93+
rc, text = _run_capture(cmd, cfg.root)
94+
if rc != 0 or not text.strip():
95+
print(f"sources: {d.name} — `glab issue view {issue_id}` produced nothing (rc {rc}); "
96+
"skipping gitlab source", file=sys.stderr)
97+
return
98+
(out / f"gitlab-{issue_id}.txt").write_text(text, encoding="utf-8")
99+
100+
101+
def _csv(cfg: Config, d: Path, out: Path, issue_id: str, spec: dict, i: int) -> None:
102+
"""Copy the issue's row from a CSV (matched on the configured key column), or, if no
103+
match column is given, the whole export, into sources/."""
104+
import csv as _csvmod
105+
106+
path = (cfg.root / str(spec.get("path", ""))).resolve()
107+
if not path.is_file():
108+
print(f"sources: {d.name} — csv source {path} not found; skipping", file=sys.stderr)
109+
return
110+
key = spec.get("key", "") # the id column header; "" ⇒ copy the whole file
111+
dst = out / (spec.get("out") or f"{path.stem}.csv")
112+
if not key:
113+
shutil.copyfile(path, dst)
114+
return
115+
with path.open(encoding="utf-8", newline="") as fh:
116+
reader = _csvmod.DictReader(fh)
117+
rows = [r for r in reader if (r.get(key) or "").strip() == issue_id]
118+
if not rows:
119+
print(f"sources: {d.name} — no row with {key}={issue_id} in {path}; skipping",
120+
file=sys.stderr)
121+
return
122+
with dst.open("w", encoding="utf-8", newline="") as wfh:
123+
writer = _csvmod.DictWriter(wfh, fieldnames=reader.fieldnames or [])
124+
writer.writeheader()
125+
writer.writerows(rows)
126+
127+
128+
def _file(cfg: Config, d: Path, out: Path, issue_id: str, spec: dict, i: int) -> None:
129+
"""Copy a linked artifact (ADR / proposal / spec) into sources/. ``path`` interpolates
130+
``{id}`` and may be a glob (e.g. ``docs/adr/*{id}*.md``)."""
131+
pattern = str(spec.get("path", "")).format(id=issue_id)
132+
if not pattern:
133+
return
134+
matches = sorted(cfg.root.glob(pattern)) if not os.path.isabs(pattern) else \
135+
sorted(Path(pattern).parent.glob(Path(pattern).name))
136+
if not matches:
137+
print(f"sources: {d.name} — file source {pattern!r} matched nothing; skipping",
138+
file=sys.stderr)
139+
return
140+
for m in matches:
141+
if m.is_file():
142+
shutil.copyfile(m, out / m.name)
143+
144+
145+
def _command(cfg: Config, d: Path, out: Path, issue_id: str, spec: dict, i: int) -> None:
146+
"""The escape hatch — a ``.format(id=)`` shell command (exactly today's notes_cmd). It
147+
runs with ``$PDCA_BUNDLE`` (the bundle) and ``$PDCA_SOURCES`` (sources/) set and is
148+
responsible for writing its own output there. Captured stdout, if any, is also saved."""
149+
cmd = str(spec.get("cmd", "")).format(id=issue_id)
150+
if not cmd:
151+
return
152+
env = {**os.environ, "PDCA_BUNDLE": str(d), "PDCA_SOURCES": str(out)}
153+
r = subprocess.run(cmd, shell=True, cwd=str(cfg.root), env=env,
154+
capture_output=True, text=True)
155+
if r.returncode != 0:
156+
print(f"sources: {d.name} — command source failed (rc {r.returncode}): "
157+
f"{(r.stderr or '').strip()}", file=sys.stderr)
158+
return
159+
if spec.get("out") and (r.stdout or "").strip():
160+
(out / str(spec["out"])).write_text(r.stdout, encoding="utf-8")
161+
162+
163+
_PROVIDERS = {
164+
"github": _github,
165+
"gitlab": _gitlab,
166+
"csv": _csv,
167+
"file": _file,
168+
"command": _command,
169+
}

0 commit comments

Comments
 (0)