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
4 changes: 2 additions & 2 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@
},
"metadata": {
"description": "Structured agent-user communication: validated forms, decision cards with recommendations, structured pushback, and progress reports \u2014 batch questions instead of asking one at a time.",
"version": "0.7.0"
"version": "0.8.0"
},
"plugins": [
{
"name": "attune-forms",
"description": "The communication grammar for AI agents: batch independent questions into ONE validated form; offer recommendations as decision cards with rationales and per-option tradeoffs; disagree constructively via pushback cards; report multi-step progress with a blocked-item picker. Renders rich HTML where the host supports widgets and degrades cleanly to plain questions everywhere else. Powered by the attune-forms PyPI package via a bundled MCP server.",
"source": "./plugin",
"version": "0.7.0",
"version": "0.8.0",
"author": {
"name": "Smart AI Memory",
"email": "admin@smartaimemory.com"
Expand Down
37 changes: 37 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,43 @@ follow [SemVer](https://semver.org/).

## [Unreleased]

## [0.8.0] — 2026-08-24

Per-stage form telemetry: the lifecycle is now measurable end to end
(chair-approved 2026-08-24, route "measure first" — the prior log had
only `form_surface` and a bare `form_submitted`, so per-stage latency
was not computable).

### Added
- **`FormSchema.form_id`** — a telemetry join key on every parsed
form. An explicit top-level `"form_id"` in the definition wins
(short `[A-Za-z0-9._-]` token, validated); otherwise
`form_from_dict` derives a deterministic content hash, so the
render call and the collect call — which each re-parse the same
dict — land on the same id without the agent threading anything.
- **Stage events** in `~/.attune/telemetry/form_events.jsonl` (same
append-only JSONL, consent gates, and 5 MB rotation):
- `form_build` (`form_id`, `source`: `"dict"` or
`"template:<name>"` — the V7 template-adoption signal,
`question_count`) — emitted by `form_from_dict` /
`form_from_template` on every successful cast.
- `form_rendered` (`form_id`, `duration_ms`, `html_bytes`) —
emitted by `form_to_widget_html`.
- `form_submitted` now carries `form_id` (the MCP collect handler
passes it; the zero-arg form stays valid for older callers).
- `form_surface` records also carry `form_id`.
- **`stage_latency()`** — reads the log back as per-stage p50/p95:
render cost from each `form_rendered`'s own `duration_ms`, and the
user-facing wait as first `form_rendered` → first `form_submitted`
per `form_id`; plus build/render/submission counts and the
cast-source mix.
- `log_form_build` / `log_form_rendered` log helpers (same
never-raises contract), shared `_append` write path.

### Changed
- `form_from_dict` accepts a keyword-only `source` (default
`"dict"`); `form_from_template` passes `template:<name>`.

## [0.7.0] — 2026-08-20

The output of a four-stage library review (checkpoint-1 sweep, a
Expand Down
2 changes: 1 addition & 1 deletion plugin/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "attune-forms",
"version": "0.7.0",
"version": "0.8.0",
"description": "Structured agent-user communication \u2014 validated forms, decision cards, pushback, progress reports, deliberation, triage boards, confirm gates, rankings, and assumption reviews via the attune-forms MCP server.",
"author": {
"name": "Smart AI Memory",
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "attune-forms"
version = "0.7.0"
version = "0.8.0"
description = "Dynamic forms library: declarative FormSchema, multi-surface renderers (widget HTML, AskUserQuestion, MCP elicitation), and template-driven intake generation"
readme = "README.md"
requires-python = ">=3.10"
Expand Down
58 changes: 54 additions & 4 deletions src/attune_forms/bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,17 @@

from __future__ import annotations

import hashlib
import json
import math
import os
import re
from collections.abc import Callable
from datetime import datetime
from pathlib import Path
from typing import Any

from attune_forms.form_events import log_surface_decision
from attune_forms.form_events import log_form_build, log_surface_decision
from attune_forms.models import (
ASSUMPTION_RULINGS,
ASSUMPTION_TEXT_SUFFIX,
Expand Down Expand Up @@ -802,7 +804,34 @@ def _parse_list_style(
# the bound is never built and collect(99999) validates clean). These
# sets must track exactly what form_from_dict and its _parse_* helpers
# read; the parity test against the MCP _field_schema ratchets that.
_DEFINITION_TOP_KEYS = frozenset({"title", "description", "fields", "questions"})
_DEFINITION_TOP_KEYS = frozenset({"title", "description", "fields", "questions", "form_id"})

#: An explicit definition ``form_id``: short, filesystem/log-safe token.
_FORM_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")


def _derived_form_id(data: dict[str, Any]) -> str:
"""Deterministic id from the definition content.

The render call and the collect call each re-parse the same dict,
so hashing the canonical JSON gives both the SAME id — lifecycle
stages join in telemetry without the agent threading anything.
Content-addressed, not unique-per-cast: two casts of an identical
definition share an id, which is exactly what the stage-latency
join wants (first render → first submission).
"""
try:
canonical = json.dumps(data, sort_keys=True, separators=(",", ":"), default=str)
except Exception: # noqa: BLE001
# ``default=str`` re-raises whatever a value's __str__ raises, so
# TypeError/ValueError alone is not enough — a telemetry id must
# never make a valid definition fail to parse (codex cross-review
# finding 3, 2026-08-24).
return ""
digest = hashlib.sha1(canonical.encode("utf-8", "replace"), usedforsecurity=False)
return digest.hexdigest()[:12]


_DEFINITION_FIELD_KEYS = frozenset(
{
"id",
Expand Down Expand Up @@ -835,7 +864,7 @@ def _parse_list_style(
)


def form_from_dict(data: dict[str, Any]) -> FormSchema:
def form_from_dict(data: dict[str, Any], *, source: str = "dict") -> FormSchema:
"""Build a :class:`FormSchema` from plain serializable data (D3).

The declarative artifact a skill / future designer / data source
Expand All @@ -851,6 +880,12 @@ def form_from_dict(data: dict[str, Any]) -> FormSchema:
"options"?: list[str], "default"?: str, "help_text"?: str,
"required"?: bool}``. ``"label"`` is accepted as an alias for
``"text"``; ``"questions"`` as an alias for ``"fields"``.
An optional top-level ``"form_id"`` (short
``[A-Za-z0-9._-]`` token) names the telemetry lifecycle id
explicitly; omitted, a deterministic content hash is used.
source: Where the definition came from, recorded on the
``form_build`` telemetry event — ``"dict"`` (default) or
``"template:<name>"`` (set by ``form_from_template``).

Returns:
A validated :class:`FormSchema`.
Expand All @@ -873,6 +908,17 @@ def form_from_dict(data: dict[str, Any]) -> FormSchema:
problems.append("form must have a non-empty 'fields' list")
raw_fields = []

form_id = ""
raw_form_id = data.get("form_id")
if raw_form_id is not None:
if isinstance(raw_form_id, str) and _FORM_ID_RE.match(raw_form_id):
form_id = raw_form_id
else:
problems.append(
"form 'form_id' must be a 1-64 char [A-Za-z0-9._-] string"
" starting with a letter or digit"
)

for key in data:
if key not in _DEFINITION_TOP_KEYS:
problems.append(f"form has unknown definition key {key!r}")
Expand Down Expand Up @@ -1048,11 +1094,14 @@ def form_from_dict(data: dict[str, Any]) -> FormSchema:
if problems:
raise FormValidationError(problems)

return FormSchema(
schema = FormSchema(
title=title,
description=data.get("description", "") or "",
questions=questions,
form_id=form_id or _derived_form_id(data),
)
log_form_build(schema.form_id, source=source, question_count=len(questions))
return schema


#: Question types that lose fidelity on ``AskUserQuestion`` — either
Expand Down Expand Up @@ -1267,6 +1316,7 @@ def select_form_surface(
log_surface_decision(
surface,
reason=reason,
form_id=form.form_id,
question_count=len(form.questions),
chosen=chosen,
agreed=None if chosen is None else chosen == surface,
Expand Down
Loading
Loading