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.11.0"
"version": "0.11.1"
},
"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.11.0",
"version": "0.11.1",
"author": {
"name": "Smart AI Memory",
"email": "admin@smartaimemory.com"
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ follow [SemVer](https://semver.org/).

## [Unreleased]

## [0.11.1] — 2026-08-31

### Fixed
- Consequential workspace actions now use an inline two-click confirmation
instead of `window.confirm`, which is unavailable in sandboxed MCP App
hosts. Choosing another action disarms and restores the pending action.

## [0.11.0] — 2026-08-30

Project path selection becomes a reusable form capability while preserving
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.11.0",
"version": "0.11.1",
"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.11.0"
version = "0.11.1"
description = "Dynamic forms and command workspaces: validated multi-surface interaction documents for AI agents"
readme = "README.md"
requires-python = ">=3.10"
Expand Down
17 changes: 16 additions & 1 deletion src/attune_forms/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -650,12 +650,27 @@ def action_button(action: WorkspaceAction) -> str:
)
script = f"""<script>(function(){{
var root=document.getElementById('{root_id}'); if(!root)return;
function disarmExplicitActions(except){{
root.querySelectorAll('[data-explicit="1"][data-confirm-armed="1"]').forEach(function(x){{
if(x===except)return;
x.removeAttribute('data-confirm-armed');
x.textContent=x.getAttribute('data-original-label');
}});
}}
root.addEventListener('click',function(e){{
var b=e.target.closest?e.target.closest('[data-workspace-action]'):null;
if(!b||!root.contains(b))return;
var consequence=b.getAttribute('data-consequence');
var explicit=b.getAttribute('data-explicit')==='1';
if(explicit&&!window.confirm(consequence))return;
disarmExplicitActions(b);
if(explicit&&b.getAttribute('data-confirm-armed')!=='1'){{
b.setAttribute('data-confirm-armed','1');
b.setAttribute('data-original-label',b.textContent);
b.textContent='Confirm '+b.textContent;
var confirmation=root.querySelector('.ae-ws-dispatch');
if(confirmation)confirmation.textContent=consequence+' Click again to confirm.';
return;
}}
var payload={{{json.dumps(WIDGET_RESPONSE_MARKER)}:true,
title:root.getAttribute('data-workspace-title'),
view:root.getAttribute('data-workspace-view'),
Expand Down
82 changes: 81 additions & 1 deletion tests/test_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,9 @@ def test_widget_display_actions_post_only_stable_action_id() -> None:
assert 'data-workspace-action="edit_contract"' in html
assert "action:b.getAttribute('data-workspace-action')" in html
assert "typeof sendPrompt==='function'" in html
assert "window.confirm(consequence)" in html
assert "window.confirm" not in html
assert "data-confirm-armed" in html
assert "Click again to confirm." in html
assert 'data-consequence="Execute the previewed contract."' in html
assert "Workspace action submitted" in html
assert "view:root.getAttribute('data-workspace-view')" in html
Expand All @@ -416,6 +418,84 @@ def test_widget_display_action_script_parses() -> None:
assert result.returncode == 0, result.stderr


@pytest.mark.skipif(shutil.which("node") is None, reason="node is not installed")
def test_consequential_action_requires_two_clicks_and_other_action_disarms() -> None:
html = workspace_to_widget_html(showcase_views()[1], instance_id="two-click")
script = re.search(r"<script>(.*?)</script>", html, re.DOTALL)
assert script is not None
harness = f"""
const assert = require('node:assert/strict');
const listeners = {{}};
const sent = [];
function button(action, label, explicit, consequence) {{
const attrs = {{'data-workspace-action': action}};
if (explicit) attrs['data-explicit'] = '1';
if (consequence) attrs['data-consequence'] = consequence;
return {{
textContent: label,
disabled: false,
getAttribute: function (name) {{ return attrs[name] || null; }},
setAttribute: function (name, value) {{ attrs[name] = value; }},
removeAttribute: function (name) {{ delete attrs[name]; }}
}};
}}
const edit = button('edit_contract', 'Edit contract', false, null);
const run = button('run_fix', 'Run Fix', true, 'Execute the previewed contract.');
const status = {{textContent: ''}};
const root = {{
addEventListener: function (name, callback) {{ listeners[name] = callback; }},
contains: function () {{ return true; }},
getAttribute: function (name) {{
return name === 'data-workspace-title' ? 'Fix preview' : 'preview';
}},
querySelector: function () {{ return status; }},
querySelectorAll: function (selector) {{
if (selector === '[data-workspace-action]') return [edit, run];
if (selector === '[data-explicit="1"][data-confirm-armed="1"]') {{
return run.getAttribute('data-confirm-armed') === '1' ? [run] : [];
}}
return [];
}}
}};
global.document = {{getElementById: function () {{ return root; }}}};
global.sendPrompt = function (value) {{ sent.push(value); }};
eval({json.dumps(script.group(1))});
function click(target) {{
listeners.click({{
target: {{closest: function () {{ return target; }}}}
}});
}}
click(run);
assert.equal(sent.length, 0);
assert.equal(run.textContent, 'Confirm Run Fix');
assert.equal(run.getAttribute('data-confirm-armed'), '1');
assert.match(status.textContent, /Click again to confirm/);
click(run);
assert.equal(sent.length, 1);
assert.match(sent[0], /"action":"run_fix"/);
assert.match(sent[0], /"confirmed":true/);

run.disabled = false;
run.removeAttribute('data-confirm-armed');
run.textContent = 'Run Fix';
sent.length = 0;
click(run);
click(edit);
assert.equal(run.textContent, 'Run Fix');
assert.equal(run.getAttribute('data-confirm-armed'), null);
assert.equal(sent.length, 1);
assert.match(sent[0], /"action":"edit_contract"/);
assert.match(sent[0], /"confirmed":false/);
"""
result = subprocess.run(
["node", "-e", harness],
text=True,
capture_output=True,
check=False,
)
assert result.returncode == 0, result.stderr


def test_form_workspace_preserves_explicit_action_semantics() -> None:
intake = showcase_views()[0]
action = WorkspaceAction(
Expand Down
Loading