diff --git a/artifacts/TASK-console-ux-audit/desktop-briefing.jpg b/artifacts/TASK-console-ux-audit/desktop-briefing.jpg new file mode 100644 index 0000000..afdeb09 Binary files /dev/null and b/artifacts/TASK-console-ux-audit/desktop-briefing.jpg differ diff --git a/artifacts/TASK-console-ux-audit/desktop-conversations.jpg b/artifacts/TASK-console-ux-audit/desktop-conversations.jpg new file mode 100644 index 0000000..0b698ca Binary files /dev/null and b/artifacts/TASK-console-ux-audit/desktop-conversations.jpg differ diff --git a/artifacts/TASK-console-ux-audit/desktop-evidence.jpg b/artifacts/TASK-console-ux-audit/desktop-evidence.jpg new file mode 100644 index 0000000..8ad4ce8 Binary files /dev/null and b/artifacts/TASK-console-ux-audit/desktop-evidence.jpg differ diff --git a/artifacts/TASK-console-ux-audit/desktop-fleet.jpg b/artifacts/TASK-console-ux-audit/desktop-fleet.jpg new file mode 100644 index 0000000..310b47c Binary files /dev/null and b/artifacts/TASK-console-ux-audit/desktop-fleet.jpg differ diff --git a/artifacts/TASK-console-ux-audit/desktop-flow-watch.jpg b/artifacts/TASK-console-ux-audit/desktop-flow-watch.jpg new file mode 100644 index 0000000..fae23bb Binary files /dev/null and b/artifacts/TASK-console-ux-audit/desktop-flow-watch.jpg differ diff --git a/artifacts/TASK-console-ux-audit/desktop-keyboard-tab.jpg b/artifacts/TASK-console-ux-audit/desktop-keyboard-tab.jpg new file mode 100644 index 0000000..184f9a1 Binary files /dev/null and b/artifacts/TASK-console-ux-audit/desktop-keyboard-tab.jpg differ diff --git a/artifacts/TASK-console-ux-audit/desktop-operations.jpg b/artifacts/TASK-console-ux-audit/desktop-operations.jpg new file mode 100644 index 0000000..937c1d7 Binary files /dev/null and b/artifacts/TASK-console-ux-audit/desktop-operations.jpg differ diff --git a/artifacts/TASK-console-ux-audit/desktop-organization.jpg b/artifacts/TASK-console-ux-audit/desktop-organization.jpg new file mode 100644 index 0000000..92e7f1d Binary files /dev/null and b/artifacts/TASK-console-ux-audit/desktop-organization.jpg differ diff --git a/artifacts/TASK-console-ux-audit/desktop-overview.jpg b/artifacts/TASK-console-ux-audit/desktop-overview.jpg new file mode 100644 index 0000000..c193603 Binary files /dev/null and b/artifacts/TASK-console-ux-audit/desktop-overview.jpg differ diff --git a/artifacts/TASK-console-ux-audit/desktop-progress.jpg b/artifacts/TASK-console-ux-audit/desktop-progress.jpg new file mode 100644 index 0000000..cd0b033 Binary files /dev/null and b/artifacts/TASK-console-ux-audit/desktop-progress.jpg differ diff --git a/artifacts/TASK-console-ux-audit/desktop-release-gates.jpg b/artifacts/TASK-console-ux-audit/desktop-release-gates.jpg new file mode 100644 index 0000000..f123cdc Binary files /dev/null and b/artifacts/TASK-console-ux-audit/desktop-release-gates.jpg differ diff --git a/artifacts/TASK-console-ux-audit/desktop-work.jpg b/artifacts/TASK-console-ux-audit/desktop-work.jpg new file mode 100644 index 0000000..6787963 Binary files /dev/null and b/artifacts/TASK-console-ux-audit/desktop-work.jpg differ diff --git a/artifacts/TASK-console-ux-audit/mobile-overview.jpg b/artifacts/TASK-console-ux-audit/mobile-overview.jpg new file mode 100644 index 0000000..127cd27 Binary files /dev/null and b/artifacts/TASK-console-ux-audit/mobile-overview.jpg differ diff --git a/artifacts/TASK-console-ux-audit/mobile-release-gates.jpg b/artifacts/TASK-console-ux-audit/mobile-release-gates.jpg new file mode 100644 index 0000000..225804b Binary files /dev/null and b/artifacts/TASK-console-ux-audit/mobile-release-gates.jpg differ diff --git a/reports/design-system/task-console-ux-audit-owner-console-2026-09-30.md b/reports/design-system/task-console-ux-audit-owner-console-2026-09-30.md new file mode 100644 index 0000000..51ad13f --- /dev/null +++ b/reports/design-system/task-console-ux-audit-owner-console-2026-09-30.md @@ -0,0 +1,164 @@ +# Console UX audit: Owner Console evidence and coverage addendum + +Task: `TASK-console-ux-audit` (blocked, revision 5) +Audit target: `http://127.0.0.1:4174/console` +Local branch: `torch/owner-console` + +## Current status + +This is a partial, evidence-bounded addendum to the arbiter's canonical report, +`reports/design-system/live-console-arbiter-audit-2026-09-30.md` at canonical +commit `7b7c6584b9d315d901133cff828f26f0fcc2003f`. Its measurements remain +attributed to that report, not to this audit. + +The current TORCH backlog record is blocked at revision 5 and points to this +report and its exact viewport capture blocker. Desktop evidence is committed; +the audit is not complete or ready for integration. + +I resumed the existing Brave browser binding and listed its existing Console +tab; attaching to that tab timed out twice. A fresh agent-created tab in the +same browser session worked. Live Console actions stayed read-only except for +unsent test strings in the local request form; neither string was sent and no +durable Console state was changed. + +**Not complete:** mobile coverage beyond Overview, an exact 390 x 844 CSS +viewport, exhaustive keyboard order, empty/loading/failed-action states, and +task-owned artifact publication for every screen. The owner and peer guidance +identified a viewport transform: 351 x 760 at DPR 0.9 yields a 390 x 844 CSS +viewport. A fresh-tab attempt at that exact CSS size showed the Overview DOM, +but its single screenshot attempt still failed with the 5-second CDP timeout. +The earlier 433 x 938 CSS screenshot is retained as narrow-view evidence only; +it does not satisfy the exact viewport requirement. See the evidence below. + +## Personally observed evidence + +| View | URL fragment | Viewport | Viewed observation / evidence | +|---|---|---|---| +| Overview | `#overview` | 1237 x 677 | [Desktop screenshot](../../artifacts/TASK-console-ux-audit/desktop-overview.jpg). “Healthy” appears beside six attention items, including five generic doctor warnings; project facts also show `Clean at snapshot` and `ONE-DISK`. | +| Overview | `#overview` | 433 x 938 CSS, DPR 0.9 (requested 390 x 844) | [Narrow screenshot](../../artifacts/TASK-console-ux-audit/mobile-overview.jpg). Overview content fits the measured viewport width, but the horizontal nav clips after “Progress” and displays its own horizontal scrollbar. Screenshot dimensions are 462 x 1041 pixels. | +| Overview | `#overview` | 390 x 844 CSS, DPR 0.9 | Fresh-tab DOM was visible before the authorized screenshot attempt. The screenshot timed out with `Timed out after 5000ms waiting for CDP command Page.captureScreenshot.` No image was produced; this is blocker evidence, not visual coverage. | +| Briefing | `#owner-briefing` | 1237 x 677 | [Desktop screenshot](../../artifacts/TASK-console-ux-audit/desktop-briefing.jpg). Empty briefing state gives CLI build/publish instructions. On the first navigation, Work was highlighted while Briefing content was shown; repeated navigation selected Flow watch correctly. | +| Flow watch | `#flow-watch` | 1237 x 677 | [Desktop screenshot](../../artifacts/TASK-console-ux-audit/desktop-flow-watch.jpg). Check-in and approval panels sit side by side; timer installation is explicitly unverified and only named approvers can decide. | +| Work | `#work` | 1237 x 677 | [Desktop screenshot](../../artifacts/TASK-console-ux-audit/desktop-work.jpg). Filters form a dense block above five board columns; 44 active items make cards narrow and text wrap heavily. | +| Progress | `#initiative-progress` | 1237 x 677 | [Desktop screenshot](../../artifacts/TASK-console-ux-audit/desktop-progress.jpg). Feature/milestone summaries and the start of Fleet inventory share one long page. | +| Fleet | `#fleet` | 1237 x 677 | [Desktop screenshot](../../artifacts/TASK-console-ux-audit/desktop-fleet.jpg). Session state and ownership inventories sit side by side; repeated runtime/model/profile data and long paths dominate. | +| Conversations | `#communications` | 1237 x 677 | [Desktop screenshot](../../artifacts/TASK-console-ux-audit/desktop-conversations.jpg). Long messages fill two columns; an identical owner message appears in both columns. | +| Organization | `#organization` | 1237 x 677 | [Desktop screenshot](../../artifacts/TASK-console-ux-audit/desktop-organization.jpg). Three-column role cards repeat reporting/coordination links and show lengthy authority/path descriptions. | +| Evidence | `#evidence` | 1237 x 677 | [Desktop screenshot](../../artifacts/TASK-console-ux-audit/desktop-evidence.jpg). Check receipts use a narrow left column with repeated pass entries and large empty space to the right; entries are collapsed disclosures. | +| Release gates | `#delivery` | 1237 x 677 | [Desktop screenshot](../../artifacts/TASK-console-ux-audit/desktop-release-gates.jpg). Integration is “ready” while delivery, fetch, and operation receipts are absent; copy distinguishes landing from deployment. | +| Operations | `#delivery` (expanded disclosure) | 1237 x 677 | [Desktop screenshot](../../artifacts/TASK-console-ux-audit/desktop-operations.jpg). Expansion reveals Worktrees and Resources; details continue below the viewport. | + +## Interaction evidence + +- **Navigation/history:** section links update the hash. Back from Briefing returned + to Overview, and Forward returned to Briefing. The document remains one long + page rather than separate routes. +- **Keyboard:** pressing Tab after navigating to Overview moved focus to the + visible “Open relevant view” link near the bottom of Overview, with an orange + focus outline. This confirms visible focus for that control, not complete + tab-order coverage. +- **Manual refresh:** an unsent `AUDIT DRAFT ONLY — do not send.` string in the + request form survived clicking Refresh. +- **Automatic refresh:** an unsent `AUTO-REFRESH DRAFT ONLY — do not send.` string + remained after the 15-second update interval; the header reported “Updated just + now.” +- **Page reload:** the draft was gone after browser reload and reopening the + request disclosure. In-session refresh preserves it; a full reload does not. +- **Form validation:** Preview request with no recipient kept the form local and + displayed “Choose an identity and write the request before previewing.” No + message was sent. The form states that sending adds one durable inbox message. +- **Mobile:** the first viewport override did not apply to a newly created tab; + the owner-directed fresh-tab capture exposed that mismatch. On the fresh tab, + the 390 x 844 request measured 433 x 938 CSS pixels at DPR 0.9. DOM rectangles + showed the Overview section in view and no page-level horizontal overflow + (`documentWidth` 416, viewport width 433), while the visible navigation itself + had a horizontal scrollbar. The one authorized fresh-tab screenshot is + `mobile-overview.jpg` (462 x 1041 image pixels). The earlier + `mobile-release-gates.jpg` remains blank (3,157 bytes), followed by the exact + CDP screenshot timeout; it is not visual evidence of that view. QA and Provider + Runtime classified that failure as a browser/CDP capture limitation. Following + the owner's measured workaround, I created one new browser session, opened a + fresh tab, set 351 x 760 before navigation, then verified exact 390 x 844 CSS + dimensions at DPR 0.9. DOM geometry showed document width 373, Overview width + 347.74, and nav scroll width 997 against client width 373; both header buttons + fit within 248.76 CSS pixels. The one screenshot attempt again returned the + exact 5-second CDP timeout. The override was reset immediately. No screenshot + was produced, so exact-size mobile visual coverage remains incomplete. + +Selected screenshots are registered in private TORCH artifact storage with task, +session, and commit provenance: + +| Image | Artifact ID | Commit | +|---|---|---| +| Desktop Overview | `d4c3bd6c-92b8-4791-9a3b-56c636bd6848` | `e0637f8255b79401fb483dddf3fe8d602aae687c` | +| Desktop Work | `703126cb-31b9-47ad-ba57-73d5f873b754` | `e0637f8255b79401fb483dddf3fe8d602aae687c` | +| Desktop Conversations | `c81acd53-da4e-4113-b5a8-ea3c75b0ac16` | `e0637f8255b79401fb483dddf3fe8d602aae687c` | +| Desktop Operations | `472d28b8-31f7-4130-abde-88f82c582970` | `e0637f8255b79401fb483dddf3fe8d602aae687c` | +| Desktop keyboard focus | `9e93f19d-f826-4d93-8ef7-c4a2474cf3bf` | `e0637f8255b79401fb483dddf3fe8d602aae687c` | +| Blank mobile capture (failure evidence) | `3bc724b8-a22f-4f93-8629-eec92ce06ce3` | `e0637f8255b79401fb483dddf3fe8d602aae687c` | +| Fresh-tab narrow Overview | `6d7848d9-a679-4005-8a5a-e5d8eb7d67de` | `412643da93df7df9e2da7a79cd2fd45b945decc0` | + +The arbiter report provides the broader measured baseline: 50,288 px desktop +document, Work 36,662 px with 43 active tasks and 41 queue cards, Conversations +5,723 px, Organization 2,931 px, and a roughly 572 px narrow-screen filter +block. These are attributed to the arbiter report, not measurements from my +incomplete pass. + +## Focused workspace proposal + +Use separate, reloadable destinations with a persistent project/freshness +header and a concise overview. Suggested destinations: + +- Overview: project outcome, owner decisions, blockers, recent events. +- Work: active tasks and a separately filtered proposed queue; compact rows + open exact task details. +- Attention: one card per underlying issue, with identity, branch/path, cause, + severity, and only actions supported by current authority. +- Agents: compact state/work/wait list; agent detail owns chat, profile, and + implementation ownership. +- Evidence: checks and artifacts linked to task, author, candidate commit, + and blocking reason. Preserve incomplete receipts as incomplete. +- Operations: resource/queue/timer and lifecycle evidence on its own screen. +- Organization and Releases: relationship/authority details and the separate + integration/delivery gates. + +Keep the TORCH mark, dark mode, explicit approval previews, revision/evidence +checks, and honest unknown/incomplete states. Include the owner's requested +`TASK-console-attention-actions`: Approve/Reject only for an actual +owner-addressed approval; warnings should open the exact task, thread, or +worktree review. Deduplicate message counts and cards; do not add dismiss, +mass-acknowledge, merge, cleanup, or publication shortcuts. + +## Measurable implementation acceptance criteria + +1. Each primary destination has a distinct addressable URL. Reload, Back, and + Forward restore the expected view while retaining project identity and + freshness context. +2. At 390 x 844 CSS px, primary destinations have no horizontal page overflow; + work is a readable list or selected-column view, not a forced full-board + carousel. Secondary filters do not consume the first 572 px of the view. +3. Keyboard-only users can reach every primary destination and action, see focus, + and identify the current destination through an accessible current-page state. +4. A unique underlying attention issue appears once. Labels name the affected + identity and branch/path where available; approval actions remain hidden or + disabled for non-owner requests and use existing preview/revision/evidence + confirmation. +5. Automatic polling, explicit refresh, and route changes preserve an unsent + draft. A controlled failed or interrupted action displays pending/error/ + unknown accurately and never announces success without a receipt. +6. Evidence links resolve to the exact task, candidate commit, author, receipt, + and blocker. A dashboard check that changes tracked files remains incomplete + even if its process exits zero. +7. Overview and agent lists stay compact as task/message counts grow; long + descriptions and message bodies open in contextual detail views. Duplicate + message cards are absent across recipient/group projections. + +## Next evidence needed + +Coordinate the capture behavior with Provider Runtime and QA. If a later task +authorization permits more mobile captures, inspect every navigation view at an +exact 390 x 844 CSS viewport for overflow, wrapping, nav behavior, and density. +Continue keyboard order and representative empty/loading/pending/failed/unknown +states, using the isolated demo for state-changing actions. Publish only +task-owned evidence after local commit. Live state stays read-only. No UI +implementation or release is proposed by this audit. diff --git a/reports/test-audit/console-attention-integrity-report-2026-10-01.md b/reports/test-audit/console-attention-integrity-report-2026-10-01.md new file mode 100644 index 0000000..7c6c4d8 --- /dev/null +++ b/reports/test-audit/console-attention-integrity-report-2026-10-01.md @@ -0,0 +1,51 @@ +# Console attention test integrity + +```yaml +test_integrity_note: + change_type: ["new_tests"] + scenarios: + - id: SCN-console-attention-ownership + given: "Pending approvals name either the project owner or another approver" + when: "The Console projects attention groups" + then: "Only owner-addressed decisions appear under Waiting on you; peer approvals route to their named approver without approve or reject actions" + - id: SCN-console-attention-dedup + given: "Doctor and snapshot evidence describe the same unacknowledged message queue" + when: "Attention items are projected" + then: "One aggregate message item appears with the higher observed count and recipient-owned acknowledgement guidance" + - id: SCN-console-attention-worktrees + given: "A worktree has unique commits, local changes, or an active Git operation" + when: "Attention items are projected" + then: "Findings group by identity and branch/path; retained commits remain labeled as progress while unsafe operations remain review findings" + - id: SCN-console-attention-wait-reasons + given: "A task is blocked and repository recovery has no off-machine copy" + when: "The Console projects attention" + then: "The recorded block reason and single-disk recovery consequence remain visible with the responsible domain" + - id: SCN-console-attention-empty + given: "Approval evidence is unavailable and no queue or doctor finding is observed" + when: "Attention items are projected" + then: "No synthetic owner decision or operational issue is created" + - id: SCN-console-attention-authority + given: "Integration records may name a manager as authorizer while other runtime records lack an arbiter grant" + when: "Attention ownership is projected from the live roster" + then: "Roster titles supply display names, integration remains Fleet handling, and no arbiter-only authority is inferred" + observable_outcomes: + - "Attention groups distinguish owner decisions, Fleet work, and arbiter work" + - "Approval action ownership follows the named approver" + - "Repeated message queue evidence produces one item" + - "Worktree progress and unsafe Git state have distinct descriptions" + - "Unknown evidence stays explicit rather than gaining an invented owner" + - "Manager-authorized integration is not mislabeled as an owner or arbiter decision" + determinism_controls: + - "Tests pass fixed in-memory snapshots; no clock, network, or filesystem is consulted" + anti_cheat_rationale: + prevents: + - "hard-coded return value" + - "over-mocking internal logic" + - "snapshot rubber-stamping" + - "retry/sleep-based flake masking" + relaxation: + did_relax_any_assertion: false + if_true_explain_spec_basis: "" +``` + +The focused attention scenarios passed. The existing owner approval scenario did not start because this worktree lacks the installed `zod` dependency; no assertion was changed or skipped in its source. diff --git a/reports/test-audit/test-integrity-task-console-attention-actions-2026-10-01.md b/reports/test-audit/test-integrity-task-console-attention-actions-2026-10-01.md new file mode 100644 index 0000000..f87842e --- /dev/null +++ b/reports/test-audit/test-integrity-task-console-attention-actions-2026-10-01.md @@ -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. diff --git a/site/attention-actions.js b/site/attention-actions.js new file mode 100644 index 0000000..f89e8b7 --- /dev/null +++ b/site/attention-actions.js @@ -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); diff --git a/site/attention-projection.js b/site/attention-projection.js new file mode 100644 index 0000000..588f913 --- /dev/null +++ b/site/attention-projection.js @@ -0,0 +1,151 @@ +(function (global) { + const shortCommit = (value) => value ? String(value).slice(0, 9) : '—'; +function worktreeProblemLabel(problem) { + if (problem === 'worktree-dirty') return 'Uncommitted changes are present'; + if (problem === 'worktree-missing') return 'The managed worktree is missing'; + if (problem.startsWith('unique-commits:')) return `${problem.split(':')[1]} unique commit(s) are ahead`; + if (problem.startsWith('git-operation:')) return `Git operation needs resolution (${problem.slice('git-operation:'.length)})`; + if (problem.startsWith('branch-mismatch:')) return `Branch differs from its manifest (${problem.slice('branch-mismatch:'.length)})`; + return problem.replaceAll('-', ' '); +} + +function attentionGroups(snapshot) { + const groups = { owner: [], fleet: [], arbiter: [] }; + const agents = new Map((snapshot.agents ?? []).map((agent) => [agent.areaId, agent])); + const agentName = (id) => agents.get(id)?.title ?? id ?? 'Owner not recorded'; + const add = (group, item) => groups[group].push(item); + const approvals = (snapshot.approvalRequests?.items ?? []).filter((approval) => approval.status === 'pending'); + + for (const approval of approvals) { + const isOwner = approval.approver === 'owner'; + add(isOwner ? 'owner' : 'fleet', { + tone: isOwner ? 'decision' : 'review', + title: approval.title || 'Approval request', + owner: isOwner ? 'Project owner' : agentName(approval.approver), + detail: `${agentName(approval.requester)} is waiting on ${isOwner ? 'your decision' : agentName(approval.approver)}. ${approval.summary || 'Request consequence and options are not recorded.'}`, + evidence: [approval.task, approval.evidence].filter(Boolean).join(' · ') || 'Request evidence not recorded', + waitSince: approval.createdAt ?? null, + href: `#approval-${encodeURIComponent(approval.id)}`, + action: isOwner ? 'Review request and evidence' : 'Open approval details', + decisionApprovalId: isOwner ? approval.id : null, + }); + } + + const pendingChanges = (snapshot.fleetChanges ?? []).filter((change) => change.state === 'proposed'); + const pendingHierarchy = (snapshot.hierarchyProposals ?? []).filter((proposal) => proposal.state === 'proposed'); + for (const proposal of [...pendingChanges, ...pendingHierarchy]) add('owner', { + tone: 'decision', title: proposal.title ?? proposal.proposal?.title ?? 'Organization proposal', + owner: 'Project owner', detail: `${proposal.rationale ?? proposal.proposal?.impact ?? 'The requested consequence is not recorded.'} This proposal does not change the active Fleet without a separate decision.`, + evidence: proposal.id, href: '#organization-proposals', action: 'Review proposal', + }); + + const findings = snapshot.doctor?.findings ?? []; + const messageFinding = findings.find((finding) => finding.code === 'MESSAGE_BACKLOG'); + const awaiting = Math.max(Number(snapshot.messages?.unacknowledged) || 0, Number(messageFinding?.unacknowledged) || 0); + if (awaiting) add('fleet', { + tone: 'review', title: `${awaiting} durable message${awaiting === 1 ? '' : 's'} awaiting acknowledgement`, + owner: 'Named recipients', detail: 'This aggregate covers the Fleet. Each named recipient owns their acknowledgement; other identities should not acknowledge it.', + evidence: 'Unread queues are summarized once; individual message ownership remains visible in Conversations.', + href: '#communications', action: 'Open Conversations', + }); + + const worktreeFindings = findings.filter((finding) => finding.code === 'WORKTREE_PROBLEM'); + const groupedWorktrees = new Map(); + for (const finding of worktreeFindings) { + const key = finding.area ?? finding.areaId ?? 'unknown-owner'; + const group = groupedWorktrees.get(key) ?? { area: key, path: finding.path, problems: [] }; + group.path ??= finding.path; + group.problems.push(finding.problem ?? finding.message ?? 'Worktree condition is not recorded'); + groupedWorktrees.set(key, group); + } + for (const item of groupedWorktrees.values()) { + const worktree = (snapshot.worktrees ?? []).find((entry) => entry.area === item.area); + const hasAhead = item.problems.some((problem) => problem.startsWith('unique-commits:')); + const hasUnsafeProblem = item.problems.some((problem) => !problem.startsWith('unique-commits:')); + const labels = item.problems.map(worktreeProblemLabel); + const identity = agentName(item.area); + add('fleet', { + tone: !hasUnsafeProblem ? 'info' : item.problems.some((problem) => problem.startsWith('git-operation:') || problem === 'worktree-missing') ? 'urgent' : 'review', + title: !hasUnsafeProblem ? `${identity} has work ahead of main` : `${identity} worktree needs review`, + owner: identity, + detail: `${labels.join('; ')}.${hasAhead ? ' Ahead commits are retained work progress, not a broken repository.' : ''} ${hasUnsafeProblem ? 'Review the exact worktree before deciding on recovery.' : 'Ask the owning specialist or manager to review a safe convergence plan.'}`, + evidence: [worktree?.branch, worktree?.path ?? item.path, worktree?.ahead != null ? `${worktree.ahead} ahead / ${worktree.behind ?? 0} behind` : null].filter(Boolean).join(' · ') || 'Branch and path not recorded', + href: `#worktree-${encodeURIComponent(item.area)}`, action: 'Inspect this worktree', + requestOwner: item.area !== 'unknown-owner' ? item.area : null, + }); + } + + for (const finding of findings.filter((entry) => ['error', 'warning'].includes(entry.severity) + && !['MESSAGE_BACKLOG', 'WORKTREE_PROBLEM'].includes(entry.code))) { + const recoveryGap = finding.code === 'RECOVERABILITY' && !finding.offMachine; + const humanTitle = recoveryGap + ? 'Off-machine recovery copy is not verified' + : finding.code.replaceAll('_', ' ').toLowerCase().replace(/^./, (letter) => letter.toUpperCase()); + add('fleet', { + tone: finding.severity === 'error' ? 'urgent' : 'review', title: humanTitle, + owner: finding.area ? agentName(finding.area) : 'Responsible domain not recorded', + detail: recoveryGap + ? `${finding.recommendation ?? 'Configure and synchronize an off-machine canonical Git boundary.'} A single disk failure could remove the only recorded recovery copy.` + : finding.message ?? finding.recommendation ?? 'Review this condition with the responsible domain; its consequence is not recorded.', + evidence: [finding.level, finding.commit ? shortCommit(finding.commit) : null, finding.path, finding.problem].filter(Boolean).join(' · ') || 'Doctor finding', + href: finding.area ? `#worktree-${encodeURIComponent(finding.area)}` : '#evidence', + action: recoveryGap ? 'Review recovery evidence' : finding.area ? 'Inspect owner evidence' : 'Review evidence', + }); + } + + for (const task of (snapshot.backlog ?? []).filter((item) => item.state === 'blocked')) add('fleet', { + tone: 'review', title: task.title ?? task.id, owner: agentName(task.owner), + detail: task.blockedReason || 'This task is blocked; the reason is not recorded in the snapshot.', + evidence: task.id, href: '#backlog-board', action: 'Inspect task status', + }); + + const waitingIntegration = (snapshot.integration ?? []).filter((item) => !['landed', 'rejected', 'superseded'].includes(item.state)); + for (const item of waitingIntegration) add('fleet', { + tone: 'review', title: `Integration ${item.state}`, owner: item.authorizedBy ? agentName(item.authorizedBy) : 'Integration authority not recorded', + detail: 'Review the exact candidate and required receipts under the current named landing authority. Queue state does not establish that a separate owner decision is required.', + evidence: [item.sourceArea, shortCommit(item.sourceCommit), item.authorizedBy ? `authorized by ${agentName(item.authorizedBy)}` : null, item.reason ?? 'reason not recorded'].filter(Boolean).join(' · '), + href: '#delivery', action: 'Inspect integration queue', + }); + + const unknownWakes = snapshot.managerWakes?.blockingCount ?? 0; + if (unknownWakes) { + const reservations = (snapshot.managerWakes?.reservations ?? []).filter((item) => item.blocksManagerWake); + const wakeOwners = [...new Set(reservations.map((item) => agentName(item.managerId)))]; + add('fleet', { + tone: 'urgent', title: `${unknownWakes} scheduled manager launch${unknownWakes === 1 ? '' : 'es'} ${unknownWakes === 1 ? 'needs' : 'need'} inspection`, + owner: wakeOwners.join(', ') || 'Scheduled wake owner not recorded', + detail: 'A recorded reservation blocks another wake. Inspect current runtime evidence before acting; this record does not prove a provider is still running or establish who may restart it.', + evidence: reservations.map((item) => [item.scheduleId, item.reservedAt, item.outcome].filter(Boolean).join(' · ')).join('; ') || 'Reservation detail unavailable', + href: '#manager-wakes', action: 'Inspect manager launches', + }); + } + + const unresolvedDelivery = (snapshot.deliveryOperations ?? []).filter((operation) => ['running', 'unknown', 'succeeded'].includes(operation.state)); + if (unresolvedDelivery.length) add('fleet', { + tone: 'urgent', title: `${unresolvedDelivery.length} delivery operation${unresolvedDelivery.length === 1 ? '' : 's'} need review`, + owner: [...new Set(unresolvedDelivery.map((operation) => operation.actor ? agentName(operation.actor) : 'Operation owner not recorded'))].join(', '), + detail: 'Inspect recorded attempts and external state before considering recovery. Do not repeat effects automatically.', + evidence: unresolvedDelivery.map((operation) => `${operation.operation} · ${operation.state} · ${shortCommit(operation.commit)}`).join('; '), + href: '#operation-outcomes', action: 'Inspect operation outcomes', + }); + + const neglected = (snapshot.backlogActivity?.tasks ?? []).filter((task) => task.status === 'stale' && task.ownerRequested); + if (neglected.length) add('fleet', { + tone: 'review', title: `${neglected.length} owner-requested task${neglected.length === 1 ? '' : 's'} need activity review`, + owner: 'Task owner not recorded', detail: 'No named commit appears within the configured review window. Blocked work may be waiting as expected.', + evidence: 'Review the recorded task activity before asking for a change.', href: '#activity-review', action: 'Open activity review', + }); + + const staleEvidence = (snapshot.backlogHealth?.findings ?? []).filter((finding) => + ['BACKLOG_OBSERVED_COMMIT_STALE', 'BACKLOG_OBSERVED_COMMIT_MISSING'].includes(finding.code)); + if (staleEvidence.length) add('fleet', { + tone: 'review', title: `${staleEvidence.length} task evidence record${staleEvidence.length === 1 ? '' : 's'} need refresh`, + owner: 'Task owner not recorded', detail: 'Some backlog evidence refers to an old or unreachable commit. Re-check it before dispatch.', + evidence: staleEvidence.map((finding) => `${finding.taskId ?? 'task not recorded'} · ${finding.code}`).join('; '), + href: '#backlog-board', action: 'Inspect backlog evidence', + }); + return groups; +} + + global.TorchAttentionProjection = Object.freeze({ groups: attentionGroups }); +})(globalThis); diff --git a/site/console.html b/site/console.html index bba7b05..662156a 100644 --- a/site/console.html +++ b/site/console.html @@ -14,6 +14,8 @@ + + diff --git a/site/console.js b/site/console.js index 89e3f96..cba404d 100644 --- a/site/console.js +++ b/site/console.js @@ -706,7 +706,7 @@ function renderFlowWatch(snapshot) { const waitClass = approval.approver === 'owner' ? 'wait-owner' : approval.approver === 'session-manager' ? 'wait-manager' : ''; const refs = [approval.task, approval.evidence].filter(Boolean).map((reference) => escapeHtml(reference)).join(' · '); - return `
+ return `

${escapeHtml(approval.title)}

${escapeHtml(requester?.title ?? approval.requester)} is waiting on ${escapeHtml(approver)}${refs ? ` · ${refs}` : ''}

Request details · ${escapeHtml(ageLabel(approval.createdAt))}

${escapeHtml(approval.summary)}

@@ -725,60 +725,38 @@ function renderFlowWatch(snapshot) { : empty(approvalState?.reason ?? 'Structured approval requests are not available.')); } -function attentionItems(snapshot) { - const items = []; - const unresolvedDelivery = (snapshot.deliveryOperations ?? []).filter((operation) => ['running', 'unknown', 'succeeded'].includes(operation.state)); - if (unresolvedDelivery.length) items.push({ tone: 'urgent', title: `${unresolvedDelivery.length} delivery operation${unresolvedDelivery.length === 1 ? ' needs' : 's need'} review`, - detail: 'Inspect recorded attempts and external state before owner-approved recovery. Do not automatically repeat effects.', href: '#operation-outcomes' }); - const neglected = (snapshot.backlogActivity?.tasks ?? []).filter((task) => task.status === 'stale' && task.ownerRequested); - if (neglected.length) items.push({ tone: 'review', title: `${neglected.length} owner request${neglected.length === 1 ? ' needs' : 's need'} activity review`, - detail: 'No named commit within the configured review window. Blocked work may be waiting as expected.', href: '#activity-review' }); - const unknownWakes = snapshot.managerWakes?.blockingCount ?? 0; - if (unknownWakes) items.push({ - tone: 'urgent', title: `${unknownWakes} manager ${unknownWakes === 1 ? 'launch needs' : 'launches need'} inspection`, - detail: 'An unknown launch blocks another wake. Inspect the runtime before owner-approved recovery; no automatic restart occurs.', - href: '#manager-wakes', - }); - const findings = snapshot.doctor?.findings ?? []; - for (const finding of findings.filter((entry) => entry.severity === 'error' || entry.severity === 'warning').slice(0, 5)) { - items.push({ - tone: finding.severity === 'error' ? 'urgent' : 'review', - title: finding.areaId ? `${finding.areaId}: ${finding.code}` : finding.code, - detail: finding.message ?? finding.recommendation ?? 'Doctor reported a project condition to review.', - }); - } - const awaiting = snapshot.messages?.unacknowledged ?? 0; - if (awaiting) items.push({ - tone: 'review', title: `${awaiting} unacknowledged ${awaiting === 1 ? 'message' : 'messages'}`, - detail: 'Open Conversations to inspect the latest durable inter-session handoffs.', - href: '#communications', - }); - const ownerApprovals = (snapshot.approvalRequests?.items ?? []).filter((approval) => - approval.status === 'pending' && approval.approver === 'owner'); - if (ownerApprovals.length) items.push({ - tone: 'decision', title: `${ownerApprovals.length} approval ${ownerApprovals.length === 1 ? 'request' : 'requests'} need your decision`, - detail: 'The named owner is the approver; review the request evidence in Flow watch.', href: '#flow-watch', - }); - const pendingChanges = (snapshot.fleetChanges ?? []).filter((change) => !['active', 'rejected'].includes(change.state)); - const pendingHierarchy = (snapshot.hierarchyProposals ?? []).filter((proposal) => proposal.state === 'proposed'); - if (pendingChanges.length + pendingHierarchy.length) items.push({ - tone: 'decision', title: `${pendingChanges.length + pendingHierarchy.length} organization ${pendingChanges.length + pendingHierarchy.length === 1 ? 'proposal' : 'proposals'}`, - detail: 'Organization proposals wait for owner review; they do not change the active Fleet.', - href: '#organization-proposals', - }); - const waitingIntegration = (snapshot.integration ?? []).filter((item) => !['landed', 'rejected'].includes(item.state)); - if (waitingIntegration.length) items.push({ - tone: 'decision', title: `${waitingIntegration.length} integration ${waitingIntegration.length === 1 ? 'request' : 'requests'}`, - detail: 'Review exact source commits and required checks before landing.', href: '#delivery', - }); - const staleEvidence = (snapshot.backlogHealth?.findings ?? []).filter((finding) => - ['BACKLOG_OBSERVED_COMMIT_STALE', 'BACKLOG_OBSERVED_COMMIT_MISSING'].includes(finding.code)); - if (staleEvidence.length) items.push({ - tone: 'review', title: `${staleEvidence.length} task ${staleEvidence.length === 1 ? 'evidence record needs' : 'evidence records need'} refresh`, - detail: 'Some backlog items were observed against an old or unreachable commit. Re-check them before dispatch.', - href: '#backlog-board', - }); - return items; +function attentionGroups(snapshot) { + return globalThis.TorchAttentionProjection.groups(snapshot); +} +function renderAttention(groups) { + const definitions = [ + ['owner', 'Waiting on you', 'Only requests that name the project owner as decision-maker.'], + ['fleet', 'Fleet handling', 'A named domain owns the next step; unknown ownership stays explicit.'], + ['arbiter', 'Arbiter handling', 'No observed item has structured evidence assigning its next step exclusively to an arbiter.'], + ]; + const total = Object.values(groups).reduce((sum, items) => sum + items.length, 0); + setText('#attention-count', `${total} ${total === 1 ? 'item' : 'items'}`); + setHtml('#attention-list', definitions.map(([key, title, description]) => { + const items = groups[key]; + return `
+

${title}

${items.length}
+ ${items.length ? items.map((item) => `
+
+ ${escapeHtml(item.title)}

${escapeHtml(item.detail)}

+ 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))}` : ''} + ${item.evidence.length > 120 + ? `
Evidence and references

${escapeHtml(item.evidence)}

` + : `Evidence: ${escapeHtml(item.evidence)}`} + ${escapeHtml(item.action)} + ${item.decisionApprovalId ? `
+ + + +
` : ''} + ${item.requestOwner ? `Request review from ${escapeHtml(item.owner)}` : ''} +
`).join('') : `

${escapeHtml(description)}

`} +
`; + }).join('')); } function render(snapshot) { @@ -789,7 +767,7 @@ function render(snapshot) { const active = tasks.filter((task) => !['completed', 'cancelled'].includes(task.state)); const working = active.filter((task) => task.state === 'in_progress').length; const blocked = active.filter((task) => task.state === 'blocked').length; - const attention = attentionItems(snapshot); + const attention = attentionGroups(snapshot); const branch = snapshot.repository?.branch ?? 'branch not recorded'; setText('#project-name', project?.name ?? project?.id ?? 'Not installed'); @@ -807,8 +785,9 @@ function render(snapshot) { const parts = [`${agents.length} persistent ${agents.length === 1 ? 'identity' : 'identities'}`, `${working} ${working === 1 ? 'task' : 'tasks'} in progress`]; if (blocked) parts.push(`${blocked} blocked`); + const attentionCount = Object.values(attention).reduce((sum, items) => sum + items.length, 0); setText('#project-summary', snapshot.mode === 'installed' - ? `${parts.join(' · ')}. ${attention.length ? `${attention.length} item${attention.length === 1 ? '' : 's'} may need your review.` : 'No owner decision is currently waiting in the observed queues.'}` + ? `${parts.join(' · ')}. ${attentionCount ? `${attentionCount} item${attentionCount === 1 ? '' : 's'} need a named next step.` : 'No owner decision is currently waiting in the observed queues.'}` : 'This repository has not been installed as a TORCH Fleet. The view is showing repository intake only.'); renderTaskCreateOptions(agents, tasks, snapshot.mode === 'installed'); renderOwnerRequestOptions(agents, tasks, snapshot.mode === 'installed'); @@ -816,13 +795,7 @@ function render(snapshot) { renderFlowWatch(snapshot); setHtml('#owner-briefing-content', globalThis.TorchOwnerDigest.render(snapshot.ownerDigest)); - setText('#attention-count', `${attention.length} ${attention.length === 1 ? 'item' : 'items'}`); - setHtml('#attention-list', attention.length ? attention.map((item) => ` -
- -
${escapeHtml(item.title)}

${escapeHtml(item.detail)}

- ${item.href ? `Open relevant view` : ''}
-
`).join('') : empty('No owner decisions are waiting in the observed queues.')); + renderAttention(attention); renderBacklog(tasks, snapshot.backlogHealth?.findings ?? []); setHtml('#activity-review-content', globalThis.TorchTaskActivity.render(snapshot.backlogActivity)); @@ -849,7 +822,7 @@ function render(snapshot) { const refs = [message.task, message.path, message.commit].filter(Boolean); const ack = (message.acknowledgedBy ?? []).length ? `Acknowledged by ${message.acknowledgedBy.join(', ')}` : 'Awaiting acknowledgement'; - return `
${escapeHtml(message.sender)} to ${escapeHtml(message.recipient)}${escapeHtml(ageLabel(message.createdAt))}

${escapeHtml(message.body)}

${escapeHtml(ack)}${refs.length ? ` · ${escapeHtml(refs.join(' · '))}` : ''}
`; + return `
${escapeHtml(message.sender)} to ${escapeHtml(message.recipient)}${escapeHtml(ageLabel(message.createdAt))}

${escapeHtml(message.body)}

${escapeHtml(ack)}${refs.length ? ` · ${escapeHtml(refs.join(' · '))}` : ''}
`; }).join('') : empty('No durable messages yet. Runtime-native private reasoning is not shown here.')); setHtml('#check-list', globalThis.TorchCheckEvidence.render(snapshot.checks)); @@ -906,7 +879,7 @@ function render(snapshot) { const worktrees = snapshot.worktrees ?? []; setHtml('#worktree-list', worktrees.length ? worktrees.map((worktree) => ` -
${escapeHtml(worktree.area)}${escapeHtml(worktree.branch)} · ${escapeHtml(worktree.path)}
${escapeHtml(`${worktree.ahead ?? 0} ahead / ${worktree.behind ?? 0} behind`)}
+
${escapeHtml(worktree.area)}${escapeHtml(worktree.branch)} · ${escapeHtml(worktree.path)}
${escapeHtml(`${worktree.ahead ?? 0} ahead / ${worktree.behind ?? 0} behind`)}
`).join('') : empty('No managed worktrees have been created.')); const holders = snapshot.resources?.holders ?? []; @@ -1864,8 +1837,46 @@ $('#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; + if (recipient) { + const form = $('#owner-request-form'); + const recipientSelect = form?.elements.namedItem('recipient'); + const body = form?.elements.namedItem('body'); + const status = form?.querySelector('.owner-request-status'); + const hasRecipient = [...(recipientSelect?.options ?? [])].some((option) => option.value === recipient); + if (form && recipientSelect && body && hasRecipient && !recipientSelect.value && !body.value.trim()) { + recipientSelect.value = recipient; + if (status) status.textContent = `Selected ${recipient}. Add context and review the preview before sending anything.`; + } + } for (let parent = target?.parentElement; parent; parent = parent.parentElement) { if (parent.tagName === 'DETAILS') parent.open = true; } diff --git a/site/styles.css b/site/styles.css index 0a87989..7ca7687 100644 --- a/site/styles.css +++ b/site/styles.css @@ -173,15 +173,30 @@ footer div { display: flex; gap: 1.5rem; } .panel-heading { padding-bottom: .65rem; border-bottom: 1px solid var(--line); } .panel-heading h2, .panel-heading h3 { margin: 0; font: 650 1rem/1.2 var(--display); } .panel-heading span, .section-heading > span { color: var(--muted); font-size: .76rem; text-align: right; } -.attention-list { display: grid; } -.attention-item { display: grid; grid-template-columns: 9px 1fr; gap: .7rem; padding: .8rem 0; border-bottom: 1px solid var(--line); } +.attention-list { display: grid; gap: 1rem; } +.attention-group { min-width: 0; padding-top: .6rem; border-top: 1px solid var(--line); } +.attention-group-heading { display: flex; align-items: baseline; justify-content: space-between; gap: .8rem; } +.attention-group-heading h3 { margin: 0; font: 650 .9rem/1.25 var(--display); } +.attention-group-heading span, .attention-empty { color: var(--muted); font-size: .72rem; } +.attention-empty { margin: .35rem 0 0; line-height: 1.4; } +.attention-item { display: grid; grid-template-columns: 9px minmax(0, 1fr); gap: .7rem; padding: .75rem 0; border-bottom: 1px solid var(--line); } .attention-mark { width: 7px; height: 7px; margin-top: .45rem; background: var(--field); } .tone-urgent .attention-mark { background: var(--signal); } .tone-review .attention-mark { background: var(--route); } .tone-decision .attention-mark { background: #f3bd55; } +.tone-info .attention-mark { background: var(--field); } +.attention-copy { min-width: 0; } .attention-item strong { display: block; font-size: .86rem; overflow-wrap: anywhere; } -.attention-item p { margin: .18rem 0; color: var(--muted); font-size: .78rem; line-height: 1.4; } -.attention-item a { color: var(--route); font-size: .76rem; font-weight: 650; } +.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; } diff --git a/src/console/server.mjs b/src/console/server.mjs index bfaf17c..596a39c 100644 --- a/src/console/server.mjs +++ b/src/console/server.mjs @@ -28,6 +28,8 @@ const ROUTES = new Map([ ['/check-evidence.js', 'check-evidence.js'], ['/task-activity.js', 'task-activity.js'], ['/operation-outcomes.js', 'operation-outcomes.js'], + ['/attention-projection.js', 'attention-projection.js'], + ['/attention-actions.js', 'attention-actions.js'], ['/live-refresh.js', 'live-refresh.js'], ]); const TYPES = new Map([ diff --git a/src/observability/snapshot.mjs b/src/observability/snapshot.mjs index b8ff51a..12ab6d0 100644 --- a/src/observability/snapshot.mjs +++ b/src/observability/snapshot.mjs @@ -138,7 +138,8 @@ function backlog(repositoryRoot, manifest) { const task = json(join(root, name)); tasks.push({ id: task.id, title: task.title, description: task.description ?? '', - state: task.state, owner: task.owner ?? null, priority: task.priority ?? null, + state: task.state, owner: task.owner ?? null, blockedReason: task.blockedReason ?? null, + priority: task.priority ?? null, affectedDomains: task.affectedDomains ?? [], dependencies: task.dependencies ?? [], feature: task.feature ?? null, milestone: task.milestone ?? null, acceptanceCriteria: task.acceptanceCriteria ?? [], evidence: task.evidence ?? [], diff --git a/test/fleet/console-attention.test.mjs b/test/fleet/console-attention.test.mjs new file mode 100644 index 0000000..35b0377 --- /dev/null +++ b/test/fleet/console-attention.test.mjs @@ -0,0 +1,180 @@ +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); + +test('SCN-console-attention-ownership: only owner-addressed pending decisions enter Waiting on you', () => { + const groups = project({ + agents: [ + { areaId: 'provider-runtime', title: 'Provider Runtime' }, + { areaId: 'qa', title: 'Independent QA' }, + ], + approvalRequests: { items: [ + { id: 'owner-review', status: 'pending', approver: 'owner', requester: 'provider-runtime', + title: 'Review executor boundary', task: 'TASK-RUNTIME', evidence: 'receipt:17', createdAt: '2026-10-01T06:00:00Z', summary: 'Confirm the bounded scope.' }, + { id: 'qa-review', status: 'pending', approver: 'qa', requester: 'owner-console', + title: 'Confirm scenario path', summary: 'Review the owned test path.' }, + ] }, + }); + + 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 }, + doctor: { findings: [ + { severity: 'warning', code: 'MESSAGE_BACKLOG', unacknowledged: 3 }, + { severity: 'warning', code: 'MESSAGE_BACKLOG', unacknowledged: 3 }, + ] }, + }); + + const queue = groups.fleet.filter((item) => item.href === '#communications'); + assert.equal(queue.length, 1); + assert.equal(queue[0].title, '4 durable messages awaiting acknowledgement'); + assert.match(queue[0].detail, /Each named recipient owns their acknowledgement/); +}); + +test('SCN-console-attention-worktrees: findings group by owning area and distinguish retained commits from unsafe operations', () => { + const groups = project({ + agents: [{ areaId: 'provider-runtime', title: 'Provider Runtime' }], + doctor: { findings: [ + { severity: 'warning', code: 'WORKTREE_PROBLEM', area: 'provider-runtime', path: '/work/provider', problem: 'unique-commits:2' }, + { severity: 'warning', code: 'WORKTREE_PROBLEM', areaId: 'provider-runtime', path: '/work/provider', problem: 'worktree-dirty' }, + { severity: 'warning', code: 'WORKTREE_PROBLEM', area: 'project-kernel', path: '/work/kernel', problem: 'unique-commits:1' }, + { severity: 'error', code: 'WORKTREE_PROBLEM', area: 'qa', path: '/work/qa', problem: 'git-operation:REBASE_HEAD' }, + ] }, + worktrees: [ + { area: 'provider-runtime', branch: 'runtime/fix', path: '/work/provider', ahead: 2, behind: 0 }, + { area: 'project-kernel', branch: 'kernel/change', path: '/work/kernel', ahead: 1, behind: 0 }, + { area: 'qa', branch: 'qa/audit', path: '/work/qa', ahead: 0, behind: 0 }, + ], + }); + + assert.equal(groups.fleet.length, 3); + const runtime = groups.fleet.find((item) => item.owner === 'Provider Runtime'); + assert.equal(runtime.tone, 'review'); + assert.match(runtime.detail, /Ahead commits are retained work progress, not a broken repository/); + assert.match(runtime.evidence, /runtime\/fix · \/work\/provider/); + assert.equal(runtime.requestOwner, 'provider-runtime'); + const kernel = groups.fleet.find((item) => item.owner === 'project-kernel'); + assert.equal(kernel.tone, 'info'); + assert.match(kernel.detail, /owning specialist or manager/); + const qa = groups.fleet.find((item) => item.owner === 'qa'); + assert.equal(qa.tone, 'urgent'); + assert.match(qa.title, /worktree needs review/); +}); + +test('SCN-console-attention-wait-reasons: recovery and blocked task evidence keep consequences and actual reason visible', () => { + const groups = project({ + agents: [{ areaId: 'release-self-host', title: 'Release and Self-host' }], + doctor: { findings: [{ severity: 'warning', code: 'RECOVERABILITY', level: 'ONE-DISK', + offMachine: false, commit: 'abcdef0123456789', recommendation: 'Configure and synchronize an off-machine canonical Git boundary.' }] }, + backlog: [{ id: 'TASK-BLOCKED', title: 'Capture mobile UI', state: 'blocked', owner: 'owner-console', + blockedReason: 'Browser CDP timed out; no image was saved.' }], + }); + + const recovery = groups.fleet.find((item) => item.title === 'Off-machine recovery copy is not verified'); + assert.equal(recovery.owner, 'Responsible domain not recorded'); + assert.match(recovery.detail, /single disk failure could remove/); + assert.match(recovery.evidence, /ONE-DISK · abcdef012/); + assert.match(groups.fleet.find((item) => item.evidence === 'TASK-BLOCKED').detail, /no image was saved/); +}); + +test('SCN-console-attention-authority: roster titles name owners and integration stays with Fleet unless arbiter authority is explicit', () => { + const groups = project({ + agents: [{ areaId: 'coordinator-7', title: 'Delivery Coordinator' }], + integration: [ + { state: 'awaiting-checks', sourceArea: 'build-team', sourceCommit: '1234567890abcdef', authorizedBy: 'coordinator-7' }, + { state: 'queued', sourceArea: 'docs-team', sourceCommit: 'abcdef0123456789' }, + ], + managerWakes: { blockingCount: 1, reservations: [{ managerId: 'coordinator-7', scheduleId: 'nightly-review', + outcome: 'reserved', blocksManagerWake: true }] }, + }); + + assert.equal(groups.fleet.length, 3); + assert.equal(groups.fleet[0].owner, 'Delivery Coordinator'); + assert.match(groups.fleet[0].evidence, /authorized by Delivery Coordinator/); + assert.equal(groups.fleet[1].owner, 'Integration authority not recorded'); + assert.equal(groups.fleet[2].owner, 'Delivery Coordinator'); + assert.deepEqual(groups.arbiter, []); +}); + +test('SCN-console-attention-empty: an unavailable signal does not manufacture owner work', () => { + const groups = project({ + approvalRequests: { available: false, reason: 'Structured approval service unavailable.' }, + messages: { unacknowledged: 0 }, doctor: { findings: [] }, + }); + + assert.deepEqual(groups, { owner: [], fleet: [], arbiter: [] }); +}); diff --git a/test/fleet/product-surface.test.mjs b/test/fleet/product-surface.test.mjs index 78cb14a..eb37b42 100644 --- a/test/fleet/product-surface.test.mjs +++ b/test/fleet/product-surface.test.mjs @@ -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, /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/);