Skip to content

Commit 5097dd9

Browse files
eduralphclaude
andcommitted
feat(plan): source the tracker thread once via role="tracker" (#132)
Plan seeding fetched the tracker issue twice in one cycle: seed() ran the legacy [tracker].notes_cmd (→ notes.json) AND, unconditionally, the [[plan.source]] providers — so a GitHub-Issues project with a `github` source stored the issue in both notes.json and sources/github-<id>.json, and a notes_cmd moved into a `command` source ran twice. A project couldn't de-dupe by dropping notes_cmd because notes.json is load-bearing by filename (the planner and the id-seeded batch flow read it), and the providers only wrote into sources/. Add a `role = "tracker"` declaration on a plan.source: a github/gitlab/command source so marked writes the canonical notes.json itself (at the bundle root), and seed() then skips the legacy notes_cmd. notes_cmd and a tracker-role plan.source are mutually exclusive; notes.json stays available (no planner / id-seeded-flow regression), and a project with neither sees no change. This is the minimum unification (issue #132 option C) plus the small role/redirect needed to keep notes.json present; the fuller option A (read the tracker thread from sources/ by role) remains future work. Documented in pdca.toml.jinja and docs/03-plan.md. Tests in test_sources.py: tracker-role github sources once and skips notes_cmd; tracker-role command writes notes.json; a non-tracker source leaves notes_cmd running. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2c6bd2f commit 5097dd9

4 files changed

Lines changed: 99 additions & 14 deletions

File tree

docs/03-plan.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,10 @@ so the planner briefs from the **full** picture, not one scrape (issue #102). Bu
5252
types: `github` (`gh`), `gitlab` (`glab`), `csv`, `file` (a path/glob, `{id}`
5353
interpolated), and `command` (the escape hatch — exactly `notes_cmd`, run with
5454
`$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":
55+
runs alongside them — **unless** a source sets `role = "tracker"`, which makes that source
56+
the tracker thread (it writes `notes.json` itself) and suppresses `notes_cmd` so the issue
57+
is sourced once, not fetched and stored twice (issue #132). `notes_cmd` and a tracker-role
58+
plan.source are mutually exclusive. For example, "the GitHub issue **and** its linked ADR":
5659

5760
```toml
5861
[[plan.source]]

template/pdca.toml.jinja

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,14 @@ issue_trailer = "Fixes #{id}"
7979
# / file / failing command is non-fatal). Built-in types: github (gh), gitlab (glab), csv,
8080
# file (a path/glob, {id} interpolated), command (the escape hatch — a `.format(id=)` shell
8181
# 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):
82+
# notes_cmd above still runs alongside these, so they are purely additive — EXCEPT a source
83+
# that sets `role = "tracker"` (issue #132): it IS the tracker thread, writes the canonical
84+
# notes.json itself, and SUPPRESSES notes_cmd, so the issue is sourced once rather than
85+
# fetched/stored twice. `notes_cmd` and a tracker-role plan.source are mutually exclusive.
86+
# Examples (uncomment to use):
8387
# [[plan.source]]
8488
# type = "github" # gh issue view {id} --json … → sources/github-<id>.json
89+
# # role = "tracker" # make THIS the tracker fetch (→ notes.json) and drop notes_cmd
8590
# # repo = "owner/repo" # optional; omit to use gh's default repo
8691
# [[plan.source]]
8792
# type = "file" # a linked design doc / ADR / proposal / spec

template/src/pdca_harness/sources.py

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,16 @@
1818
1919
Every provider is **best-effort**: a missing tool, an absent file, or a failing command is
2020
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.
21+
human.
22+
23+
**The tracker thread is sourced once (#132).** The legacy ``[tracker].notes_cmd`` still
24+
runs for back-compat — *unless* a ``[[plan.source]]`` declares itself the tracker thread
25+
with ``role = "tracker"``. A tracker-role ``github``/``gitlab``/``command`` source writes
26+
the canonical ``notes.json`` (at the bundle root, where the planner and the id-seeded
27+
batch flow read it) and ``seed()`` then **skips** ``notes_cmd``, so a GitHub-Issues
28+
project configures the tracker fetch once instead of fetching/storing the same issue
29+
twice. ``notes_cmd`` and a tracker-role plan.source are therefore mutually exclusive; a
30+
project that sets neither sees no change.
2331
"""
2432

2533
from __future__ import annotations
@@ -37,17 +45,26 @@ def seed(cfg: Config, d: Path) -> None:
3745
"""Seed bundle ``d`` from every configured Plan source, plus the legacy notes_cmd.
3846
3947
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.
48+
into ``d/sources/``; the legacy ``notes_cmd`` writes ``d/notes.json``. The planner
49+
reads both.
50+
51+
The tracker issue is sourced **once** (#132): if a plan.source declares ``role =
52+
"tracker"`` it supplies the canonical ``notes.json`` itself, and the legacy
53+
``notes_cmd`` is skipped so the same issue isn't fetched and stored twice.
4254
"""
4355
from . import leaves # lazy: leaves imports sources via do_plan; avoid an import cycle
4456

45-
leaves.ensure_notes(cfg, d) # legacy [tracker].notes_cmd → notes.json (#65), unchanged
46-
if not cfg.plan_sources:
57+
plan_sources = cfg.plan_sources or []
58+
# Skip the legacy notes_cmd when a plan.source is the declared tracker thread —
59+
# otherwise the issue is fetched twice (notes_cmd AND the provider) and both copies
60+
# land in the bundle (#132). notes_cmd stays as back-compat for projects with none.
61+
if not any(_is_tracker_source(s) for s in plan_sources):
62+
leaves.ensure_notes(cfg, d) # legacy [tracker].notes_cmd → notes.json (#65)
63+
if not plan_sources:
4764
return
4865
sources_dir = d / "sources"
4966
issue_id = d.name.removeprefix("issue_")
50-
for i, spec in enumerate(cfg.plan_sources):
67+
for i, spec in enumerate(plan_sources):
5168
kind = (spec.get("type") or "").strip().lower()
5269
provider = _PROVIDERS.get(kind)
5370
if provider is None:
@@ -72,8 +89,26 @@ def _run_capture(cmd: list[str], cwd: Path) -> tuple[int, str]:
7289
return r.returncode, (r.stdout or "")
7390

7491

92+
def _is_tracker_source(spec: dict) -> bool:
93+
"""True iff this plan.source declares itself the tracker thread (``role =
94+
"tracker"``) — a github/gitlab/command source that supplies the canonical
95+
``notes.json``, making the legacy ``notes_cmd`` redundant (#132)."""
96+
return (spec.get("role") or "").strip().lower() == "tracker"
97+
98+
99+
def _tracker_dest(d: Path, sources_dir: Path, spec: dict, default_name: str) -> Path:
100+
"""Where a fetched issue is written: the canonical ``d/notes.json`` when this source
101+
is the declared tracker thread (so the planner / id-seeded flow find it), else its
102+
default file under ``sources/`` (supplementary context). #132."""
103+
if _is_tracker_source(spec):
104+
return d / "notes.json"
105+
return sources_dir / (spec.get("out") or default_name)
106+
107+
75108
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."""
109+
"""`gh issue view <id>` as JSON. A ``repo`` in the spec scopes it; absent ⇒ gh's
110+
default. ``role = "tracker"`` writes the canonical ``notes.json`` (#132) instead of
111+
``sources/github-<id>.json`` so this provider can be the single tracker source."""
77112
fields = spec.get("fields", "title,body,comments,url,state,labels")
78113
cmd = ["gh", "issue", "view", issue_id, "--json", fields]
79114
if spec.get("repo"):
@@ -83,7 +118,7 @@ def _github(cfg: Config, d: Path, out: Path, issue_id: str, spec: dict, i: int)
83118
print(f"sources: {d.name} — `gh issue view {issue_id}` produced nothing (rc {rc}); "
84119
"skipping github source", file=sys.stderr)
85120
return
86-
(out / f"github-{issue_id}.json").write_text(text, encoding="utf-8")
121+
_tracker_dest(d, out, spec, f"github-{issue_id}.json").write_text(text, encoding="utf-8")
87122

88123

89124
def _gitlab(cfg: Config, d: Path, out: Path, issue_id: str, spec: dict, i: int) -> None:
@@ -95,7 +130,7 @@ def _gitlab(cfg: Config, d: Path, out: Path, issue_id: str, spec: dict, i: int)
95130
print(f"sources: {d.name} — `glab issue view {issue_id}` produced nothing (rc {rc}); "
96131
"skipping gitlab source", file=sys.stderr)
97132
return
98-
(out / f"gitlab-{issue_id}.txt").write_text(text, encoding="utf-8")
133+
_tracker_dest(d, out, spec, f"gitlab-{issue_id}.txt").write_text(text, encoding="utf-8")
99134

100135

101136
def _csv(cfg: Config, d: Path, out: Path, issue_id: str, spec: dict, i: int) -> None:
@@ -145,7 +180,9 @@ def _file(cfg: Config, d: Path, out: Path, issue_id: str, spec: dict, i: int) ->
145180
def _command(cfg: Config, d: Path, out: Path, issue_id: str, spec: dict, i: int) -> None:
146181
"""The escape hatch — a ``.format(id=)`` shell command (exactly today's notes_cmd). It
147182
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."""
183+
responsible for writing its own output there. Captured stdout, if any, is also saved —
184+
to the canonical ``notes.json`` when ``role = "tracker"`` (#132, so a notes_cmd moved
185+
into a tracker plan.source still seeds notes.json), else to ``sources/<out>``."""
149186
cmd = str(spec.get("cmd", "")).format(id=issue_id)
150187
if not cmd:
151188
return
@@ -156,7 +193,9 @@ def _command(cfg: Config, d: Path, out: Path, issue_id: str, spec: dict, i: int)
156193
print(f"sources: {d.name} — command source failed (rc {r.returncode}): "
157194
f"{(r.stderr or '').strip()}", file=sys.stderr)
158195
return
159-
if spec.get("out") and (r.stdout or "").strip():
196+
if _is_tracker_source(spec) and (r.stdout or "").strip():
197+
(d / "notes.json").write_text(r.stdout, encoding="utf-8")
198+
elif spec.get("out") and (r.stdout or "").strip():
160199
(out / str(spec["out"])).write_text(r.stdout, encoding="utf-8")
161200

162201

template/tests/test_sources.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,44 @@ def test_legacy_notes_cmd_still_runs(self) -> None:
120120
sources.seed(cfg, d)
121121
self.assertTrue((d / "notes.json").exists()) # #65 back-compat preserved
122122

123+
def test_tracker_role_github_sources_once_and_skips_notes_cmd(self) -> None:
124+
# #132: a github plan.source declared role="tracker" writes the canonical
125+
# notes.json and the legacy notes_cmd is skipped — the issue is fetched once,
126+
# not stored in both notes.json and sources/github-<id>.json.
127+
cfg = _cfg(
128+
self.tmp,
129+
notes_cmd="printf NOTES_CMD_RAN > \"$PDCA_BUNDLE/notes.json\"",
130+
plan_sources=[{"type": "github", "role": "tracker"}])
131+
d = self._bundle(cfg)
132+
fake = SimpleNamespace(returncode=0, stdout='{"title":"t"}', stderr="")
133+
with mock.patch("pdca_harness.sources.subprocess.run", return_value=fake):
134+
sources.seed(cfg, d)
135+
self.assertEqual((d / "notes.json").read_text(encoding="utf-8"), '{"title":"t"}')
136+
self.assertNotIn("NOTES_CMD_RAN", (d / "notes.json").read_text(encoding="utf-8"))
137+
self.assertFalse((d / "sources" / "github-42.json").exists()) # not stored twice
138+
139+
def test_tracker_role_command_writes_notes_json_and_skips_notes_cmd(self) -> None:
140+
# The `command` escape hatch as the tracker source: its stdout becomes notes.json
141+
# and notes_cmd does not also run (moving a notes_cmd into a tracker plan.source
142+
# must not run it twice).
143+
cfg = _cfg(
144+
self.tmp,
145+
notes_cmd="printf NOTES_CMD_RAN > \"$PDCA_BUNDLE/notes.json\"",
146+
plan_sources=[{"type": "command", "role": "tracker", "cmd": "echo thread"}])
147+
d = self._bundle(cfg)
148+
sources.seed(cfg, d)
149+
self.assertEqual((d / "notes.json").read_text(encoding="utf-8"), "thread\n")
150+
151+
def test_non_tracker_source_leaves_notes_cmd_running(self) -> None:
152+
# Back-compat: a plain (non-tracker) plan.source does NOT suppress notes_cmd.
153+
cfg = _cfg(
154+
self.tmp,
155+
notes_cmd="printf thread > \"$PDCA_BUNDLE/notes.json\"",
156+
plan_sources=[{"type": "file", "path": "missing-{id}.md"}])
157+
d = self._bundle(cfg)
158+
sources.seed(cfg, d)
159+
self.assertEqual((d / "notes.json").read_text(encoding="utf-8"), "thread")
160+
123161

124162
if __name__ == "__main__":
125163
unittest.main()

0 commit comments

Comments
 (0)