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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,31 @@ follow [SemVer](https://semver.org/).

## [Unreleased]

### Changed
- 0.6.x cleanup batch (architecture review findings F4/F5/F7,
2026-08-20) — single-sourcing and schema hygiene, output
byte-identical (pinned by the characterization suite):
- The last policy duplications moved into `models`:
`BOOLEAN_OPTIONS` (was defined independently in bridge and
widget), `recommended_first()` (was implemented three times —
widget, markdown surface, and inline in `to_ask_user_format`),
`RATIONALE_HEADERS` and `PROGRESS_STATUS_ICONS` (each surface
carried its own copy with a "matches the widget" comment nothing
enforced). New `tests/test_single_sourcing.py` pins the
single-sourcing per surface
- `bridge._CONFIRM_DEFAULT_OPTIONS` deleted — the bridge now
consumes `models.CONFIRM_DEFAULT_OPTIONS`, making the 0.5.0
changelog's single-sourcing claim true
- The MCP `_field_schema` types its object-array extras
(`progress_items`, `triage_items`, `consequences`, `assumptions`)
and gains a drift test: every `QuestionType` value must appear in
the schema's type enum and every `FormQuestion` field in its
properties (the prose description stays hand-written on purpose)
- Stale docstrings corrected: `keyboard_mode_enabled` and the
package overview now document `ATTUNE_FORMS_KEYBOARD_MODE` as the
preferred override with `ATTUNE_KEYBOARD_MODE` as the legacy
fallback, matching what the code reads

### Fixed
- **Directly-built D2 gate with a `default` can no longer pass
unanswered** (checkpoint-2 promoted item, 2026-08-20 — empirically
Expand Down
3 changes: 2 additions & 1 deletion src/attune_forms/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
than a question, and is never silently skipped.
- :func:`keyboard_mode_enabled` — the user's terse/keyboard opt-out
(D17), persisted per project in ``attune.config.json`` with
``ATTUNE_KEYBOARD_MODE`` as a session override.
``ATTUNE_FORMS_KEYBOARD_MODE`` (legacy fallback:
``ATTUNE_KEYBOARD_MODE``) as a session override.
- :func:`set_keyboard_mode` — persist that preference (what
``attune config set keyboard_mode`` calls).
- :func:`needs_widget` — low-level *controls* check: True iff a form
Expand Down
33 changes: 14 additions & 19 deletions src/attune_forms/bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
from attune_forms.models import (
ASSUMPTION_RULINGS,
ASSUMPTION_TEXT_SUFFIX,
BOOLEAN_OPTIONS,
CONFIRM_DEFAULT_OPTIONS,
FormQuestion,
FormResponse,
FormSchema,
Expand All @@ -34,10 +36,6 @@
triage_item_key,
)

#: The answer values accepted for a BOOLEAN question (its
#: ``to_ask_user_format`` renders as a Yes/No single-select).
_BOOLEAN_OPTIONS = ("Yes", "No")

#: ISO-8601 calendar-date format used by DATE questions.
_DATE_FORMAT = "%Y-%m-%d"

Expand Down Expand Up @@ -492,11 +490,6 @@ def _parse_suggested(
return suggested, []


#: The options a CONFIRM carries when the author names none. Exactly
#: two, always — the gate is two-way by ruling (confirm-construct D1).
_CONFIRM_DEFAULT_OPTIONS = ["Approve", "Abort"]


#: The D2 constructs forbid a ``default`` outright — a pre-selected
#: approval, a pre-filled order, or a pre-marked ruling defeats the
#: two-way gate; approving/ordering/ruling must be an explicit act.
Expand All @@ -522,9 +515,9 @@ def _parse_confirm_extras(
"""Parse the v7 CONFIRM extras and enforce its gate rules.

Returns ``(consequences, options, problems)`` — options come back
defaulted to :data:`_CONFIRM_DEFAULT_OPTIONS` when the author named
none, and any count other than two is a definition error (D1:
the gate is two-way, always).
defaulted to :data:`~attune_forms.models.CONFIRM_DEFAULT_OPTIONS`
when the author named none, and any count other than two is a
definition error (D1: the gate is two-way, always).

``consequences`` is required for CONFIRM (a confirm with nothing to
preview is a bare boolean and should be one) and invalid elsewhere:
Expand All @@ -547,7 +540,7 @@ def _parse_confirm_extras(
problems: list[str] = []

if not options:
options = list(_CONFIRM_DEFAULT_OPTIONS)
options = list(CONFIRM_DEFAULT_OPTIONS)
elif len(options) != 2:
problems.append(f"{where} type confirm requires exactly 2 options (got {len(options)})")

Expand Down Expand Up @@ -1258,10 +1251,12 @@ def keyboard_mode_enabled(project_root: Path | None = None) -> bool:
lives under ``keyboard_mode`` in the project-local
``attune.config.json``.

``ATTUNE_KEYBOARD_MODE`` remains a session-scoped override in both
directions: set it truthy to force terse mode for one shell, or
falsey to force rich forms even where the project opted out. Unset
(or unrecognised) defers to the project file.
``ATTUNE_FORMS_KEYBOARD_MODE`` (legacy fallback:
``ATTUNE_KEYBOARD_MODE``, consulted only when the preferred name is
unset) remains a session-scoped override in both directions: set it
truthy to force terse mode for one shell, or falsey to force rich
forms even where the project opted out. Unset (or unrecognised)
defers to the project file.

Args:
project_root: Directory holding ``attune.config.json``. Defaults
Expand Down Expand Up @@ -1510,8 +1505,8 @@ def _validate_assumption_review(question: FormQuestion, value: Any) -> str | Non


def _validate_boolean(question: FormQuestion, value: Any) -> str | None:
"""BOOLEAN: value must be exactly one of ``_BOOLEAN_OPTIONS``."""
if value not in _BOOLEAN_OPTIONS:
"""BOOLEAN: value must be exactly one of ``BOOLEAN_OPTIONS``."""
if value not in BOOLEAN_OPTIONS:
return f"{question.id!r} boolean value {value!r} must be 'Yes' or 'No'"
return None

Expand Down
30 changes: 7 additions & 23 deletions src/attune_forms/markdown_surface.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,26 +23,18 @@

from attune_forms.models import (
ASSUMPTION_RULINGS,
PROGRESS_STATUS_ICONS,
RATIONALE_HEADERS,
FormQuestion,
FormSchema,
QuestionType,
expansion_items,
ranking_slot_count,
recommended_first,
suggested_pick,
)
from attune_forms.widget import WIDGET_RESPONSE_MARKER

#: Status icon per default-style progress status (matches the widget).
_PROGRESS_ICONS = {"done": "✓", "in_flight": "◐", "blocked": "✕"}


def _ordered_recommended_first(q: FormQuestion) -> list[str]:
"""``q.options`` with ``q.recommended`` first, when it names one."""
ordered = list(q.options)
if q.recommended and q.recommended in ordered:
ordered = [q.recommended] + [o for o in ordered if o != q.recommended]
return ordered


def _option_lines(q: FormQuestion, *, badge_for: dict[str, str] | None = None) -> list[str]:
"""Bullet (or numbered) lines for a select-like question's options.
Expand All @@ -53,7 +45,7 @@ def _option_lines(q: FormQuestion, *, badge_for: dict[str, str] | None = None) -
"""
badges = badge_for or {}
notes = q.option_notes or {}
ordered = _ordered_recommended_first(q) if badges else list(q.options)
ordered = recommended_first(q) if badges else list(q.options)
lines = []
for idx, opt in enumerate(ordered):
marker = f"{idx + 1}." if q.list_style == "ordered" else "-"
Expand Down Expand Up @@ -85,7 +77,7 @@ def _progress_lines(q: FormQuestion) -> list[str]:
if q.progress_style == "report":
lines.append(f"- `{status}` {label}{detail}")
else:
icon = _PROGRESS_ICONS.get(status, "•")
icon = PROGRESS_STATUS_ICONS.get(status, "•")
lines.append(f"- {icon} {label}{detail}")
if q.options:
head = (
Expand Down Expand Up @@ -174,7 +166,7 @@ def _control_lines(q: FormQuestion) -> list[str]:
return [
line + _endorsement_suffix(q, opt)
for line, opt in zip(
lines, _ordered_recommended_first(q) if badges else list(q.options), strict=False
lines, recommended_first(q) if badges else list(q.options), strict=False
)
]
if q.type == QuestionType.PROGRESS:
Expand All @@ -201,14 +193,6 @@ def _control_lines(q: FormQuestion) -> list[str]:
return lines


#: Rationale callout header per construct (matches the widget's).
_RATIONALE_HEADERS = {
QuestionType.PUSHBACK: "Why I'd push back",
QuestionType.PROGRESS: "Summary",
QuestionType.DELIBERATION: "Synthesis",
}


def _field_lines(q: FormQuestion) -> list[str]:
"""All markdown lines for one question."""
req = "" if q.required else " *(optional)*"
Expand All @@ -221,7 +205,7 @@ def _field_lines(q: FormQuestion) -> list[str]:
lines.append(f"> guessed: `{q.default}` — {q.inferred_from}")
lines.extend(_control_lines(q))
if q.rationale:
header = _RATIONALE_HEADERS.get(q.type, "Why")
header = RATIONALE_HEADERS.get(q.type, "Why")
lines.append(f"> **{header}:** {q.rationale}")
return lines

Expand Down
9 changes: 8 additions & 1 deletion src/attune_forms/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,14 +117,19 @@ def _field_schema() -> dict[str, Any]:
"recommended": {"type": "string"},
"option_notes": {"type": "object"},
"user_position": {"type": "string"},
"progress_items": {"type": "array"},
"progress_items": {
"type": "array",
"items": {"type": "object"},
"description": "progress: [{label, status, detail?}, ...]",
},
"progress_style": {"type": "string", "enum": ["report"]},
"endorsements": {
"type": "object",
"description": "deliberation: {option: [voice, ...]}",
},
"triage_items": {
"type": "array",
"items": {"type": "object"},
"description": "triage: [{label, id?, detail?, tag?}, ...]",
},
"dispositions": {
Expand All @@ -143,6 +148,7 @@ def _field_schema() -> dict[str, Any]:
},
"assumptions": {
"type": "array",
"items": {"type": "object"},
"description": (
"assumption_review: [{label, id?, detail?, source?}, ...] — "
"the inferred assumptions; source = where it was inferred from"
Expand All @@ -154,6 +160,7 @@ def _field_schema() -> dict[str, Any]:
},
"consequences": {
"type": "array",
"items": {"type": "object"},
"description": "confirm: [{label, severity?, detail?}, ...]",
},
"list_style": {"type": "string", "enum": ["ordered", "unordered"]},
Expand Down
42 changes: 39 additions & 3 deletions src/attune_forms/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,44 @@ def triage_item_key(item: dict[str, str]) -> str:
#: :class:`FormQuestion` default identically (0.5.0 cleanup batch).
CONFIRM_DEFAULT_OPTIONS = ("Approve", "Abort")

#: The Yes/No vocabulary of a BOOLEAN question — the values every
#: surface renders its control with and the only answers the validator
#: accepts. Single-sourced here so the widget's select, the flat-surface
#: options, and ``collect_form_response`` can never disagree (0.6.x
#: cleanup batch: it was defined independently in bridge and widget).
BOOLEAN_OPTIONS = ("Yes", "No")

#: Status icon per PROGRESS status — the widget rows and the markdown
#: surface render the same three glyphs (0.6.x cleanup batch: each
#: surface carried its own copy with a "matches the widget" comment
#: nothing enforced).
PROGRESS_STATUS_ICONS = {"done": "✓", "in_flight": "◐", "blocked": "✕"}

#: Rationale callout header per construct — the widget and markdown
#: surfaces show the same words above ``rationale`` (0.6.x cleanup
#: batch: previously duplicated per surface). Types absent here head
#: the callout "Why".
RATIONALE_HEADERS: dict["QuestionType", str] = {
QuestionType.PUSHBACK: "Why I'd push back",
QuestionType.PROGRESS: "Summary",
QuestionType.DELIBERATION: "Synthesis",
}


def recommended_first(question: "FormQuestion") -> list[str]:
"""``question.options`` with ``question.recommended`` moved to the
front, when it names one of them.

The recommended-first ordering is construct policy (the proposal
leads on every surface), so the widget's cards, the markdown option
lines, and the AskUserQuestion fallback all order through this one
function (0.6.x cleanup batch: it was implemented three times).
"""
ordered = list(question.options)
if question.recommended and question.recommended in ordered:
ordered = [question.recommended] + [o for o in ordered if o != question.recommended]
return ordered


def expansion_items(question: "FormQuestion") -> list[tuple[str, dict[str, str]]]:
"""The reviewed rows of an item-keyed construct as ``(key, item)``
Expand Down Expand Up @@ -410,9 +448,7 @@ def to_ask_user_format(self) -> dict[str, Any]:
QuestionType.PROGRESS,
QuestionType.DELIBERATION,
):
opts = list(self.options)
if self.recommended and self.recommended in opts:
opts = [self.recommended] + [o for o in opts if o != self.recommended]
opts = recommended_first(self)
# DELIBERATION: the fallback surface has no chip row, so the
# per-option endorsements fold into help_text — otherwise the
# 2-1 split (the construct's whole payload) would be invisible.
Expand Down
Loading
Loading