Skip to content

timeboxing: asked ≠ started — a question to the Schedular is answered, never turned into a session (#316–#320) - #328

Open
hugolytics wants to merge 16 commits into
mainfrom
feat/asked-not-started
Open

timeboxing: asked ≠ started — a question to the Schedular is answered, never turned into a session (#316–#320)#328
hugolytics wants to merge 16 commits into
mainfrom
feat/asked-not-started

Conversation

@hugolytics

Copy link
Copy Markdown
Collaborator

Linked issues

The problem

03:43 today, under the Admonisher's planning card ("Not added yet"), Hugo typed "Is it planned?" The card's interpreter read it correctly — a question, decision: none (logs/llm_io_20260905_010643_9295.jsonl, record 3) — and the reply was still handed to timeboxing_agent, which opened a fresh five-stage session and posted the Stage-1 day-confirm card. No model was consulted for that.

Two faults. The routing half — sticky DM focus outranking a planning card's ownership of its own thread — landed as #310. This branch is the other half: the Schedular had no door that does not start a session. derive_timebox_intent returned StartSession() for any text before a day was locked, with no model asked, on the docstring's claim that "there is nothing to decide about"; a committed session offered only provide_facts or revise, and the interpreter had no none, so a question to the agent that just planned the day was coerced into a fact or a revision (#287 is the memory-side echo of that same coercion).

Hugo's expectation, stated: either the timeboxing agent that just did the session answers, or the Schedular that can pull my calendar sees there is no planning session and says so. The receptionist already refers "is a session planned?" to planner_agent — the mechanism existed and was never reached.

Spec: docs/superpowers/specs/2026-09-05-asked-not-started-design.md. Map: #157. Tickets: #316 #320 #317 #318 #319.

What lands

Asked is not started, and asked is not revised. A question changes nothing in the session it is asked of — and, after the final review, nothing one layer up either.

  • Kernel (timeboxing: AskQuestion intent and Asked outcome — question is a decision in every session state #316): AskQuestion intent and Asked outcome. kernel.turn returns Asked before applying or saving anything; the snapshot the next load sees is byte-identical (tested by full model_dump() equality). question is offered in every open state; cancelled still offers nothing. The user's words reach the answerer verbatim — the schema carries no text field for the decision, so the model cannot paraphrase them.
  • A session row is created only by an intent that starts one (timeboxing: the no-day short-circuit becomes a judged no_session state — start, question, cancel #318, final review's Critical): the kernel loads non-creating first; no row + question → in-memory snapshot; no row + cancel → TurnFailed(nothing_to_cancel); a cancel on an existing day-less, artifact-less row is refused the same way. Before this, "never mind" as the first words in a DM wrote cancelled onto the thread-blind DM key and every later typed turn died with the generic sentence.
  • The no-day short-circuit is a judged state (timeboxing: the no-day short-circuit becomes a judged no_session state — start, question, cancel #318): no_session offers start / question / cancel; the interpreter decides. Only an empty opening turn (a bare command) starts without asking. The AST guard proves the one remaining StartSession() return sits under an if on user_text — and was shown to fail on the reverted code. cancel before a day is auto-start: a restart during the opening turn leaves a half-open, day-less session that blocks for an hour and cannot be cancelled or expired #299's option 3, landed here.
  • The host answers (timeboxing: describe(snapshot) from StageCard, and the host answers an Asked outcome through planner_agent #317): describe_session is the prose renderer beside the Block Kit one, over the same StageCard (day, stage, context, decided items by owner, the open question with its why_needed and offered options, the commit receipt). An Asked outcome is sent to planner_agent — the calendar's answerer, the same one the receptionist refers to — as the described session plus the verbatim question; the "thinking…" card becomes the answer. No card transition, no panel sync, no relabel, no activity mark, no ladder cancel, no row. A failed answer is one in-thread line and one metered error (surface_intent/answer_failure), never a retry, never a session start.
  • Focus demotes to the receptionist (slack: focus demotes to the receptionist, and the two ownership rules land in the proposal contract #320): fix(slack): the planning card's own thread is resolved before sticky focus (recovered work) #310 demoted to the channel default, a no-op when that default is itself timeboxing_agent. The two general rules are now in docs/architecture/proposal_object_contract.md §7: a surface owns the thread its root posted, over focus; an agent that owns a workflow offers question in every state its surface allows.
  • Eval (eval: question-vs-start and question-vs-facts on the timeboxing surface, n=8 threshold 7, with a break-it check #319): tests/integration/test_eval_timebox_question.py, @slow, n=8 / threshold 7, on the production interpreter client (build_autogen_chat_client("timeboxing_agent") → the deepseek pro pin as .env has it), no temperature pin. 19/19 cases, every positive ≥ 7/8, zero wrong decisions. The break-it check as first specified was unpassable and the eval said so: with question in allowed_decisions, the label alone reads a pure interrogative as a question (7–8/8 with the paragraph stripped). What the paragraph actually carries, and what the check now strips — asserting the flip (wrong outnumbering right), not the absence of the right answer: a question carrying a fact on a committed session ("did you move lunch? I sleep 00:30-08:30" → fact 8/8 with it, question wins without), and a fresh-session calendar question ("what's on my calendar tomorrow?"AskQuestion 8/8 with it, StartSession 8/8 vs 0/8 without — the incident, reproduced on demand). A draw is redrawn once only when its failure carries a transport __cause__; a wrong decision is never redrawn. Every case whose words appeared in the paragraph was reworded. The final commit, a47e0bb, is from the peer session admonish-1-34 whose Task 5 agent measured the fresh-session flip; its report is task-5-report-second-agent.md in the (git-ignored) SDD workspace.

Verification

  • Subagent-driven: five tasks, each a fresh opus implementer and a fresh opus task review; two fix rounds (Task 1: the state-matrix guard covered 2 of 6 states; Task 4: no_session had been placed before the cancelled check, and the AST guard passed on the reverted code). Whole-branch review (opus): 1 Critical, 2 Important, 3 promoted Minors — one fix wave, scoped re-review clean. Every ruling and every deferred minor is in the ledger, .superpowers/sdd/2026-09-05-asked-not-started/progress.md (git-ignored; ask for it).
  • Offline suite on this tree, rebased on 7ef1013: 3096 passed, 10 skipped, 1 xfailed, 1 failedtests/e2e/test_slack_handoff_flow.py::test_slack_handoff_sets_focus_and_forwards, pre-existing on main (asserts the *agent*\ntext format fix(slack): a reply on a proposal thread is read against the proposal (#88 increment one) #281 removed; bisected by a peer to a71f213, theirs to fix).
  • Every new test was written failing first; each fix was mutated to confirm the test catches it (documented per task in the reports).
  • No live Slack run. Reproducing the incident needs a real timeboxing session first to pin sticky focus, then a typed question under a planning card — that is the rubric below, for the human.

Commands run (from .worktrees/asked-not-started):

PYTHONPATH=src ../../.venv/bin/python -m pytest tests -m "not slow" -q
# 1 failed, 3096 passed, 10 skipped, 101 deselected, 1 xfailed   (rebased on 7ef1013)
set -a; source .env; set +a
PYTHONPATH=src ../../.venv/bin/python -m pytest tests/integration/test_eval_timebox_question.py -m slow -q -p no:cacheprovider
# 18 passed (n=8 per case), 4 transport retries in 144 draws, model deepseek/deepseek-v4-pro-0813:nitro
git merge-tree --write-tree origin/main HEAD   # exit 0: clean against 7ef1013

Found on the way, not fixed here

Deferred, by ruling (in the ledger, none a merge gate)

A question turn is not idempotent (never saved, so a Slack redelivery re-asks — forced by the byte-identical invariant; the answer edits one message in place). The nothing_to_cancel card still draws Cancel/Retry. An interpreter that raises no longer marks activity (it is already a loud SurfaceIntentError). strftime('%A') in the description is locale-dependent. The start gloss in QUESTION_PARAGRAPH has positive evidence but no break-it of its own. Map #157's compounding ask: the second read-only intent is cheaper for describe_session, and still pays a label in seven hand-built _display_context tuples — a shared suffix and a _READ_ONLY_INTENTS tuple would make it near-free.

Before merging (Hugo)

  • Restart the bot from a clean checkout at this branch, stock scripts/demo.py start, and confirm the log names the pins, not gemini.
  • Run one real timeboxing session in the DM to completion (this pins user_focus to the Schedular), then type "Is it planned?" under a planning card. Expected: the receptionist → planner_agent answers from the calendar in the card's thread; no Stage-1 card.
  • In the DM with no session open, type "what's on my calendar tomorrow?". Expected: an answer, no session opened, no timeboxing_session_states row created, and the planning nudge for that hour still fires.
  • In the DM with no session open, type "never mind". Expected: "There is no planning session to cancel yet." — and the next message still works.
  • After a committed session, type "when is deep work?" in its thread. Expected: an answer from the receipt; the session's revision unchanged in timeboxing_session_states.
  • Decide timeboxing interpreter: the pro pin runs away to 16384 completion tokens on ~3–5% of calls, so one typed turn in ~25 fails as SurfaceIntentError #325's fix (a max_tokens cap on the interpreter client, or re-measuring high effort on it) — an agent changes no pin.

🤖 Generated with Claude Code

hugocool and others added 16 commits September 5, 2026 18:01
…, never turned into a session (#316-#320)

Design approved 2026-09-05 (S2 kernel outcome, N1 judged no_session with
cancel, A1 planner_agent answers, D1 describe from StageCard, F1 demote to
receptionist) and the five-task plan the subagents work from.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…#316)

AskQuestion joins the intent union and Asked the outcome union. The kernel
returns Asked before it applies or saves anything, so the revision the next
load sees is the one the turn loaded. Every open state offers question; a
cancelled session still accepts nothing. The user's words are bound by the
host, verbatim.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e state it hit (#316)

The first guard iterated two snapshots that both landed on capture and
committed, so planning_day, skeleton, review_commit and refine were never
reached while the docstring claimed per-state coverage. One parametrised case
per state `_display_context` returns, each asserting the state name it hit
before the decision set, so a miswired snapshot cannot hide behind another
state's answer. cancelled is pinned separately at ().

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…and the contract says why (#320)

The channel default is a no-op when it is itself timeboxing_agent. The
receptionist refers rather than starts, which is what a card's thread needs.
Two rules land in the proposal contract: a surface owns its thread over focus,
and a workflow-owning agent offers question in every state.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… with the session described (#317)

describe_session is the prose renderer beside the Block Kit one, over the same
StageCard. The host answers an Asked outcome through planner_agent, which holds
the calendar tools; the thinking card becomes the answer and no stage card
moves. A failed answer is one failure line and one metered error.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… question or cancel (#318)

The unconditional StartSession before a planning day is gone, with the
docstring claim that there was nothing to decide. A fresh session is a
no_session state the interpreter reads; an empty opening turn still starts.
cancel rides along, which is #299's option 3.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…nd the start guard guards (#318)

Cancel is now on offer at zero artifacts, so a fresh session can reach
cancelled with no artifacts at all -- and a no_session branch read before
the status would hand that closed thread a start, then kill it on the
confirmation with the generic failure line. The order is reversed and the
cancelled state is pinned for both shapes of closed session.

The AST guard counted one StartSession return, which the code this ticket
deletes also had; it now asserts the enclosing if tests user_text. The
prompt fragment glosses start, which until now the model saw only as a
name in the allowed set.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…reak-it check says the paragraph is not what carries it (#319)

Question-vs-start-vs-cancel on a fresh session and question-vs-fact-vs-revise
on a committed one, eight draws each, seven to pass. Every decision landed:
across two full runs, no draw that recorded an outcome chose the wrong intent.

The break-it check does not hold, and the record says so rather than asserting
less. Strip QUESTION_PARAGRAPH and the three question cases still read as
questions at 7/8, 8/8 and 8/8 -- for a plain interrogative the paragraph is
not the discriminator, `question` being in allowed_decisions already is. The
patch does bite: exactly its 570 bytes leave the system prompt. And the
paragraph is load-bearing, just not on these texts -- "did you move lunch? I
sleep 00:30-08:30" reads as the fact 8/8 with it and as a question 7/8
without, which is the clause about a reply that asks and also supplies one.
The break-it cases name the wrong texts; that is a case-list decision, so it
is reported, not quietly made here.

Every miss whose cause was recorded was a LengthFinishReasonError, never a
wrong decision: the pin runs away to 16384 completion tokens on roughly 3-5%
of calls. That is why `_count` now prints what the misses were and not only
how many -- without it the first run's 5/8 reads as a prompt failure, and the
fix for it would have been an edit to a paragraph that was working.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…d a draw that reached no decision is retried once (#319)

The old break-it cases were plain interrogatives, and they never measured the
paragraph: strip it and "is it planned?" and "what did we settle on for lunch?"
still answer question at 7/8 and 8/8, because the label is already in
allowed_decisions and the preamble says to choose from that list. What the
paragraph carries is the clause about a reply that asks *and* supplies a fact.
Aimed there, the check bites -- "did you move lunch? I sleep 00:30-08:30" is
the fact 8/8 with the paragraph and 2/8 without, and "is deep work still at 9?
also I get up at 07:00" is 8/8 against 0/8. Both ride as positive cases too,
so the pair states the discrimination in both directions. Without the
paragraph the user gets an answer to their question while the sleep boundary
they just stated goes nowhere, which is the silent-wrong-answer shape the ban
exists to stop.

A draw that raises reached no decision, never the wrong one: the interpreter
returns a typed intent for every reading the model produces and only raises
when the endpoint gave it nothing to read. Those are retried once and counted;
the count is printed, never asserted, because the endpoint's error rate is
#325's problem and folding it into the rate is what made a working paragraph
look like a prompt bug in the first run. Four retries in this run of 144 draws.

"did you add the gym?" as an eval case is now "did you put the gym in?" -- its
exact words are in QUESTION_PARAGRAPH, so it measured recall of the prompt.
The paragraph is unchanged. Spec section 5's break-it bullet now says what was
measured instead of what was assumed.

18 passed, on deepseek/deepseek-v4-pro-0813:nitro via the timeboxing_agent pin.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ts one (#318)

`cancel` as the first word typed into a DM wrote `cancelled` at revision 1 on
the thread-blind `{channel}:dm` key, and every later turn in that DM hit a
cancelled session and the generic failure sentence. Asking wrote a revision-0
`open` envelope, which is a session as far as the host's nudge suppressor is
concerned.

`_turn_guarded` now loads without creating: a question is answered over an
in-memory snapshot, a cancel with no row is refused as `nothing_to_cancel`, and
only an intent that starts something reaches `load_or_create`. A cancel against
a row that locked no day and holds no artifacts is refused too, so an envelope
an earlier code path left behind cannot be closed either.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…t either (#317)

The turn handler marked the user active, cancelled the pending Admonisher
ladder and persisted a session row before it knew the turn was a question. On
a DM with no session that row silenced the planning nudge for an hour: the user
asks "is it planned?", is told no, and the reminder that would have told them
to plan it is suppressed by the asking.

Both reads now go through `_load_or_new`, which loads and never writes, and the
two activity signals wait until the intent is derived and are skipped for
`AskQuestion`. The button path never carries a question, so it is unchanged.
The turn-handler fakes gain the `load` the repository protocol always had, in
place of the `load_or_create` the handler no longer calls.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… does (#317)

The docstring promises the same fields the card renders, and the `asking`
branch rendered the question alone -- not why it is needed, not the options on
screen, not what each would do. A user looking at two blocker buttons who types
"what are my choices?" got planner_agent answering from a description that
contained neither.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The code demotes any `timeboxing_agent` no explicit per-thread binding chose,
the channel default included -- pinned by
`test_a_planning_thread_in_a_timeboxing_channel_is_demoted_to_the_receptionist`.
The bullet said "arrived by focus", which is narrower than the rule.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…s facts (#316)

The comment the Task 1 ruling asked for: a `question` reading that arrives
carrying facts is the model contradicting its own prompt -- which says a
question that carries a fact is a fact, measured 8/8 -- so the words the user
typed win and the structure is dropped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…loads (#317)

Same change as the unit fakes: the turn handler reads through `load` now, and a
fake that only offers `load_or_create` fails the turn with an AttributeError
the walk reports as a wrong first card.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…and measures the clause the ticket is named for (#319)

The retry was blind. `except Exception` around a draw was justified as "the
interpreter only raises when the endpoint gave it nothing to read", and three
raise sites say otherwise: a decision outside allowed_decisions, output that
does not fit the narrowed schema, and the binder's own refusals. The first is
the exact degenerate answer a stripped paragraph is supposed to produce, so a
blind retry re-rolled the break-it result until the run agreed with it. Proven
against a stub: a disallowed decision took 8 retries and 16 calls before, and
0 and 8 after, while a transport failure is still redrawn once and a second
failure is still reported.

Both break-it families now assert the flip rather than an absence. A bar of
'the right decision fell below threshold' is cleared by two lost calls with the
paragraph doing nothing at all; the wrong decision outnumbering the right one
is not, because a lost draw subtracts from both counts.

Adds the fresh-session half, which is the regression the branch is named for:
stripped, "what's on my calendar tomorrow?" answers StartSession 8/8 against
AskQuestion 0/8, and unstripped it answers AskQuestion 8/8 with StartSession
never once. Asserted as a flip, not a bar -- 6/8 and 6/8 on two earlier draws
would have failed a bar while the paragraph was plainly working.

Seven case texts were quoted verbatim inside QUESTION_PARAGRAPH and measured
recall of the prompt rather than the judgement; each is reworded to the same
intent in other words. Spec section 5 now names the cases the code runs.

19 passed. Every positive case 7/8 or 8/8, every miss a LengthFinishReasonError
and not one a wrong decision.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants