Skip to content

Commit a291ab2

Browse files
eduralphclaude
andcommitted
Feed back from gramps-testbed-v2: complete iterate carry-forward, Success-criterion mandate, no-id path
Three template-bound deltas still instance-ahead after the v0.9.0 catch-up (issue #7). 1. Complete the iterate carry-forward (was partial: §9 field + record(delta=) only). - signoff.iteration_delta(): read the §9 "Iteration delta" back ([ \t]-anchored so an empty field can't run past the line). - leaves.signoff_decision() now reads the token from line 1; new signoff_rationale() reads the human's "why / what to change" written below it. - flow._apply_decision flattens that rationale and records it into §9 (delta=). - driver._carry_forward_into_brief(): before the iterate clear, append an "## Iteration N — carry-forward" block (the §9 rationale + the failing gates, gating and advisory) to brief.md, so the rebuild's Do isn't blind. Adapted to the template's clear model (no archive dir). signoff.md / builder.md prose to match. 2. Do builds to the brief's Success criterion, not a proxy (#22): _build_prompt + builder.md.jinja mandate the real end result, proven red→green — a green mechanical check on something adjacent is not "done". 3. First-class "no tracker id yet" path (replaces the magic #0000): publish --no-issue (pending_id) relaxes a failing T4 to a flag instead of aborting, records the bundle id_pending, and the publisher omits the trailer rather than inventing a placeholder. cli/publish/leaves/publisher.md/pdca.toml wired; the trade-off (it relaxes all of T4, not just the trailer) is a deliberate flagged escape hatch for human follow-up. Tests: test_carry_forward.py (decision file token+rationale, §9 delta read-back, carry-forward block, build-prompt mandate); test_publish_slice no-issue T4 relaxation. Full suite 64 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 964c091 commit a291ab2

12 files changed

Lines changed: 277 additions & 18 deletions

File tree

template/.claude/agents/builder.md.jinja

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,15 @@ You implement the contribution the brief specs. Read `brief.md` **only** — not
2424
prior cycles, not the conformance ruleset (Check applies that), not project
2525
context beyond what the brief cites. Narrow input is deliberate.
2626

27+
**Build to satisfy the brief's `Success criterion`** — the real end result — not a
28+
narrower proxy: an item is done only when that end result holds, proven red→green. A
29+
green mechanical check on something *adjacent* is not "done" (the same standard as "a
30+
green mechanical check is not a correctness verification").
31+
32+
**On a re-run, read the brief's `## Iteration N — carry-forward` block** if present —
33+
the driver appends it on an iterate with the previous attempt's sign-off rationale and
34+
failing gate. Address it; do **not** re-submit the rejected approach unchanged.
35+
2736
## Output — three files, in lockstep
2837

2938
- `patch.diff` — the change.

template/.claude/agents/publisher.md.jinja

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,10 @@ not push, branch, or open a PR yourself.**
3737
- reference any other commit by its **full hash**;
3838
- the **last line** is the issue trailer the project configures
3939
(`[tracker].issue_trailer`, e.g. `Fixes #<id>`) — the T4 gate enforces it. If you
40-
add a `Co-Authored-By:` line, place it **above** the trailer.
40+
add a `Co-Authored-By:` line, place it **above** the trailer. **If no tracker id is
41+
assigned yet** (the bundle id is not a real tracker number), OMIT the trailer rather
42+
than invent a placeholder like `#0000` — `pdca publish --no-issue` relaxes T4 to a
43+
flag and records the contribution `id_pending` for the human to fill the id in.
4144

4245
2. **`pr-description.md`** — the PR body (see `templates/pr-description.md.tpl`): the
4346
sections **Root cause / Fix / Verified against / Test**, citing `path:lines` on the

template/.claude/agents/signoff.md.jinja

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,16 +33,22 @@ and especially **§6 NEEDS-HUMAN** — the items only a human can clear.
3333
pointer, not a write-up: a full **process delta** is recorded at the Act beat (in
3434
the act-log / upstream), not stored in the summary.
3535
3. Once the human has decided the disposition, write the agreed token — exactly
36-
one of `accept`, `iterate-do`, `iterate-plan` — into a file named
37-
**`signoff-decision`** in the bundle directory. That is your decision output of record.
36+
one of `accept`, `iterate-do`, `iterate-plan` — as the **first line** of a file
37+
named **`signoff-decision`** in the bundle directory. That is your decision output
38+
of record. **On an `iterate-do` / `iterate-plan`, add the human's rationale on the
39+
lines *below* the token** — the *why rejected / what to change next*. The driver
40+
folds it into the brief's carry-forward so the next attempt isn't blind; without
41+
it, the rebuild reads an unchanged brief and repeats the rejected approach. Keep it
42+
to the actionable insight; do not restate the whole review.
3843

3944
## Boundaries — write exactly three things, reset nothing
4045

4146
**You write exactly three things, nothing else:** (a) `- [ ]` → `- [x]` in §6 of
4247
`SUMMARY.md`, only with the human's explicit OK; (b) **append-only** bullets under
4348
**§10 Act candidates** of `SUMMARY.md`, dictated by the human (append a new line —
44-
never edit or delete an existing §10 line); and (c) the `signoff-decision` token
45-
file. That is the complete list. §10 is the one append channel you have because it
49+
never edit or delete an existing §10 line); and (c) the `signoff-decision` file (the
50+
token on line 1, plus the iterate rationale on the lines below it). That is the
51+
complete list. §10 is the one append channel you have because it
4652
is non-binding "hints for the next Act review" and has no effect on disposition;
4753
everything that *is* the decision record stays off-limits. **Never delete or modify
4854
any other part of any bundle file** — not `SUMMARY.md` §9 / §1–§8, not `patch.diff`,

template/pdca.toml.jinja

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ issue_id_example = "{{ issue_id_example }}"
2020
# Trailer linking a commit/PR to the issue ({id} = the bug id). Trackers differ
2121
# (GitHub/Mantis "Fixes #{id}", Jira "Fixes PROJ-{id}"); "" enforces none. The
2222
# publish step's commit-msg ends with this; the T4 gate (if any) checks it.
23+
# No tracker id yet? Use `pdca publish --no-issue`: it omits the trailer, relaxes
24+
# T4 to a flag, and records the bundle `id_pending` (no magic "#0000" placeholder).
2325
issue_trailer = "Fixes #{id}"
2426
# Optional default tracker export the Plan leaf reads the issue row from when `flow`
2527
# runs without --from-csv (it reads ONLY that issue's row, not a repo scan):

template/src/pdca_harness/cli.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ def main(argv: list[str] | None = None) -> int:
8383
p_publish.add_argument("issue_id")
8484
p_publish.add_argument("--dry-run", action="store_true", help="print the git/gh commands without running them")
8585
p_publish.add_argument("--no-pr", action="store_true", help="push the branch but don't open the draft PR")
86+
p_publish.add_argument("--no-issue", action="store_true",
87+
help="no tracker id yet: relax T4 to a flag, record id_pending (vs a magic #0000)")
8688
p_publish.add_argument("--by", default="", help="who published (recorded in publish.json)")
8789

8890
args = parser.parse_args(argv)
@@ -110,7 +112,7 @@ def main(argv: list[str] | None = None) -> int:
110112
return _signoff(cfg, args)
111113
if args.cmd == "publish":
112114
return publish.publish(cfg, args.issue_id, dry_run=args.dry_run,
113-
open_pr=not args.no_pr, by=args.by)
115+
open_pr=not args.no_pr, by=args.by, pending_id=args.no_issue)
114116
return 2
115117

116118

template/src/pdca_harness/driver.py

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@
1010

1111
from __future__ import annotations
1212

13+
import json
1314
import sys
1415
from pathlib import Path
1516

16-
from . import assemble, brief, gates, leaves, state
17+
from . import assemble, brief, gates, leaves, signoff, state
1718
from .config import Config
1819

1920

@@ -52,9 +53,11 @@ def advance(d: Path, cfg: Config) -> None:
5253
assemble.assemble_summary(d, cfg) # pure code → SUMMARY.md §1–8
5354
elif s == state.ITERATE_DO:
5455
_say(f"→ {d.name}: iterate-to-Do — clearing downstream, rebuilding…")
55-
_clear_downstream_of_brief(d) # re-run Do against the same brief
56+
_carry_forward_into_brief(d) # persist the WHY before the clear wipes it
57+
_clear_downstream_of_brief(d) # re-run Do against the (now annotated) brief
5658
elif s == state.ITERATE_PLAN:
5759
_say(f"→ {d.name}: iterate-to-Plan — versioning brief…")
60+
_carry_forward_into_brief(d) # annotate the brief before it is versioned
5861
_version_brief_and_clear(d) # preserve brief.vN.md; human re-authors
5962
# UNPLANNED / AWAITING_SIGNOFF / COMPLETE: nothing for the driver to do.
6063

@@ -97,3 +100,57 @@ def _next_brief_version(d: Path) -> int:
97100
existing = [p.stem for p in d.glob("brief.v*.md")]
98101
nums = [int(s.split("brief.v")[1]) for s in existing if s.split("brief.v")[1].isdigit()]
99102
return (max(nums) + 1) if nums else 1
103+
104+
105+
# ----------------------------------------------------------------------------
106+
# Iterate carry-forward — persist the WHY into the one input the next beat reads.
107+
# ----------------------------------------------------------------------------
108+
def _carry_forward_into_brief(d: Path) -> None:
109+
"""Fold the previous iteration's insight into ``brief.md`` BEFORE the clear wipes
110+
SUMMARY/check-*, so the next attempt isn't blind. On iterate-do the annotated
111+
brief stays in place (the rebuild reads it); on iterate-plan it is versioned to
112+
``brief.vN.md`` with the re-authoring context attached.
113+
114+
Captures whatever is available — the §9 sign-off rationale AND the failing gates
115+
(gating *and* advisory, since an iterate is often driven by an advisory red), so
116+
an iterate with no recorded rationale still carries context. Best-effort: it must
117+
never break the transition, so any failure is swallowed.
118+
"""
119+
brief_path = d / "brief.md"
120+
if not brief_path.exists():
121+
return
122+
try:
123+
delta = signoff.iteration_delta(d / "SUMMARY.md")
124+
fails = _failing_gate_lines(d / "check-gates.json")
125+
if not delta and not fails:
126+
return
127+
n = brief_path.read_text(encoding="utf-8").count("## Iteration ") + 1
128+
out = [f"\n## Iteration {n} — carry-forward (from the previous attempt)\n"]
129+
if delta:
130+
out.append(f"- Sign-off rationale: {delta}\n")
131+
for f in fails:
132+
out.append(f"- Failing gate: {f}\n")
133+
out.append("- Address the above; do NOT re-attempt the rejected approach "
134+
"unchanged. Satisfy the brief's Success criterion (the end result).\n")
135+
with brief_path.open("a", encoding="utf-8") as fh:
136+
fh.write("".join(out))
137+
except Exception: # noqa: BLE001 — carry-forward is advisory; never break the iterate
138+
pass
139+
140+
141+
def _failing_gate_lines(gates_json: Path) -> list[str]:
142+
"""``"check — evidence"`` for each failing row in ``check-gates.json`` — gating AND
143+
advisory, since an iterate is often driven by an advisory red. Best-effort."""
144+
if not gates_json.exists():
145+
return []
146+
try:
147+
data = json.loads(gates_json.read_text(encoding="utf-8"))
148+
except (ValueError, OSError):
149+
return []
150+
out: list[str] = []
151+
for r in data.get("rows", []):
152+
if r.get("result") == "fail":
153+
ev = r.get("path_line") or r.get("oracle") or ""
154+
tag = "" if r.get("gating") else " (advisory)"
155+
out.append(f"{r.get('check', '?')}{tag}{ev}".strip(" —"))
156+
return out

template/src/pdca_harness/flow.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,12 @@ def _apply_decision(
9696
if action == "accept" and signoff.open_needs_human(d / "SUMMARY.md"):
9797
print(f"flow: {d.name} — cannot accept, §6 NEEDS-HUMAN still open (C6)", file=sys.stderr)
9898
return "blocked"
99-
signoff.record(d / "SUMMARY.md", action=action, by=by or cfg.author or "unknown", date=today)
99+
# The iterate rationale ("why rejected / what to change") rides §9 → the driver
100+
# folds it into the brief's carry-forward so the next iteration isn't blind.
101+
# §9's "Iteration delta" is a single line, so flatten a multi-line rationale.
102+
rationale = " ".join(leaves.signoff_rationale(d).split())
103+
signoff.record(d / "SUMMARY.md", action=action, by=by or cfg.author or "unknown",
104+
date=today, delta=rationale)
100105
(d / leaves.SIGNOFF_DECISION).unlink(missing_ok=True)
101106
if apply_now:
102107
driver.run_issue(d, cfg) # apply the transition: COMPLETE | ITERATE_* → re-loop

template/src/pdca_harness/leaves.py

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,12 @@ def do_build(d: Path, cfg: Config) -> None:
227227

228228
def _build_prompt(d: Path) -> str:
229229
return (
230-
f"You are the Do builder. Read {d}/brief.md. Produce, in the bundle directory "
230+
f"You are the Do builder. Read {d}/brief.md. Build to satisfy its **Success "
231+
"criterion** (the real end result), not a narrower proxy — an item is done only "
232+
"when that end result holds, proven red→green; a green mechanical check on "
233+
"something adjacent is not done. If brief.md carries an '## Iteration N — "
234+
"carry-forward' block, address it (the previous attempt's rationale + failing "
235+
"gate) and do NOT repeat the rejected approach. Produce, in the bundle directory "
231236
f"{d}: (1) patch.diff — a unified diff against the brief's target branch; "
232237
"(2) the test file the brief names, red before the fix and green after; "
233238
"(3) build-notes.md — your rationale (withheld from the reviewer). Cite "
@@ -455,14 +460,31 @@ def _signoff_batch_prompt(bundles: list[Path]) -> str:
455460

456461

457462
def signoff_decision(d: Path) -> str:
458-
"""The token the sign-off leaf wrote, or "" if absent/invalid."""
463+
"""The decision token (first line of ``signoff-decision``), or "" if absent/invalid.
464+
465+
The file is ``<token>`` optionally followed by a free-text **rationale** on the
466+
remaining lines (read by :func:`signoff_rationale`) — the human's "why iterate /
467+
what to change" the driver carries forward into the brief on an iterate."""
459468
p = d / SIGNOFF_DECISION
460469
if not p.exists():
461470
return ""
462-
token = p.read_text(encoding="utf-8").strip()
471+
lines = p.read_text(encoding="utf-8").splitlines()
472+
token = lines[0].strip() if lines else ""
463473
return token if token in VALID_DECISIONS else ""
464474

465475

476+
def signoff_rationale(d: Path) -> str:
477+
"""The iterate rationale the sign-off leaf wrote below the token, or "" if none.
478+
479+
Lines after the first of ``signoff-decision`` — the actionable insight ("why this
480+
Do attempt was rejected / what to change next") that the flow records into §9 and
481+
the driver folds into the brief's carry-forward so the next iteration isn't blind."""
482+
p = d / SIGNOFF_DECISION
483+
if not p.exists():
484+
return ""
485+
return "\n".join(p.read_text(encoding="utf-8").splitlines()[1:]).strip()
486+
487+
466488
# ----------------------------------------------------------------------------
467489
# Leaf 4 — Act (act, interactive): review frozen cycles, suggest deltas if sensible.
468490
# ----------------------------------------------------------------------------
@@ -511,7 +533,11 @@ def _publish_prompt(d: Path, cfg: Config) -> str:
511533
trailer = cfg.issue_trailer.format(id=issue_id) if cfg.issue_trailer else ""
512534
trailer_line = (
513535
f"The LAST line of commit-msg.txt is the issue trailer `{trailer}` (the T4 gate "
514-
"enforces it); a Co-Authored-By line, if any, goes ABOVE it. " if trailer else ""
536+
"enforces it); a Co-Authored-By line, if any, goes ABOVE it. If no tracker id is "
537+
"assigned yet (the bundle id is not a real tracker number), OMIT the trailer "
538+
"entirely rather than invent a placeholder — `pdca publish --no-issue` records "
539+
"the contribution as id_pending for the human to fill the id in later. "
540+
if trailer else ""
515541
)
516542
return (
517543
"You are the Publish leaf — the closing work of Check. The fix for issue "

template/src/pdca_harness/publish.py

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,20 @@ def publish(
4747
by: str = "",
4848
today: str | None = None,
4949
skip_if_no_target: bool = False,
50+
pending_id: bool = False,
5051
) -> int:
5152
"""Contribute an accepted bundle's fix as a draft PR. Return a process code.
5253
5354
``skip_if_no_target`` (set by the flow): a bundle whose brief names no upstream
5455
``Repo + branch target`` is a non-contributing cycle (e.g. an internal fix) —
5556
warn and return 0 rather than erroring, so it doesn't fail the continuous flow.
57+
58+
``pending_id`` (``--no-issue``): the first-class "no tracker id yet" path. A
59+
project may need to contribute before a tracker number is assigned; rather than a
60+
magic ``Fixes #0000`` placeholder, declare it here. The T4 contribution gate is
61+
then **relaxed to a flag** instead of a hard block, and the bundle is recorded
62+
``id_pending`` so the human adds the real id and re-gates T4 before marking the PR
63+
ready. The publisher leaf omits the trailer (no invented id) in this case.
5664
"""
5765
d = cfg.bundle(issue_id)
5866
today = today or datetime.date.today().isoformat()
@@ -84,11 +92,20 @@ def publish(
8492
print(f"publish: {COMMIT_MSG} / {PR_BODY} still missing — aborting", file=sys.stderr)
8593
return 1
8694

87-
# T4 contribution gate — the artifacts MUST pass before anything is pushed.
95+
# T4 contribution gate — the artifacts MUST pass before anything is pushed,
96+
# UNLESS pending_id (--no-issue): then a T4 failure is relaxed to a flag, since the
97+
# one thing legitimately missing is the not-yet-assigned tracker id. The bundle is
98+
# recorded id_pending so the human adds the id and re-gates T4 before ready.
8899
if not _t4_passes(cfg, d):
89-
print(f"publish: T4 contribution gate FAILED on {COMMIT_MSG} / {PR_BODY} — "
90-
"fix them and retry", file=sys.stderr)
91-
return 1
100+
if pending_id:
101+
print(f"publish: T4 contribution gate not satisfied on {COMMIT_MSG} / "
102+
f"{PR_BODY} — proceeding in --no-issue (pending-id) mode; the "
103+
"contribution is FLAGGED. Add the tracker id and re-run T4 before "
104+
"marking the PR ready.", file=sys.stderr)
105+
else:
106+
print(f"publish: T4 contribution gate FAILED on {COMMIT_MSG} / {PR_BODY} — "
107+
"fix them and retry", file=sys.stderr)
108+
return 1
92109

93110
branch = _branch_name(cfg, d, slug)
94111
summary_line = (d / COMMIT_MSG).read_text(encoding="utf-8").splitlines()[0]
@@ -155,6 +172,7 @@ def publish(
155172
(d / "publish.json").write_text(json.dumps({
156173
"branch": branch, "pr_url": pr_url, "base": base, "repo": repo_spec,
157174
"by": by or _signoff_by(d) or cfg.author or "unknown", "date": today,
175+
"id_pending": pending_id,
158176
}, indent=2) + "\n", encoding="utf-8")
159177

160178
# A requested-but-failed PR is a partial run, not a success — the branch is
@@ -166,6 +184,9 @@ def publish(
166184
print(f"\nDraft PR prepared on {repo_spec} ({branch}{base}).")
167185
if pr_url:
168186
print(f" {pr_url}\n watch CI: gh pr checks {pr_url} --watch")
187+
if pending_id:
188+
print(" ⚠ id_pending: contributed without a tracker id — add the trailer "
189+
"(Fixes #N) and re-run T4 before marking the PR ready.")
169190
print(" STOP: review CI, then mark it ready / merge yourself — the human's step.")
170191
return 0
171192

template/src/pdca_harness/signoff.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@
2424
}
2525

2626
_OUTCOME_RE = re.compile(r"^- Outcome:\s*(.*?)\s*$", re.MULTILINE)
27+
# Anchored with [ \t] (NOT \s) so an empty field stops at the line end instead of
28+
# running past the newline into the next line.
29+
_DELTA_RE = re.compile(r"^- Iteration delta \(if iterating\):[ \t]*(.*?)[ \t]*$", re.MULTILINE)
2730

2831

2932
def outcome_token(summary_path: Path) -> str:
@@ -47,6 +50,18 @@ def is_set(summary_path: Path) -> bool:
4750
return outcome_token(summary_path) in VALID_OUTCOMES
4851

4952

53+
def iteration_delta(summary_path: Path) -> str:
54+
"""The §9 'Iteration delta (if iterating)' value, or "" if unset/absent.
55+
56+
The human's rationale for an iterate ("why rejected / what to change"), which the
57+
driver folds into the brief's carry-forward so the next iteration isn't blind."""
58+
if not summary_path.exists():
59+
return ""
60+
section = _section(summary_path.read_text(encoding="utf-8"), "9. Check sign-off")
61+
m = _DELTA_RE.search(section)
62+
return (m.group(1).strip() if m else "")
63+
64+
5065
def open_needs_human(summary_path: Path) -> list[str]:
5166
"""Unchecked ``- [ ]`` items under §6 NEEDS-HUMAN (must be empty before accept)."""
5267
section = _section(summary_path.read_text(encoding="utf-8"), "6. NEEDS-HUMAN")

0 commit comments

Comments
 (0)