Skip to content

The stage card shows what it is asking you to approve (#344, #267, #259) - #397

Open
hugolytics wants to merge 22 commits into
mainfrom
feat/stage-card-whole
Open

The stage card shows what it is asking you to approve (#344, #267, #259)#397
hugolytics wants to merge 22 commits into
mainfrom
feat/stage-card-whole

Conversation

@hugolytics

Copy link
Copy Markdown
Collaborator

Note: #359 (Stage 1 asks) merged into main at 6762520 on 2026-09-07, before this PR
was opened. This branch's rebase onto origin/feat/stage1-elicitation-loop was already a
no-op (merge-base identical to origin/main's), and git merge-tree against current main
produces no conflicts. So despite the plan's "blocked by #359" framing, this branch is not
actually blocked and can merge directly into main
— flagging this since the plan and task
brief were written before #359 merged.

Problem

Sunday 2026-09-06 committed 8 blocks at 00:04:37 and was corrected at 00:06:54. The card printed raw Markdown, buried the day under Decided, gave no sign that a sci-fi reading block came from a stored rule rather than from the user, and had nowhere to put the planner's open question.

Rubric

  • the day is a header block; groups are their own sections; nothing collapses behind Show more
  • only items the user did not state carry a marker, and a rule is named, not categorised
  • a rule_uid the planner invented fails the turn instead of naming a rule that does not exist
  • a non-blocking question rides with the artifact; a blocking one still stops the ladder

Human checklist

  • open the card in #ff-e2e and confirm no Show more
  • confirm a rule you recognise is named correctly
  • press Proceed with a question unanswered and confirm the plan is still produced

Rendered live: a real SkeletonPayload was run through map_outcome/_artifact_groups and
render_stage_card (the actual code path, no hand-written blocks) and posted to #ff-e2e
thread 1788780130.551149 (ts 1788828740.625789), then read back to confirm: the day is a
header block, each group ("Morning"/"Afternoon"/"Evening") is its own section, only the
rule/assumed/calendar items carry a marker (a real rule name, "my guess", "on your
calendar" respectively) while the two user items carry none, the non-blocking question rides
below the artifact with Proceed/Back/Cancel still present, and the message came back whole —
11 blocks, no "Show more".

🤖 Generated with Claude Code

hugocool and others added 20 commits September 7, 2026 14:41
…, #267, #259)

Nine Block Kit variants in #ff-e2e settled the shape: a header block for
the day, one section per group, Context and Decided folded into context
blocks. Provenance names the rule and marks only what did not come from
the user. The planner may ask one question below the artifact rather
than instead of it.

Reverses #267's flat-markdown payload: it cannot carry provenance the
system can verify, and an unverified rule name on the card is the
model-supplied-identifier failure #330 already found once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Self-review dropped a task: the snapshot already carries
applicable_constraints as flat rows with uid and name (#202), so the
planned ACTIVE_CONSTRAINTS extension would have duplicated data the
codebase already has. Spec corrected to say where the rules come from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
applicable_constraints carries uid and name per row on every resolve
(#202). The first draft of the plan would have built a parallel copy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pre-flight scan found a test demanding behaviour Slack has no syntax
for: * and _ cannot be escaped in mrkdwn. Aligns both card paths on
html.escape(quote=False) and records what is deliberately not handled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Provenance per item cannot live in flat markdown without the rule name
being unverified model text (#267, #344).

SkeletonItem/SkeletonGroup are new; SkeletonPayload now carries
day_label/groups/reasoning instead of markdown/reasoning, refusing the
old shape by name. The submit-gate refusal message, the planner
obligation text in harness_bridge.py, and the two _known_field_names
tuples that recognise these fields as system-minted all follow.

stage_cards.py's skeleton branch does the minimum to keep drawing a
card -- flattening groups/items to plain lines -- with a TODO for
Task 4, which rewrites it to carry per-line provenance.

Every existing test that built the old {"markdown": ...} shape is
updated to the new one; none of them are weakened.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
762cea2's message overstated: it claimed every existing test building
the old {"markdown": ...} skeleton payload was updated. It was not.
Old-shape fixtures remained in test_adaptive_timeboxing.py,
test_planner_can_ask_for_another_turn.py, test_timeboxing_intents.py,
test_slack_timeboxing_channel_redirect.py, test_timeboxing_readiness.py,
test_adaptive_stage1.py, test_harness_approval_action.py, and -- missed
by the prior review pass too -- three recorded skeleton payloads inside
tests/replay/fixtures/timeboxing_incident_20260829.json.

None of these currently reach SkeletonPayload.model_validate, which is
why the suite stayed green, but they teach a shape the contract now
refuses, and Task 2's kernel-side verification on artifact acceptance
would have hit several of them (the replay fixture in particular, which
drives the real kernel) as confusing failures one task later.

Every listed fixture now carries {day_label, groups, reasoning}: a
minimal single-group, single source:"user" item where the test's
assertions don't depend on the payload's content, or the original
bullet text preserved verbatim as item text in the replay recording,
where it does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Never act on a model-supplied identifier. #330 was a judge mistyping a
uid by one character; here that would name a rule that does not exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
open_questions was never a field the kernel read, so a planner with a
question had nowhere to put it. A placement question is also unanswerable
without the placement on screen, so it sits below the artifact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…#259)

Fix round 1, two Important findings:

- blocking=False + a matching artifact + result.continuation is not None
  dropped the question with no guarantee the planner would re-raise it.
  NeedsAnotherTurn now carries an optional question instead of discarding
  it; refusing the combination was rejected because it would discard the
  turn's real work, the exact cost PlannerContinuation exists to avoid.

- a riding question's option buttons could not be answered: the kernel
  never called _hold_question for it, and _release_question would have
  wiped pending_blocker on the same save regardless. Confirmed first that
  readiness and approval never read pending_blocker, then held it for both
  the riding and continuation-with-question branches and widened
  _release_question's exemption to match -- a plain approval or any other
  outcome still clears it, so a stale record self-heals once superseded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Style A, trailing and italic. Only what did not come from the user is
marked, so every marker is something to argue with (#267).

Reconciles the two Asking types: deletes stage_cards.Asking and
repoints StageCard.asking at session_contracts.Asking, so
AwaitingApproval.question rides onto the card with no conversion.
_decided now returns facts only -- an assumption is marked inline on
the artifact line it decided, not listed again in Decided.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ry card

_decided(snapshot) gained include_assumptions=True by default, restoring
DenyControl on stage 1/2/4 -- the global facts-only cut in the prior
commit stripped deny from the stage-4 candidate approval, the last
human gate before the calendar is written, with no inline marker to
compensate. Only the SKELETON branch passes
include_assumptions=False, where the inline _(my guess)_ marker
already carries the same information.

Restores the two tests that were rewritten to assert absence back to
asserting presence for non-skeleton cards, adds a skeleton-scoped
suppression test and a stage-4 DenyControl guard (#267).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A single long section collapses behind Slack's Show more, so the user
clicked to see the day they were approving. A header block plus one
section per group renders whole (#344).

Decided items with no control (facts) fold into one uncapped context
block; items with a control (an assumption's DenyControl) stay their
own section with the overflow attached, since a context block cannot
carry an accessory -- folding those too would re-break the Deny
regression Task 4 just restored. Context and Decided both move to the
end of the card, after asking/gate/nav, as supporting material rather
than the thing being approved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…y once

Review findings on #344 (2026-09-08):

1. _artifact_groups (stage_cards.py) is the sole escaper for a CardGroup
   -- one html.escape(x, quote=False) call covers both name and lines.
   render_stage_card was escaping group.name a second time, turning
   'R&D' into 'R&amp;amp;D'. It now renders the name verbatim, same as
   lines and body. The regression test that missed this constructed a
   raw CardGroup directly, bypassing the seam where the bug lived;
   replaced with a real-pipeline test (SkeletonPayload through
   _artifact_groups through render_stage_card) plus a pass-through test
   mirroring the one already covering card.body.

2. '*Decided*' now labels the section exactly once whenever card.decided
   is non-empty: above the controlled items when there are any, or on
   the folded context block only when every item folded. Previously an
   all-controlled Decided list (nothing left to fold) rendered as bare
   bullets with no heading. Block-budget arithmetic updated: 22 + N,
   N <= 18 (was 21 + N, N <= 19) -- the heading is one more block in the
   Decided worst case.

Both fixes broken on purpose and confirmed to fail before restoring.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
_planning_obligation() said nothing about a blocker's `blocking` field:
grep -c blocking on this file returned 0. A planner had no way to learn it
may raise a non-blocking question that rides beside an artifact instead of
replacing it, or that a second question in the same turn fails as
too_many_questions regardless of blocking's value (#259 already paid for
the silent version of this gap once, with an invented open_questions field
the kernel never read).

The paragraph is gated on skeleton and validated_candidate: those are the
two targets _apply_planning_result actually reaches (per _derive_target),
so they are the only turns that can end in AwaitingApproval with a riding
question. day_frame and captured_inputs go through _planning_day_gate and
_stage1_outcome instead and never see this branch.

Drift guards: extended the SkeletonPayload field-name guard to
SkeletonItem.model_fields (item fields appear as JSON-literal keys in
payload_shape, not backtick references, so the check matches that form),
and added one asserting the prompt names `blocking` for both gated
targets. Left the .j2 template and prompt_rendering.py alone -- they are
imported only by the legacy agent.py path slated for deletion (#192) and
never produce a SkeletonPayload.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Task 6's prompt told the planner it could submit a non-blocking blocker
beside an artifact, but two gaps beneath `submit_planning_result` made
that unreachable in production:

Gap 1 -- `_validated()` refused an artifact and any blocker together,
unconditionally, regardless of `blocking`. Corrected to a case split:
one non-blocking blocker rides with the artifact (the feature Task 3
built at the kernel); a *blocking* blocker beside an artifact is refused
by name (`[blocking_blocker_with_artifact]`) because `_apply_planning_
result` checks `pending_question[1].blocking` before it ever looks at
`artifact_updates` -- reaching the kernel would silently discard the
artifact, not fail the turn. Two or more blockers beside an artifact are
refused here too (`[too_many_questions]`), duplicated from the kernel
the same way `required_block_missing` already is: the kernel's own
refusal only fires once `_apply_planning_result` reaches the blocker
branch, by which point this call has already returned `_RECORDED` and
the planner believes the turn succeeded.

Gap 2, the one that mattered most: `BlockerInput` never declared
`blocking`, and neither it nor FastMCP's generated arg model sets
`extra="forbid"`, so a `blocking: true` a planner actually sent over
MCP was silently dropped before this tool's body -- and therefore before
Gap 1's fix -- ever ran. Every existing test called the bare Python
function with dicts, bypassing FastMCP's schema coercion entirely, so a
green suite here proved nothing about the real wire. Added `blocking`
to `BlockerInput`; confirmed via `mcp.list_tools()` that the generated
schema now carries it, and via `mcp.call_tool()` (the real entry point)
that it survives into the stored `UserBlockerDraft` and drives the
correct refusal -- both traced in task-6b-report.md.

Rewrote the tool docstring, which still said a blocker always replaces
the artifact. Split `test_a_blocker_beside_an_artifact_is_refused` into
three: non-blocking rides and succeeds, blocking is refused, two
blockers is refused -- plus two wire-level tests using `mcp.call_tool`
that would have caught Gap 2 on their own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Coordinator-directed addendum to the previous commit. `BlockerInput`,
`AssumptionInput` and `BlockerOptionInput` set no `extra="forbid"`, so
an undeclared field a planner sent over MCP was silently coerced away
before it ever reached `_validated` -- the identical failure class as
the `blocking`-drop just fixed, in the same three models, discovered in
the same session. Re-read AssumptionInput's "loose on purpose"
docstring before making this change: it says the strict contract lives
in `_validated`, so this model's *types* don't have to be the gate
(hence `value: Any`) -- it says nothing about tolerating an unknown
field name, so `extra="forbid"` does not contradict it.

Added `model_config = ConfigDict(extra="forbid")` to all three models
-- this is an internal contract between one planner and one tool, not a
versioned external API, so there is no forward-compatibility case for
tolerating an unrecognised key. Amended the comment on `BlockerInput.
blocking` to say the hazard is closed and why, so the next reader does
not loosen it back.

Verified over the real wire (`mcp.call_tool`, not the bare-dict path
every other test in this file uses, which is what hid the original
hazard): an unknown field on a blocker or an assumption is now refused
with `pydantic.errors.extra_forbidden`, loudly, instead of disappearing.
No existing caller or test sent an undeclared field, so nothing else in
the suite was touched by tightening this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Task 4's _rule_names snippet read an ACTIVE_CONSTRAINTS fact -- leftover
from a draft. That fact carries only a count; the implementation reads
PlanningSessionSnapshot.applicable_constraints, the rows the host already
resolved for the day (#202). Task 6's file target named
skeleton_draft_system_prompt.j2 via render_skeleton_draft_system_prompt(),
but that renderer is imported only by the legacy agent.py (slated for
deletion, #192) whose Stage 3 never produces a SkeletonPayload; the live
planner instruction actually changed was _planning_obligation() in
harness_bridge.py. Both were controller overrides during execution and the
plan document never caught up.

Also appended a Task 6b section recording the added task: the MCP
submission path refused an artifact and a riding blocker together
unconditionally, and BlockerInput had no blocking field, so a planner's
blocking:true was silently dropped before the tool body ran. Without both
fixes the non-blocking-question feature built across Task 3 and Task 6 was
unreachable in production.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ays asked

Two ways the one non-blocking question could destroy something.

The obligation invited a question on the candidate turn. It cannot be
answered there: `evaluate` returns only the turn's target's gaps, and every
user-owned requirement in the catalog targets SKELETON, so a blocker on a
stage-4 turn names a gap that is `system` or `planner` owned and
`_apply_planning_result` refuses the whole turn as `invalid_planner_result`
-- discarding the finished candidate with it. The gate is skeleton-only, and
says why at the gate so it is not widened again. A test asserts the kernel
fact it rests on rather than leaving it to be re-derived.

Re-presenting an unapproved skeleton dropped its riding question. The
`_pending_approval` short-circuit answered with the artifact alone,
`_release_question` saw no question and cleared the held record, the redrawn
card had no question and the card that had one was receipted with its buttons
stripped -- so a press on it refused as `stale_blocker_choice`, silently, on
any Advance, NextControl or StartSession. `_still_asking` re-attaches it: the
question text from the catalog (where `_asking` already reads it, so the same
sentence), the options from the held record (a press binds against what was
offered, never against what the catalog would offer now), and nothing at all
once the requirement is satisfied.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five small ones from the whole-branch review.

`_ctx` sliced its join at Slack's block cap with no indication and both
docstrings called it uncapped, so a long Decided list lost its tail and cut
its last visible line mid-word -- on the card the user is being asked to
approve. It now takes the items rather than the join, drops whole ones off
the tail until they fit, and says how many went. The count cap stays retired;
the claim of no cap at all does not.

`unknown_rule_uid` reached the user as "I could not carry that through", and
so did a stored skeleton in the retired shape -- where Retry is the one move
that cannot work, because the next turn re-presents the same artifact and
fails identically. Both have their own sentence now, and the undrawable one
has its own code so the sentence can name Back and Proceed instead.

`_known_rule_uids` admitted any row carrying a `uid` while `_rule_names`
needs a string `name` to draw it with, so a row with one and not the other
passed verification, was stored, and then raised inside `_artifact_groups`
while drawing -- after the write. They read the rows the same way now.

The stage-4 branch's missing `asking=` is now stated as the deliberate thing
it is, and the skeleton validator's comment names the fields it actually
checks rather than the `markdown` this branch retired.

`test_a_non_blocking_question_keeps_proceed_and_a_blocking_one_does_not`
asserted "Proceed" was absent from a card handed no `ApproveControl` -- true
however the question renders. Replaced with the property it was reaching for:
the question and its options are drawn, below the day, with Proceed still
live.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Resolves one conflict in harness_bridge._planning_obligation: both sides
appended a fragment to the obligation string (question_channel here,
work_lines on main). Both are kept.

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