A card joins a phase without the file being opened
Phases are meant to arrive whole — members listed, dependencies filled in, readable in a diff before any of it runs. This is the other case: the card you decide belongs after all. ⟶ phase sits on backlog/ and to-do/ cards that are not already in a phase and are not phase cards themselves, and opens a sheet naming the phase cards waiting in to-do/ with what each already holds. Picking one appends `- <n> — <title>` to the end of that phase's ## Cards — the way a person writes it, because the section is authored and read by hand and a machine-shaped line is how a format stops being pleasant. Only to-do/. A phase in in-progress/ is running: its branch exists and its members are being worked in the order the list had when it started, so appending mid-flight is a different feature with different questions. Offer it and someone finds that out the hard way. No phase waiting there and the action is absent rather than present and empty. Nothing else moves. One line into the phase card, nothing at all into the card added — membership runs one direction and joining a phase is not a commitment to start it. The append goes out through append_to_section, the same door the phase log uses, so it commits itself under BOARD_COMMIT_MOVES, reaches the other boards, and reads the phase card off the disk rather than off a render: two boards adding to one phase produce two lines, not a lost one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1096,6 +1096,19 @@ function phaseLabel(phase) {
|
||||
return name.length > 22 ? name.slice(0, 21) + '…' : name;
|
||||
}
|
||||
|
||||
/* Which phases this card could join, which is also whether the action is
|
||||
there at all: the phase cards waiting in to-do/, and none whatsoever for
|
||||
a card already in a phase, a phase card itself (they do not nest), or a
|
||||
card with no number for a list to name it by. A phase in in-progress/ is
|
||||
running — its members are being worked in the order the list had when it
|
||||
started — so it is not on offer. The board offers what it can do. */
|
||||
function joinablePhases(task) {
|
||||
if (task.phase || task.isPhase || !task.number) return [];
|
||||
if (!['backlog', 'to-do'].includes(task.stage)) return [];
|
||||
const stage = (S.state?.board.stages || []).find(s => s.slug === 'to-do');
|
||||
return stage ? stage.tasks.filter(t => t.isPhase) : [];
|
||||
}
|
||||
|
||||
function cardFor(task) {
|
||||
const el = document.createElement('article');
|
||||
const agent = agentOnTask(task.file);
|
||||
@@ -1229,6 +1242,14 @@ function cardFor(task) {
|
||||
} else if (task.stage === 'done') {
|
||||
actions.push({ glyph: '↺', label: 'reopen', busy: 'reopening…', title: 'Put it back in the queue',
|
||||
run: () => move(task.file, 'done', 'to-do') });
|
||||
} else if (joinablePhases(task).length) {
|
||||
// the small path, on the two stages that have room for it: the card
|
||||
// you decide belongs in a phase after all. It writes one line into
|
||||
// the phase card and moves nothing, so it neither costs tokens nor
|
||||
// stops work — the sheet's named choice is the confirmation.
|
||||
actions.push({ glyph: '⟶', label: 'phase', busy: 'choosing…',
|
||||
title: "Add this card to the end of a phase's list — the phases waiting in to-do/",
|
||||
run: () => { phaseSheet(task); return true; } });
|
||||
}
|
||||
// work in review with no PR: no board opens one behind your back, so
|
||||
// the card offers it instead of the relevance check
|
||||
@@ -1680,6 +1701,52 @@ function completeSheet(task, from) {
|
||||
});
|
||||
}
|
||||
|
||||
/* Joining a phase is a choice between a few named options, so it is put in
|
||||
front of you the way finishing a card with work on it is — a short list
|
||||
of phases, each saying what it already holds, rather than a guess. What
|
||||
it writes is one line at the end of that phase's `## Cards`; the card
|
||||
itself does not move, and says nothing about the phase it joined. */
|
||||
function phaseSheet(task) {
|
||||
const phases = joinablePhases(task);
|
||||
if (!phases.length) { toast('no phase is waiting in to-do/', true); return; }
|
||||
const wrap = $('#sheetwrap');
|
||||
wrap.innerHTML =
|
||||
`<div class="sheet">` +
|
||||
`<div class="stitle"><span>${esc(task.title)}</span>` +
|
||||
`<span class="mono">${task.number ? '#' + esc(task.number) + ' · ' : ''}⟶ phase</span></div>` +
|
||||
`<p>Which phase runs this card? It goes at the end of that phase's list. ` +
|
||||
`The card stays in ${esc(task.stage)}/ — joining a phase is not a commitment to start it.</p>` +
|
||||
`<div class="sbtns">` +
|
||||
phases.map((p, index) => {
|
||||
const held = (p.cards || []).length;
|
||||
return `<button data-pick="${index}">${esc(p.title)}` +
|
||||
`<small>${held ? `holds ${held} card${held === 1 ? '' : 's'} — this one runs after them`
|
||||
: 'empty so far — this one would be its first card'}</small></button>`;
|
||||
}).join('') +
|
||||
`<button id="sh-nophase">Not now<small>Nothing is written.</small></button>` +
|
||||
`</div></div>`;
|
||||
wrap.classList.add('open');
|
||||
wrap.addEventListener('click', (e) => { if (e.target === wrap) closeSheet(); });
|
||||
$('#sh-nophase').addEventListener('click', closeSheet);
|
||||
wrap.querySelectorAll('[data-pick]').forEach(btn =>
|
||||
btn.addEventListener('click', () => {
|
||||
closeSheet();
|
||||
addToPhase(task, phases[+btn.dataset.pick]);
|
||||
}));
|
||||
}
|
||||
|
||||
async function addToPhase(task, phase) {
|
||||
const res = await fetch('/api/phase/add', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ file: task.file, stage: task.stage, phase: phase.file }),
|
||||
});
|
||||
const data = await res.json();
|
||||
toast(res.ok ? `${task.file} added to ${data.phaseName}`
|
||||
: (data.error || 'that card did not join'), !res.ok);
|
||||
await loadState();
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
function showDetail(task) {
|
||||
const changed = !S.selected || S.selected.file !== task.file;
|
||||
S.selected = task;
|
||||
|
||||
@@ -208,6 +208,17 @@ class Handler(BaseHTTPRequestHandler):
|
||||
# ‖ hold on a phase card: the run stops, nothing is unwound
|
||||
self._json(200, {"phase": phases.stop_phase(payload["file"],
|
||||
payload["stage"])})
|
||||
elif path == "/api/phase/add":
|
||||
payload = self._read_body()
|
||||
# one line into the phase card, nothing into the card added:
|
||||
# membership lives in one place and joining one is not a move
|
||||
result = taskfiles.add_to_phase(payload["file"], payload["stage"],
|
||||
payload["phase"])
|
||||
state.record_board_event({
|
||||
"kind": "phase", "actor": "you", "file": result["phase"],
|
||||
"summary": f"{result['phase']} gained {result['entry']}"})
|
||||
state.broadcast({"type": "board"})
|
||||
self._json(200, result)
|
||||
elif path == "/api/pr/open":
|
||||
payload = self._read_body()
|
||||
self._json(200, {"url": github.open_pr_now(payload["file"])})
|
||||
|
||||
@@ -39,6 +39,10 @@ CARDS_SECTION_RE = re.compile(r"^##\s+Cards\s*$(.*?)(?=^##\s|\Z)",
|
||||
re.MULTILINE | re.DOTALL)
|
||||
CARD_ITEM_RE = re.compile(r"^(?:[-*+]\s+)?#?0*(\d+)\b")
|
||||
DEPENDS_ITEM_RE = re.compile(r"#?\s*0*(\d+)")
|
||||
# titles are written `51 — Add a card to a phase`, and a member line names
|
||||
# the number itself, so the title's own copy of it is dropped — the same
|
||||
# cut the chip's label makes
|
||||
LEADING_NUMBER_RE = re.compile(r"^\s*\d+\s*[—–-]\s*")
|
||||
|
||||
STAGE_ORDER = {slug: index for index, (slug, _) in enumerate(config.STAGES)}
|
||||
CLAIM_FROM = {"backlog", "to-do"} # the unstarted stages: leaving one claims
|
||||
@@ -484,6 +488,96 @@ def append_to_section(filename: str, stage: str, heading: str, line: str,
|
||||
return True
|
||||
|
||||
|
||||
PHASE_JOIN_FROM = {"backlog", "to-do"} # a card joins a phase before it starts
|
||||
PHASE_HOST = "to-do" # and only a phase still waiting accepts it
|
||||
|
||||
|
||||
def _member_entry(number: str, title: str) -> str:
|
||||
"""`33 — The landing page` — the way a person writes it.
|
||||
|
||||
The section is authored by hand and read by hand, so a line the board
|
||||
adds has to be one you would have typed. Nothing parses what follows
|
||||
the number, which is exactly why it must stay readable. The bullet is
|
||||
left off here so the ticker can say the same thing in prose.
|
||||
"""
|
||||
rest = LEADING_NUMBER_RE.sub("", title).strip()
|
||||
return f"{number} — {rest}" if rest else number
|
||||
|
||||
|
||||
def _phase_holding(number: str) -> dict | None:
|
||||
"""The phase card that already lists this card, read off the disk.
|
||||
|
||||
Membership runs one direction only, so the answer is only ever found by
|
||||
looking at every phase card — and it is looked for here rather than
|
||||
taken from what a tab rendered, because the file may have gained the
|
||||
card since.
|
||||
"""
|
||||
for slug in config.STAGE_DIRS:
|
||||
directory = config.TASKS / slug
|
||||
if not directory.is_dir():
|
||||
continue
|
||||
for path in sorted(directory.glob("*.md")):
|
||||
task = read_task(path, slug)
|
||||
if task["isPhase"] and number in task["cards"]:
|
||||
return task
|
||||
return None
|
||||
|
||||
|
||||
def add_to_phase(filename: str, stage: str, phase_file: str) -> dict:
|
||||
"""Append a card to the end of a phase's `## Cards` section.
|
||||
|
||||
The convenience path for the card you decide belongs after all: one
|
||||
line into the phase card, and nothing at all into the card being added
|
||||
— membership lives in one place and this does not move it. It goes out
|
||||
through `append_to_section` like the phase log does, so it commits
|
||||
under the same gate every other board-made write to a task file does;
|
||||
an addition that never left one working tree is not an addition the
|
||||
phase would run.
|
||||
|
||||
Only a phase in `to-do/` takes cards. One in `in-progress/` is
|
||||
running: its branch exists and its members are being worked in the
|
||||
order the list had when it started, so appending mid-flight is a
|
||||
different feature with different questions.
|
||||
"""
|
||||
if stage not in PHASE_JOIN_FROM:
|
||||
raise ValueError("a card joins a phase from backlog/ or to-do/ only")
|
||||
for name in (filename, phase_file):
|
||||
if Path(name).name != name or not name.endswith(".md"):
|
||||
raise ValueError("bad filename")
|
||||
|
||||
card_path = config.TASKS / stage / filename
|
||||
phase_path = config.TASKS / PHASE_HOST / phase_file
|
||||
if not card_path.is_file():
|
||||
raise ValueError(f"{filename} is no longer in {stage}/ — refresh the board")
|
||||
if not phase_path.is_file():
|
||||
raise ValueError(f"{phase_file} is no longer in {PHASE_HOST}/ — a phase takes "
|
||||
"cards while it waits, not while it runs")
|
||||
|
||||
card = read_task(card_path, stage)
|
||||
phase = read_task(phase_path, PHASE_HOST)
|
||||
if not phase["isPhase"]:
|
||||
raise ValueError(f"{phase_file} is not a phase card")
|
||||
if card["isPhase"]:
|
||||
raise ValueError(f"{filename} is a phase — phases do not nest")
|
||||
if not card["number"]:
|
||||
raise ValueError(f"{filename} has no number, and a phase lists its cards by number")
|
||||
|
||||
number = canonical_number(card["number"])
|
||||
holder = _phase_holding(number)
|
||||
if holder is not None:
|
||||
where = ("this phase already" if holder["file"] == phase_file
|
||||
else f"phase {_phase_name(holder)} already")
|
||||
raise ValueError(f"{where} lists {card['number']}")
|
||||
|
||||
entry = _member_entry(card["number"], card["title"])
|
||||
if not append_to_section(phase_file, PHASE_HOST, "Cards", f"- {entry}",
|
||||
f"gained {number}"):
|
||||
raise ValueError(f"{phase_file} could not be written")
|
||||
return {"file": filename, "number": card["number"], "title": card["title"],
|
||||
"phase": phase_file, "phaseName": _phase_name(phase),
|
||||
"entry": entry, "line": f"- {entry}"}
|
||||
|
||||
|
||||
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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user