Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
547 changes: 547 additions & 0 deletions docs/superpowers/plans/2026-09-09-stage1-first.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,10 @@ Each `ArtifactRequirement` gains a **`stage: int`**. The card takes the stage fr
requirement behind the blocker, and `_QUESTION_STAGE`, the hand-authored map from fact
kind to stage, is deleted. `skeleton.day_frame` and every `elicit.*` cell are stage 1;
`skeleton.requested_activity` and `skeleton.activity_reading` are stage 2. That closes
#276: the ladder is 1, 1, …, 2, 3 by construction.
#276: the ladder is 1, 1, …, 2, 3 by construction. The run loop enforces the order this
implies: it consults Stage 1 before `first_hard_user_blocker()` whenever the target
artifact is the skeleton and the stage is still open, so the request and the frame are
asked only after Stage 1 closes (#411).

`skeleton.day_frame` stays user-owned and hard. The `bounded` row reads the `DAY_FRAME`
fact as a stated fact. When memory supplied the frame silently, which is the defect in
Expand Down
55 changes: 33 additions & 22 deletions src/fateforger/agents/timeboxing/adaptive_timeboxing.py
Original file line number Diff line number Diff line change
Expand Up @@ -643,7 +643,6 @@ async def _turn_guarded(
await progress_sink.emit(
{"phase": "resolving_context", "status": "started"}
)
readiness = self._requirements.evaluate(target, snapshot)
try:
resolved = await self._context.resolve(
snapshot,
Expand Down Expand Up @@ -696,6 +695,28 @@ async def _turn_guarded(
update={"work_refs_unresolved": resolved.work_refs_unresolved}
)
readiness = self._requirements.evaluate(target, snapshot)

# Stage 1 before the hard-blocker check, deliberately. The catalog's
# user-owned hard requirements -- the request, the frame -- are still
# guaranteed before a skeleton is drafted, because this branch is
# skipped once `stage1 == "closed"` and the check below then runs as
# it always did. What changes is the order: the shape of the day is
# elicited first, and "what do you want out of the day" is asked when
# the stage closes. The other order asked the Priorities question on
# every auto-started session before a single probe, and the ladder
# read 1 -> 2 -> 1 (#411, #276). The Stage 1 spec states this ladder
# as `1, 1, ..., 2, 3 by construction`; the loop now matches it.
if target is ArtifactKind.SKELETON and snapshot.stage1 != "closed":
stage1_snapshot, stage1_outcome = self._stage1_outcome(
snapshot, readiness, list(resolved.probes)
)
return await self._save(
stage1_snapshot,
base_revision=base_revision,
request=request,
outcome=stage1_outcome,
)

blocker = readiness.first_hard_user_blocker()
if blocker is not None:
# No options. The catalog knows what it needs, not what today's
Expand All @@ -713,17 +734,6 @@ async def _turn_guarded(
),
)

if target is ArtifactKind.SKELETON and snapshot.stage1 != "closed":
stage1_snapshot, stage1_outcome = self._stage1_outcome(
snapshot, readiness, list(resolved.probes)
)
return await self._save(
stage1_snapshot,
base_revision=base_revision,
request=request,
outcome=stage1_outcome,
)

if readiness.system_owned_gaps():
return await self._save(
snapshot,
Expand Down Expand Up @@ -962,12 +972,12 @@ def _apply_intent(
else pending,
}
)
# Always fall through. The run loop evaluates readiness fresh,
# holds a hard user blocker before it ever reaches Stage 1, and
# arrives at `_stage1_outcome` itself once Stage 1 is open --
# `stage1_gate` already subtracts any cell this assumption
# answers, so the run loop's own gate agrees with this turn
# rather than needing this branch to pre-empt it.
# Always fall through. The run loop evaluates readiness fresh and
# reaches Stage 1 first while it is open, arriving at
# `_stage1_outcome` itself; a hard user blocker is only held once
# the stage has closed. `stage1_gate` already subtracts any cell
# this assumption answers, so the run loop's own gate agrees with
# this turn rather than needing this branch to pre-empt it.
return updated, None
if isinstance(intent, DenyAssumption):
kept = [a for a in snapshot.assumptions if a.assumption_id != intent.assumption_id]
Expand Down Expand Up @@ -1097,10 +1107,11 @@ def _stage1_outcome(
"""What Stage 1 shows right now: the top open cell, or a proposal to close.

The one place `stage1_gate` becomes a `TurnOutcome`, and the run
loop -- after resolving context and holding any hard user blocker --
is its only caller: `FileAssumption` and `GoBack` fall through to it
rather than answering for it, so `GateMet` and the `stage1 ==
"proposed"` transition happen exactly once per turn, in one place.
loop -- after resolving context, and before it ever consults the hard
user blocker -- is its only caller: `FileAssumption` and `GoBack` fall
through to it rather than answering for it, so `GateMet` and the
`stage1 == "proposed"` transition happen exactly once per turn, in
one place.
`stage1_gate` itself already subtracts any cell a filed assumption
answers, so the `Gate` read back here needs no further narrowing.

Expand Down
59 changes: 57 additions & 2 deletions src/fateforger/agents/timeboxing/elicitation_judges.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import asyncio
import json
from dataclasses import dataclass
from datetime import date, datetime
from typing import Any, Literal

from autogen_core.models import ChatCompletionClient, SystemMessage, UserMessage
Expand Down Expand Up @@ -254,10 +255,26 @@ class _CoverageJudgement(BaseModel):
Do not raise a concern the person never raised. For the "alternatives"
criterion, answer "would_ask" only where a rule in this row is genuinely at
risk given what they said today; a contingency nobody needs is not worth their
time. Return only the requested schema.
time.

You are told the current time and the day being planned. When the planning
day is today, an arrival or start the person named that is already behind the
clock is not an open question; when it is a later day, the clock only tells
you how far off it is. Return only the requested schema.
"""


def _clock(now: datetime, planning_day: date) -> dict[str, Any]:
"""What the judges are told about time. Weekday and zone are spelled out
because a model reads "Tuesday 10:09 Europe/Amsterdam" more reliably than
an ISO string; the day count is date arithmetic, not a judgement."""
return {
"now": f"{now:%A %Y-%m-%d %H:%M} {now.tzinfo}",
"planning_day": f"{planning_day:%A %Y-%m-%d}",
"days_until_planning_day": (planning_day - now.date()).days,
}


def _rule_for_classify(row: dict[str, Any], *, with_text: bool) -> dict[str, str]:
"""Name and necessity always; the description only where the criterion
needs it. Sending every description on all 45 cells is the token cost the
Expand All @@ -282,6 +299,8 @@ async def classify(
stated: list[str],
request: str | None,
session_key: str,
now: datetime,
planning_day: date,
) -> tuple[CellState, str]:
row: Concern = ROWS[cell.row]
criterion = CRITERION_BY_KEY[cell.criterion]
Expand All @@ -297,6 +316,7 @@ async def classify(
],
"stated": stated,
"request": request,
"clock": _clock(now, planning_day),
},
ensure_ascii=False,
separators=(",", ":"),
Expand Down Expand Up @@ -343,6 +363,11 @@ class _ProbeJudgement(BaseModel):
planner place. Offer "options" only when the sensible answers form a closed
set of at most four; otherwise leave it empty.

You are told the current time and the day being planned. Anything the person
said relative to now -- "in 2 hours", "after lunch", "before I leave" --
resolves against that clock. Never ask about a moment that has already passed
on the planning day, and name times as clock times the person can check.

If nothing the user has said grounds a question about this cell, set grounded
to false and leave the rest null: a no-op is a perfectly good outcome; do not
invent a question to justify the run. Return only the requested schema.
Expand All @@ -361,6 +386,8 @@ async def generate(
conversation: list[str],
request: str | None,
session_key: str,
now: datetime,
planning_day: date,
) -> ProbeDraft | None:
row: Concern = ROWS[cell.row]
criterion = CRITERION_BY_KEY[cell.criterion]
Expand All @@ -374,6 +401,7 @@ async def generate(
],
"conversation": conversation,
"request": request,
"clock": _clock(now, planning_day),
},
ensure_ascii=False,
separators=(",", ":"),
Expand Down Expand Up @@ -544,6 +572,7 @@ async def elicit(
judges: Judges,
*,
session_key: str,
now: datetime,
concurrency: int = 16,
generate_for: int = 3,
) -> ElicitationResult:
Expand All @@ -555,8 +584,26 @@ async def elicit(
`uncovered`: the gate is "nothing uncovered", and `unaskable` only sorts
a cell last.
"""
if now.tzinfo is None or now.utcoffset() is None:
# A naive clock cannot be placed against a planning day in a named
# zone; a probe about the wrong hour is worse than no probe (#412).
raise ValueError("elicit needs a tz-aware `now` in the planning timezone")
if snapshot.planning_day is None:
raise ValueError("elicit needs a locked planning day")
# The message above promises "in the planning timezone", not merely
# tz-aware: a `now` in UTC (or any other zone) passes the check above
# and then has its wall-clock hour rendered straight into both judges'
# prompts by `_clock`, so "in 2 hours" resolves from the wrong hour
# silently -- the exact #412 regression, just not caught at the door.
# `now_zone`/`planning_day.timezone` are identifiers this system minted
# (an IANA key from `zoneinfo.ZoneInfo`, and the string the host locked
# the day with), so comparing them is arithmetic, not a judgement.
now_zone = getattr(now.tzinfo, "key", None)
if now_zone != snapshot.planning_day.timezone:
raise ValueError(
"elicit needs `now` in the planning day's timezone "
f"({snapshot.planning_day.timezone!r}), got {now_zone!r}"
)
day = snapshot.planning_day.date
suspended = _suspended_uids(snapshot)
live_rows = [row for row in rows if str(row.get("uid")) not in suspended]
Expand Down Expand Up @@ -598,6 +645,8 @@ async def _one(cell: CellRef) -> tuple[str, CellState]:
stated=stated_lines,
request=request,
session_key=session_key,
now=now,
planning_day=day,
)
return cell.id, state

Expand Down Expand Up @@ -626,7 +675,13 @@ async def _one(cell: CellRef) -> tuple[str, CellState]:
drafts = await asyncio.gather(
*(
judges.probe.generate(
cell=cell, rules_full=by_row[cell.row], conversation=conversation, request=request, session_key=session_key
cell=cell,
rules_full=by_row[cell.row],
conversation=conversation,
request=request,
session_key=session_key,
now=now,
planning_day=day,
)
for cell in targets
)
Expand Down
16 changes: 9 additions & 7 deletions src/fateforger/slack_bot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,13 +103,15 @@ for the top open cell: `ProbeJudge` phrases it from what the user has said
this session, with up to four option buttons (`BlockerOption`); a cell no
probe could ground falls back to the concern floor's own criterion wording
instead. The card's gate line -- what Stage 1 still needs, across every open
cell, not only the one being asked -- names at most eight cells before an
overflow marker (`GATE_LINE_CAP` in `stage_cards.py`, `_gate_line`). Measured
2026-09-07 against the card renderer: all 45 cells open on turn one rendered
1589 characters, eleven short of Slack's 1600-character section-text limit
and being sliced mid-word by the section builder rather than raising; capped
at eight, the same turn renders in 295 characters and the worst case across
the catalog's longest labels is 374.
cell, not only the one being asked -- groups open cells by row rather than
listing one clause per cell (`_gate_line` in `stage_cards.py`): one clause
per row that has an open cell, rows in `ROWS` order, each row's open
criteria listed in `CRITERIA` order and parenthesised after the row's label.
There is no cap -- every row and criterion key is checked against the
catalog first, and a cell naming one outside it raises `ValueError` rather
than being silently dropped. Measured against the card renderer: with all
45 cells open on turn one, the grouped line renders in 862 characters,
comfortably under Slack's 1600-character section-text limit.

### Thread Focus

Expand Down
59 changes: 39 additions & 20 deletions src/fateforger/slack_bot/stage_cards.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,13 @@

from pydantic import BaseModel, ConfigDict, Field

from fateforger.agents.timeboxing.elicitation import criterion_label, row_label
from fateforger.agents.timeboxing.elicitation import (
CRITERIA,
CRITERION_BY_KEY,
ROWS,
criterion_label,
row_label,
)
from fateforger.agents.timeboxing.readiness import TimeboxRequirements
from fateforger.agents.timeboxing.session_contracts import (
ArtifactKind,
Expand Down Expand Up @@ -397,31 +403,44 @@ def _nav(*, back: bool) -> list[Control]:
return controls


#: How many open cells the gate line names before it says "and N more".
#: Cut by count, not by characters, so the last pair shown is a whole pair --
#: the same shape `_bullets` uses in `timeboxing_cards`. Eight is chosen
#: against the longest labels in the catalog: eight of the longest row and
#: criterion pairs render in 374 characters, well inside Slack's 1600 per
#: section. Uncapped, turn one -- when all 45 cells are open -- rendered 1589
#: characters and was eleven from being silently truncated mid-word by the
#: section builder, which slices rather than raises.
GATE_LINE_CAP = 8


def _gate_line(gate: Gate) -> str:
"""What Stage 1 still needs, one clause per open row.

Grouped by row rather than listed per cell: four rows open on the same
criterion used to read as that criterion named four times, as if it were
four separate needs (#413). Row and criterion keys are identifiers this
system minted, so grouping and ordering them is arithmetic. Nine rows is
the whole floor, so nothing is capped.

Grouping by membership in `ROWS`/`CRITERIA` would silently drop a cell
keyed outside either catalog instead of raising -- a gate line that
claims less is open than actually is is the exact failure shape #342
exists to prevent one layer up, so every key is checked before anything
is composed.
"""
if not gate.open_cells:
return (
f"That's what I know to ask about a {gate.day_label}. "
"Anything else, or shall I plan?"
)
shown = gate.open_cells[:GATE_LINE_CAP]
needs = ", ".join(
f"{row_label(cell.row)}, {criterion_label(cell.criterion)}" for cell in shown
)
rest = len(gate.open_cells) - len(shown)
if rest > 0:
return f"Still need: {needs}. _+{rest} more_"
return f"Still need: {needs}."
bad = [
cell.id
for cell in gate.open_cells
if cell.row not in ROWS or cell.criterion not in CRITERION_BY_KEY
]
if bad:
raise ValueError(f"gate.open_cells names cells outside the catalog: {', '.join(bad)}")
open_by_row: dict[str, set[str]] = {}
for cell in gate.open_cells:
open_by_row.setdefault(cell.row, set()).add(cell.criterion)
clauses = [
f"{row_label(row)} ("
+ ", ".join(criterion_label(c.key) for c in CRITERIA if c.key in open_by_row[row])
+ ")"
for row in ROWS
if row in open_by_row
]
return "Still need: " + " · ".join(clauses) + "."


def _rule_names(snapshot: PlanningSessionSnapshot) -> dict[str, str]:
Expand Down
8 changes: 7 additions & 1 deletion src/fateforger/slack_bot/timeboxing_host.py
Original file line number Diff line number Diff line change
Expand Up @@ -415,7 +415,13 @@ async def _frame_from_corpus(
else snapshot.model_copy(update={"facts": [*snapshot.facts, frame]})
)
result = await elicit(
seen, constraints, build_judges(model_client), session_key=snapshot.session_key
seen,
constraints,
build_judges(model_client),
session_key=snapshot.session_key,
# The bot's clock is UTC; the day is planned in its own zone, and
# "in 2 hours" means two hours from the local time (#412).
now=self._now().astimezone(ZoneInfo(planning_day.timezone)),
)
return PlanningContext(
facts=([frame] if frame is not None else []) + [result.matrix_fact],
Expand Down
Loading