Skip to content

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
mainfrom
feat/stage1-first
Draft

Stage 1 first: the shape of the day, a clock for the judges, and a readable gate line (#411, #412, #413)#437
hugolytics wants to merge 33 commits into
mainfrom
feat/stage1-first

Conversation

@hugolytics

Copy link
Copy Markdown
Collaborator

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 the
1, 1, …, 2, 3 the design intends. Second, the clock: the user said "im
going 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, 3 by construction, matching the design (design doc addendum in
this branch, #411 named at the exact paragraph that states the ladder).

#412 — a clock for the judges. elicit() now requires a tz-aware now
and raises before any judge runs if it is naive. Both the coverage judge
(classify) and the probe judge (generate) receive now and
planning_day inside a "clock" object on every prompt, so a judgement
about 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 the
right shape — not a stub that only proves an argument was passed.

#413 — a gate line grouped by row. _gate_line now groups every open
cell 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 ValueError if a cell names a row or criterion outside the
catalog, rather than silently under-reporting what's open.

Evidence

  • Ladder (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): tests/unit/test_adaptive_stage1.py::test_the_shape_of_the_day_is_asked_before_the_priorities_question
    and ::test_the_priorities_question_is_still_asked_once_stage_one_closes
    assert the order directly.
  • Clock, the gate (timeboxing: Stage 1 has no clock — 'in 2 hours' at 10:09 became 'by 9:30 AM' #412): tests/unit/test_elicitation_judges.py::test_classify_sends_the_clock_object
    and ::test_generate_sends_the_clock_object assert the sent prompt (not a
    stub's received args) carries "clock". These are what CI gates.
  • Clock, the eval (timeboxing: Stage 1 has no clock — 'in 2 hours' at 10:09 became 'by 9:30 AM' #412), informational only: tests/evals/test_stage1_clock.py
    targets the exact mechanism of the live bug (fixed/contradictory, the
    real 09:30 work-start must rule, the office statement) and asks a
    non-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 line (timeboxing: the Stage 1 gate line joins row and criterion with the same comma, so 'assumptions' reads as four separate needs #413): with every one of the 45 cells open, _gate_line
    produces 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.
  • A live Stage 1 turn, rendered by the real path: ran elicit() against
    the frozen fixture store (data/fixtures/stage1-20260905.db) for the
    working_tuesday fixture day (2026-09-08), a DAY_FRAME of 07:00–23:00,
    and one ELICITED_STATEMENT"im going to my own office in 2 hours, so
    thats a 30 minute commute."
    — with now pinned to 2026-09-08 10:09
    Europe/Amsterdam, contender judges on the production timeboxing_judge
    agent type (the flash pin). Built the AwaitingUser the kernel's
    _stage1_outcome produces from the resulting gate and top probe, ran it
    through map_outcome and render_stage_card, and posted the actual
    blocks (not hand-written ones) to #ff-e2e, threaded under
    1788780130.551149. The posted card (ts 1788954054.922099) asked "Will
    you 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, as
    designed. Read back via conversations.replies: the message came back
    whole, 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 actions block with multiple
    buttons sharing one static action_id
    (ff_timebox_blocker_option) — Slack's chat.postMessage rejected the
    whole message with invalid_blocks / action_id already exists. This
    reproduces 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

  • Start a session (no prior facts) and confirm the first card the bot
    sends is a Stage 1 probe, not the Priorities question.
  • Reply with something like "in 2 hours" to a Stage 1 probe and confirm
    the next probe's stated time, if it names one, is after the current
    time rather than a stale earlier hour.
  • Confirm a gate line with several rows open reads as one clause per
    row, criteria in parentheses — not one row name repeated per
    criterion.

Status

Rebased onto origin/feat/stage-card-whole#397 is still OPEN, so
this PR's diff includes #397's commits until it merges into main. Not
blocked from opening as a draft; will need a rebase onto main once #397
lands, same as any stacked branch.

🤖 Generated with Claude Code

hugocool and others added 30 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>
…ble gate line (#411, #412, #413)

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>
hugocool and others added 2 commits September 9, 2026 14:03
…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>
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