A member of a phase branches from the phase, not from main

Two pieces the runner needs, in the modules that own them.

taskfiles grows a third door: append_to_section, one line under one
heading. The phase log is a running record, and append_to_task would
scatter its lines through the file as other sections landed between
them — the record would stop being readable in the one place a person
looks. It commits like every other board-made write.

agents learns where a phase member starts. That is the whole reason a
phase has a branch: related cards run one after another, so card two
branched from main could not see card one's work while card one sat
unmerged in review/ — it would conflict, or quietly build the same
thing twice. A card in no phase, or one whose phase has not been
started, takes the ordinary fresh branch point, and the ticker names
the unusual base as it already does.

claim_for_launch loses its underscore: a phase run claims its card the
same way starting work on one does, from another module.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
istos
2026-08-01 09:43:35 +02:00
co-authored by Claude Opus 5
parent 4bccbd4a4c
commit ecde8abb53
3 changed files with 88 additions and 10 deletions
+49 -5
View File
@@ -22,8 +22,17 @@ import config
import events
import reports
import state
from taskfiles import (actor_name, append_to_task, find_stage_of, move_task,
read_task, set_assignee)
from taskfiles import (actor_name, append_to_task, collect, find_stage_of,
move_task, read_task, set_assignee)
# A phase runs on an integration branch of its own — phases.py owns the
# behaviour, the name lives here because this is where a launch decides
# what to branch from.
PHASE_BRANCH_PREFIX = "phase/"
def phase_branch(phase_file: str) -> str:
return PHASE_BRANCH_PREFIX + phase_file[:-3]
def _report_of(record: dict, text: str | None = None) -> str:
@@ -201,7 +210,38 @@ def _fresh_branch_point() -> tuple[str | None, str | None]:
return "origin/main", None
def _claim_for_launch(filename: str, stage: str, takeover: bool) -> None:
def phase_branch_point(filename: str) -> tuple[str | None, str | None]:
"""Where a *member of a running phase* branches from: the phase's own
branch, not main.
That is the whole reason a phase has a branch. Related cards run one
after another, so card two branched from main could not see card one's
work while card one sat unmerged in review/ — it would conflict, or
quietly build the same thing twice. Branching from the phase tip is
what makes the list add up.
(None, None) for a card in no phase, or one whose phase has not been
started — then the ordinary fresh branch point applies.
"""
phase = None
try:
for stage in collect()["stages"]:
for task in stage["tasks"]:
if task["file"] == filename:
phase = task.get("phase")
except OSError:
return None, None
if not phase:
return None, None
branch = phase_branch(phase["file"])
exists = subprocess.run(["git", "-C", str(config.REPO), "rev-parse",
"--verify", "--quiet", branch], capture_output=True)
if exists.returncode != 0:
return None, None
return branch, f"branched from {branch}, the phase's own branch"
def claim_for_launch(filename: str, stage: str, takeover: bool = False) -> None:
"""One agent per task is a board-memory rule; across machines the card
file is the only thing every board can see, so the claim is what gates
a launch here.
@@ -238,7 +278,7 @@ def start_agent(filename: str, stage: str, takeover: bool = False) -> dict:
# Moving a card to in-progress is the commitment; only then does work start.
_validate(filename, stage, {"in-progress"},
"work starts from in-progress/ — move the card there first")
_claim_for_launch(filename, stage, takeover)
claim_for_launch(filename, stage, takeover)
stem = filename[:-3]
branch = f"task/{stem}"
@@ -267,7 +307,11 @@ def start_agent(filename: str, stage: str, takeover: bool = False) -> dict:
base = _git("merge-base", "main", branch).stdout.strip()
result = _git("worktree", "add", str(worktree), branch)
else:
point, base_note = _fresh_branch_point()
# A phase member starts from its phase's tip; everything else
# from the newest main this checkout can see.
point, base_note = phase_branch_point(filename)
if point is None:
point, base_note = _fresh_branch_point()
if point:
base = _git("rev-parse", point).stdout.strip()
result = _git("worktree", "add", "--no-track", "-b", branch,
+34
View File
@@ -450,6 +450,40 @@ def append_to_task(filename: str, stage: str, text: str, what: str) -> bool:
return True
def append_to_section(filename: str, stage: str, heading: str, line: str,
what: str) -> bool:
"""Add one line under `## <heading>`, creating the section at the end of
the card when it is not there yet.
The third door, and the narrowest: a running record where every entry is
one line and belongs under one heading the phase log. `append_to_task`
would scatter those lines through the file as other sections (a work
report, a review) landed between them, and the record would stop being
readable in the one place a person looks. It commits like every other
board-made write.
"""
path = config.TASKS / stage / filename
try:
text = path.read_text(encoding="utf-8")
except OSError:
return False
section = re.compile(rf"^##\s+{re.escape(heading)}\s*$(.*?)(?=^##\s|\Z)",
re.MULTILINE | re.DOTALL).search(text)
if section:
body = section.group(1).strip("\n")
updated = f"## {heading}\n\n{body}\n{line}\n\n" if body else \
f"## {heading}\n\n{line}\n\n"
text = text[:section.start()] + updated + text[section.end():]
else:
text = text.rstrip("\n") + f"\n\n## {heading}\n\n{line}\n"
try:
path.write_text(text, encoding="utf-8")
except OSError:
return False
commit_edit(filename, stage, what)
return True
def move_task(filename: str, source: str, target: str, actor: str = "you") -> dict:
"""Move a task file between stage directories and fix its Status line.