TASK-console-attention-actions: add guarded decision shortcuts

This commit is contained in:
thePR0M3TH3AN committed 2026-10-01 03:54:01 -04:00
1 parent 9c806c4b09
commit 65367dccbb
8 files changed
+173 -2

No files matched your search

@@ -0,0 +1,37 @@
# Test integrity note — TASK-console-attention-actions
```yaml
test_integrity_note:
change_type: [new_tests]
scenarios:
- id: SCN-console-attention-owner-shortcut
given: "A pending approval names the project owner and has an available decision form."
when: "The owner chooses an Approve or Reject shortcut from its attention card."
then: "Only the matching decision field is selected and the existing preview path starts; no decision is submitted directly."
- id: SCN-console-attention-shortcut-drafts
given: "The form is unavailable, has an active preview, or contains the opposite decision draft."
when: "An attention shortcut is selected."
then: "The action is declined with an explicit status and existing draft or preview values remain unchanged."
- id: SCN-console-attention-ownership
given: "An approval is pending for the owner or another named approver."
when: "Attention actions are projected."
then: "Only the owner-addressed item carries an owner decision shortcut identifier."
observable_outcomes:
- "Owner attention cards expose Approve and Reject controls only for pending requests addressed to owner."
- "A shortcut enters the existing revision/evidence preview and confirmation flow."
- "Missing forms, active previews, and conflicting drafts remain visible and unchanged."
- "Long evidence is available through a compact expandable disclosure."
determinism_controls:
- "Projection and shortcut scenarios use fixed in-memory fixtures without clock, network, or randomness."
anti_cheat_rationale:
prevents:
- "direct decision submission without preview and confirmation"
- "peer approval authority exposed as an owner control"
- "overwriting a pending decision draft or preview"
- "long evidence silently omitted from compact cards"
relaxation:
did_relax_any_assertion: false
if_true_explain_spec_basis: ""
```
Existing approval-console scenarios continue to cover stale revisions, named-approver enforcement, preview confirmation, and failed decisions. The attention tests add coverage at the UI projection and shortcut boundary; actual desktop/mobile visual acceptance remains separate and incomplete until a real browser capture is available.
+25
View File
@@ -0,0 +1,25 @@
(function (global) {
function prepareOwnerDecisionShortcut(form, decision, beginPreview) {
if (!['approved', 'rejected'].includes(decision)) {
return { started: false, reason: 'Choose approve or reject from an owner-only shortcut.' };
}
const choice = form?.elements?.namedItem('decision');
if (!form || !choice) {
return { started: false, reason: 'This owner decision form is no longer available. Refresh the current evidence before acting.' };
}
if (form.dataset.previewToken || choice.disabled) {
return { started: false, reason: 'An existing decision preview is preserved. Review or edit it in Flow watch before using another shortcut.' };
}
if (choice.value && choice.value !== decision) {
return { started: false, reason: 'Your existing decision draft is preserved. Review or change it in Flow watch before choosing another shortcut.' };
}
if (typeof beginPreview !== 'function') {
return { started: false, reason: 'The guarded decision preview is unavailable. Refresh before acting.' };
}
choice.value = decision;
beginPreview?.(form);
return { started: true };
}
global.TorchAttentionActions = Object.freeze({ prepareOwnerDecisionShortcut });
})(globalThis);
+1
View File
@@ -27,6 +27,7 @@ function attentionGroups(snapshot) {
waitSince: approval.createdAt ?? null,
href: `#approval-${encodeURIComponent(approval.id)}`,
action: isOwner ? 'Review request and evidence' : 'Open approval details',
decisionApprovalId: isOwner ? approval.id : null,
});
}
+1
View File
@@ -15,6 +15,7 @@
<script src="/task-activity.js" defer></script>
<script src="/operation-outcomes.js" defer></script>
<script src="/attention-projection.js" defer></script>
<script src="/attention-actions.js" defer></script>
<script src="/live-refresh.js" defer></script>
<script src="/demo.js" defer></script>
<script src="/console.js" defer></script>
+35 -1
View File
@@ -743,8 +743,16 @@ function renderAttention(groups) {
${items.length ? items.map((item) => `<article class="attention-item tone-${escapeHtml(item.tone)}">
<span class="attention-mark" aria-hidden="true"></span><div class="attention-copy">
<strong>${escapeHtml(item.title)}</strong><p>${escapeHtml(item.detail)}</p>
<small>Severity: ${escapeHtml(({ urgent: 'Urgent', review: 'Review', decision: 'Decision required', info: 'Progress' })[item.tone] ?? 'Unknown')} · Owner: ${escapeHtml(item.owner)}${item.waitSince ? ` · Waiting ${escapeHtml(ageLabel(item.waitSince))}` : ''} · ${escapeHtml(item.evidence)}</small>
<small>Severity: ${escapeHtml(({ urgent: 'Urgent', review: 'Review', decision: 'Decision required', info: 'Progress' })[item.tone] ?? 'Unknown')} · Owner: ${escapeHtml(item.owner)}${item.waitSince ? ` · Waiting ${escapeHtml(ageLabel(item.waitSince))}` : ''}</small>
${item.evidence.length > 120
? `<details class="attention-evidence"><summary>Evidence and references</summary><p>${escapeHtml(item.evidence)}</p></details>`
: `<small class="attention-evidence-short">Evidence: ${escapeHtml(item.evidence)}</small>`}
<a href="${escapeHtml(item.href)}">${escapeHtml(item.action)}</a>
${item.decisionApprovalId ? `<div class="attention-quick-actions" role="group" aria-label="Owner decision shortcuts for ${escapeHtml(item.title)}">
<button type="button" data-attention-decision="approved" data-attention-approval="${escapeHtml(item.decisionApprovalId)}">Approve</button>
<button type="button" class="quiet-action" data-attention-decision="rejected" data-attention-approval="${escapeHtml(item.decisionApprovalId)}">Reject</button>
<p class="attention-action-status" role="status" aria-live="polite" hidden></p>
</div>` : ''}
${item.requestOwner ? `<a href="#owner-request-form" data-attention-recipient="${escapeHtml(item.requestOwner)}">Request review from ${escapeHtml(item.owner)}</a>` : ''}
</div></article>`).join('') : `<p class="attention-empty">${escapeHtml(description)}</p>`}
</section>`;
@@ -1829,6 +1837,32 @@ $('#change-list')?.addEventListener('click', async (event) => {
});
$('#attention-list')?.addEventListener('click', (event) => {
const decisionButton = event.target.closest('[data-attention-decision]');
if (decisionButton) {
const card = decisionButton.closest('.attention-item');
const status = card?.querySelector('.attention-action-status');
const approvalId = decisionButton.dataset.attentionApproval;
const decision = decisionButton.dataset.attentionDecision;
const form = [...document.querySelectorAll('#approval-request-list .approval-decision-form')]
.find((candidate) => candidate.dataset.approvalId === approvalId);
const previewButton = form?.querySelector('[data-approval-preview]');
const shortcut = globalThis.TorchAttentionActions.prepareOwnerDecisionShortcut(form, decision, previewButton ? (approvalForm) => {
approvalForm.scrollIntoView({ behavior: 'smooth', block: 'center' });
previewButton.click();
} : null);
if (!shortcut.started) {
if (status) {
status.textContent = shortcut.reason;
status.hidden = false;
}
return;
}
if (status) {
status.textContent = `Opening the guarded ${decision} preview for this owner-addressed request. Confirm only after reviewing its current evidence.`;
status.hidden = false;
}
return;
}
const link = event.target.closest('a[href^="#"]');
const target = link && document.getElementById(link.getAttribute('href').slice(1));
const recipient = link?.dataset.attentionRecipient;
+7
View File
@@ -190,6 +190,13 @@ footer div { display: flex; gap: 1.5rem; }
.attention-item p { margin: .18rem 0 .35rem; color: var(--muted); font-size: .78rem; line-height: 1.4; }
.attention-item small { display: block; color: var(--muted); font-size: .69rem; line-height: 1.35; overflow-wrap: anywhere; }
.attention-item a { display: inline-block; margin: .35rem .8rem 0 0; color: var(--route); font-size: .76rem; font-weight: 650; }
.attention-evidence { margin-top: .28rem; color: var(--muted); font-size: .69rem; }
.attention-evidence summary { width: fit-content; cursor: pointer; color: var(--route); font-weight: 650; }
.attention-evidence p { margin: .25rem 0 0; white-space: pre-wrap; overflow-wrap: anywhere; }
.attention-quick-actions { display: flex; align-items: center; gap: .4rem; flex-wrap: wrap; margin-top: .4rem; }
.attention-quick-actions button { min-height: 2rem; padding: .3rem .55rem; }
.attention-action-status { flex-basis: 100%; margin: .1rem 0 0; color: var(--muted); font-size: .7rem; line-height: 1.4; }
.attention-action-status[hidden] { display: none; }
.board-section, .console-section { padding-block: 1.65rem; border-bottom: 1px solid var(--line); scroll-margin-top: 1rem; }
.section-heading { margin-bottom: 1rem; }
.section-heading h2 { margin: 0; }
+63
View File
@@ -1,6 +1,8 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import '../../site/attention-projection.js';
import '../../site/attention-actions.js';
import '../../site/live-refresh.js';
const project = (snapshot) => globalThis.TorchAttentionProjection.groups(snapshot);
@@ -20,13 +22,74 @@ test('SCN-console-attention-ownership: only owner-addressed pending decisions en
assert.deepEqual(groups.owner.map((item) => item.title), ['Review executor boundary']);
assert.equal(groups.owner[0].href, '#approval-owner-review');
assert.equal(groups.owner[0].decisionApprovalId, 'owner-review');
assert.match(groups.owner[0].detail, /bounded scope/);
assert.equal(groups.owner[0].waitSince, '2026-10-01T06:00:00Z');
assert.equal(groups.fleet[0].owner, 'Independent QA');
assert.equal(groups.fleet[0].decisionApprovalId, null);
assert.equal(groups.fleet[0].action, 'Open approval details');
assert.doesNotMatch(JSON.stringify(groups.fleet), /Approve|Reject/);
});
test('SCN-console-attention-owner-shortcut: a shortcut selects only an owner decision and retains the preview-confirm boundary', () => {
const choice = { value: '', disabled: false };
const form = { dataset: {}, elements: { namedItem: () => choice } };
let previewCalls = 0;
const result = globalThis.TorchAttentionActions.prepareOwnerDecisionShortcut(form, 'approved', () => { previewCalls += 1; });
assert.equal(result.started, true);
assert.equal(choice.value, 'approved');
assert.equal(previewCalls, 1, 'the caller can only start the established preview handler');
const nonOwnerForm = { dataset: {}, elements: { namedItem: () => ({ value: '', disabled: false }) } };
const noOwnerShortcut = globalThis.TorchAttentionActions.prepareOwnerDecisionShortcut(nonOwnerForm, 'published', () => { previewCalls += 1; });
assert.equal(noOwnerShortcut.started, false);
assert.equal(nonOwnerForm.elements.namedItem().value, '');
});
test('SCN-console-attention-shortcut-drafts: missing forms, active previews, and opposite drafts remain unchanged', () => {
const missing = globalThis.TorchAttentionActions.prepareOwnerDecisionShortcut(null, 'rejected');
assert.equal(missing.started, false);
assert.match(missing.reason, /no longer available/);
const previewForm = { dataset: { previewToken: 'current-preview' },
elements: { namedItem: () => ({ value: 'approved', disabled: true }) } };
const preview = globalThis.TorchAttentionActions.prepareOwnerDecisionShortcut(previewForm, 'rejected');
assert.equal(preview.started, false);
assert.equal(previewForm.elements.namedItem().value, 'approved');
const draftChoice = { value: 'approved', disabled: false };
const draftForm = { dataset: {}, elements: { namedItem: () => draftChoice } };
const draft = globalThis.TorchAttentionActions.prepareOwnerDecisionShortcut(draftForm, 'rejected');
assert.equal(draft.started, false);
assert.equal(draftChoice.value, 'approved');
assert.match(draft.reason, /draft is preserved/);
});
test('SCN-console-attention-refresh-draft: an edited owner decision remains protected while newer evidence renders', () => {
const choice = { value: '', checked: false, tagName: 'SELECT', selectedOptions: [] };
const note = { value: '', checked: false, tagName: 'TEXTAREA' };
const fields = [choice, note];
const form = {
contains: () => false,
matches: () => false,
querySelector: () => null,
querySelectorAll: () => fields,
};
const document = { activeElement: null, querySelectorAll: () => fields };
const protection = globalThis.TorchLiveRefresh.createProtection(document);
protection.remember();
choice.value = 'rejected';
note.value = 'Need the exact impact first.';
assert.equal(protection.protects(form), true);
const dirty = protection.dirty();
protection.remember({ preserve: dirty });
assert.equal(protection.protects(form), true);
assert.equal(choice.value, 'rejected');
assert.equal(note.value, 'Need the exact impact first.');
});
test('SCN-console-attention-dedup: doctor and snapshot message counts become one honest queue item', () => {
const groups = project({
messages: { unacknowledged: 4 },
+4 -1
View File
@@ -30,6 +30,7 @@ test('SCN-product-site / SCN-dashboard-demo / SCN-torch-brand-surface: public si
const html = readFileSync(new URL('../../site/index.html', import.meta.url), 'utf8');
const demoHtml = readFileSync(new URL('../../site/demo.html', import.meta.url), 'utf8');
const consoleHtml = readFileSync(new URL('../../site/console.html', import.meta.url), 'utf8');
const consoleScript = readFileSync(new URL('../../site/console.js', import.meta.url), 'utf8');
const styles = readFileSync(new URL('../../site/styles.css', import.meta.url), 'utf8');
const torchMark = readFileSync(new URL('../../site/torch-mark.svg', import.meta.url), 'utf8');
assert.match(html, /<title>TORCH — an AI fleet shaped by your project<\/title>/);
@@ -70,6 +71,9 @@ test('SCN-product-site / SCN-dashboard-demo / SCN-torch-brand-surface: public si
assert.match(consoleHtml, /console-rail/);
assert.match(consoleHtml, /id="backlog-board"/);
assert.match(consoleHtml, /id="attention-list"/);
assert.match(consoleHtml, /src="\/attention-actions\.js" defer/);
assert.match(consoleScript, /data-attention-decision/);
assert.match(consoleScript, /data-approval-preview/);
assert.match(consoleHtml, /id="artifact-list"/);
assert.match(consoleHtml, /id="organization-map"/);
assert.match(consoleHtml, /id="organization-lifecycle"/);
@@ -93,7 +97,6 @@ test('SCN-product-site / SCN-dashboard-demo / SCN-torch-brand-surface: public si
assert.match(consoleHtml, /Create a task proposal/);
assert.match(consoleHtml, /Session Manager still owns triage and assignment/);
assert.match(consoleHtml, /gates remain separate/);
const consoleScript = readFileSync(new URL('../../site/console.js', import.meta.url), 'utf8');
assert.match(consoleScript, /artifact-feedback/);
assert.match(consoleScript, /feedback\/preview/);
assert.match(consoleScript, /data-feedback-confirm/);