Stage 1 first: the shape of the day, a clock for the judges, and a readable gate line (#411, #412, #413) - #437
Draft
hugolytics wants to merge 33 commits into
Draft
Stage 1 first: the shape of the day, a clock for the judges, and a readable gate line (#411, #412, #413)#437hugolytics wants to merge 33 commits into
hugolytics wants to merge 33 commits into
Conversation
…, #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;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>
The run loop consulted first_hard_user_blocker() before the Stage 1 branch, so every auto-started session asked 'what do you want out of the day' before a single probe and the ladder read 1 -> 2 -> 1. The shape of the day comes first now; the request is asked when the stage closes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A second copy of the pre-reorder ordering sentence survived in the FileAssumption branch's comment, and the replay fixture's own provenance note still described the shape from when Stage 1 first landed. Both now state the current order and turn shape. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
'in 2 hours' at 10:09 came back as 'by 9:30 AM' because the judges knew the day being planned and not the time of day the conversation was happening. elicit takes a required tz-aware clock; the host derives it from its injectable clock in the planning timezone; both prompts say what to do with it. A naive clock is refused before any judge runs. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… test that catches a deleted key (#412) Fix round 1, two findings. Finding 1 (critical): the Stage 1 clock eval gated a negative metric ("does the probe name an already-past time"), minimised by silence -- so it scored a clock-blind judge better than the fix. Rewritten twice more: v2 called the probe judge directly for movement/tacit_knowledge with a positive three-question design, but that criterion elicits an open, no-moment-named question either way and didn't discriminate. v3 (shipped) targets fixed/contradictory with the real 09:30 work-start rule -- the live bug's exact mechanism -- and gates a lower bound on 'reflects_correct_timing'. A paired/monkeypatch v4 attempt is documented and rejected: an empty clock object isn't equivalent to the key being absent and overstated the effect. The true effect is modest (~12% aware vs ~5% blind); this is reported honestly, including the one aware resample that scored 0/8, rather than resampled until it looked clean. Finding 2 (important): no deterministic test failed when the clock key was deleted from a prompt, because the existing plumbing test asserts what a stub received, not what the real prompt sends. Two new unit tests read the sent JSON back, the same shape as this file's other sent[...] assertions, and fail exactly where the gap was. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Fix round 2, ruling (c): the eval's positive discriminator floor is dropped from a gate to a measurement. At the measured rates (aware 4/32, blind 1/20) no sample size a CI would pay for gets both the false-fail and false-pass rate under 5% -- n=8/k=1 (what shipped) was 34.4%/33.7%; n=200/k=16 is the first pair under 5%, at 800 model calls a run. Only 2 of the 4 aware hits contained clock-derived arithmetic, and the discriminator itself mislabelled one blind draw against ~20 opposite verdicts on the identical sentence -- the gap being gated on was not separable from the labeller's own noise. What stays asserted: relevant (not vacuous, 8/8 every aware run) and the commits-to-stale-time ceiling (0/56 across every resample, the literal shape of the live bug, negligible false-fail risk). The FLOOR-named-ceiling defect from fix round 1 is fixed under its correct name. The two deterministic sent["clock"] unit tests in test_elicitation_judges.py are the regression gate for the clock mechanism; this file is informational on top of that. No production file changed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Row and criterion were joined with the same comma, so four rows open on 'assumptions' read as 'assumptions' four times over. Grouped by row. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Grouping by membership in ROWS/CRITERIA silently dropped a cell keyed outside either catalog instead of raising -- a gate line claiming less is open than the gate says is #342's exact failure shape one layer up. Every key is now checked before anything is composed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The "Cells are catalog requirements" section stated the ladder as a consequence of the stage numbers alone. It is also enforced at the run loop: Stage 1 is consulted before first_hard_user_blocker() while the stage is still open, so the request and the frame are asked only after Stage 1 closes (#411). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This was referenced Sep 9, 2026
…ly did Whole-branch review, three drive-by doc fixes: - src/fateforger/slack_bot/README.md documented GATE_LINE_CAP and its eight-cell overflow marker, both deleted by #413. Rewritten to describe the grouped line: one clause per open row, rows in ROWS order, criteria in CRITERIA order, no cap -- all 45 cells measure at 862 characters against Slack's 1600-character limit, and a cell outside the catalog raises rather than being dropped. - stage_cards.py's `_gate_line` docstring said "Eight rows is the whole floor"; there are nine (bounded, fixed, movement, body, fragile, not_today, method, unplaced, request). - The Stage 1 design doc's #411 sentence said the run loop consults Stage 1 "whenever the stage is still open"; the code also requires the target artifact to be the skeleton. Named that qualifier. Also: tests/evals/test_stage1_clock.py's documented run command was missing `-s`, so pytest swallows the printed rate on a pass -- the exact number the eval-demotion ruling turns on was invisible to whoever ran the command as written. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… review cleanups elicit()'s docstring-adjacent error message promised a `now` "in the planning timezone", but only tz-awareness was ever checked. A caller passing e.g. `datetime.now(timezone.utc)` sailed through the guard and had its wall-clock hour rendered straight into both judges' prompts by `_clock` -- "in 2 hours" resolves from 08:09 instead of 10:09, the #412 regression again, just past the door instead of caught at it. Now the zone name is compared too: `now.tzinfo`'s IANA `key` against `snapshot.planning_day.timezone`, both identifiers this system minted (a `zoneinfo.ZoneInfo` key and the string the host locked the day with), so the comparison is arithmetic, not a judgement about anything a user said. Raises `ValueError` naming both zones on a mismatch, before any judge runs. Two new unit tests: the existing naive-clock test's zone-aware sibling (a UTC `now` against a Europe/Amsterdam planning day raises before any judge runs -- confirmed to fail without the check, and confirmed passing again with it) plumb straight through the production call site in `timeboxing_host.py`, which already always builds `now` via `.astimezone(ZoneInfo(planning_day.timezone))`, so this tightens the contract without touching any real caller. Also, whole-branch review findings: - tests/replay/test_timeboxing_incident_20260829.py:: test_no_turn_asks_the_user_for_a_planner_owned_placement asserted `asked.isdisjoint(not_the_user_s)` against the "incident" scenario, where Stage 1 running first (#411) means nothing is ever asked -- `asked` is the empty set and the assertion held unconditionally, vacuous. Re-pointed at "hard_conflict", the one scenario where a question IS asked (`skeleton.requested_activity`, user-owned), so `asked` is non-empty and the assertion has something to fail against. Confirmed it now fails when a planner-owned id is injected into `asked`, and passes again without the injection. - adaptive_timeboxing.py: deleted a dead `readiness = ...` assignment, unread before being unconditionally reassigned a few lines later once `resolve_context` has updated the snapshot. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
On 2026-09-08, a real session showed three things wrong at once. First, the
ladder: an auto-started session asked "What do you want to get out of the
day?" (stage 2, Priorities) before any Stage 1 probe had run, then a Stage 1
question, then back to a stage-1-shaped question —
1 -> 2 -> 1, not the1, 1, …, 2, 3the design intends. Second, the clock: the user said "imgoing to my own office in 2 hours, so thats a 30 minute commute" at 10:09
local, and the probe that came back asked "Will you be at your office by
9:30 AM to start work?" — forty minutes in the past relative to when they
were speaking, because neither judge knew what time it was. Third, once
several rows were open at once the gate line read as one criterion repeated
per row in a comma-separated run, unreadable as anything but noise.
What changed
#411 — Stage 1 before the hard user blocker. The run loop now consults
Stage 1 before
first_hard_user_blocker()whenever the stage is still open,so the shape of the day is elicited first and "what do you want out of the
day" is asked once Stage 1 closes. The branch this check used to skip is
unchanged once the stage has closed — a fully-open catalog still guards the
request and the frame as hard, user-owned requirements. The ladder is now
1, 1, …, 2, 3by construction, matching the design (design doc addendum inthis branch, #411 named at the exact paragraph that states the ladder).
#412 — a clock for the judges.
elicit()now requires a tz-awarenowand raises before any judge runs if it is naive. Both the coverage judge
(
classify) and the probe judge (generate) receivenowandplanning_dayinside a"clock"object on every prompt, so a judgementabout whether a stated time is still reachable has something to compute
against. The regression gate for this is two deterministic unit tests that
read the sent JSON back and assert the
"clock"key is present with theright shape — not a stub that only proves an argument was passed.
#413 — a gate line grouped by row.
_gate_linenow groups every opencell by its row and lists each row once, with its open criteria in
parentheses, instead of repeating the row's label once per open criterion.
It raises
ValueErrorif a cell names a row or criterion outside thecatalog, rather than silently under-reporting what's open.
Evidence
tests/unit/test_adaptive_stage1.py::test_the_shape_of_the_day_is_asked_before_the_priorities_questionand
::test_the_priorities_question_is_still_asked_once_stage_one_closesassert the order directly.
tests/unit/test_elicitation_judges.py::test_classify_sends_the_clock_objectand
::test_generate_sends_the_clock_objectassert the sent prompt (not astub's received args) carries
"clock". These are what CI gates.tests/evals/test_stage1_clock.pytargets the exact mechanism of the live bug (
fixed/contradictory, thereal 09:30 work-start
mustrule, the office statement) and asks anon-contender model whether each probe reflects correct timing. Measured
across seven resamples: 4/32 relevant probes aware (~12%) vs 1/20 blind
(~5%). Directionally real — the aware side never scored below the blind
side — but the absolute rate is low enough that a binomial at n=8 has a
real chance of landing on zero for a correct build too (one aware resample
did). This is reported as a finding, not a pass/fail gate: too weak a
signal to gate on at any affordable sample size.
_gate_lineproduces a single 862-character line, one clause per row, each row named
exactly once — under Slack's per-block text cap and with no cell dropped
silently.
elicit()againstthe frozen fixture store (
data/fixtures/stage1-20260905.db) for theworking_tuesdayfixture day (2026-09-08), aDAY_FRAMEof 07:00–23:00,and one
ELICITED_STATEMENT— "im going to my own office in 2 hours, sothats a 30 minute commute." — with
nowpinned to 2026-09-08 10:09Europe/Amsterdam, contender judges on the production
timeboxing_judgeagent type (the flash pin). Built the
AwaitingUserthe kernel's_stage1_outcomeproduces from the resulting gate and top probe, ran itthrough
map_outcomeandrender_stage_card, and posted the actualblocks (not hand-written ones) to
#ff-e2e, threaded under1788780130.551149. The posted card (ts1788954054.922099) asked "Willyou be working remotely before you head to the office at 12:09?" — a
probe about the office trip, naming a clock time 2 hours after 10:09,
exactly the "in 2 hours" statement resolved forward rather than the stale
9:30 the live bug produced. The gate line read
Still need: how the day is bounded (assumptions, unstated knowledge) · what is fixed (assumptions, alternatives, clarity, unstated knowledge) · …— one clause per row, asdesigned. Read back via
conversations.replies: the message came backwhole, with the "Decided" line correctly showing "you said: im going to my
own office in 2 hours, so thats a 30 minute commute."
Finding, not part of these three tickets: while resampling this turn,
a probe whose judge attached closed-set options (e.g. suggested times for
the end-of-day planning block) produced an
actionsblock with multiplebuttons sharing one static
action_id(
ff_timebox_blocker_option) — Slack'schat.postMessagerejected thewhole message with
invalid_blocks/action_id already exists. Thisreproduces regardless of timeboxing: the kernel asks the Priorities question before Stage 1 runs, so the ladder reads 1 → 2 → 1 and the shape of the day comes second #411/timeboxing: Stage 1 has no clock — 'in 2 hours' at 10:09 became 'by 9:30 AM' #412/timeboxing: the Stage 1 gate line joins row and criterion with the same comma, so 'assumptions' reads as four separate needs #413 (multi-option Stage 1 probes exist
independently of this branch) and existing unit tests never catch it
because they assert decoded button values, never post to the real API.
Worth its own ticket.
Human checklist
sends is a Stage 1 probe, not the Priorities question.
the next probe's stated time, if it names one, is after the current
time rather than a stale earlier hour.
row, criteria in parentheses — not one row name repeated per
criterion.
Status
Rebased onto
origin/feat/stage-card-whole— #397 is still OPEN, sothis PR's diff includes #397's commits until it merges into
main. Notblocked from opening as a draft; will need a rebase onto
mainonce #397lands, same as any stacked branch.
🤖 Generated with Claude Code