diff --git a/.githooks/dw_pmo/__init__.py b/.githooks/dw_pmo/__init__.py index 8498805..ce0f24a 100644 --- a/.githooks/dw_pmo/__init__.py +++ b/.githooks/dw_pmo/__init__.py @@ -187,6 +187,15 @@ build_program_live_progress, build_run_live_progress, ) +from .bounded_actions import ( + BOUNDED_ACTIONS_KIND, + BOUNDED_ACTIONS_SCHEMA_VERSION, + build_program_bounded_actions, + build_refusal_explanation, + build_response_guidance, + build_run_bounded_actions, + classify_measurement, +) from .step import ( DEFAULT_STEP_OUTPUT_BYTES, STEP_KIND, diff --git a/.githooks/dw_pmo/bounded_actions.py b/.githooks/dw_pmo/bounded_actions.py new file mode 100644 index 0000000..74289de --- /dev/null +++ b/.githooks/dw_pmo/bounded_actions.py @@ -0,0 +1,1285 @@ +"""Plain-language bounded actions over canonical delivery facts. + +The run and program surfaces pass their already-derived controls, requests, +limits, blockers, and receipts into this module. The builders explain those +facts; they do not decide applicability, create response options, mint a +token, select work, spend permission, or write an event. +""" + +from __future__ import annotations + +import math +import re + + +BOUNDED_ACTIONS_KIND = "delivery-workbench-bounded-actions" +BOUNDED_ACTIONS_SCHEMA_VERSION = 1 + +_UNBOUNDED = {"unbounded", "unlimited", "infinite", "infinity", "∞"} +_COST_BUDGETS = { + "max_tokens", + "max_observed_cost_microunits", + "max_wall_seconds", + "max_artifact_bytes", +} +_CONTROL_RECEIPTS = { + "run_paused": ("pause", "Delivery paused"), + "run_resumed": ("resume", "Delivery resumed"), + "run_revoked": ("revoke", "Delivery permission permanently stopped"), + "run_cancelled": ("cancel", "Bounded delivery cancelled"), + "request_decided": ("request", "Decision recorded"), + "request_refused": ("request", "Decision response refused"), + "node_claimed": ("tick", "Bounded work started"), + "node_released": ("tick", "Bounded work outcome recorded"), + "program_paused": ("pause", "Program paused"), + "program_resumed": ("resume", "Program resumed"), + "program_revoked": ( + "revoke", "Program permission permanently stopped", + ), + "program_cancelled": ("cancel", "Program cancelled"), + "program_exhausted": ("limit", "Program stopped at a finite limit"), + "claim_completed": ("tick", "Program work outcome recorded"), +} + + +def _objects(value: object) -> list[dict[str, object]]: + if not isinstance(value, list): + return [] + return [item for item in value if isinstance(item, dict)] + + +def _strings(value: object) -> list[str]: + if not isinstance(value, list): + return [] + return [str(item) for item in value if isinstance(item, str)] + + +def _words(value: object) -> str: + text = re.sub(r"[_./:-]+", " ", str(value or "")).strip() + return " ".join(part for part in text.split() if part) + + +def _title(value: object, fallback: str = "Delivery work") -> str: + text = _words(value) + return text[:1].upper() + text[1:] if text else fallback + + +def _bounded_text(value: object, fallback: str) -> str: + text = " ".join(str(value or "").split()).strip() + return text or fallback + + +def classify_measurement( + kind: str, + value: object = None, + *, + unit: str = "units", + applicable: bool = True, + unbounded: bool = False, +) -> dict[str, object]: + """Classify one value without collapsing zero, unknown, or unbounded. + + ``applicable=False`` is an explicit not-applicable value. ``None`` while + applicable is unknown, never zero. Unbounded must be explicit either via + the flag or one of the recognized exact source spellings. + """ + state = "unknown" + normalized: int | float | None = None + if not applicable: + state = "not-applicable" + elif unbounded or ( + isinstance(value, str) and value.strip().lower() in _UNBOUNDED + ): + state = "unbounded" + elif ( + isinstance(value, (int, float)) + and not isinstance(value, bool) + and math.isfinite(float(value)) + ): + normalized = value + state = "zero" if value == 0 else "finite" + return { + "kind": kind, + "state": state, + "value": normalized, + "unit": unit, + } + + +def _usage_item( + item_id: str, + label: str, + category: str, + unit: str, + *, + actual: object = None, + limit: object = None, + remaining: object = None, + estimate: object = None, + actual_applicable: bool = True, + limit_applicable: bool = True, + remaining_applicable: bool = True, + estimate_applicable: bool = False, + primary: bool = True, +) -> dict[str, object]: + limit_unbounded = ( + isinstance(limit, str) and limit.strip().lower() in _UNBOUNDED + ) + remaining_unbounded = limit_unbounded and remaining is None + return { + "id": item_id, + "label": label, + "category": category, + "primary": primary, + "measurements": { + "limit": classify_measurement( + "limit", + limit, + unit=unit, + applicable=limit_applicable, + unbounded=limit_unbounded, + ), + "estimate": classify_measurement( + "estimate", + estimate, + unit=unit, + applicable=estimate_applicable, + ), + "actual": classify_measurement( + "actual", + actual, + unit=unit, + applicable=actual_applicable, + ), + "remaining": classify_measurement( + "remaining", + remaining, + unit=unit, + applicable=remaining_applicable, + unbounded=remaining_unbounded, + ), + }, + } + + +def _usage( + budgets: object, + live_progress: dict[str, object], +) -> dict[str, object]: + rows = _objects((live_progress.get("limits") or {}).get("counts")) + row_by_id = {str(item.get("id")): item for item in rows} + items: list[dict[str, object]] = [] + if isinstance(budgets, dict): + for item_id, raw in budgets.items(): + if not isinstance(raw, dict): + continue + display = row_by_id.get(str(item_id), {}) + label = str(display.get("label") or _words(item_id)) + unit = str(display.get("unit") or "units") + category = ( + "measured-cost" + if str(item_id) in _COST_BUDGETS + else "permission-consumption" + ) + items.append(_usage_item( + str(item_id), + label, + category, + unit, + actual=raw.get("used"), + limit=raw.get("limit"), + remaining=raw.get("remaining"), + primary=bool(display.get("primary", True)), + )) + + progress = live_progress.get("progress") + progress_doc = progress if isinstance(progress, dict) else {} + known_total = progress_doc.get("known_total") + completed = progress_doc.get("completed") + progress_known = ( + isinstance(known_total, int) + and not isinstance(known_total, bool) + and isinstance(completed, int) + and not isinstance(completed, bool) + ) + items.insert(0, _usage_item( + "declared-work-progress", + "declared work", + "progress", + "work items", + actual=completed if progress_known else None, + limit=known_total if progress_known else None, + remaining=( + max(0, int(known_total) - int(completed)) + if progress_known else None + ), + )) + if not any(item["id"] == "max_observed_cost_microunits" for item in items): + items.append(_usage_item( + "money-cost", + "money cost", + "measured-cost", + "money units", + actual=None, + limit=None, + remaining=None, + actual_applicable=True, + limit_applicable=False, + remaining_applicable=False, + )) + return { + "items": items, + "legend": { + "limit": "The maximum allowed by the exact permission.", + "estimate": "A declared forecast, only when the source records one.", + "actual": "Measured consumption recorded so far.", + "remaining": "The exact finite limit minus measured use.", + "zero": "Zero means none; it never means unbounded.", + "unbounded": ( + "Unbounded appears only when the exact source explicitly says " + "there is no finite ceiling." + ), + "unknown": "Unknown means the source does not record the value.", + "not-applicable": "Not applicable means this delivery does not use that measure.", + }, + "adds_incomparable_units": False, + } + + +def build_refusal_explanation( + happened: str, + unchanged: str, + *, + effect_may_have_occurred: bool | None, + safe_next: str, + technical_evidence: dict[str, object] | None = None, +) -> dict[str, object]: + return { + "what_happened": _bounded_text(happened, "The action was refused."), + "what_stayed_unchanged": _bounded_text( + unchanged, "The saved delivery state stayed unchanged." + ), + "effect_may_have_occurred": effect_may_have_occurred, + "effect_answer": ( + "yes" + if effect_may_have_occurred is True + else "no" + if effect_may_have_occurred is False + else "unknown" + ), + "safe_next_step": _bounded_text( + safe_next, "Reload the saved state before choosing another action." + ), + "technical_evidence": technical_evidence or {}, + } + + +def _decision_effect(decision: str, *, context: str) -> tuple[str, str]: + normalized = str(decision or "").strip().lower() + boundary = ( + "program frontier" + if context == "program" + else "bounded delivery state" + ) + if normalized in {"approve", "accept", "yes", "continue"}: + return ( + "Records approval only for this exact outstanding request.", + f"The canonical {boundary} is recalculated; only work it then permits may proceed.", + ) + if normalized in {"reject", "deny", "no", "stop"}: + return ( + "Records rejection only for this exact outstanding request.", + f"Affected work stays stopped or follows the saved rejection route; permission is not revoked.", + ) + return ( + "Records this exact closed response only for the named outstanding request.", + f"The canonical {boundary} is recalculated from that saved response.", + ) + + +def build_response_guidance( + *, + context: str, + affected_work: str, + correlation_id: str, + decisions: list[str], +) -> dict[str, object]: + """Build transport-safe response guidance from one exact response set.""" + choices = [] + for decision in decisions: + effect, after = _decision_effect(decision, context=context) + choices.append({ + "decision": decision, + "label": f"{_title(decision)} this request", + "effect": effect, + "after": after, + }) + return { + "affected_work": affected_work, + "correlation_id": correlation_id, + "choices": choices, + "transport_role": "response-carrier", + "transport_can_draft_response": True, + "transport_grants_authority": False, + "decisive_checks": [ + "the canonical local principal is authorized", + "the exact request is still outstanding", + "the response is in the request's closed response set", + "the local preview token is fresh for the current ledger and generation", + "the apply boundary accepts the same request and response", + ], + "safe_next_step": ( + "Send one listed response or leave the request pending; the local " + "exact boundary must still accept it." + ), + "starts_work": False, + "writes_events": False, + "grants_authority": False, + } + + +def _action_semantics( + action: str, + *, + context: str, + decision: str, + next_step: dict[str, object], +) -> dict[str, object]: + subject = "program" if context == "program" else "delivery" + if action == "tick": + repair = str(next_step.get("kind")) == "repair" + return { + "label": ( + "Retry the bounded repair" + if repair + else "Continue one reviewed step" + ), + "effect": ( + "Starts only the next repair attempt already selected by the " + "saved delivery plan, within remaining permission and limits." + if repair + else "Starts at most the one next step already selected by the " + "saved delivery state, within remaining permission and limits." + ), + "unchanged": ( + "It does not change retry policy, select different work, or " + "broaden permission." + ), + "after": ( + "The resulting receipt is saved and the canonical next step is recalculated." + ), + "severity": "start", + "permanent": False, + } + if action == "supervise": + return { + "label": "Continue within reviewed ceilings", + "effect": ( + "May start successive canonical program steps only until the " + "reviewed tick, time, checkpoint, stop, or terminal ceiling." + ), + "unchanged": ( + "It does not choose another workflow, expand scope, or raise a limit." + ), + "after": "An exact receipt and stop reason are shown for the bounded pass.", + "severity": "start", + "permanent": False, + } + if action == "pause": + return { + "label": f"Pause the {subject}", + "effect": ( + f"Stops new {subject} work from starting while preserving " + "completed work, current requests, remaining limits, and history." + ), + "unchanged": ( + "Pause is reversible; it does not revoke permission or erase prior effects." + ), + "after": "A separately reviewed resume is required before new work can start.", + "severity": "caution", + "permanent": False, + } + if action == "resume": + return { + "label": "Resume reviewed work", + "effect": ( + "Rechecks the saved permission and current facts, then leaves " + "the delivery eligible only for work its canonical state permits." + ), + "unchanged": ( + "Resume does not repeat completed work, expand scope, or start " + "a step without its separate canonical control." + ), + "after": "The current next step is recalculated from the refreshed saved state.", + "severity": "caution", + "permanent": False, + } + if action == "revoke": + return { + "label": f"Permanently stop the {subject}", + "effect": ( + f"Permanently prevents new {subject} work under this permission " + "and expires any outstanding request bound to it." + ), + "unchanged": "Completed work and the exact history remain available for inspection.", + "after": "This permission cannot resume; new authority would require a separate grant.", + "severity": "danger", + "permanent": True, + } + if action == "cancel": + return { + "label": f"Cancel this bounded {subject}", + "effect": ( + f"Ends this {subject} as cancelled and expires its outstanding requests." + ), + "unchanged": ( + "Completed effects and exact history remain; cancellation does " + "not certify, merge, release, or revoke any separate authority." + ), + "after": ( + "The cancelled delivery cannot resume. Program cancellation also " + "interrupts its recorded active claims." + ), + "severity": "danger", + "permanent": True, + } + if action == "request": + effect, after = _decision_effect(decision, context=context) + return { + "label": f"{_title(decision)} this request", + "effect": effect, + "unchanged": ( + "No other request, permission ceiling, or completed work changes." + ), + "after": after, + "severity": ( + "danger" + if str(decision).lower() in {"reject", "deny", "no", "stop"} + else "caution" + ), + "permanent": False, + } + if action == "retry": + return { + "label": "Retry is controlled by the delivery plan", + "effect": ( + "No operator retry is available. Only an attempt already " + "selected by the saved failure policy can run through continue." + ), + "unchanged": "Retry policy, attempts, permission, and delivery state stay unchanged.", + "after": "Review the failed check and the saved repair route.", + "severity": "unavailable", + "permanent": False, + } + if action == "elevate": + return { + "label": "Request new permission separately", + "effect": "This control cannot add permission or raise a limit.", + "unchanged": "Current scope, limits, and forbidden effects stay unchanged.", + "after": "A new grant must be reviewed through its separate start boundary.", + "severity": "unavailable", + "permanent": False, + } + return { + "label": _title(action), + "effect": "Applies only the exact operation described by its fresh preview.", + "unchanged": "No other delivery fact or permission changes.", + "after": "The saved state is replayed and its exact receipt is shown.", + "severity": "caution", + "permanent": False, + } + + +def _control_issue(control: dict[str, object]) -> str: + issues = _strings(control.get("issues")) + issue = str(control.get("issue") or "") + return "; ".join([*issues, *([issue] if issue else [])]) or ( + "This action is not applicable in the current saved state." + ) + + +def _actions( + controls: list[dict[str, object]], + *, + context: str, + live_progress: dict[str, object], +) -> list[dict[str, object]]: + actions: list[dict[str, object]] = [] + next_step = live_progress.get("next_step") + next_doc = next_step if isinstance(next_step, dict) else {} + for index, control in enumerate(controls): + action = str(control.get("action") or "") + decision = str(control.get("decision") or "") + semantics = _action_semantics( + action, + context=context, + decision=decision, + next_step=next_doc, + ) + correlation = str( + control.get("correlation_id") + or control.get("request_id") + or "" + ) + action_id = ":".join( + part for part in (action, correlation, decision) if part + ) or f"control-{index + 1}" + available = bool(control.get("available")) + issue = "" if available else _control_issue(control) + entry = { + "id": action_id, + "kind": "decision" if action == "request" else "control", + "action": action, + "decision": decision or None, + "correlation_id": correlation or None, + "label": semantics["label"], + "available": available, + "issue": issue or None, + "reason_required": bool(control.get("reason_required")), + "preview_required": bool(control.get("preview_required")), + "confirmation_required": bool( + available and control.get("preview_required") + ), + "may_start_work": bool(control.get("starts_work")), + "permanent": semantics["permanent"], + "severity": semantics["severity"], + "consequences": { + "effect": semantics["effect"], + "unchanged": semantics["unchanged"], + "after": semantics["after"], + }, + "exact_binding": { + "action": action, + "decision": decision or None, + ( + "request_id" + if context == "program" + else "correlation_id" + ): correlation or None, + "control_index": index, + }, + "source": { + "model": ( + "delivery-workbench-program-view" + if context == "program" + else "delivery-workbench-run-view" + ), + "path": f"/controls/{index}", + }, + } + if not available: + entry["refusal"] = build_refusal_explanation( + issue, + semantics["unchanged"], + effect_may_have_occurred=False, + safe_next=str(semantics["after"]), + technical_evidence=entry["source"], + ) + actions.append(entry) + return actions + + +def _read_actions( + *, + context: str, + has_decision: bool, + has_failure: bool, +) -> list[dict[str, object]]: + items = [ + { + "id": "reload-delivery-state", + "kind": "read", + "action": None, + "read_action": "reload", + "label": "Reload delivery state", + "available": True, + "confirmation_required": False, + "consequences": { + "effect": "Replays the canonical saved state and starts no work.", + "unchanged": "Delivery state, permission, and cost remain unchanged.", + "after": "The latest verified blockers, actions, limits, and receipts are shown.", + }, + }, + { + "id": "review-remaining-limits", + "kind": "read", + "action": None, + "read_action": "limits", + "label": "Review remaining limits", + "available": True, + "confirmation_required": False, + "consequences": { + "effect": "Opens current permission, consumption, and cost facts.", + "unchanged": "No work starts and no limit or permission changes.", + "after": "Choose an available bounded action or leave state unchanged.", + }, + }, + { + "id": "open-technical-details", + "kind": "read", + "action": None, + "read_action": "technical", + "label": "Open Technical details", + "available": True, + "confirmation_required": False, + "consequences": { + "effect": "Opens exact identities, controls, hashes, and ordered history.", + "unchanged": "No delivery state changes.", + "after": "Return to the same delivery summary when inspection is complete.", + }, + }, + { + "id": "return-without-change", + "kind": "read", + "action": None, + "read_action": "leave", + "label": ( + "Return without stopping" + if context == "program" + else "Leave delivery unchanged" + ), + "available": True, + "confirmation_required": False, + "consequences": { + "effect": "Closes any local preview and applies nothing.", + "unchanged": "Current delivery state stays unchanged.", + "after": "The saved state remains available for later review.", + }, + }, + ] + if has_decision: + items.insert(0, { + "id": "leave-decision-pending", + "kind": "read", + "action": None, + "read_action": "leave", + "label": "Decide later", + "available": True, + "confirmation_required": False, + "consequences": { + "effect": "Leaves this exact request pending and starts no affected work.", + "unchanged": "The request, affected work, permission, and cost stay unchanged.", + "after": "Reload the saved state before responding later.", + }, + }) + if has_failure: + items.insert(0, { + "id": "review-failed-check", + "kind": "read", + "action": None, + "read_action": "failure", + "label": "Review the failed check", + "available": True, + "confirmation_required": False, + "consequences": { + "effect": "Opens the saved failure and repair explanation.", + "unchanged": "No replacement work starts and the prior failure remains visible.", + "after": "Use only the repair step selected by the saved delivery plan.", + }, + }) + for index, item in enumerate(items): + item.update({ + "decision": None, + "correlation_id": None, + "reason_required": False, + "preview_required": False, + "may_start_work": False, + "permanent": False, + "severity": "read", + "exact_binding": None, + "source": { + "model": BOUNDED_ACTIONS_KIND, + "path": f"/read_actions/{index}", + }, + }) + return items + + +def _permission( + *, + context: str, + facts: dict[str, object], + live_progress: dict[str, object], + usage: dict[str, object], +) -> dict[str, object]: + limits = live_progress.get("limits") + limits_doc = limits if isinstance(limits, dict) else {} + permission = limits_doc.get("permission") + permission_doc = permission if isinstance(permission, dict) else {} + scope = facts.get("scope") + if context == "bounded-run": + story = facts.get("story") + story_doc = story if isinstance(story, dict) else {} + scope = { + "project": facts.get("project"), + "story_id": story_doc.get("id"), + "story_title": story_doc.get("title"), + } + stop_conditions = _strings(facts.get("stop_conditions")) + if context == "bounded-run": + stop_conditions = [ + "permission expiry", + "a finite counted limit reaching its ceiling", + "a terminal or failure route in the saved delivery plan", + "a separately confirmed pause, revoke, or cancel", + ] + elif not stop_conditions: + stop_conditions = [ + "permission expiry", + "a finite program limit reaching its ceiling", + "a saved program frontier stop or checkpoint", + "a separately confirmed pause, revoke, or cancel", + ] + current_use = [] + for item in usage["items"]: + if item["category"] == "progress": + continue + measurements = item["measurements"] + actual = measurements["actual"] + remaining = measurements["remaining"] + current_use.append({ + "id": item["id"], + "label": item["label"], + "actual": actual, + "remaining": remaining, + }) + return { + "status": permission_doc.get("status", "unknown"), + "allowed_effects": list(permission_doc.get("may_still_use") or []), + "scope": scope, + "ceilings": [ + item["id"] + for item in usage["items"] + if item["category"] != "progress" + and item["measurements"]["limit"]["state"] + in {"finite", "zero", "unbounded"} + ], + "expires_at": limits_doc.get("expires_at") or facts.get("expires_at"), + "stop_conditions": [ + _title(item) for item in stop_conditions + ], + "current_use": current_use, + "forbidden_effects": list( + permission_doc.get("will_not_use") + or facts.get("permanent_exclusions") + or [] + ), + "summary": permission_doc.get( + "summary", "Permission facts are unavailable." + ), + "source": { + "model": ( + "delivery-workbench-program" + if context == "program" + else "delivery-workbench-run" + ), + "paths": [ + "/capabilities", + "/scope" if context == "program" else "/story", + "/budgets", + "/expires_at", + "/permanent_exclusions", + ], + }, + } + + +def _receipt_from_event( + event: dict[str, object], + *, + context: str, +) -> dict[str, object] | None: + event_name = str(event.get("event") or "") + if event_name not in _CONTROL_RECEIPTS: + return None + action, label = _CONTROL_RECEIPTS[event_name] + detail = event.get("detail") + detail_doc = detail if isinstance(detail, dict) else {} + decision = str(detail_doc.get("decision") or "") + if event_name == "request_decided" and decision: + label = f"{_title(decision)} decision recorded" + if event_name == "request_refused": + label = "Decision response refused without applying it" + exact_ref = str( + event.get("event_hash") + or detail_doc.get("receipt_hash") + or "" + ) + return { + "id": f"{context}:{event.get('seq', len(exact_ref))}:{event_name}", + "label": label, + "action": action, + "decision": decision or None, + "outcome": str( + detail_doc.get("outcome") + or detail_doc.get("result") + or detail_doc.get("reason") + or detail_doc.get("to_state") + or "recorded" + ), + "at": event.get("ts") or event.get("at"), + "exact_reference": exact_ref or None, + "source": { + "model": ( + "delivery-workbench-program-event" + if context == "program" + else "delivery-workbench-run-event" + ), + "path": f"/timeline/{event.get('seq')}", + }, + } + + +def _receipts( + events: list[dict[str, object]], + *, + context: str, + program_receipts: list[dict[str, object]] | None = None, +) -> list[dict[str, object]]: + items = [ + item + for item in ( + _receipt_from_event(event, context=context) for event in events + ) + if item is not None + ] + for receipt in program_receipts or []: + if str(receipt.get("action_kind")) != "checkpoint-request": + continue + decision = receipt.get("decision") + decision_doc = decision if isinstance(decision, dict) else {} + option = str(decision_doc.get("option") or receipt.get("result") or "") + items.append({ + "id": str(receipt.get("receipt_hash") or receipt.get("action_id")), + "label": f"{_title(option)} decision recorded", + "action": "request", + "decision": option or None, + "outcome": receipt.get("result") or "recorded", + "at": receipt.get("issued_at"), + "exact_reference": receipt.get("receipt_hash"), + "source": { + "model": "delivery-workbench-program-receipt", + "path": f"/activities/completed/{receipt.get('action_id')}", + }, + }) + deduped: dict[str, dict[str, object]] = {} + for item in items: + deduped[str(item["id"])] = item + return list(deduped.values())[-8:][::-1] + + +def _request_actions( + actions: list[dict[str, object]], + correlation_id: str, +) -> list[dict[str, object]]: + return [ + item + for item in actions + if item["action"] == "request" + and item.get("correlation_id") == correlation_id + ] + + +def _choice(action: dict[str, object]) -> dict[str, object]: + return { + "action_id": action["id"], + "label": action["label"], + "decision": action.get("decision"), + "available": action["available"], + "effect": action["consequences"]["effect"], + "after": action["consequences"]["after"], + } + + +def _fallback_choices(actions: list[dict[str, object]]) -> list[dict[str, object]]: + preferred = [ + item for item in actions + if item["available"] + and item.get("action") in {"tick", "pause", "resume", "revoke", "cancel"} + ] + if not preferred: + preferred = [ + item for item in actions + if item["id"] in { + "review-failed-check", + "reload-delivery-state", + "open-technical-details", + } + ] + return [_choice(item) for item in preferred[:5]] + + +def _run_inbox( + projection: dict[str, object], + decision: dict[str, object], + graph_nodes: list[dict[str, object]], + actions: list[dict[str, object]], +) -> list[dict[str, object]]: + inbox: list[dict[str, object]] = [] + node_by_id = { + str(item.get("id")): item for item in graph_nodes + } + for request in _objects(projection.get("outstanding_requests")): + correlation = str(request.get("correlation_id") or "") + choices = _request_actions(actions, correlation) + affected = _title( + request.get("origin_node") or request.get("origin"), + "This bounded delivery", + ) + inbox.append({ + "id": f"decision:{correlation}", + "kind": "decision", + "status": "needs-decision", + "affected_work": affected, + "why": _bounded_text( + request.get("schema_summary"), + "The saved delivery is waiting for one exact closed response.", + ), + "resolver": "The named checkpoint owner through the fresh local request boundary.", + "valid_choices": [_choice(item) for item in choices], + "after_no_choice": ( + "The request remains pending and affected work does not advance." + ), + "technical_reference": correlation, + "source": { + "model": "delivery-workbench-run", + "path": "/outstanding_requests", + }, + }) + seen: set[str] = set() + for blocked in _objects(decision.get("blocked")): + node_id = str(blocked.get("node_id") or "") + reason = str(blocked.get("reason") or "unknown blocker") + if reason == "dependencies" or node_id in seen: + continue + seen.add(node_id) + node = node_by_id.get(node_id, {}) + inbox.append({ + "id": f"blocker:{node_id}", + "kind": "blocker", + "status": "blocked", + "affected_work": _title(node.get("title") or node_id), + "why": _title(reason), + "resolver": ( + "The saved failure route, a required external fact, or an " + "operator using one currently available exact control." + ), + "valid_choices": _fallback_choices(actions), + "after_no_choice": "Affected work remains stopped in the saved state.", + "technical_reference": node_id, + "source": { + "model": "delivery-workbench-conductor-decision", + "path": "/blocked", + }, + }) + if projection.get("state") == "blocked" and not any( + item["kind"] == "blocker" for item in inbox + ): + inbox.append({ + "id": "blocker:run-state", + "kind": "blocker", + "status": "blocked", + "affected_work": _title( + (projection.get("story") or {}).get("title") + if isinstance(projection.get("story"), dict) + else "This bounded delivery" + ), + "why": "The saved bounded policy reached its blocked terminal state.", + "resolver": "The saved delivery policy; this grant cannot invent another route.", + "valid_choices": _fallback_choices(actions), + "after_no_choice": "The blocked state and completed evidence remain unchanged.", + "technical_reference": projection.get("ledger_head"), + "source": { + "model": "delivery-workbench-run", + "path": "/state", + }, + }) + for refusal in _objects(projection.get("request_refusals")): + reason = str(refusal.get("reason") or "request refusal") + inbox.append({ + "id": f"refusal:{refusal.get('seq', refusal.get('correlation_id'))}", + "kind": "refusal", + "status": "refused", + "affected_work": _title( + refusal.get("origin_node") or refusal.get("origin"), + "The named request", + ), + "why": _title(reason), + "resolver": "Reload the exact request state; do not guess another response.", + "valid_choices": [ + _choice(item) for item in actions + if item["id"] in { + "reload-delivery-state", + "open-technical-details", + } + ], + "after_no_choice": "No decision is applied by this refusal.", + "technical_reference": ( + refusal.get("response_hash") or refusal.get("correlation_id") + ), + "explanation": build_refusal_explanation( + f"The request response was refused: {_words(reason)}.", + "The live request and affected delivery state were not changed by the refusal.", + effect_may_have_occurred=False, + safe_next="Reload the current requests and respond only to an exact outstanding request.", + technical_evidence={ + "model": "delivery-workbench-run", + "path": "/request_refusals", + }, + ), + "source": { + "model": "delivery-workbench-run", + "path": "/request_refusals", + }, + }) + return inbox + + +def _program_inbox( + authority: dict[str, object], + frontier: dict[str, object], + actions: list[dict[str, object]], + refusal: dict[str, object] | None, +) -> list[dict[str, object]]: + inbox: list[dict[str, object]] = [] + current_story = "" + selection = authority.get("selection") + if isinstance(selection, dict): + story = selection.get("story") + current_story = str( + story.get("id") if isinstance(story, dict) else story or "" + ) + for request in _objects(authority.get("outstanding_requests")): + request_id = str(request.get("claim_id") or "") + choices = _request_actions(actions, request_id) + inbox.append({ + "id": f"decision:{request_id}", + "kind": "decision", + "status": "needs-decision", + "affected_work": _title( + current_story or request.get("port"), + "The current program work", + ), + "why": ( + f"The saved program is waiting at {_words(request.get('port') or 'checkpoint')}." + ), + "resolver": "The granted program operator through the fresh local request boundary.", + "valid_choices": [_choice(item) for item in choices], + "after_no_choice": "The request remains pending and affected work does not advance.", + "technical_reference": request_id, + "source": { + "model": "delivery-workbench-program", + "path": "/outstanding_requests", + }, + }) + for obligation in _objects(authority.get("blocking_obligations")): + obligation_id = str(obligation.get("id") or "") + inbox.append({ + "id": f"blocker:{obligation_id}", + "kind": "blocker", + "status": "blocked", + "affected_work": _title( + obligation.get("target") or current_story, + "The current program work", + ), + "why": _bounded_text( + obligation.get("statement") or obligation.get("reason"), + "A saved blocking obligation is still open.", + ), + "resolver": _title( + obligation.get("accountable_role"), + "The accountable role named by the saved obligation", + ), + "valid_choices": _fallback_choices(actions), + "after_no_choice": "Program progression remains stopped while the obligation is blocking.", + "technical_reference": obligation_id, + "source": { + "model": "delivery-workbench-program", + "path": "/blocking_obligations", + }, + }) + stop = str(frontier.get("stop") or "") + if stop and stop not in {"integration-required", "scope-complete"}: + inbox.append({ + "id": f"blocker:frontier:{stop}", + "kind": "blocker", + "status": "blocked", + "affected_work": _title(current_story, "The current program scope"), + "why": f"The saved program frontier stopped at {_words(stop)}.", + "resolver": ( + "The saved program rules, a required role or fact, or an " + "operator using one currently available exact control." + ), + "valid_choices": _fallback_choices(actions), + "after_no_choice": "No new program work advances from this saved stop.", + "technical_reference": stop, + "source": { + "model": "delivery-workbench-program-frontier", + "path": "/stop", + }, + }) + if refusal: + inbox.append({ + "id": f"refusal:{refusal.get('code', 'program')}", + "kind": "refusal", + "status": "refused", + "affected_work": _title(current_story, "The current program scope"), + "why": str(refusal.get("message") or refusal.get("code") or "Program refusal"), + "resolver": "Reload exact program evidence; this view will not guess a frontier.", + "valid_choices": [ + _choice(item) for item in actions + if item["id"] in { + "reload-delivery-state", + "open-technical-details", + } + ], + "after_no_choice": "No work starts from an invalid frontier.", + "technical_reference": refusal.get("ledger_head"), + "explanation": build_refusal_explanation( + str(refusal.get("message") or "The program frontier was refused."), + "The last verified program ledger remains authoritative.", + effect_may_have_occurred=False, + safe_next="Inspect the exact ledger and reload after correcting the source fact.", + technical_evidence={ + "model": "delivery-workbench-program-view", + "path": "/current/refusal", + }, + ), + "source": { + "model": "delivery-workbench-program-view", + "path": "/current/refusal", + }, + }) + return inbox + + +def _base_document( + *, + context: str, + facts: dict[str, object], + live_progress: dict[str, object], + controls: list[dict[str, object]], + inbox_builder, + events: list[dict[str, object]], + program_receipts: list[dict[str, object]] | None = None, +) -> dict[str, object]: + usage = _usage(facts.get("budgets"), live_progress) + mutating_actions = _actions( + controls, + context="program" if context == "program" else "bounded-run", + live_progress=live_progress, + ) + has_decision = bool(facts.get("outstanding_requests")) + has_failure = bool( + (live_progress.get("review") or {}).get("failed_evidence") + if isinstance(live_progress.get("review"), dict) + else False + ) or str((live_progress.get("next_step") or {}).get("kind")) == "repair" + read_actions = _read_actions( + context="program" if context == "program" else "bounded-run", + has_decision=has_decision, + has_failure=has_failure, + ) + actions = [*read_actions, *mutating_actions] + inbox = inbox_builder(actions) + return { + "kind": BOUNDED_ACTIONS_KIND, + "schema_version": BOUNDED_ACTIONS_SCHEMA_VERSION, + "context": context, + "summary": ( + f"{len(inbox)} decision, blocker, or refusal item" + f"{'s' if len(inbox) != 1 else ''}; " + f"{sum(1 for item in mutating_actions if item['available'])} " + "exact bounded actions currently available." + ), + "inbox": inbox, + "permission": _permission( + context=context, + facts=facts, + live_progress=live_progress, + usage=usage, + ), + "usage": usage, + "actions": actions, + "receipts": _receipts( + events, + context="program" if context == "program" else "bounded-run", + program_receipts=program_receipts, + ), + "error_contract": { + "required_parts": [ + "what happened", + "what stayed unchanged", + "whether an effect may already have occurred", + "the safe next step", + "exact technical evidence", + ], + "unknown_effect_rule": ( + "If transport ends without an exact refusal or receipt, effect " + "status is unknown until the saved ledger is reloaded." + ), + }, + "transport_boundary": { + "role": "response-carrier", + "notification_or_remote_grants_authority": False, + "decisive_checks": [ + "canonical principal", + "exact request identity", + "closed response set", + "fresh preview token", + "current ledger and generation", + ], + }, + "starts_work": False, + "writes_events": False, + "selects_action": False, + "selects_next_work": False, + "grants_authority": False, + "changes_retry_policy": False, + "sends_notifications": False, + } + + +def build_run_bounded_actions( + projection: dict[str, object], + decision: dict[str, object], + graph_nodes: list[dict[str, object]], + controls: list[dict[str, object]], + live_progress: dict[str, object], + events: list[dict[str, object]], +) -> dict[str, object]: + """Explain exact bounded-run controls without selecting or applying one.""" + return _base_document( + context="bounded-run", + facts=projection, + live_progress=live_progress, + controls=controls, + inbox_builder=lambda actions: _run_inbox( + projection, decision, graph_nodes, actions + ), + events=events, + ) + + +def build_program_bounded_actions( + authority: dict[str, object], + frontier: dict[str, object], + controls: list[dict[str, object]], + live_progress: dict[str, object], + events: list[dict[str, object]], + *, + refusal: dict[str, object] | None, + receipts: list[dict[str, object]], +) -> dict[str, object]: + """Explain exact program controls without selecting or applying one.""" + return _base_document( + context="program", + facts=authority, + live_progress=live_progress, + controls=controls, + inbox_builder=lambda actions: _program_inbox( + authority, frontier, actions, refusal + ), + events=events, + program_receipts=receipts, + ) diff --git a/.githooks/dw_pmo/notifications.py b/.githooks/dw_pmo/notifications.py index 15c0a8d..e593795 100644 --- a/.githooks/dw_pmo/notifications.py +++ b/.githooks/dw_pmo/notifications.py @@ -14,6 +14,7 @@ from datetime import datetime, timezone from pathlib import Path +from .bounded_actions import build_response_guidance from .model import DwError from .orchestration import canonical_json from .orchestration_run import run_inventory @@ -118,6 +119,25 @@ def _run_notifications(root, now=None): projection = entry["run"] for request in projection.get("outstanding_requests", []): request_kind = str(request.get("kind") or "") + affected_work = str( + request.get("origin_node") + or request.get("origin") + or projection.get("story", {}).get("id") + or "bounded delivery" + ) + decisions = [ + str(item) + for item in request.get("response_schema", {}).get( + "decision", [] + ) + ] + correlation_id = str(request.get("correlation_id") or "") + guidance = build_response_guidance( + context="bounded-run", + affected_work=affected_work, + correlation_id=correlation_id, + decisions=decisions, + ) kind = ( "checkpoint-pending" if request_kind == "checkpoint" @@ -140,9 +160,10 @@ def _run_notifications(root, now=None): else "an uncovered nudge preview is waiting for a decision" ), "request": { - "correlation_id": request.get("correlation_id"), + "correlation_id": correlation_id, "response_schema": request.get("response_schema"), "boundary": "dw run request (fresh exact act token)", + "guidance": guidance, }, }) for republish in request.get("republished", []): @@ -159,9 +180,10 @@ def _run_notifications(root, now=None): "node": str(request.get("origin_node") or request.get("origin") or ""), "detail": "an outstanding request was republished after resume or restart", "request": { - "correlation_id": request.get("correlation_id"), + "correlation_id": correlation_id, "response_schema": request.get("response_schema"), "boundary": "dw run request (fresh exact act token)", + "guidance": guidance, }, }) for request in projection.get("request_history", []): @@ -272,6 +294,21 @@ def _program_notifications(root, now=None): "boundary": ( "dw program request (fresh exact act token)" ), + "guidance": build_response_guidance( + context="program", + affected_work=str( + (view.get("phase_progress") or {}).get( + "selected_stories", [] + )[-1] + if (view.get("phase_progress") or {}).get( + "selected_stories" + ) + else request.get("port") + or "current program work" + ), + correlation_id=request_id, + decisions=["approve", "reject"], + ), }, }) @@ -672,16 +709,25 @@ def render_outbound(notification): lines.append(notification.get("detail", "")) request = notification.get("request") if request: + guidance = request.get("guidance") or {} + if guidance.get("affected_work"): + lines.append(f"affected work: {guidance['affected_work']}") options = request.get("response_schema", {}).get( "decision", ["approve", "reject"] ) + for choice in guidance.get("choices", []): + lines.append( + f"choice {choice.get('decision')}: " + f"{choice.get('after') or choice.get('effect')}" + ) lines.append( - "to decide, reply: " + "to carry this response, reply: " f"/decision {request['correlation_id']} {'|'.join(options)}" ) lines.append( - "the decision applies only through the local exact-token " - "request boundary" + "chat does not grant permission: the canonical local principal, " + "outstanding request, closed response, freshness, and exact-token " + "checks still decide" ) lines.append(f"ack: {notification['id']}") return "\n".join(line for line in lines if line) diff --git a/.githooks/dw_pmo/orchestration_surface.py b/.githooks/dw_pmo/orchestration_surface.py index b736ba3..0867244 100644 --- a/.githooks/dw_pmo/orchestration_surface.py +++ b/.githooks/dw_pmo/orchestration_surface.py @@ -15,6 +15,7 @@ from datetime import datetime from pathlib import Path +from .bounded_actions import build_run_bounded_actions from .live_progress import build_run_live_progress from .model import DwError from .orchestration import canonical_json @@ -462,7 +463,9 @@ def _control_catalog( "issues": issues, "reason_required": reason_required, "preview_required": True, - "starts_work": starts_work, + "starts_work": ( + starts_work and projection["state"] == "active" + ), }) controls.extend([ { @@ -560,6 +563,15 @@ def build_run_view( safe_artifacts, events, ) + controls = _control_catalog(root, projection) + bounded_actions = build_run_bounded_actions( + projection, + decision, + graph_nodes, + controls, + live_progress, + events, + ) terminal = projection["state"] in TERMINAL_STATES terminal_meaning = { "awaiting-certification": "work is handed back for human inspection, certification, and commit", @@ -586,6 +598,7 @@ def build_run_view( "ledger_head": projection["ledger_head"], "ledger_events": projection["ledger_events"], "live_progress": live_progress, + "bounded_actions": bounded_actions, "graph": { "nodes": graph_nodes, "layout": compiled.get("layout", {}), @@ -615,7 +628,7 @@ def build_run_view( "fact_binding": projection["fact_binding"], "external_commits": projection["external_commits"], "timeline": events, - "controls": _control_catalog(root, projection), + "controls": controls, "terminal": terminal, "terminal_meaning": terminal_meaning, "privacy": { diff --git a/.githooks/dw_pmo/program_run.py b/.githooks/dw_pmo/program_run.py index f864b6d..7e1df24 100644 --- a/.githooks/dw_pmo/program_run.py +++ b/.githooks/dw_pmo/program_run.py @@ -1753,6 +1753,8 @@ def replay_program(root: Path, run_id: str, *, now: str | datetime | None = None "event_count": len(events), "capabilities": list(grant["authority"]["capabilities"]), # type: ignore[index] "budgets": budget_state, + "stop_conditions": list(grant["authority"]["stop_conditions"]), # type: ignore[index] + "cost_accounting": grant["authority"]["cost_accounting"], # type: ignore[index] "scope": grant["scope"], "selection": grant["selection"], "roster": grant["roster"], diff --git a/.githooks/dw_pmo/program_surface.py b/.githooks/dw_pmo/program_surface.py index 54d73af..ee6ada5 100644 --- a/.githooks/dw_pmo/program_surface.py +++ b/.githooks/dw_pmo/program_surface.py @@ -17,6 +17,7 @@ import re import time +from .bounded_actions import build_program_bounded_actions from .live_progress import build_program_live_progress from .model import DwError from .orchestration import canonical_json @@ -892,6 +893,14 @@ def _control_catalog(authority: dict[str, object]) -> list[dict[str, object]]: { "action": action, "available": available, + "issue": ( + None + if available + else ( + f"{action} is unavailable while program permission " + f"is {state}" + ) + ), "reason_required": action in _REASON_ACTIONS, "decision": None, "request_id": None, @@ -1109,6 +1118,16 @@ def build_program_view( timeline = tail_program_events( root, run_id, after_seq=0, limit=_MAX_TAIL_EVENTS )["events"] + controls = _control_catalog(authority) + bounded_actions = build_program_bounded_actions( + authority, + frontier, + controls, + live_progress, + timeline, + refusal=refusal, + receipts=receipts, + ) stop = frontier.get("stop") terminal_meaning = { "complete": "the exact granted roadmap scope completed", @@ -1143,6 +1162,7 @@ def build_program_view( "expired": authority["expired"], "scope": authority["scope"], "live_progress": live_progress, + "bounded_actions": bounded_actions, "current": { "selection": selection, "lineage": frontier.get("lineage"), @@ -1237,10 +1257,12 @@ def build_program_view( "scope_completion": authority["scope_completion"], }, "budgets": authority["budgets"], + "stop_conditions": authority["stop_conditions"], + "cost_accounting": authority["cost_accounting"], "capabilities": authority["capabilities"], "permanent_exclusions": authority["permanent_exclusions"], "timeline": timeline, - "controls": _control_catalog(authority), + "controls": controls, "terminal": authority["state"] in TERMINAL_AUTHORITY_STATES, "terminal_meaning": terminal_meaning, "privacy": { diff --git a/.githooks/workbench/app.js b/.githooks/workbench/app.js index 9996c29..a3a5642 100644 --- a/.githooks/workbench/app.js +++ b/.githooks/workbench/app.js @@ -22,6 +22,9 @@ function esc(s) { const SNAPSHOT_MODE = new URLSearchParams(location.search).has("snapshot"); const SNAPSHOT_LIVE_STATE = new URLSearchParams(location.search).get("liveconnection"); const LIVE_TECHNICAL_OPEN = new URLSearchParams(location.search).has("livetechnical"); +const SNAPSHOT_BOUNDED_FOCUS = new URLSearchParams(location.search).get("boundedfocus"); +const SNAPSHOT_BOUNDED_PREVIEW = new URLSearchParams(location.search).get("boundedpreview"); +const SNAPSHOT_BOUNDED_ERROR = new URLSearchParams(location.search).get("boundederror"); function syncGet(path) { const xhr = new XMLHttpRequest(); @@ -1179,7 +1182,7 @@ let orchState = { name: "", score: null, exists: false, selected: null, view: "design", preview: null, inventory: [], validationTimer: null, jsonDraft: "", runInventory: [], runs: [], runId: "", runView: null, runLoading: false, - runError: "", runPlan: null, runAct: null, runStream: null, + runError: "", runPlan: null, runAct: null, runResult: null, runStream: null, runConnection: { status: SNAPSHOT_LIVE_STATE === "stale" ? "stale" : "checking" }, grantDraft: { project: "", story: "", operator: "", minutes: 60 }, controlReason: "", @@ -1567,25 +1570,15 @@ function runTimelineHtml(view) { } function runControlsHtml(view) { - const available = (view.controls || []).filter((control) => control.available); - const unavailable = (view.controls || []).filter((control) => !control.available); - const requestControls = available.filter((control) => control.action === "request"); - if (view.terminal && !requestControls.length) return `
terminal handoff${esc(view.state)}

${esc(view.terminal_meaning)}

No certification, commit, elevation, retry, or apply control is exposed in this state.

`; - const shown = view.terminal ? requestControls : available; - return `
${view.terminal ? `
terminal handoff with a typed request${esc(view.state)}

${esc(view.terminal_meaning)}

Only the correlated request response is available; certification, commit, elevation, and retry remain absent.

` : ""}
separate act boundaryPreview, inspect, then confirm exactly one control
${badge("no automatic continuation", "warn")}
- ${available.some((control) => control.reason_required) ? `` : ""} -
${shown.map((control) => ``).join("") || 'No bounded control is applicable.'}
-
${unavailable.map((control) => `
${esc(control.action)}${control.decision ? ` · ${esc(control.decision)}` : ""}${esc((control.issues || []).join("; "))}
`).join("")}
+ return `
exact control catalogApplicability copied from the current saved run
${badge("inspection only", "ok")}
+
${(view.controls || []).map((control, index) => `
${esc(control.action)}${control.decision ? ` · ${esc(control.decision)}` : ""}${control.available ? "available through the ordinary action review above" : esc((control.issues || []).join("; ") || "not applicable in the current state")}/controls/${esc(index)}
`).join("")}
`; } function runActPreviewHtml(preview) { if (!preview) return ""; - return ``; + return `
Exact run preview
state + intent token${esc(preview.act_token)}

Observed ${esc(preview.state)} at generation ${esc(preview.control_generation)} and ledger ${esc(preview.ledger_head)}.

+ ${preview.correlation_id ? `

bound request: ${esc(preview.correlation_id)} · ${esc(preview.response_outcome)}

` : ""}${preview.reason ? `

bound reason: ${esc(preview.reason)}

` : ""}${(preview.issues || []).map((issue) => `

${esc(issue)}

`).join("")}
`; } function runStreamHtml(stream) { @@ -1693,15 +1686,147 @@ function liveLimitsHtml(progress) { return `
Remaining permission and costWhat this delivery may still use
Change permission${esc(permission.status || "unknown")}

${esc(permission.summary || "")}

${(permission.will_not_use || []).length ? `Will not use: ${esc(permission.will_not_use.join(", "))}` : ""}
Money cost${esc(cost.status || "unknown")}

${esc(cost.summary || "")}

${primaryCounts.map((item) => `
${esc(item.label)}${esc(item.remaining)} ${esc(item.unit)} left${esc(item.used)} used of ${esc(item.limit)}
`).join("")}
${limits.expires_at ? `

Permission ends ${esc(limits.expires_at)}.

` : ""}
`; } +function boundedScopeText(scope) { + if (!scope || typeof scope !== "object") return String(scope || "No scope is recorded."); + return Object.entries(scope) + .filter(([, value]) => value !== null && value !== undefined && value !== "") + .map(([key, value]) => `${key.replaceAll("_", " ")}: ${Array.isArray(value) ? value.join(", ") : typeof value === "object" ? JSON.stringify(value) : value}`) + .join(" · ") || "No scope is recorded."; +} + +function boundedMeasurementHtml(measurement) { + const item = measurement || { state: "unknown", unit: "units" }; + if (item.state === "finite" || item.state === "zero") return `${esc(item.value)} ${esc(item.unit)}${esc(item.state)}`; + const label = item.state === "not-applicable" ? "Not applicable" + : item.state === "unbounded" ? "Unbounded" + : "Unknown"; + return `${label}${esc(item.state)}`; +} + +function boundedMeasurementText(measurement) { + const item = measurement || { state: "unknown", unit: "units" }; + if (item.state === "finite" || item.state === "zero") return `${item.value} ${item.unit}`; + return item.state === "not-applicable" ? "not applicable" + : item.state === "unbounded" ? "unbounded" : "unknown"; +} + +function boundedUsageTable(model, all = false) { + const items = (model?.usage?.items || []).filter((item) => all || item.primary !== false); + return `
${items.map((item) => `${["limit", "estimate", "actual", "remaining"].map((kind) => ``).join("")}`).join("")}
MeasureLimitEstimateActualRemaining
${esc(item.label)}${esc(item.category)}${boundedMeasurementHtml(item.measurements?.[kind])}
`; +} + +function boundedPermissionHtml(model) { + const permission = model?.permission || {}; + const allowed = permission.allowed_effects || []; + const forbidden = permission.forbidden_effects || []; + const current = permission.current_use || []; + return `
Before any action

Permission, scope, limits, and cost

${badge(permission.status || "unknown", permission.status === "available" ? "ok" : "warn")}
+
Allowed effects

${allowed.map((item) => badge(String(item).replace(/[_:.-]/g, " "), "ok")).join(" ") || "No change effect is currently available."}

Affected scope

${esc(boundedScopeText(permission.scope))}

Expiry and stops

${permission.expires_at ? `Permission ends ${esc(permission.expires_at)}.` : "No expiry value is recorded."}

    ${(permission.stop_conditions || []).map((item) => `
  • ${esc(item)}
  • `).join("")}
Still forbidden

${forbidden.map((item) => badge(String(item).replace(/[_:.-]/g, " "), "issue")).join(" ") || "No explicit exclusion is recorded."}

+

Current consumption: ${current.slice(0, 8).map((item) => `${esc(item.label)} — ${esc(boundedMeasurementText(item.actual))} used, ${esc(boundedMeasurementText(item.remaining))} remaining`).join(" · ") || "No counted consumption is recorded."}

+ ${boundedUsageTable(model)} +
Every limit and measurement${boundedUsageTable(model, true)}

${esc(model?.usage?.legend?.zero || "")} ${esc(model?.usage?.legend?.unbounded || "")} Unknown and not applicable remain separate.

+
`; +} + +function boundedInboxHtml(model) { + const inbox = model?.inbox || []; + return `
Decision and blocker inbox

${inbox.length ? `${esc(inbox.length)} item${inbox.length === 1 ? "" : "s"} need attention` : "Nothing needs a decision right now"}

${badge(inbox.length ? "attention" : "clear", inbox.length ? "warn" : "ok")}
+
${inbox.map((item) => `
${esc(item.kind)}${badge(item.status, item.kind === "refusal" ? "issue" : "warn")}

${esc(item.affected_work)}

Why it cannot proceed
${esc(item.why)}
Who or what resolves it
${esc(item.resolver)}
Valid choices and what follows
    ${(item.valid_choices || []).map((choice) => `
  • ${esc(choice.label)}${choice.available ? "" : " — unavailable"}${esc(choice.effect)} ${esc(choice.after)}
  • `).join("") || "
  • Inspect onlyNo state-changing choice is currently valid; review exact evidence.
  • "}

If you do nothing: ${esc(item.after_no_choice)}

${item.explanation ? boundedFailureDetailsHtml(item.explanation, "Recorded refusal") : ""}
Technical details${esc(item.technical_reference || "no exact reference")}
`).join("") || '

The saved state has no blocker, pending decision, or refusal.

'}
+
`; +} + +function boundedFailureDetailsHtml(explanation, label = "Action could not be completed") { + const item = explanation || {}; + const effect = item.effect_answer === "no" || item.effect_may_have_occurred === false + ? "No — this refusal records no effect." + : item.effect_answer === "yes" || item.effect_may_have_occurred === true + ? "Yes — inspect the saved receipt before another action." + : "Unknown — reload the saved history before another action."; + return ``; +} + +function boundedErrorHtml(message) { + if (!message) return ""; + const refusedBeforeEffect = /before work|no event was appended|no decision was applied|no grant was created/i.test(message); + return boundedFailureDetailsHtml({ + what_happened: message, + what_stayed_unchanged: refusedBeforeEffect + ? "No work or saved event changed at this refusal boundary." + : "The last verified view remains visible; no alternative action was inferred.", + effect_may_have_occurred: refusedBeforeEffect ? false : null, + safe_next_step: "Reload the saved state, inspect the exact history, and preview only a currently available action.", + technical_evidence: { message }, + }); +} + +function boundedActionMatch(model, preview, target) { + if (!preview) return null; + return (model?.actions || []).find((item) => item.action === preview.action + && String(item.decision || "") === String(preview.decision || "") + && String(item.correlation_id || "") === String(target === "program" ? preview.request_id || "" : preview.correlation_id || "")); +} + +function boundedPreviewHtml(model, preview, target) { + if (!preview) return ""; + const action = boundedActionMatch(model, preview, target); + const consequences = action?.consequences || {}; + const applicable = Boolean(preview.applicable); + const exact = target === "run" ? runActPreviewHtml(preview) : `
Exact program preview
state + ledger + parameter token${esc(preview.act_token)}

Observed ${esc(preview.state)} at generation ${esc(preview.generation)} and ledger ${esc(preview.ledger_head)}.

lane: ${esc(preview.operation?.lane || "—")} · next: ${esc(programScalar(preview.operation?.next_action))}

${(preview.issues || []).map((issue) => `

${esc(issue)}

`).join("")}
`; + const refusal = applicable ? "" : boundedFailureDetailsHtml({ + what_happened: (preview.issues || []).join("; ") || "The current saved state refused this preview.", + what_stayed_unchanged: "Previewing changed no work, permission, cost, or saved event.", + effect_may_have_occurred: false, + safe_next_step: "Close this preview and choose only a currently available action.", + technical_evidence: { action: preview.action, issues: preview.issues || [] }, + }, "Preview refused"); + return `
Review before confirmation

${esc(action?.label || preview.action)}

${badge(preview.starts_work ? "may start bounded work" : "one saved action", preview.starts_work ? "warn" : "ok")}
What this will do
${esc(consequences.effect || "Apply only this exact reviewed operation.")}
What it will not do
${esc(consequences.unchanged || "It will not broaden permission or select a different action.")}
What follows
${esc(consequences.after || "The saved state and receipt will be reloaded.")}
${refusal}${exact}
`; +} + +function boundedReceiptsHtml(model, result) { + const receipts = model?.receipts || []; + const resultHtml = result ? `
Just completed${badge("recorded", "ok")}

${esc(result.kind || "Bounded action completed")}

${esc(result.stop || result.state || result.result || result.decision || "The saved operation completed.")}

Exact receipt
${esc(JSON.stringify(result, null, 2))}
` : ""; + return `
After completion

Readable receipts

${badge(`${receipts.length + (result ? 1 : 0)} shown`, "ok")}
${resultHtml}${receipts.map((item) => `
${esc(item.action)}${badge(item.outcome || "recorded", "ok")}

${esc(item.label)}

${esc(item.at || "time recorded in exact history")}

Exact receipt${esc(item.exact_reference || "see ordered history")}
`).join("") || (!result ? '

No bounded action receipt has been recorded yet.

' : "")}
`; +} + +function boundedActionButtonsHtml(model, target) { + const actions = model?.actions || []; + const read = actions.filter((item) => item.kind === "read"); + const controls = actions.filter((item) => item.kind !== "read"); + const controlButton = (item) => { + const attrs = target === "run" + ? `data-run-act="${esc(item.action)}" data-run-decision="${esc(item.decision || "")}" data-run-correlation="${esc(item.correlation_id || "")}"` + : `data-program-act="${esc(item.action)}" data-program-decision="${esc(item.decision || "")}" data-program-request="${esc(item.correlation_id || "")}"`; + return `
${esc(item.kind)}${badge(item.available ? "available" : "unavailable", item.available ? "ok" : "warn")}

${esc(item.label)}

${esc(item.consequences?.effect)}

Then: ${esc(item.consequences?.after)}${item.available ? `` : `

${esc(item.issue)}

`}
`; + }; + return `
${read.map((item) => ``).join("")}
${controls.map(controlButton).join("")}
`; +} + +function boundedActionCenterHtml(model, preview, error, result, target) { + if (!model) return ""; + const available = (model.actions || []).filter((item) => item.available); + const needsReason = available.some((item) => item.reason_required); + const hasSupervise = available.some((item) => item.action === "supervise"); + const reason = target === "run" ? orchState.controlReason : programState.reason; + return `
Actions and decisions

Understand the consequence, then review one exact action

${esc(model.summary)}

${badge("nothing applies without confirmation", "warn")}
+ ${boundedInboxHtml(model)} + ${boundedPermissionHtml(model)} +
Available choices

Pause, resume, stop, cancel, reject, and continue stay distinct

${needsReason ? `` : ""}${hasSupervise ? `
` : ""}${boundedActionButtonsHtml(model, target)}
+ ${boundedErrorHtml(error)} + ${boundedPreviewHtml(model, preview, target)} + ${boundedReceiptsHtml(model, result)} +
`; +} + function liveActivityHtml(progress) { const activity = progress.activity || []; return `
Readable activityRelated work and outcomes grouped together
${badge(`${activity.length} groups`)}
    ${activity.map((item) => `
  1. ${esc(item.title)}${badge(item.status, ["active", "complete"].includes(item.status) ? "ok" : ["blocked"].includes(item.status) ? "issue" : "warn")}

    ${esc(item.summary || "")}

    ${(item.outcomes || []).length ? `Outcomes: ${esc(item.outcomes.join(", "))}` : ""}
  2. `).join("") || "
  3. No delivery activity has been recorded yet.

  4. "}
`; } -function liveProgressShell(progress, connection, toolbar, technicalHtml, technicalOpen = false) { +function liveProgressShell(progress, connection, toolbar, actionHtml, technicalHtml, technicalOpen = false) { const ordinary = `
Delivery state${esc(progress.status?.label)}

${esc(progress.status?.meaning)}

Current scope${esc(progress.delivery?.scope || "")}

${esc(progress.delivery?.current_story || progress.delivery?.work_id || "")}

${liveAnswerGrid(progress)} ${liveNextHtml(progress)} + ${actionHtml} ${liveProgressGroups(progress)}
${livePeopleHtml(progress)}${liveReviewHtml(progress)}
${liveLimitsHtml(progress)} @@ -1726,11 +1851,73 @@ function openLiveTechnical() { details.querySelector("summary")?.focus(); } +async function handleBoundedRead(action, target) { + if (action === "reload") { + if (target === "run") await refreshRunData(); + else await refreshProgramView(); + return; + } + if (action === "technical") { + openLiveTechnical(); + return; + } + if (action === "leave") { + if (target === "run") { + orchState.runAct = null; orchState.runError = ""; + renderOrchestration(); + } else { + programState.act = null; programState.error = ""; + renderPrograms(); + } + return; + } + const selector = action === "limits" + ? '[data-bounded-section="limits"]' + : '[data-bounded-section="failure"]'; + const section = document.querySelector(selector); + section?.scrollIntoView({ + behavior: SNAPSHOT_MODE ? "auto" : "smooth", + block: "start", + }); + section?.querySelector("h3, h4")?.setAttribute("tabindex", "-1"); + section?.querySelector("h3, h4")?.focus(); +} + +function focusBoundedSnapshot() { + if (!SNAPSHOT_MODE || !SNAPSHOT_BOUNDED_FOCUS) return; + const selectors = { + actions: ".bounded-action-center", + inbox: ".bounded-inbox", + limits: ".bounded-permission", + preview: ".bounded-preview", + error: ".bounded-failure", + receipts: ".bounded-receipts", + }; + const focus = () => { + const target = document.querySelector( + selectors[SNAPSHOT_BOUNDED_FOCUS] || ".bounded-action-center" + ); + if (!target) return; + const center = target.closest(".bounded-action-center"); + const live = center?.closest(".live-delivery"); + const hero = center?.querySelector(".bounded-action-hero"); + if (center && target !== center && hero) hero.after(target); + const header = live?.querySelector(".live-header"); + if (center && header) header.after(center); + const top = target.getBoundingClientRect().top + window.scrollY - 8; + window.scrollTo({ top: Math.max(0, top), behavior: "auto" }); + }; + focus(); + requestAnimationFrame(() => requestAnimationFrame(focus)); + setTimeout(focus, 100); +} + function runViewHtml() { if (orchState.runLoading) return `
${stateHtml("Replaying the authoritative run ledger…")}
`; const error = orchState.runError ? `` : ""; if (!orchState.runs.length || !orchState.runView) return `
${error}${runEmptyHtml()}
`; const view = orchState.runView; + const actions = boundedActionCenterHtml(view.bounded_actions, orchState.runAct, orchState.runError, orchState.runResult, "run"); const toolbar = `
`; const technical = `
exact state${esc(view.state)}${esc(view.terminal_meaning)}
ledger${esc(view.ledger_events)} events${esc(view.ledger_head)}
attempts${esc(view.attempts.active.length)} active · ${esc(view.attempts.completed.length)} completegeneration ${esc(view.control_generation)}
authority${view.dispatch_allowed ? "dispatch permitted" : "dispatch stopped"}${view.expired ? "grant expired" : "grant fresh by time"}
${runBudgetHtml(view.budgets)} @@ -1739,10 +1926,10 @@ function runViewHtml() {
declared output conventionsArtifact metadata and lineage
${runArtifactHtml(view)}
typed human request portsOutstanding requests, age, origin, schemas, and checkpoint lineage
${badge("inspect-only history", "ok")}
${runRequestsHtml(view)}
${runRoutesHtml(view)}
- ${runControlsHtml(view)}${runActPreviewHtml(orchState.runAct)} + ${runControlsHtml(view)}
operator notificationsDerived from the ledger and signal chains; ack is receipted
${badge("previews, never tokens", "ok")}
${runNotificationsHtml(view)}
hash-chained receiptsRun ledger timeline
${badge("content-safe metadata", "ok")}
${runTimelineHtml(view)}
`; - return `
${error}${liveProgressShell(view.live_progress, orchState.runConnection, toolbar, technical, Boolean(orchState.runAct || orchState.runStream))}
`; + return `
${liveProgressShell(view.live_progress, orchState.runConnection, toolbar, actions, technical, Boolean(orchState.runStream))}
`; } function runNotificationsHtml(view) { @@ -2119,7 +2306,7 @@ async function confirmRunGrant() { async function previewRunAct(action, decision, correlation) { const control = (orchState.runView?.controls || []).find((item) => item.action === action && String(item.decision || "") === String(decision || "") && String(item.correlation_id || "") === String(correlation || "")); const reason = control?.reason_required ? orchState.controlReason.trim() : ""; - orchState.runAct = null; orchState.runError = ""; renderOrchestration(); + orchState.runAct = null; orchState.runError = ""; orchState.runResult = null; renderOrchestration(); const { status, body } = await postJson("/api/runs/preview", { run_id: orchState.runId, action, ...(reason ? { reason } : {}), ...(decision ? { decision } : {}), ...(correlation ? { correlation_id: correlation } : {}) }); if (status >= 400 || body.ok === false) { orchState.runError = (body.issues && body.issues[0]) || `run preview failed (${status})`; } else orchState.runAct = body.data; @@ -2135,7 +2322,7 @@ async function confirmRunAct() { orchState.runLoading = false; if (status === 409) { orchState.runAct = null; orchState.runError = "Stale run act refused before work or ledger change. Refresh once and preview the current state."; renderOrchestration(); return; } if (status >= 400 || body.ok === false) { orchState.runError = (body.issues && body.issues[0]) || `run act failed (${status})`; renderOrchestration(); return; } - orchState.runAct = null; orchState.controlReason = ""; await refreshRunData(); + orchState.runResult = body.data; orchState.runAct = null; orchState.controlReason = ""; await refreshRunData(); } async function openRunStream(button) { @@ -2149,7 +2336,7 @@ async function openRunStream(button) { function wireRunView() { document.getElementById("run-refresh")?.addEventListener("click", refreshRunData); document.querySelector("[data-live-technical]")?.addEventListener("click", openLiveTechnical); - document.getElementById("run-select")?.addEventListener("change", async (event) => { orchState.runId = event.target.value; orchState.runAct = null; orchState.runStream = null; await refreshRunData(); }); + document.getElementById("run-select")?.addEventListener("change", async (event) => { orchState.runId = event.target.value; orchState.runAct = null; orchState.runResult = null; orchState.runStream = null; await refreshRunData(); }); document.getElementById("run-grant-form")?.addEventListener("submit", (event) => { event.preventDefault(); previewRunGrant(event.currentTarget); }); document.getElementById("run-start-confirm")?.addEventListener("click", confirmRunGrant); document.getElementById("run-plan-close")?.addEventListener("click", () => { orchState.runPlan = null; renderOrchestration(); }); @@ -2157,6 +2344,7 @@ function wireRunView() { document.querySelectorAll("[data-run-act]").forEach((button) => button.addEventListener("click", () => previewRunAct(button.dataset.runAct, button.dataset.runDecision, button.dataset.runCorrelation))); document.getElementById("run-act-confirm")?.addEventListener("click", confirmRunAct); document.getElementById("run-act-close")?.addEventListener("click", () => { orchState.runAct = null; renderOrchestration(); }); + document.querySelectorAll("[data-bounded-read]").forEach((button) => button.addEventListener("click", () => handleBoundedRead(button.dataset.boundedRead, "run"))); document.querySelectorAll("[data-run-stream]").forEach((button) => button.addEventListener("click", () => openRunStream(button))); document.querySelectorAll("[data-ntf-ack]").forEach((button) => button.addEventListener("click", () => ackNotification(button.dataset.ntfAck))); document.getElementById("run-stream-close")?.addEventListener("click", () => { orchState.runStream = null; renderOrchestration(); }); @@ -2206,6 +2394,7 @@ async function viewOrchestration(name) { orchState.score = minimalScore(); orchState.name = orchState.score.slug; orchState.exists = false; orchState.preview = null; } orchState.selected = null; orchState.jsonDraft = ""; + orchState.runAct = null; orchState.runResult = null; orchState.runError = ""; selectScoreRuns(); const requestedView = new URLSearchParams(location.search).get("orchview"); if (["design", "validate", "json", "run"].includes(requestedView)) orchState.view = requestedView; @@ -2213,12 +2402,41 @@ async function viewOrchestration(name) { try { orchState.runView = (await api(`/api/runs/${encodeURIComponent(orchState.runId)}/view`)).data; orchState.runConnection.status = SNAPSHOT_LIVE_STATE === "stale" ? "stale" : SNAPSHOT_MODE ? "verified" : "checking"; + if (SNAPSHOT_MODE && SNAPSHOT_BOUNDED_PREVIEW) { + const control = (orchState.runView.controls || []).find((item) => item.available && ( + SNAPSHOT_BOUNDED_PREVIEW === "decision" + ? item.action === "request" && item.decision === "approve" + : item.action === SNAPSHOT_BOUNDED_PREVIEW + )); + if (control) { + orchState.controlReason = control.reason_required + ? "Review this deterministic viewport action." + : ""; + const response = await postJson("/api/runs/preview", { + run_id: orchState.runId, + action: control.action, + ...(orchState.controlReason ? { reason: orchState.controlReason } : {}), + ...(control.decision ? { decision: control.decision } : {}), + ...(control.correlation_id ? { correlation_id: control.correlation_id } : {}), + }); + if (response.status < 400 && response.body.ok !== false) { + orchState.runAct = response.body.data; + } + } + } + if (SNAPSHOT_MODE && SNAPSHOT_BOUNDED_ERROR) { + orchState.runError = SNAPSHOT_BOUNDED_ERROR === "stale" + ? "Stale run action refused before work or saved event change. Reload once and review the current action." + : "The action response ended without a confirmed receipt."; + } } catch (err) { orchState.runError = err.message; orchState.runView = null; } startRunLive(); } renderOrchestration(); + focusBoundedSnapshot(); await refreshOrchValidation(); + focusBoundedSnapshot(); } /* ── autonomous program control room (WLA-26-11) ───────────────── @@ -2345,24 +2563,14 @@ function programQualityHtml(view) { } function programControlsHtml(view) { - const available = (view.controls || []).filter((item) => item.available); - const unavailable = (view.controls || []).filter((item) => !item.available); - return `
separate act boundaryPreview, inspect, then confirm one exact program operation
${badge("no auto-start daemon", "warn")}
- ${available.some((item) => item.reason_required) ? `` : ""} - ${available.some((item) => item.action === "supervise") ? `
` : ""} -
${available.map((item) => ``).join("") || 'No control is applicable in this authority state.'}
-
${unavailable.map((item) => `
${esc(item.action)}${esc(item.issue || "not applicable in the current authority state")}
`).join("")}
- ${programActHtml(programState.act)} + return `
exact control catalogApplicability copied from the current saved program
${badge("inspection only", "ok")}
+
${(view.controls || []).map((item, index) => `
${esc(item.action)}${item.decision ? ` · ${esc(item.decision)}` : ""}${item.available ? "available through the ordinary action review above" : esc(item.issue || "not applicable in the current authority state")}/controls/${esc(index)}
`).join("")}
`; } function programActHtml(preview) { if (!preview) return ""; - return ``; + return `
Exact program preview
state + ledger + parameter token${esc(preview.act_token)}

Observed ${esc(preview.state)} at generation ${esc(preview.generation)} and ledger ${esc(preview.ledger_head)}.

lane: ${esc(preview.operation?.lane || "—")} · next: ${esc(programScalar(preview.operation?.next_action))}

${(preview.issues || []).map((issue) => `

${esc(issue)}

`).join("")}
`; } function programTimelineHtml(view) { @@ -2381,6 +2589,7 @@ function programNotificationsHtml(view) { function programRunHtml(view) { const runs = programState.inventory?.runs || []; const progress = view.phase_progress || {}; + const actions = boundedActionCenterHtml(view.bounded_actions, programState.act, programState.error, programState.result, "program"); const toolbar = `
`; const technical = `${programState.result ? `
bounded operation completed${esc(programState.result.kind)} · ${esc(programState.result.stop || programState.result.state || programState.result.result || "recorded")}
` : ""}
authority${esc(view.state)}${esc(view.terminal_meaning)}
operational frontier${esc(view.operational_state)}${esc(view.current?.stop || "ready")}
ledger${esc(view.event_count)} events${esc(view.ledger_head)}
scope progress${esc((progress.selected_stories || []).length)} selected${esc(programScalar(progress.scope_completion))}
@@ -2393,7 +2602,7 @@ function programRunHtml(view) { ${programControlsHtml(view)}
phase and authority boundaryGranted scope, selected progress, capabilities, and permanent exclusions

phase progress

${esc(JSON.stringify(progress, null, 2))}

capabilities

${(view.capabilities || []).map((item) => badge(item, "ok")).join(" ") || "none"}

permanently excluded

${(view.permanent_exclusions || []).map((item) => badge(item, "warn")).join(" ") || "none"}

hash-chained receiptsProgram authority timeline
${badge("content-safe metadata", "ok")}
${programTimelineHtml(view)}
`; - return `
${programState.error ? `` : ""}${liveProgressShell(view.live_progress, programState.connection, toolbar, technical, Boolean(programState.act || programState.stream))}
`; + return `
${liveProgressShell(view.live_progress, programState.connection, toolbar, actions, technical, Boolean(programState.stream))}
`; } function renderPrograms() { @@ -2535,7 +2744,9 @@ async function confirmProgramAct() { const { status, body } = await postJson(`/api/programs/${encodeURIComponent(preview.action)}`, request); if (status >= 400 || body.ok === false) { programState.act = null; - programState.error = (body.issues && body.issues[0]) || `program act failed (${status})`; + programState.error = status === 409 + ? "Stale program action refused before work or saved event change. Reload once and review the current action." + : (body.issues && body.issues[0]) || `program act failed (${status})`; renderPrograms(); return; } programState.result = body.data; programState.act = null; programState.reason = ""; @@ -2573,6 +2784,7 @@ function wirePrograms() { document.querySelectorAll("[data-program-act]").forEach((button) => button.addEventListener("click", () => previewProgramAct(button))); document.getElementById("program-act-confirm")?.addEventListener("click", confirmProgramAct); document.getElementById("program-act-close")?.addEventListener("click", () => { programState.act = null; renderPrograms(); }); + document.querySelectorAll("[data-bounded-read]").forEach((button) => button.addEventListener("click", () => handleBoundedRead(button.dataset.boundedRead, "program"))); document.querySelectorAll("[data-program-stream]").forEach((button) => button.addEventListener("click", () => openProgramStream(button))); document.querySelectorAll("[data-program-ntf-ack]").forEach((button) => button.addEventListener("click", () => ackProgramNotification(button.dataset.programNtfAck))); document.getElementById("program-stream-close")?.addEventListener("click", () => { programState.stream = null; renderPrograms(); }); @@ -2590,9 +2802,40 @@ async function viewPrograms(runId = "") { if (runId) { programState.view = (await api(`/api/programs/${encodeURIComponent(runId)}/view`)).data; programState.connection.status = SNAPSHOT_LIVE_STATE === "stale" ? "stale" : SNAPSHOT_MODE ? "verified" : "checking"; + if (SNAPSHOT_MODE && SNAPSHOT_BOUNDED_PREVIEW) { + const control = (programState.view.controls || []).find((item) => item.available && ( + SNAPSHOT_BOUNDED_PREVIEW === "decision" + ? item.action === "request" && item.decision === "approve" + : item.action === SNAPSHOT_BOUNDED_PREVIEW + )); + if (control) { + programState.reason = control.reason_required + ? "Review this deterministic viewport action." + : ""; + const response = await postJson("/api/programs/preview", { + run_id: programState.runId, + action: control.action, + ...(programState.reason ? { reason: programState.reason } : {}), + ...(control.decision ? { decision: control.decision } : {}), + ...(control.request_id ? { request_id: control.request_id } : {}), + ...(["tick", "supervise"].includes(control.action) ? { + max_ticks: 100, max_seconds: 300, + } : {}), + }); + if (response.status < 400 && response.body.ok !== false) { + programState.act = response.body.data; + } + } + } + if (SNAPSHOT_MODE && SNAPSHOT_BOUNDED_ERROR) { + programState.error = SNAPSHOT_BOUNDED_ERROR === "stale" + ? "Stale program action refused before work or saved event change. Reload once and review the current action." + : "The action response ended without a confirmed receipt."; + } startProgramLive(); } renderPrograms(); + focusBoundedSnapshot(); } /* ── delivery-shaped front door (WLA-27-03) ──────────────────────── diff --git a/.githooks/workbench/style.css b/.githooks/workbench/style.css index ddf878b..44fd582 100644 --- a/.githooks/workbench/style.css +++ b/.githooks/workbench/style.css @@ -1152,6 +1152,136 @@ details.blane.closed[open] summary { margin-bottom: 8px; } } .live-technical-intro { color: var(--muted); } +/* ── understandable bounded actions (WLA-27-07) ──────────────── */ +.bounded-action-center { + margin-top: 10px; padding: 14px; border: 1px solid var(--warn); + border-radius: 10px; + background: + radial-gradient(circle at 100% 0, rgba(214,164,75,.11), transparent 31%), + linear-gradient(130deg, rgba(108,182,255,.045), var(--panel) 48%); +} +.bounded-action-hero, .bounded-section-head { + display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; +} +.bounded-action-hero > div, .bounded-section-head > div { min-width: 0; } +.bounded-action-hero > div > span, .bounded-section-head span, +.bounded-permission-grid article > span, .bounded-inbox-item > div > span, +.bounded-action-card > div > span, .bounded-receipt-grid article > div > span { + display: block; color: var(--muted); font: 9px/1.25 var(--mono); + letter-spacing: .07em; text-transform: uppercase; +} +.bounded-action-hero h2 { margin: 4px 0; font-size: 19px; } +.bounded-action-hero p { margin: 3px 0 0; color: var(--muted); } +.bounded-action-center > section { + margin-top: 10px; padding: 11px; border: 1px solid var(--line); + border-radius: 7px; background: rgba(16,20,24,.48); +} +.bounded-section-head h3 { margin: 3px 0 0; font-size: 14px; } +.bounded-inbox-grid, .bounded-action-grid, .bounded-receipt-grid { + display: grid; grid-template-columns: repeat(auto-fit, minmax(235px, 1fr)); + gap: 8px; margin-top: 9px; +} +.bounded-inbox-item, .bounded-action-card, .bounded-receipt-grid article { + min-width: 0; padding: 10px; border: 1px solid var(--line); + border-left: 3px solid var(--warn); border-radius: 6px; background: var(--panel-2); +} +.bounded-inbox-item.kind-refusal, .bounded-action-card.severity-danger { + border-left-color: var(--err); +} +.bounded-inbox-item > div, .bounded-action-card > div, +.bounded-receipt-grid article > div { + display: flex; align-items: center; justify-content: space-between; gap: 7px; +} +.bounded-inbox-item h4, .bounded-action-card h4, +.bounded-receipt-grid h4 { margin: 7px 0 5px; } +.bounded-inbox-item h5 { + margin: 9px 0 4px; color: var(--muted); font: 10px/1.3 var(--mono); + text-transform: uppercase; +} +.bounded-inbox-item dl, .bounded-failure dl, .bounded-consequence { + display: grid; gap: 6px; margin: 7px 0; +} +.bounded-inbox-item dl > div, .bounded-failure dl > div, +.bounded-consequence > div { + padding: 7px; border: 1px solid var(--line); border-radius: 4px; + background: rgba(12,16,20,.35); +} +.bounded-inbox-item dt, .bounded-failure dt, .bounded-consequence dt { + color: var(--muted); font: 9px/1.25 var(--mono); text-transform: uppercase; +} +.bounded-inbox-item dd, .bounded-failure dd, .bounded-consequence dd { + margin: 4px 0 0; overflow-wrap: anywhere; +} +.bounded-inbox-item ul { margin: 5px 0; padding-left: 18px; } +.bounded-inbox-item li { margin: 5px 0; } +.bounded-inbox-item li strong, .bounded-inbox-item li span { display: block; } +.bounded-inbox-item li span, .bounded-inbox-item p, +.bounded-inbox-item details { color: var(--muted); font-size: 10px; } +.bounded-permission { border-color: var(--link) !important; } +.bounded-permission-grid { + display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 7px; margin-top: 9px; +} +.bounded-permission-grid article { + min-width: 0; padding: 9px; border: 1px solid var(--line); + border-radius: 5px; background: var(--panel-2); +} +.bounded-permission-grid p { margin: 6px 0; overflow-wrap: anywhere; } +.bounded-permission-grid ul { margin: 5px 0 0; padding-left: 18px; color: var(--muted); } +.bounded-permission-grid .badge { margin: 0 3px 4px 0; } +.bounded-current-use { + margin: 8px 0; padding: 8px; border-left: 3px solid var(--link); + color: var(--muted); background: rgba(108,182,255,.04); +} +.bounded-current-use strong, .bounded-current-use small { display: inline; } +.bounded-usage-table { margin-top: 8px; } +.bounded-usage-table table { min-width: 720px; } +.bounded-usage-table td strong, .bounded-usage-table td small { + display: block; overflow-wrap: anywhere; +} +.bounded-usage-table td small { color: var(--muted); } +.bounded-all-usage { margin-top: 8px; color: var(--muted); } +.bounded-all-usage summary, .bounded-exact-preview summary, +.bounded-receipt-grid summary, .bounded-inbox-item summary, +.bounded-failure summary { + cursor: pointer; color: var(--link); font: 10px/1.3 var(--mono); +} +.bounded-read-actions { + display: flex; flex-wrap: wrap; gap: 6px; margin: 9px 0; +} +.bounded-read-actions button { + display: grid; max-width: 260px; gap: 3px; text-align: left; +} +.bounded-read-actions button span { color: var(--muted); font-size: 9px; } +.bounded-action-card p { margin: 5px 0; color: var(--muted); } +.bounded-action-card > small { display: block; min-height: 35px; color: var(--muted); } +.bounded-action-card button { width: 100%; margin-top: 9px; } +.bounded-action-card .starts-work { color: var(--warn); border-color: var(--warn); } +.bounded-action-card .danger, .bounded-preview .danger { + color: var(--err); border-color: var(--err); +} +.bounded-action-card.unavailable { opacity: .78; border-left-color: #52606e; } +.bounded-action-issue { + padding-top: 6px; border-top: 1px solid var(--line); color: var(--warn) !important; +} +.bounded-preview { border-color: var(--warn) !important; } +.bounded-preview.refused, .bounded-failure { + border-color: var(--err) !important; background: rgba(229,83,75,.055) !important; +} +.bounded-exact-preview { + margin-top: 9px; padding: 8px; border: 1px solid var(--line); + border-radius: 5px; background: var(--bg); +} +.bounded-exact-preview code, .bounded-receipt-grid code { + display: block; overflow-wrap: anywhere; +} +.bounded-failure { margin-top: 10px; padding: 10px; border: 1px solid var(--err); border-radius: 6px; } +.bounded-failure h4 { margin: 0 0 7px; color: var(--err); } +.bounded-failure pre { max-height: 180px; overflow: auto; white-space: pre-wrap; } +.bounded-result { border-left-color: var(--accent) !important; } +.bounded-receipt-grid article p { color: var(--muted); } +.exact-control-audit code { display: block; color: var(--muted); } + @media (max-width: 900px) { .orch-toolbar { align-items: flex-start; flex-direction: column; } .orch-score-actions { justify-content: flex-start; } @@ -1169,6 +1299,7 @@ details.blane.closed[open] summary { margin-bottom: 8px; } .live-answers { grid-template-columns: repeat(2, minmax(0, 1fr)); } .live-answers article[data-answer] { grid-column: span 1; } .live-next, .live-two-column { grid-template-columns: 1fr; } + .bounded-permission-grid { grid-template-columns: 1fr; } } @media (max-width: 520px) { @@ -1214,6 +1345,10 @@ details.blane.closed[open] summary { margin-bottom: 8px; } .live-work-groups { grid-template-columns: 1fr; } .live-panel { padding: 10px; } .live-recovery > div { align-items: flex-start; flex-direction: column; } + .bounded-action-center { padding: 9px; } + .bounded-action-hero, .bounded-section-head { align-items: flex-start; flex-direction: column; } + .bounded-inbox-grid, .bounded-action-grid, .bounded-receipt-grid { grid-template-columns: 1fr; } + .bounded-read-actions, .bounded-read-actions button { width: 100%; max-width: none; } } /* ── autonomous program control room (WLA-26-11) ──────────────── */ diff --git a/README.md b/README.md index 61f3cec..fd54e79 100644 --- a/README.md +++ b/README.md @@ -193,9 +193,12 @@ The optional `dw program` namespace now spans pure `preview`→`tick|supervise|request|pause|resume|revoke|cancel`, verified `tail`, and bounded `stream`. MCP, localhost HTTP, SSE, and the progressively disclosed Workbench `#/programs` control room adapt the same content-safe -projection and exact-token acts. No program configuration remains a healthy -ordinary mode, and opening a read or the Workbench starts no program, poller, -stream, process, or notification. +projection and exact-token acts. Run and program control rooms now lead each +bounded action with affected work, permission, limits, cost, and distinct +consequences; decisions come only from the exact outstanding response set, +and refusals name unchanged state plus a safe recovery step. No program +configuration remains a healthy ordinary mode, and opening a read or the +Workbench starts no program, poller, stream, process, or notification. Parked work is first-class: a story goes on-hold only with a recorded reason, whole phases pause and resume (`dw phase pause diff --git a/docs/interop.md b/docs/interop.md index 6eea603..69dc00a 100644 --- a/docs/interop.md +++ b/docs/interop.md @@ -93,6 +93,7 @@ is added when an external consumer asks for one. | Delivery-plan authoring view | `delivery-workbench-delivery-plan-authoring` v1 | `plan_authoring.build_delivery_plan_authoring` | | Team-and-review application view | `delivery-workbench-team-review` v1 | `team_review.build_team_review` / `team_review.build_live_team_review` | | Live delivery progress view | `delivery-workbench-live-progress` v1 | `live_progress.build_run_live_progress` / `live_progress.build_program_live_progress` | +| Bounded delivery actions view | `delivery-workbench-bounded-actions` v1 | `bounded_actions.build_run_bounded_actions` / `bounded_actions.build_program_bounded_actions` | | Program Studio mutation preview | `delivery-workbench-program-studio-mutation-preview` v1 | `program_studio.studio_mutation_preview` | | Program Studio mutation result | `delivery-workbench-program-studio-mutation-result` v1 | `program_studio.apply_studio_mutation` | | Program frontier | `delivery-workbench-program-frontier` v1 | `program_conductor.derive_program_frontier` | @@ -150,7 +151,7 @@ is added when an external consumer asks for one. | `dw program plan --json` | `programs.build_program_plan` | without `--mode`: repository/roadmap snapshot, selected story, workflow/team/roles, policy/roster hashes, and complete derivation; pure | | `dw program plan --mode … --json` | `program_run.build_program_start_plan` | pure exact finite-grant preview with a single-use `start_token`; creates no grant or child | | `dw program start --plan --expect --approve --json` | `program_surface.start_program_by_id` | rebuilds the reviewed plan from its ids and bounded scalar request, then issues exactly one local grant; starts no child | -| `dw program show --json` | `program_surface.build_program_view` | canonical content-safe control-room projection with the shared seven-question live-progress view plus exact lineage, organization, activity, quality/dissent, gates, obligations, deliveries, limits, controls, and verified timeline | +| `dw program show --json` | `program_surface.build_program_view` | canonical content-safe control-room projection with the shared live-progress and bounded-actions views plus exact lineage, organization, activity, quality/dissent, gates, obligations, deliveries, limits, controls, and verified timeline | | `dw program preview --json` | `program_surface.build_program_act_preview` | pure action/closed-parameters/ledger-bound preview and exact `act_token` | | `dw program tick --expect --json` | `program_surface.apply_program_act` | exactly one conductor, delivery-plan, or delivery tick through the existing grant and ledger | | `dw program supervise --max-ticks --max-seconds --expect --json` | `program_surface.apply_program_act` | explicit finite repetition of the same public tick; returns every tick and stops on no-progress, checkpoint, refusal, budget, duration, or terminal state | @@ -161,7 +162,7 @@ is added when an external consumer asks for one. | `dw program cancel --reason --expect --json` | `program_surface.apply_program_act` | one ledgered cancellation before bounded interruption | | `dw program tail [--after N] [--follow]` | `program_surface.tail_program_events` | verified canonical ledger suffix; `--json` returns the stamped bounded tail and is intentionally incompatible with follow | | `dw program stream stdout\|stderr --json` | `program_surface.read_program_stream` | one explicitly opened content-safe log, independently bounded to 100,000 bytes | -| `dw notifications list --json` | `notifications.build_notifications` | derived operator notifications (pending/republished/expired requests, terminals, blocked stops, opt-in branch signals) with unread and delivery state; pure (exit 2 when none) | +| `dw notifications list --json` | `notifications.build_notifications` | derived operator notifications (pending/republished/expired requests, terminals, blocked stops, opt-in branch signals) with affected work, exact response guidance, unread, and delivery state; pure (exit 2 when none) | | `dw notifications ack ` | `notifications.acknowledge_notification` | idempotent, receipted acknowledgement in the local ack log | | `dw notifications delivered [--channel C] [--failed reason]` | `notifications.record_delivery` | one recorded delivery-attempt outcome for a channel consumer (ceiling-bounded retries) | | `dw signals list [--remote R] [--branch B] --json` | `signals.build_signals_inventory` | observed channels with hash-chained facts and read-time derived status; pure (exit 2 when none) | @@ -169,7 +170,7 @@ is added when an external consumer asks for one. | `dw run plan --project --story --json` | `orchestration_run.build_run_plan` | pure exact score/repository/status/story/capability/budget/expiry binding plus single-use start token | | `dw run start --plan --expect --approve --operator --json` | `orchestration_run.start_run` | immutable local grant and initial hash-chained projection; no node dispatch | | `dw run list|show [] --json` | `orchestration_run.run_inventory/replay_run` | authoritative ledger-derived projections, including outstanding requests and their history; disposable cache is ignored | -| `dw run view --json` | `orchestration_surface.build_run_view` | pure content-safe seven-question live-progress view plus exact graph, attempts, sessions/checks, artifact lineage, limits, routes, outstanding requests, inspect-only decision lineage, controls, and ledger | +| `dw run view --json` | `orchestration_surface.build_run_view` | pure content-safe live-progress and bounded-actions views plus exact graph, attempts, sessions/checks, artifact lineage, limits, routes, outstanding requests, inspect-only decision lineage, controls, and ledger | | `dw run preview --json` | `orchestration_surface.build_run_act_preview` | pure action+parameters+correlation+ledger-bound consent document and exact `act_token` | | `dw run pause|resume|revoke|cancel --expect --json` | `orchestration_surface.apply_run_act` | one exact preview-confirm lifecycle transition that immediately gates future dispatch | | `dw run tick --expect --json` | `orchestration_surface.apply_run_act` → `orchestration_conductor.tick_run` | one explicitly confirmed replay/reconcile/route/schedule boundary with exact receipts and no hidden continuation | @@ -240,7 +241,7 @@ or provider argv. | `POST /api/program-studio/preview` | `program_studio.build_studio_mutation_plan` | one selected policy save/delete diff, compiler projections and stale fingerprint; no grant/run/agent/check/roadmap effect | | `POST /api/program-studio/apply` | `program_studio.apply_studio_mutation` | one fresh direct-contained policy save/delete with read-back validation and explicit false runtime effects | | `GET /api/programs` | `program_surface.program_summary_inventory` | healthy empty policy/run inventory in ordinary mode, otherwise canonical content-safe run summaries; pure | -| `GET /api/programs/` / `GET /api/programs//view` | `program_surface.build_program_view` | the same canonical control-room document returned by CLI and MCP, including the shared live-progress application view | +| `GET /api/programs/` / `GET /api/programs//view` | `program_surface.build_program_view` | the same canonical control-room document returned by CLI and MCP, including the shared live-progress and bounded-actions application views | | `GET /api/programs//act/` / `POST /api/programs/preview` | `program_surface.build_program_act_preview` | pure exact action preview; POST carries bounded reason/decision/request/ceiling fields outside the URL | | `GET /api/programs//tail?after=N&limit=N` | `program_surface.tail_program_events` | stamped bounded verified ledger suffix; no token or mutation authority | | `GET /api/programs//streams//` | `program_surface.read_program_stream` | one explicit independently bounded session log; never included in list/view/event payloads | @@ -257,7 +258,7 @@ or provider argv. | `GET /api/run-plan?score=…&project=…&story=…` | `orchestration_run.build_run_plan` | exact pure grant/start preview; identifiers and timestamps only | | `GET /api/runs` | `orchestration_run.run_inventory` | authoritative local projections; no prompts, argv, source, transcripts, or artifact bytes | | `GET /api/runs/` | `orchestration_run.replay_run` | byte-identical projection in `data` | -| `GET /api/runs//view` | `orchestration_surface.build_run_view` | pure live explanation/consent model used by Workbench Run | +| `GET /api/runs//view` | `orchestration_surface.build_run_view` | pure live-progress and bounded-actions explanation used by Workbench Run; exact consent remains a separate preview | | `GET /api/runs//act/` / `POST /api/runs/preview` | `orchestration_surface.build_run_act_preview` | exact pure act preview; POST carries bounded reason/decision/correlation fields and keeps them out of the address bar | | `POST /api/runs/start` | `orchestration_surface.start_run_by_id` | identifiers/timestamps/token/approval only; grant creation dispatches nothing | | `POST /api/runs/tick`, `POST /api/runs/pause`, `POST /api/runs/resume`, `POST /api/runs/revoke`, `POST /api/runs/cancel`, `POST /api/runs/request`, `POST /api/runs/checkpoint` | `orchestration_surface.apply_run_act` | exact preview token plus only its bound reason/decision/correlation; stale is HTTP 409; checkpoint is a compatibility alias | diff --git a/docs/product-language-contract-v1.json b/docs/product-language-contract-v1.json index 02b74ac..4795a5c 100644 --- a/docs/product-language-contract-v1.json +++ b/docs/product-language-contract-v1.json @@ -439,7 +439,7 @@ "id": "workbench-bounded-delivery", "channel": "workbench", "classification": "mixed", - "sources": ["pmo-roadmap/workbench/app.js", "pmo-roadmap/lib/dw_pmo/live_progress.py", "pmo-roadmap/lib/dw_pmo/orchestration_surface.py"], + "sources": ["pmo-roadmap/workbench/app.js", "pmo-roadmap/lib/dw_pmo/bounded_actions.py", "pmo-roadmap/lib/dw_pmo/live_progress.py", "pmo-roadmap/lib/dw_pmo/orchestration_surface.py"], "entry_points": ["#/orchestration", "#/orchestration/"], "boundary": "Plan, team, work, review, decisions, limits, and progress lead; exact score nodes, run events, tokens, hashes, streams, and policy fields live under Technical details.", "owner_stories": ["WLA-27-04", "WLA-27-05", "WLA-27-06", "WLA-27-07", "WLA-27-08"] @@ -457,7 +457,7 @@ "id": "workbench-live-delivery", "channel": "workbench", "classification": "mixed", - "sources": ["pmo-roadmap/workbench/app.js", "pmo-roadmap/lib/dw_pmo/live_progress.py", "pmo-roadmap/lib/dw_pmo/program_surface.py", "pmo-roadmap/lib/dw_pmo/team_review.py"], + "sources": ["pmo-roadmap/workbench/app.js", "pmo-roadmap/lib/dw_pmo/bounded_actions.py", "pmo-roadmap/lib/dw_pmo/live_progress.py", "pmo-roadmap/lib/dw_pmo/program_surface.py", "pmo-roadmap/lib/dw_pmo/team_review.py"], "entry_points": ["#/programs", "#/programs/"], "boundary": "Progress, ownership, review, blockers, decisions, permission, cost, and next step lead; exact grants, ledgers, hashes, identifiers, events, and streams live under Technical details.", "owner_stories": ["WLA-27-06", "WLA-27-07", "WLA-27-08"] @@ -484,7 +484,7 @@ "id": "cli-bounded-delivery", "channel": "cli", "classification": "mixed", - "sources": ["pmo-roadmap/bin/dw", "pmo-roadmap/lib/dw_pmo/live_progress.py", "pmo-roadmap/lib/dw_pmo/orchestration_surface.py"], + "sources": ["pmo-roadmap/bin/dw", "pmo-roadmap/lib/dw_pmo/bounded_actions.py", "pmo-roadmap/lib/dw_pmo/live_progress.py", "pmo-roadmap/lib/dw_pmo/orchestration_surface.py"], "entry_points": ["dw orchestration", "dw run"], "boundary": "Human output uses delivery plan, team, work, review, decision, permission, progress, cost, and next step; --json and copyable commands retain exact fields and identifiers as Technical details.", "owner_stories": ["WLA-27-04", "WLA-27-05", "WLA-27-06", "WLA-27-07", "WLA-27-08"] @@ -493,7 +493,7 @@ "id": "cli-program-delivery", "channel": "cli", "classification": "mixed", - "sources": ["pmo-roadmap/bin/dw", "pmo-roadmap/lib/dw_pmo/live_progress.py", "pmo-roadmap/lib/dw_pmo/program_surface.py"], + "sources": ["pmo-roadmap/bin/dw", "pmo-roadmap/lib/dw_pmo/bounded_actions.py", "pmo-roadmap/lib/dw_pmo/live_progress.py", "pmo-roadmap/lib/dw_pmo/program_surface.py"], "entry_points": ["dw program", "dw organization", "dw workflow", "dw rubric", "dw notifications"], "boundary": "Task and outcome prose use product language; exact command names, JSON, identifiers, policy vocabulary, and audit tails remain under Technical details.", "owner_stories": ["WLA-27-04", "WLA-27-05", "WLA-27-06", "WLA-27-07", "WLA-27-08"] @@ -511,7 +511,7 @@ "id": "operator-notifications-and-telegram", "channel": "notification", "classification": "mixed", - "sources": ["pmo-roadmap/lib/dw_pmo/notifications.py", "integrations/telegram/dw_telegram/interface.py"], + "sources": ["pmo-roadmap/lib/dw_pmo/notifications.py", "integrations/telegram/dw_telegram/interface.py", "integrations/telegram/dw_telegram/rails.py"], "entry_points": ["derived notifications", "Telegram summaries and buttons"], "boundary": "The notification states affected work, blocker or decision, choices, and next step; exact request identity and transport receipt are secondary technical details and transport never creates permission.", "owner_stories": ["WLA-27-07", "WLA-27-08"] diff --git a/docs/product-language.md b/docs/product-language.md index d5eaa91..f739b18 100644 --- a/docs/product-language.md +++ b/docs/product-language.md @@ -226,6 +226,44 @@ Plain language must become more precise when an action matters. Friendly language never softens a refusal, merges materially different actions, or hides uncertainty. +## Bounded actions and refusals + +Run and program views attach +`delivery-workbench-bounded-actions@1` beside the shared live-progress view. +This pure application document turns already-derived controls, outstanding +requests, blockers, permission, limits, failures, and receipts into one +ordinary action language. It does not decide whether an action applies, +choose a response, mint a confirmation, start work, write an event, grant +permission, change retry policy, or send a notification. + +The default action view follows a fixed order: + +1. show concrete allowed effects, affected scope, finite ceilings, expiry and + stop conditions, measured use, and still-forbidden effects; +2. distinguish limit, estimate, actual use, and remaining capacity for each + unit, preserving zero, unbounded, unknown, and not applicable as different + values; +3. show every decision or blocker with affected work, cause, resolver, the + exact currently valid choices, and what follows both a choice and no choice; +4. distinguish continue/repair, pause, resume, revoke, cancel, reject, and + unavailable retry or permission elevation by their real consequences; +5. explain effects before the existing exact preview/confirmation boundary; + and +6. show a readable receipt after completion with its exact reference under + **Technical details**. + +An error or refusal always states what happened, what stayed unchanged, +whether an effect may already have occurred, the next safe step, and where to +inspect exact evidence. If a transport ends without a conclusive refusal or +receipt, the effect is unknown until saved history is reloaded; the +application must not recommend a blind retry. + +Notifications and remote clients are response carriers only. They may present +or carry one choice from the exact closed response set, but the canonical +local principal, outstanding-request identity, response set, current +ledger/generation, and fresh confirmation remain decisive. A chat response +never creates permission. + ## Versioning `delivery-workbench-application-language@1` is the Phase 27 contract. diff --git a/docs/programs.md b/docs/programs.md index 30296e7..2ecfbce 100644 --- a/docs/programs.md +++ b/docs/programs.md @@ -1525,6 +1525,36 @@ verified view, labels it stale, and offers an explicit refresh that replays the history again. A disconnected snapshot never claims that completed work vanished or that active work ran twice. +### Bounded action application view + +Both control rooms also attach +`delivery-workbench-bounded-actions@1`. It receives the canonical controls, +requests, blockers, permission limits, progress facts, failures, and receipts +that the core has already derived. The view may explain and group those facts, +but reports false for selecting an action or next work, starting work, writing +an event, granting authority, changing retry policy, and sending a +notification. + +The default Workbench action center puts permission and consumption before +state-changing controls. It names allowed effects, exact scope, ceilings, +expiry and stops, measured use, and permanent exclusions. Each measure keeps +limit, estimate, actual, and remaining values separate; zero is never rendered +as unbounded, and a missing value is unknown rather than zero. Decision and +blocker items name affected work, cause, resolver, exact valid choices, the +result of each choice, and what happens if the person leaves it pending. + +Continue, saved repair, pause, resume, permanent revoke, cancel, reject, and +unavailable retry or permission elevation remain materially different +actions. Consequences appear before the existing exact act preview, and the +resulting readable receipt links back to exact history. A refusal names what +happened, what stayed unchanged, whether an effect may already exist, the safe +next step, and exact evidence. An inconclusive transport failure requires +ledger reload, never a blind retry. + +Notification and Telegram presentation may carry one exact closed response +but cannot create authority. Local principal, request identity, response-set, +fresh-token, ledger, and generation checks remain the only decisive boundary. + The Authority and Organization inspectors also project an execution contract: portable logical profiles, exact or closed-fallback execution ports, council seat mandates and perspectives, rule-versus-judge/checkpoint authority, diff --git a/docs/signals.md b/docs/signals.md index 2be491d..5b9d1d4 100644 --- a/docs/signals.md +++ b/docs/signals.md @@ -317,13 +317,15 @@ unread/acknowledged state, acknowledge idempotently, and list identically across CLI, MCP, HTTP, and the Workbench. Delivery rides the existing Telegram surface under Phase-20 per-person -consent, unchanged. An outbound message carries facts, references, and the -pending request's preview document — never a consent token, never an apply -command. A phone reply is only ever the typed response document to a -request port; the decision itself still crosses the local exact-token -boundary. With the channel unconfigured or unreachable, facts still persist -and surface locally; delivery failure is a recorded, ceiling-bounded retry, -never a crash and never a silent drop. +consent, unchanged. An outbound message names affected work, presents only the +pending request's exact closed choices and what follows each one, and carries +facts and references — never a consent token and never an apply command. A +phone reply is only a response carrier to one request port. The local +principal, exact outstanding request, closed response set, current +ledger/generation, and fresh preview token still decide whether the response +is accepted. With the channel unconfigured or unreachable, facts still +persist and surface locally; delivery failure is a recorded, ceiling-bounded +retry, never a crash and never a silent drop. ## Storage and privacy diff --git a/docs/usability-journeys.md b/docs/usability-journeys.md index e700214..04206b2 100644 --- a/docs/usability-journeys.md +++ b/docs/usability-journeys.md @@ -241,6 +241,42 @@ The baseline below remains the dated pre-redesign observation. Its stable capture IDs now exercise the improved routes, allowing later whole-journey comparison without erasing the original finding. +## WLA-27-07 delivered slice + +`failed-review-and-repair`, `blocked-human-decision`, +`remaining-permission-and-cost`, `stop-and-revoke`, `crash-recovery`, and +`technical-inspection` now also use +`delivery-workbench-bounded-actions@1`. The pure application document is +nested in both canonical run and program views, so Workbench, CLI JSON, MCP, +HTTP, and SSE receive the same action facts. + +The action center leads with permission, affected scope, finite ceilings, +expiry and stops, current consumption, and forbidden effects before any +state-changing control. Limit, estimate, actual, and remaining are separate +columns. Finite, zero, explicitly unbounded, unknown, and not applicable are +distinct values, and incomparable units are never added. + +The decision and blocker inbox names affected work, why it cannot proceed, +who or what can resolve it, each choice copied from the exact current request, +what follows each choice, and what happens if the person decides later. +Continue or saved repair, pause, resume, permanent revoke, cancel, reject, and +unavailable retry or permission elevation remain distinct. Every available +change opens its existing exact preview only after its ordinary consequences; +completion adds a readable receipt with exact proof under **Technical +details**. + +Refusals and failures state what happened, what stayed unchanged, whether an +effect may already have occurred, the safe next step, and an exact-inspection +path. An inconclusive transport outcome is unknown until saved history is +reloaded, never permission for an ambiguous retry. Notifications and Telegram +may carry one exact closed response, but local principal, request, response, +freshness, ledger, generation, and confirmation checks remain decisive. + +The reachable-state inventory now contains 23 states. Six deterministic +wide/narrow capture pairs add run decision actions, decision preview, refusal, +program remaining limits, pause preview, and stop receipt. The full browser +harness therefore renders 88 viewports. + ## Current-friction baseline The baseline was captured on 2026-07-24 from the existing canonical UI @@ -252,7 +288,7 @@ DW_UI_CAPTURE_PATTERN='*' \ pmo-roadmap/tests/workbench-ui-smoke.sh ``` -The harness now produces all 76 views and passes. Each mapped state has a +The harness now produces all 88 views and passes. Each mapped state has a 1440×900 desktop capture and a 390×844 mobile capture. The screenshots are reproducible test output under ignored `.tmp/`; the versioned baseline records the observable findings, not machine-specific image bytes. diff --git a/integrations/telegram/dw_telegram/interface.py b/integrations/telegram/dw_telegram/interface.py index 8e6ae7d..5ef1f9a 100644 --- a/integrations/telegram/dw_telegram/interface.py +++ b/integrations/telegram/dw_telegram/interface.py @@ -488,6 +488,7 @@ def _cmd_decision(self, chat_id: int, args: list[str]) -> None: if ( item.get("kind") in { "checkpoint-pending", "request-pending", "request-republished", + "program-intervention-required", } and request.get("correlation_id") == correlation ): @@ -496,8 +497,10 @@ def _cmd_decision(self, chat_id: int, args: list[str]) -> None: if match is None: self._say( chat_id, - "✕ stale or unknown checkpoint correlation id; " - "no decision was applied", + "✕ response refused: the request is stale, closed, or unknown.\n" + "Unchanged: no decision was applied by this refusal and " + "affected work remains in its saved state.\n" + "Next: refresh current notifications before responding again.", ) return options = (match.get("request") or {}).get( @@ -509,16 +512,31 @@ def _cmd_decision(self, chat_id: int, args: list[str]) -> None: "usage: /decision " + "|".join(options), ) return - result, why = self.rails.checkpoint_decide( - repo, str(match.get("run_id", "")), correlation, decision - ) + if match.get("kind") == "program-intervention-required": + result, why = self.rails.program_request_decide( + repo, str(match.get("run_id", "")), correlation, decision + ) + else: + result, why = self.rails.checkpoint_decide( + repo, str(match.get("run_id", "")), correlation, decision + ) if result is None: - self._say(chat_id, f"✕ {why}") + self._say( + chat_id, + f"✕ response refused: {why}\n" + "Unchanged: no alternative response or permission was " + "inferred. If the local command ended after submission, an " + "effect is unknown until the exact ledger is refreshed.\n" + "Next: refresh current notifications and inspect the local " + "receipt before responding again.", + ) return self._say( chat_id, - f"✓ request {decision} applied to {match.get('run_id')} " - f"(state: {result.get('state', 'unknown')})", + f"✓ exact request response recorded: {decision}\n" + f"Affected run: {match.get('run_id')}\n" + f"Saved state: {result.get('state', 'unknown')}\n" + f"Receipt: {result.get('receipt_hash', 'inspect local exact history')}", ) def push_notifications(self, repo) -> tuple[int, int]: diff --git a/integrations/telegram/dw_telegram/rails.py b/integrations/telegram/dw_telegram/rails.py index c1e1940..836518c 100644 --- a/integrations/telegram/dw_telegram/rails.py +++ b/integrations/telegram/dw_telegram/rails.py @@ -118,6 +118,42 @@ def checkpoint_decide( "--expect", str(preview.get("act_token", "")), "--json"], ) + def program_request_decide( + self, repo: Path, run_id: str, request_id: str, decision: str + ) -> tuple[dict | None, str]: + """Carry one response through the local canonical program boundary. + + Telegram supplies neither authority nor a token. This local client + obtains a fresh preview after the paired-owner check, then applies + exactly that request and closed response. The program surface still + owns request identity, freshness, generation, and ledger checks. + """ + reason = "Response carried from the paired Telegram owner." + preview, why = self._json_doc( + repo, + [ + "program", "preview", run_id, "request", + "--request-id", request_id, + "--decision", decision, + "--reason", reason, + "--json", + ], + ) + if preview is None: + return None, why + if not preview.get("applicable"): + issues = "; ".join(preview.get("issues", [])) or "not applicable" + return None, f"program request preview refused: {issues}" + return self._json_doc( + repo, + [ + "program", "request", run_id, request_id, decision, + "--reason", reason, + "--expect", str(preview.get("act_token", "")), + "--json", + ], + ) + def read_feed(self, repo: Path) -> tuple[dict | None, str]: doc, reason = self._json_doc(repo, ["state", "--json"]) if doc is None: diff --git a/pmo-roadmap/README.md b/pmo-roadmap/README.md index 0413d8f..f12ba60 100644 --- a/pmo-roadmap/README.md +++ b/pmo-roadmap/README.md @@ -687,7 +687,14 @@ It distinguishes compatible policy from runtime-proven identity/work-area/ session separation; advanced decision groups and exact provider/model/auth/ principal provenance stay inspectable under **Technical details**. The live program control room consumes the same projection for assigned ownership and -review. See [`docs/team-review.md`](../docs/team-review.md). +review. Run and program control rooms also share the pure bounded-actions view: +permission, scope, ceilings, actual and remaining use lead; blockers list the +exact current choices; pause, resume, revoke, cancel, reject, and saved repair +remain distinct; and every preview explains consequences before the exact +confirmation. Refusals state what changed, what did not, uncertain-effect +status, and the safe reload/inspection path. See +[`docs/team-review.md`](../docs/team-review.md) and +[`docs/product-language.md`](../docs/product-language.md). Its own preview→diff→fingerprint→apply pair can write or delete one direct-contained tracked policy and cannot create a grant, run, agent, check, observer, notification, integration, or roadmap act. An empty policy inventory diff --git a/pmo-roadmap/lib/dw_pmo/__init__.py b/pmo-roadmap/lib/dw_pmo/__init__.py index 8498805..ce0f24a 100644 --- a/pmo-roadmap/lib/dw_pmo/__init__.py +++ b/pmo-roadmap/lib/dw_pmo/__init__.py @@ -187,6 +187,15 @@ build_program_live_progress, build_run_live_progress, ) +from .bounded_actions import ( + BOUNDED_ACTIONS_KIND, + BOUNDED_ACTIONS_SCHEMA_VERSION, + build_program_bounded_actions, + build_refusal_explanation, + build_response_guidance, + build_run_bounded_actions, + classify_measurement, +) from .step import ( DEFAULT_STEP_OUTPUT_BYTES, STEP_KIND, diff --git a/pmo-roadmap/lib/dw_pmo/bounded_actions.py b/pmo-roadmap/lib/dw_pmo/bounded_actions.py new file mode 100644 index 0000000..74289de --- /dev/null +++ b/pmo-roadmap/lib/dw_pmo/bounded_actions.py @@ -0,0 +1,1285 @@ +"""Plain-language bounded actions over canonical delivery facts. + +The run and program surfaces pass their already-derived controls, requests, +limits, blockers, and receipts into this module. The builders explain those +facts; they do not decide applicability, create response options, mint a +token, select work, spend permission, or write an event. +""" + +from __future__ import annotations + +import math +import re + + +BOUNDED_ACTIONS_KIND = "delivery-workbench-bounded-actions" +BOUNDED_ACTIONS_SCHEMA_VERSION = 1 + +_UNBOUNDED = {"unbounded", "unlimited", "infinite", "infinity", "∞"} +_COST_BUDGETS = { + "max_tokens", + "max_observed_cost_microunits", + "max_wall_seconds", + "max_artifact_bytes", +} +_CONTROL_RECEIPTS = { + "run_paused": ("pause", "Delivery paused"), + "run_resumed": ("resume", "Delivery resumed"), + "run_revoked": ("revoke", "Delivery permission permanently stopped"), + "run_cancelled": ("cancel", "Bounded delivery cancelled"), + "request_decided": ("request", "Decision recorded"), + "request_refused": ("request", "Decision response refused"), + "node_claimed": ("tick", "Bounded work started"), + "node_released": ("tick", "Bounded work outcome recorded"), + "program_paused": ("pause", "Program paused"), + "program_resumed": ("resume", "Program resumed"), + "program_revoked": ( + "revoke", "Program permission permanently stopped", + ), + "program_cancelled": ("cancel", "Program cancelled"), + "program_exhausted": ("limit", "Program stopped at a finite limit"), + "claim_completed": ("tick", "Program work outcome recorded"), +} + + +def _objects(value: object) -> list[dict[str, object]]: + if not isinstance(value, list): + return [] + return [item for item in value if isinstance(item, dict)] + + +def _strings(value: object) -> list[str]: + if not isinstance(value, list): + return [] + return [str(item) for item in value if isinstance(item, str)] + + +def _words(value: object) -> str: + text = re.sub(r"[_./:-]+", " ", str(value or "")).strip() + return " ".join(part for part in text.split() if part) + + +def _title(value: object, fallback: str = "Delivery work") -> str: + text = _words(value) + return text[:1].upper() + text[1:] if text else fallback + + +def _bounded_text(value: object, fallback: str) -> str: + text = " ".join(str(value or "").split()).strip() + return text or fallback + + +def classify_measurement( + kind: str, + value: object = None, + *, + unit: str = "units", + applicable: bool = True, + unbounded: bool = False, +) -> dict[str, object]: + """Classify one value without collapsing zero, unknown, or unbounded. + + ``applicable=False`` is an explicit not-applicable value. ``None`` while + applicable is unknown, never zero. Unbounded must be explicit either via + the flag or one of the recognized exact source spellings. + """ + state = "unknown" + normalized: int | float | None = None + if not applicable: + state = "not-applicable" + elif unbounded or ( + isinstance(value, str) and value.strip().lower() in _UNBOUNDED + ): + state = "unbounded" + elif ( + isinstance(value, (int, float)) + and not isinstance(value, bool) + and math.isfinite(float(value)) + ): + normalized = value + state = "zero" if value == 0 else "finite" + return { + "kind": kind, + "state": state, + "value": normalized, + "unit": unit, + } + + +def _usage_item( + item_id: str, + label: str, + category: str, + unit: str, + *, + actual: object = None, + limit: object = None, + remaining: object = None, + estimate: object = None, + actual_applicable: bool = True, + limit_applicable: bool = True, + remaining_applicable: bool = True, + estimate_applicable: bool = False, + primary: bool = True, +) -> dict[str, object]: + limit_unbounded = ( + isinstance(limit, str) and limit.strip().lower() in _UNBOUNDED + ) + remaining_unbounded = limit_unbounded and remaining is None + return { + "id": item_id, + "label": label, + "category": category, + "primary": primary, + "measurements": { + "limit": classify_measurement( + "limit", + limit, + unit=unit, + applicable=limit_applicable, + unbounded=limit_unbounded, + ), + "estimate": classify_measurement( + "estimate", + estimate, + unit=unit, + applicable=estimate_applicable, + ), + "actual": classify_measurement( + "actual", + actual, + unit=unit, + applicable=actual_applicable, + ), + "remaining": classify_measurement( + "remaining", + remaining, + unit=unit, + applicable=remaining_applicable, + unbounded=remaining_unbounded, + ), + }, + } + + +def _usage( + budgets: object, + live_progress: dict[str, object], +) -> dict[str, object]: + rows = _objects((live_progress.get("limits") or {}).get("counts")) + row_by_id = {str(item.get("id")): item for item in rows} + items: list[dict[str, object]] = [] + if isinstance(budgets, dict): + for item_id, raw in budgets.items(): + if not isinstance(raw, dict): + continue + display = row_by_id.get(str(item_id), {}) + label = str(display.get("label") or _words(item_id)) + unit = str(display.get("unit") or "units") + category = ( + "measured-cost" + if str(item_id) in _COST_BUDGETS + else "permission-consumption" + ) + items.append(_usage_item( + str(item_id), + label, + category, + unit, + actual=raw.get("used"), + limit=raw.get("limit"), + remaining=raw.get("remaining"), + primary=bool(display.get("primary", True)), + )) + + progress = live_progress.get("progress") + progress_doc = progress if isinstance(progress, dict) else {} + known_total = progress_doc.get("known_total") + completed = progress_doc.get("completed") + progress_known = ( + isinstance(known_total, int) + and not isinstance(known_total, bool) + and isinstance(completed, int) + and not isinstance(completed, bool) + ) + items.insert(0, _usage_item( + "declared-work-progress", + "declared work", + "progress", + "work items", + actual=completed if progress_known else None, + limit=known_total if progress_known else None, + remaining=( + max(0, int(known_total) - int(completed)) + if progress_known else None + ), + )) + if not any(item["id"] == "max_observed_cost_microunits" for item in items): + items.append(_usage_item( + "money-cost", + "money cost", + "measured-cost", + "money units", + actual=None, + limit=None, + remaining=None, + actual_applicable=True, + limit_applicable=False, + remaining_applicable=False, + )) + return { + "items": items, + "legend": { + "limit": "The maximum allowed by the exact permission.", + "estimate": "A declared forecast, only when the source records one.", + "actual": "Measured consumption recorded so far.", + "remaining": "The exact finite limit minus measured use.", + "zero": "Zero means none; it never means unbounded.", + "unbounded": ( + "Unbounded appears only when the exact source explicitly says " + "there is no finite ceiling." + ), + "unknown": "Unknown means the source does not record the value.", + "not-applicable": "Not applicable means this delivery does not use that measure.", + }, + "adds_incomparable_units": False, + } + + +def build_refusal_explanation( + happened: str, + unchanged: str, + *, + effect_may_have_occurred: bool | None, + safe_next: str, + technical_evidence: dict[str, object] | None = None, +) -> dict[str, object]: + return { + "what_happened": _bounded_text(happened, "The action was refused."), + "what_stayed_unchanged": _bounded_text( + unchanged, "The saved delivery state stayed unchanged." + ), + "effect_may_have_occurred": effect_may_have_occurred, + "effect_answer": ( + "yes" + if effect_may_have_occurred is True + else "no" + if effect_may_have_occurred is False + else "unknown" + ), + "safe_next_step": _bounded_text( + safe_next, "Reload the saved state before choosing another action." + ), + "technical_evidence": technical_evidence or {}, + } + + +def _decision_effect(decision: str, *, context: str) -> tuple[str, str]: + normalized = str(decision or "").strip().lower() + boundary = ( + "program frontier" + if context == "program" + else "bounded delivery state" + ) + if normalized in {"approve", "accept", "yes", "continue"}: + return ( + "Records approval only for this exact outstanding request.", + f"The canonical {boundary} is recalculated; only work it then permits may proceed.", + ) + if normalized in {"reject", "deny", "no", "stop"}: + return ( + "Records rejection only for this exact outstanding request.", + f"Affected work stays stopped or follows the saved rejection route; permission is not revoked.", + ) + return ( + "Records this exact closed response only for the named outstanding request.", + f"The canonical {boundary} is recalculated from that saved response.", + ) + + +def build_response_guidance( + *, + context: str, + affected_work: str, + correlation_id: str, + decisions: list[str], +) -> dict[str, object]: + """Build transport-safe response guidance from one exact response set.""" + choices = [] + for decision in decisions: + effect, after = _decision_effect(decision, context=context) + choices.append({ + "decision": decision, + "label": f"{_title(decision)} this request", + "effect": effect, + "after": after, + }) + return { + "affected_work": affected_work, + "correlation_id": correlation_id, + "choices": choices, + "transport_role": "response-carrier", + "transport_can_draft_response": True, + "transport_grants_authority": False, + "decisive_checks": [ + "the canonical local principal is authorized", + "the exact request is still outstanding", + "the response is in the request's closed response set", + "the local preview token is fresh for the current ledger and generation", + "the apply boundary accepts the same request and response", + ], + "safe_next_step": ( + "Send one listed response or leave the request pending; the local " + "exact boundary must still accept it." + ), + "starts_work": False, + "writes_events": False, + "grants_authority": False, + } + + +def _action_semantics( + action: str, + *, + context: str, + decision: str, + next_step: dict[str, object], +) -> dict[str, object]: + subject = "program" if context == "program" else "delivery" + if action == "tick": + repair = str(next_step.get("kind")) == "repair" + return { + "label": ( + "Retry the bounded repair" + if repair + else "Continue one reviewed step" + ), + "effect": ( + "Starts only the next repair attempt already selected by the " + "saved delivery plan, within remaining permission and limits." + if repair + else "Starts at most the one next step already selected by the " + "saved delivery state, within remaining permission and limits." + ), + "unchanged": ( + "It does not change retry policy, select different work, or " + "broaden permission." + ), + "after": ( + "The resulting receipt is saved and the canonical next step is recalculated." + ), + "severity": "start", + "permanent": False, + } + if action == "supervise": + return { + "label": "Continue within reviewed ceilings", + "effect": ( + "May start successive canonical program steps only until the " + "reviewed tick, time, checkpoint, stop, or terminal ceiling." + ), + "unchanged": ( + "It does not choose another workflow, expand scope, or raise a limit." + ), + "after": "An exact receipt and stop reason are shown for the bounded pass.", + "severity": "start", + "permanent": False, + } + if action == "pause": + return { + "label": f"Pause the {subject}", + "effect": ( + f"Stops new {subject} work from starting while preserving " + "completed work, current requests, remaining limits, and history." + ), + "unchanged": ( + "Pause is reversible; it does not revoke permission or erase prior effects." + ), + "after": "A separately reviewed resume is required before new work can start.", + "severity": "caution", + "permanent": False, + } + if action == "resume": + return { + "label": "Resume reviewed work", + "effect": ( + "Rechecks the saved permission and current facts, then leaves " + "the delivery eligible only for work its canonical state permits." + ), + "unchanged": ( + "Resume does not repeat completed work, expand scope, or start " + "a step without its separate canonical control." + ), + "after": "The current next step is recalculated from the refreshed saved state.", + "severity": "caution", + "permanent": False, + } + if action == "revoke": + return { + "label": f"Permanently stop the {subject}", + "effect": ( + f"Permanently prevents new {subject} work under this permission " + "and expires any outstanding request bound to it." + ), + "unchanged": "Completed work and the exact history remain available for inspection.", + "after": "This permission cannot resume; new authority would require a separate grant.", + "severity": "danger", + "permanent": True, + } + if action == "cancel": + return { + "label": f"Cancel this bounded {subject}", + "effect": ( + f"Ends this {subject} as cancelled and expires its outstanding requests." + ), + "unchanged": ( + "Completed effects and exact history remain; cancellation does " + "not certify, merge, release, or revoke any separate authority." + ), + "after": ( + "The cancelled delivery cannot resume. Program cancellation also " + "interrupts its recorded active claims." + ), + "severity": "danger", + "permanent": True, + } + if action == "request": + effect, after = _decision_effect(decision, context=context) + return { + "label": f"{_title(decision)} this request", + "effect": effect, + "unchanged": ( + "No other request, permission ceiling, or completed work changes." + ), + "after": after, + "severity": ( + "danger" + if str(decision).lower() in {"reject", "deny", "no", "stop"} + else "caution" + ), + "permanent": False, + } + if action == "retry": + return { + "label": "Retry is controlled by the delivery plan", + "effect": ( + "No operator retry is available. Only an attempt already " + "selected by the saved failure policy can run through continue." + ), + "unchanged": "Retry policy, attempts, permission, and delivery state stay unchanged.", + "after": "Review the failed check and the saved repair route.", + "severity": "unavailable", + "permanent": False, + } + if action == "elevate": + return { + "label": "Request new permission separately", + "effect": "This control cannot add permission or raise a limit.", + "unchanged": "Current scope, limits, and forbidden effects stay unchanged.", + "after": "A new grant must be reviewed through its separate start boundary.", + "severity": "unavailable", + "permanent": False, + } + return { + "label": _title(action), + "effect": "Applies only the exact operation described by its fresh preview.", + "unchanged": "No other delivery fact or permission changes.", + "after": "The saved state is replayed and its exact receipt is shown.", + "severity": "caution", + "permanent": False, + } + + +def _control_issue(control: dict[str, object]) -> str: + issues = _strings(control.get("issues")) + issue = str(control.get("issue") or "") + return "; ".join([*issues, *([issue] if issue else [])]) or ( + "This action is not applicable in the current saved state." + ) + + +def _actions( + controls: list[dict[str, object]], + *, + context: str, + live_progress: dict[str, object], +) -> list[dict[str, object]]: + actions: list[dict[str, object]] = [] + next_step = live_progress.get("next_step") + next_doc = next_step if isinstance(next_step, dict) else {} + for index, control in enumerate(controls): + action = str(control.get("action") or "") + decision = str(control.get("decision") or "") + semantics = _action_semantics( + action, + context=context, + decision=decision, + next_step=next_doc, + ) + correlation = str( + control.get("correlation_id") + or control.get("request_id") + or "" + ) + action_id = ":".join( + part for part in (action, correlation, decision) if part + ) or f"control-{index + 1}" + available = bool(control.get("available")) + issue = "" if available else _control_issue(control) + entry = { + "id": action_id, + "kind": "decision" if action == "request" else "control", + "action": action, + "decision": decision or None, + "correlation_id": correlation or None, + "label": semantics["label"], + "available": available, + "issue": issue or None, + "reason_required": bool(control.get("reason_required")), + "preview_required": bool(control.get("preview_required")), + "confirmation_required": bool( + available and control.get("preview_required") + ), + "may_start_work": bool(control.get("starts_work")), + "permanent": semantics["permanent"], + "severity": semantics["severity"], + "consequences": { + "effect": semantics["effect"], + "unchanged": semantics["unchanged"], + "after": semantics["after"], + }, + "exact_binding": { + "action": action, + "decision": decision or None, + ( + "request_id" + if context == "program" + else "correlation_id" + ): correlation or None, + "control_index": index, + }, + "source": { + "model": ( + "delivery-workbench-program-view" + if context == "program" + else "delivery-workbench-run-view" + ), + "path": f"/controls/{index}", + }, + } + if not available: + entry["refusal"] = build_refusal_explanation( + issue, + semantics["unchanged"], + effect_may_have_occurred=False, + safe_next=str(semantics["after"]), + technical_evidence=entry["source"], + ) + actions.append(entry) + return actions + + +def _read_actions( + *, + context: str, + has_decision: bool, + has_failure: bool, +) -> list[dict[str, object]]: + items = [ + { + "id": "reload-delivery-state", + "kind": "read", + "action": None, + "read_action": "reload", + "label": "Reload delivery state", + "available": True, + "confirmation_required": False, + "consequences": { + "effect": "Replays the canonical saved state and starts no work.", + "unchanged": "Delivery state, permission, and cost remain unchanged.", + "after": "The latest verified blockers, actions, limits, and receipts are shown.", + }, + }, + { + "id": "review-remaining-limits", + "kind": "read", + "action": None, + "read_action": "limits", + "label": "Review remaining limits", + "available": True, + "confirmation_required": False, + "consequences": { + "effect": "Opens current permission, consumption, and cost facts.", + "unchanged": "No work starts and no limit or permission changes.", + "after": "Choose an available bounded action or leave state unchanged.", + }, + }, + { + "id": "open-technical-details", + "kind": "read", + "action": None, + "read_action": "technical", + "label": "Open Technical details", + "available": True, + "confirmation_required": False, + "consequences": { + "effect": "Opens exact identities, controls, hashes, and ordered history.", + "unchanged": "No delivery state changes.", + "after": "Return to the same delivery summary when inspection is complete.", + }, + }, + { + "id": "return-without-change", + "kind": "read", + "action": None, + "read_action": "leave", + "label": ( + "Return without stopping" + if context == "program" + else "Leave delivery unchanged" + ), + "available": True, + "confirmation_required": False, + "consequences": { + "effect": "Closes any local preview and applies nothing.", + "unchanged": "Current delivery state stays unchanged.", + "after": "The saved state remains available for later review.", + }, + }, + ] + if has_decision: + items.insert(0, { + "id": "leave-decision-pending", + "kind": "read", + "action": None, + "read_action": "leave", + "label": "Decide later", + "available": True, + "confirmation_required": False, + "consequences": { + "effect": "Leaves this exact request pending and starts no affected work.", + "unchanged": "The request, affected work, permission, and cost stay unchanged.", + "after": "Reload the saved state before responding later.", + }, + }) + if has_failure: + items.insert(0, { + "id": "review-failed-check", + "kind": "read", + "action": None, + "read_action": "failure", + "label": "Review the failed check", + "available": True, + "confirmation_required": False, + "consequences": { + "effect": "Opens the saved failure and repair explanation.", + "unchanged": "No replacement work starts and the prior failure remains visible.", + "after": "Use only the repair step selected by the saved delivery plan.", + }, + }) + for index, item in enumerate(items): + item.update({ + "decision": None, + "correlation_id": None, + "reason_required": False, + "preview_required": False, + "may_start_work": False, + "permanent": False, + "severity": "read", + "exact_binding": None, + "source": { + "model": BOUNDED_ACTIONS_KIND, + "path": f"/read_actions/{index}", + }, + }) + return items + + +def _permission( + *, + context: str, + facts: dict[str, object], + live_progress: dict[str, object], + usage: dict[str, object], +) -> dict[str, object]: + limits = live_progress.get("limits") + limits_doc = limits if isinstance(limits, dict) else {} + permission = limits_doc.get("permission") + permission_doc = permission if isinstance(permission, dict) else {} + scope = facts.get("scope") + if context == "bounded-run": + story = facts.get("story") + story_doc = story if isinstance(story, dict) else {} + scope = { + "project": facts.get("project"), + "story_id": story_doc.get("id"), + "story_title": story_doc.get("title"), + } + stop_conditions = _strings(facts.get("stop_conditions")) + if context == "bounded-run": + stop_conditions = [ + "permission expiry", + "a finite counted limit reaching its ceiling", + "a terminal or failure route in the saved delivery plan", + "a separately confirmed pause, revoke, or cancel", + ] + elif not stop_conditions: + stop_conditions = [ + "permission expiry", + "a finite program limit reaching its ceiling", + "a saved program frontier stop or checkpoint", + "a separately confirmed pause, revoke, or cancel", + ] + current_use = [] + for item in usage["items"]: + if item["category"] == "progress": + continue + measurements = item["measurements"] + actual = measurements["actual"] + remaining = measurements["remaining"] + current_use.append({ + "id": item["id"], + "label": item["label"], + "actual": actual, + "remaining": remaining, + }) + return { + "status": permission_doc.get("status", "unknown"), + "allowed_effects": list(permission_doc.get("may_still_use") or []), + "scope": scope, + "ceilings": [ + item["id"] + for item in usage["items"] + if item["category"] != "progress" + and item["measurements"]["limit"]["state"] + in {"finite", "zero", "unbounded"} + ], + "expires_at": limits_doc.get("expires_at") or facts.get("expires_at"), + "stop_conditions": [ + _title(item) for item in stop_conditions + ], + "current_use": current_use, + "forbidden_effects": list( + permission_doc.get("will_not_use") + or facts.get("permanent_exclusions") + or [] + ), + "summary": permission_doc.get( + "summary", "Permission facts are unavailable." + ), + "source": { + "model": ( + "delivery-workbench-program" + if context == "program" + else "delivery-workbench-run" + ), + "paths": [ + "/capabilities", + "/scope" if context == "program" else "/story", + "/budgets", + "/expires_at", + "/permanent_exclusions", + ], + }, + } + + +def _receipt_from_event( + event: dict[str, object], + *, + context: str, +) -> dict[str, object] | None: + event_name = str(event.get("event") or "") + if event_name not in _CONTROL_RECEIPTS: + return None + action, label = _CONTROL_RECEIPTS[event_name] + detail = event.get("detail") + detail_doc = detail if isinstance(detail, dict) else {} + decision = str(detail_doc.get("decision") or "") + if event_name == "request_decided" and decision: + label = f"{_title(decision)} decision recorded" + if event_name == "request_refused": + label = "Decision response refused without applying it" + exact_ref = str( + event.get("event_hash") + or detail_doc.get("receipt_hash") + or "" + ) + return { + "id": f"{context}:{event.get('seq', len(exact_ref))}:{event_name}", + "label": label, + "action": action, + "decision": decision or None, + "outcome": str( + detail_doc.get("outcome") + or detail_doc.get("result") + or detail_doc.get("reason") + or detail_doc.get("to_state") + or "recorded" + ), + "at": event.get("ts") or event.get("at"), + "exact_reference": exact_ref or None, + "source": { + "model": ( + "delivery-workbench-program-event" + if context == "program" + else "delivery-workbench-run-event" + ), + "path": f"/timeline/{event.get('seq')}", + }, + } + + +def _receipts( + events: list[dict[str, object]], + *, + context: str, + program_receipts: list[dict[str, object]] | None = None, +) -> list[dict[str, object]]: + items = [ + item + for item in ( + _receipt_from_event(event, context=context) for event in events + ) + if item is not None + ] + for receipt in program_receipts or []: + if str(receipt.get("action_kind")) != "checkpoint-request": + continue + decision = receipt.get("decision") + decision_doc = decision if isinstance(decision, dict) else {} + option = str(decision_doc.get("option") or receipt.get("result") or "") + items.append({ + "id": str(receipt.get("receipt_hash") or receipt.get("action_id")), + "label": f"{_title(option)} decision recorded", + "action": "request", + "decision": option or None, + "outcome": receipt.get("result") or "recorded", + "at": receipt.get("issued_at"), + "exact_reference": receipt.get("receipt_hash"), + "source": { + "model": "delivery-workbench-program-receipt", + "path": f"/activities/completed/{receipt.get('action_id')}", + }, + }) + deduped: dict[str, dict[str, object]] = {} + for item in items: + deduped[str(item["id"])] = item + return list(deduped.values())[-8:][::-1] + + +def _request_actions( + actions: list[dict[str, object]], + correlation_id: str, +) -> list[dict[str, object]]: + return [ + item + for item in actions + if item["action"] == "request" + and item.get("correlation_id") == correlation_id + ] + + +def _choice(action: dict[str, object]) -> dict[str, object]: + return { + "action_id": action["id"], + "label": action["label"], + "decision": action.get("decision"), + "available": action["available"], + "effect": action["consequences"]["effect"], + "after": action["consequences"]["after"], + } + + +def _fallback_choices(actions: list[dict[str, object]]) -> list[dict[str, object]]: + preferred = [ + item for item in actions + if item["available"] + and item.get("action") in {"tick", "pause", "resume", "revoke", "cancel"} + ] + if not preferred: + preferred = [ + item for item in actions + if item["id"] in { + "review-failed-check", + "reload-delivery-state", + "open-technical-details", + } + ] + return [_choice(item) for item in preferred[:5]] + + +def _run_inbox( + projection: dict[str, object], + decision: dict[str, object], + graph_nodes: list[dict[str, object]], + actions: list[dict[str, object]], +) -> list[dict[str, object]]: + inbox: list[dict[str, object]] = [] + node_by_id = { + str(item.get("id")): item for item in graph_nodes + } + for request in _objects(projection.get("outstanding_requests")): + correlation = str(request.get("correlation_id") or "") + choices = _request_actions(actions, correlation) + affected = _title( + request.get("origin_node") or request.get("origin"), + "This bounded delivery", + ) + inbox.append({ + "id": f"decision:{correlation}", + "kind": "decision", + "status": "needs-decision", + "affected_work": affected, + "why": _bounded_text( + request.get("schema_summary"), + "The saved delivery is waiting for one exact closed response.", + ), + "resolver": "The named checkpoint owner through the fresh local request boundary.", + "valid_choices": [_choice(item) for item in choices], + "after_no_choice": ( + "The request remains pending and affected work does not advance." + ), + "technical_reference": correlation, + "source": { + "model": "delivery-workbench-run", + "path": "/outstanding_requests", + }, + }) + seen: set[str] = set() + for blocked in _objects(decision.get("blocked")): + node_id = str(blocked.get("node_id") or "") + reason = str(blocked.get("reason") or "unknown blocker") + if reason == "dependencies" or node_id in seen: + continue + seen.add(node_id) + node = node_by_id.get(node_id, {}) + inbox.append({ + "id": f"blocker:{node_id}", + "kind": "blocker", + "status": "blocked", + "affected_work": _title(node.get("title") or node_id), + "why": _title(reason), + "resolver": ( + "The saved failure route, a required external fact, or an " + "operator using one currently available exact control." + ), + "valid_choices": _fallback_choices(actions), + "after_no_choice": "Affected work remains stopped in the saved state.", + "technical_reference": node_id, + "source": { + "model": "delivery-workbench-conductor-decision", + "path": "/blocked", + }, + }) + if projection.get("state") == "blocked" and not any( + item["kind"] == "blocker" for item in inbox + ): + inbox.append({ + "id": "blocker:run-state", + "kind": "blocker", + "status": "blocked", + "affected_work": _title( + (projection.get("story") or {}).get("title") + if isinstance(projection.get("story"), dict) + else "This bounded delivery" + ), + "why": "The saved bounded policy reached its blocked terminal state.", + "resolver": "The saved delivery policy; this grant cannot invent another route.", + "valid_choices": _fallback_choices(actions), + "after_no_choice": "The blocked state and completed evidence remain unchanged.", + "technical_reference": projection.get("ledger_head"), + "source": { + "model": "delivery-workbench-run", + "path": "/state", + }, + }) + for refusal in _objects(projection.get("request_refusals")): + reason = str(refusal.get("reason") or "request refusal") + inbox.append({ + "id": f"refusal:{refusal.get('seq', refusal.get('correlation_id'))}", + "kind": "refusal", + "status": "refused", + "affected_work": _title( + refusal.get("origin_node") or refusal.get("origin"), + "The named request", + ), + "why": _title(reason), + "resolver": "Reload the exact request state; do not guess another response.", + "valid_choices": [ + _choice(item) for item in actions + if item["id"] in { + "reload-delivery-state", + "open-technical-details", + } + ], + "after_no_choice": "No decision is applied by this refusal.", + "technical_reference": ( + refusal.get("response_hash") or refusal.get("correlation_id") + ), + "explanation": build_refusal_explanation( + f"The request response was refused: {_words(reason)}.", + "The live request and affected delivery state were not changed by the refusal.", + effect_may_have_occurred=False, + safe_next="Reload the current requests and respond only to an exact outstanding request.", + technical_evidence={ + "model": "delivery-workbench-run", + "path": "/request_refusals", + }, + ), + "source": { + "model": "delivery-workbench-run", + "path": "/request_refusals", + }, + }) + return inbox + + +def _program_inbox( + authority: dict[str, object], + frontier: dict[str, object], + actions: list[dict[str, object]], + refusal: dict[str, object] | None, +) -> list[dict[str, object]]: + inbox: list[dict[str, object]] = [] + current_story = "" + selection = authority.get("selection") + if isinstance(selection, dict): + story = selection.get("story") + current_story = str( + story.get("id") if isinstance(story, dict) else story or "" + ) + for request in _objects(authority.get("outstanding_requests")): + request_id = str(request.get("claim_id") or "") + choices = _request_actions(actions, request_id) + inbox.append({ + "id": f"decision:{request_id}", + "kind": "decision", + "status": "needs-decision", + "affected_work": _title( + current_story or request.get("port"), + "The current program work", + ), + "why": ( + f"The saved program is waiting at {_words(request.get('port') or 'checkpoint')}." + ), + "resolver": "The granted program operator through the fresh local request boundary.", + "valid_choices": [_choice(item) for item in choices], + "after_no_choice": "The request remains pending and affected work does not advance.", + "technical_reference": request_id, + "source": { + "model": "delivery-workbench-program", + "path": "/outstanding_requests", + }, + }) + for obligation in _objects(authority.get("blocking_obligations")): + obligation_id = str(obligation.get("id") or "") + inbox.append({ + "id": f"blocker:{obligation_id}", + "kind": "blocker", + "status": "blocked", + "affected_work": _title( + obligation.get("target") or current_story, + "The current program work", + ), + "why": _bounded_text( + obligation.get("statement") or obligation.get("reason"), + "A saved blocking obligation is still open.", + ), + "resolver": _title( + obligation.get("accountable_role"), + "The accountable role named by the saved obligation", + ), + "valid_choices": _fallback_choices(actions), + "after_no_choice": "Program progression remains stopped while the obligation is blocking.", + "technical_reference": obligation_id, + "source": { + "model": "delivery-workbench-program", + "path": "/blocking_obligations", + }, + }) + stop = str(frontier.get("stop") or "") + if stop and stop not in {"integration-required", "scope-complete"}: + inbox.append({ + "id": f"blocker:frontier:{stop}", + "kind": "blocker", + "status": "blocked", + "affected_work": _title(current_story, "The current program scope"), + "why": f"The saved program frontier stopped at {_words(stop)}.", + "resolver": ( + "The saved program rules, a required role or fact, or an " + "operator using one currently available exact control." + ), + "valid_choices": _fallback_choices(actions), + "after_no_choice": "No new program work advances from this saved stop.", + "technical_reference": stop, + "source": { + "model": "delivery-workbench-program-frontier", + "path": "/stop", + }, + }) + if refusal: + inbox.append({ + "id": f"refusal:{refusal.get('code', 'program')}", + "kind": "refusal", + "status": "refused", + "affected_work": _title(current_story, "The current program scope"), + "why": str(refusal.get("message") or refusal.get("code") or "Program refusal"), + "resolver": "Reload exact program evidence; this view will not guess a frontier.", + "valid_choices": [ + _choice(item) for item in actions + if item["id"] in { + "reload-delivery-state", + "open-technical-details", + } + ], + "after_no_choice": "No work starts from an invalid frontier.", + "technical_reference": refusal.get("ledger_head"), + "explanation": build_refusal_explanation( + str(refusal.get("message") or "The program frontier was refused."), + "The last verified program ledger remains authoritative.", + effect_may_have_occurred=False, + safe_next="Inspect the exact ledger and reload after correcting the source fact.", + technical_evidence={ + "model": "delivery-workbench-program-view", + "path": "/current/refusal", + }, + ), + "source": { + "model": "delivery-workbench-program-view", + "path": "/current/refusal", + }, + }) + return inbox + + +def _base_document( + *, + context: str, + facts: dict[str, object], + live_progress: dict[str, object], + controls: list[dict[str, object]], + inbox_builder, + events: list[dict[str, object]], + program_receipts: list[dict[str, object]] | None = None, +) -> dict[str, object]: + usage = _usage(facts.get("budgets"), live_progress) + mutating_actions = _actions( + controls, + context="program" if context == "program" else "bounded-run", + live_progress=live_progress, + ) + has_decision = bool(facts.get("outstanding_requests")) + has_failure = bool( + (live_progress.get("review") or {}).get("failed_evidence") + if isinstance(live_progress.get("review"), dict) + else False + ) or str((live_progress.get("next_step") or {}).get("kind")) == "repair" + read_actions = _read_actions( + context="program" if context == "program" else "bounded-run", + has_decision=has_decision, + has_failure=has_failure, + ) + actions = [*read_actions, *mutating_actions] + inbox = inbox_builder(actions) + return { + "kind": BOUNDED_ACTIONS_KIND, + "schema_version": BOUNDED_ACTIONS_SCHEMA_VERSION, + "context": context, + "summary": ( + f"{len(inbox)} decision, blocker, or refusal item" + f"{'s' if len(inbox) != 1 else ''}; " + f"{sum(1 for item in mutating_actions if item['available'])} " + "exact bounded actions currently available." + ), + "inbox": inbox, + "permission": _permission( + context=context, + facts=facts, + live_progress=live_progress, + usage=usage, + ), + "usage": usage, + "actions": actions, + "receipts": _receipts( + events, + context="program" if context == "program" else "bounded-run", + program_receipts=program_receipts, + ), + "error_contract": { + "required_parts": [ + "what happened", + "what stayed unchanged", + "whether an effect may already have occurred", + "the safe next step", + "exact technical evidence", + ], + "unknown_effect_rule": ( + "If transport ends without an exact refusal or receipt, effect " + "status is unknown until the saved ledger is reloaded." + ), + }, + "transport_boundary": { + "role": "response-carrier", + "notification_or_remote_grants_authority": False, + "decisive_checks": [ + "canonical principal", + "exact request identity", + "closed response set", + "fresh preview token", + "current ledger and generation", + ], + }, + "starts_work": False, + "writes_events": False, + "selects_action": False, + "selects_next_work": False, + "grants_authority": False, + "changes_retry_policy": False, + "sends_notifications": False, + } + + +def build_run_bounded_actions( + projection: dict[str, object], + decision: dict[str, object], + graph_nodes: list[dict[str, object]], + controls: list[dict[str, object]], + live_progress: dict[str, object], + events: list[dict[str, object]], +) -> dict[str, object]: + """Explain exact bounded-run controls without selecting or applying one.""" + return _base_document( + context="bounded-run", + facts=projection, + live_progress=live_progress, + controls=controls, + inbox_builder=lambda actions: _run_inbox( + projection, decision, graph_nodes, actions + ), + events=events, + ) + + +def build_program_bounded_actions( + authority: dict[str, object], + frontier: dict[str, object], + controls: list[dict[str, object]], + live_progress: dict[str, object], + events: list[dict[str, object]], + *, + refusal: dict[str, object] | None, + receipts: list[dict[str, object]], +) -> dict[str, object]: + """Explain exact program controls without selecting or applying one.""" + return _base_document( + context="program", + facts=authority, + live_progress=live_progress, + controls=controls, + inbox_builder=lambda actions: _program_inbox( + authority, frontier, actions, refusal + ), + events=events, + program_receipts=receipts, + ) diff --git a/pmo-roadmap/lib/dw_pmo/notifications.py b/pmo-roadmap/lib/dw_pmo/notifications.py index 15c0a8d..e593795 100644 --- a/pmo-roadmap/lib/dw_pmo/notifications.py +++ b/pmo-roadmap/lib/dw_pmo/notifications.py @@ -14,6 +14,7 @@ from datetime import datetime, timezone from pathlib import Path +from .bounded_actions import build_response_guidance from .model import DwError from .orchestration import canonical_json from .orchestration_run import run_inventory @@ -118,6 +119,25 @@ def _run_notifications(root, now=None): projection = entry["run"] for request in projection.get("outstanding_requests", []): request_kind = str(request.get("kind") or "") + affected_work = str( + request.get("origin_node") + or request.get("origin") + or projection.get("story", {}).get("id") + or "bounded delivery" + ) + decisions = [ + str(item) + for item in request.get("response_schema", {}).get( + "decision", [] + ) + ] + correlation_id = str(request.get("correlation_id") or "") + guidance = build_response_guidance( + context="bounded-run", + affected_work=affected_work, + correlation_id=correlation_id, + decisions=decisions, + ) kind = ( "checkpoint-pending" if request_kind == "checkpoint" @@ -140,9 +160,10 @@ def _run_notifications(root, now=None): else "an uncovered nudge preview is waiting for a decision" ), "request": { - "correlation_id": request.get("correlation_id"), + "correlation_id": correlation_id, "response_schema": request.get("response_schema"), "boundary": "dw run request (fresh exact act token)", + "guidance": guidance, }, }) for republish in request.get("republished", []): @@ -159,9 +180,10 @@ def _run_notifications(root, now=None): "node": str(request.get("origin_node") or request.get("origin") or ""), "detail": "an outstanding request was republished after resume or restart", "request": { - "correlation_id": request.get("correlation_id"), + "correlation_id": correlation_id, "response_schema": request.get("response_schema"), "boundary": "dw run request (fresh exact act token)", + "guidance": guidance, }, }) for request in projection.get("request_history", []): @@ -272,6 +294,21 @@ def _program_notifications(root, now=None): "boundary": ( "dw program request (fresh exact act token)" ), + "guidance": build_response_guidance( + context="program", + affected_work=str( + (view.get("phase_progress") or {}).get( + "selected_stories", [] + )[-1] + if (view.get("phase_progress") or {}).get( + "selected_stories" + ) + else request.get("port") + or "current program work" + ), + correlation_id=request_id, + decisions=["approve", "reject"], + ), }, }) @@ -672,16 +709,25 @@ def render_outbound(notification): lines.append(notification.get("detail", "")) request = notification.get("request") if request: + guidance = request.get("guidance") or {} + if guidance.get("affected_work"): + lines.append(f"affected work: {guidance['affected_work']}") options = request.get("response_schema", {}).get( "decision", ["approve", "reject"] ) + for choice in guidance.get("choices", []): + lines.append( + f"choice {choice.get('decision')}: " + f"{choice.get('after') or choice.get('effect')}" + ) lines.append( - "to decide, reply: " + "to carry this response, reply: " f"/decision {request['correlation_id']} {'|'.join(options)}" ) lines.append( - "the decision applies only through the local exact-token " - "request boundary" + "chat does not grant permission: the canonical local principal, " + "outstanding request, closed response, freshness, and exact-token " + "checks still decide" ) lines.append(f"ack: {notification['id']}") return "\n".join(line for line in lines if line) diff --git a/pmo-roadmap/lib/dw_pmo/orchestration_surface.py b/pmo-roadmap/lib/dw_pmo/orchestration_surface.py index b736ba3..0867244 100644 --- a/pmo-roadmap/lib/dw_pmo/orchestration_surface.py +++ b/pmo-roadmap/lib/dw_pmo/orchestration_surface.py @@ -15,6 +15,7 @@ from datetime import datetime from pathlib import Path +from .bounded_actions import build_run_bounded_actions from .live_progress import build_run_live_progress from .model import DwError from .orchestration import canonical_json @@ -462,7 +463,9 @@ def _control_catalog( "issues": issues, "reason_required": reason_required, "preview_required": True, - "starts_work": starts_work, + "starts_work": ( + starts_work and projection["state"] == "active" + ), }) controls.extend([ { @@ -560,6 +563,15 @@ def build_run_view( safe_artifacts, events, ) + controls = _control_catalog(root, projection) + bounded_actions = build_run_bounded_actions( + projection, + decision, + graph_nodes, + controls, + live_progress, + events, + ) terminal = projection["state"] in TERMINAL_STATES terminal_meaning = { "awaiting-certification": "work is handed back for human inspection, certification, and commit", @@ -586,6 +598,7 @@ def build_run_view( "ledger_head": projection["ledger_head"], "ledger_events": projection["ledger_events"], "live_progress": live_progress, + "bounded_actions": bounded_actions, "graph": { "nodes": graph_nodes, "layout": compiled.get("layout", {}), @@ -615,7 +628,7 @@ def build_run_view( "fact_binding": projection["fact_binding"], "external_commits": projection["external_commits"], "timeline": events, - "controls": _control_catalog(root, projection), + "controls": controls, "terminal": terminal, "terminal_meaning": terminal_meaning, "privacy": { diff --git a/pmo-roadmap/lib/dw_pmo/program_run.py b/pmo-roadmap/lib/dw_pmo/program_run.py index f864b6d..7e1df24 100644 --- a/pmo-roadmap/lib/dw_pmo/program_run.py +++ b/pmo-roadmap/lib/dw_pmo/program_run.py @@ -1753,6 +1753,8 @@ def replay_program(root: Path, run_id: str, *, now: str | datetime | None = None "event_count": len(events), "capabilities": list(grant["authority"]["capabilities"]), # type: ignore[index] "budgets": budget_state, + "stop_conditions": list(grant["authority"]["stop_conditions"]), # type: ignore[index] + "cost_accounting": grant["authority"]["cost_accounting"], # type: ignore[index] "scope": grant["scope"], "selection": grant["selection"], "roster": grant["roster"], diff --git a/pmo-roadmap/lib/dw_pmo/program_surface.py b/pmo-roadmap/lib/dw_pmo/program_surface.py index 54d73af..ee6ada5 100644 --- a/pmo-roadmap/lib/dw_pmo/program_surface.py +++ b/pmo-roadmap/lib/dw_pmo/program_surface.py @@ -17,6 +17,7 @@ import re import time +from .bounded_actions import build_program_bounded_actions from .live_progress import build_program_live_progress from .model import DwError from .orchestration import canonical_json @@ -892,6 +893,14 @@ def _control_catalog(authority: dict[str, object]) -> list[dict[str, object]]: { "action": action, "available": available, + "issue": ( + None + if available + else ( + f"{action} is unavailable while program permission " + f"is {state}" + ) + ), "reason_required": action in _REASON_ACTIONS, "decision": None, "request_id": None, @@ -1109,6 +1118,16 @@ def build_program_view( timeline = tail_program_events( root, run_id, after_seq=0, limit=_MAX_TAIL_EVENTS )["events"] + controls = _control_catalog(authority) + bounded_actions = build_program_bounded_actions( + authority, + frontier, + controls, + live_progress, + timeline, + refusal=refusal, + receipts=receipts, + ) stop = frontier.get("stop") terminal_meaning = { "complete": "the exact granted roadmap scope completed", @@ -1143,6 +1162,7 @@ def build_program_view( "expired": authority["expired"], "scope": authority["scope"], "live_progress": live_progress, + "bounded_actions": bounded_actions, "current": { "selection": selection, "lineage": frontier.get("lineage"), @@ -1237,10 +1257,12 @@ def build_program_view( "scope_completion": authority["scope_completion"], }, "budgets": authority["budgets"], + "stop_conditions": authority["stop_conditions"], + "cost_accounting": authority["cost_accounting"], "capabilities": authority["capabilities"], "permanent_exclusions": authority["permanent_exclusions"], "timeline": timeline, - "controls": _control_catalog(authority), + "controls": controls, "terminal": authority["state"] in TERMINAL_AUTHORITY_STATES, "terminal_meaning": terminal_meaning, "privacy": { diff --git a/pmo-roadmap/pm/roadmap/work-log-automation/phase-27-usability-improvements/current-phase-status.md b/pmo-roadmap/pm/roadmap/work-log-automation/phase-27-usability-improvements/current-phase-status.md index 2465d3a..509b8d1 100644 --- a/pmo-roadmap/pm/roadmap/work-log-automation/phase-27-usability-improvements/current-phase-status.md +++ b/pmo-roadmap/pm/roadmap/work-log-automation/phase-27-usability-improvements/current-phase-status.md @@ -1,6 +1,6 @@ # Phase 27 - Usability Improvements -**Last updated:** 2026-07-24. +**Last updated:** 2026-07-25. ## Goal @@ -39,7 +39,7 @@ Make Delivery Workbench's everyday application layer speak and behave like a pra - [x] Program Studio makes delivery-plan/workflow authoring and team/review design understandable while round-tripping the existing exact configuration without semantic loss (WLA-27-04, WLA-27-05). -- [ ] Live operation answers the seven operator questions from the Phase 26 +- [x] Live operation answers the seven operator questions from the Phase 26 handoff and turns decisions, blockers, permission, cost, stop/revoke, and recoverable failures into clear bounded actions (WLA-27-06, WLA-27-07). - [ ] Workbench, human CLI output, notifications, help, errors, onboarding, and @@ -63,30 +63,29 @@ Make Delivery Workbench's everyday application layer speak and behave like a pra | WLA-27-04 | Make plan and workflow authoring task-shaped | done | [story-04-make-plan-and-workflow-authoring-task-shaped](./story-04-make-plan-and-workflow-authoring-task-shaped.md) | [evidence-story-04](./evidence-story-04.md) | | WLA-27-05 | Make teams and review rules understandable | done | [story-05-make-teams-and-review-rules-understandable](./story-05-make-teams-and-review-rules-understandable.md) | [evidence-story-05](./evidence-story-05.md) | | WLA-27-06 | Make live delivery explain progress and next steps | done | [story-06-make-live-delivery-explain-progress-and-next-steps](./story-06-make-live-delivery-explain-progress-and-next-steps.md) | [evidence-story-06](./evidence-story-06.md) | -| WLA-27-07 | Turn decisions, blockers, permissions, and cost into actions | backlog | [story-07-turn-decisions-blockers-permissions-and-cost-into-actions](./story-07-turn-decisions-blockers-permissions-and-cost-into-actions.md) | - | +| WLA-27-07 | Turn decisions, blockers, permissions, and cost into actions | done | [story-07-turn-decisions-blockers-permissions-and-cost-into-actions](./story-07-turn-decisions-blockers-permissions-and-cost-into-actions.md) | [evidence-story-07](./evidence-story-07.md) | | WLA-27-08 | Make every everyday word agree | backlog | [story-08-make-every-everyday-word-agree](./story-08-make-every-everyday-word-agree.md) | - | | WLA-27-09 | Harden keyboard, screen-size, and assistive use | backlog | [story-09-harden-keyboard-screen-size-and-assistive-use](./story-09-harden-keyboard-screen-size-and-assistive-use.md) | - | | WLA-27-10 | Prove the redesigned application end to end | backlog | [story-10-prove-the-redesigned-application-end-to-end](./story-10-prove-the-redesigned-application-end-to-end.md) | - | ## Where we are -Phase 27 is OPEN 6/10. Bounded-run and program control rooms now share the pure -`delivery-workbench-live-progress@1` projection. Their default view directly -answers what is being delivered, who is doing and reviewing it, what passed, -what is blocked, who must decide, what permission and measured cost remain, -and the one next step already selected by canonical state. Understandable -active, waiting, review, repair, blocked, stopped, revoked, recovering, and -complete groups remain traceable to exact identities. Mechanical checks, agent -judgment, dissent, repair, and final governed decisions remain separate. -Readable activity never replaces the ordered hash-linked audit record: -`Technical details` opens exact state, limits, controls, provenance, and -events. Stale and recovery views preserve verified completed work and explain -duplicate protection without claiming lost or repeated delivery. The -76-render wide/narrow harness, seventeen-state journey contract, focused -recovery/parity tests, fresh Python 3.9 wheel, and autonomous/no-program -consumers are green. WLA-27-07 is next: turn the already projected decisions, -blockers, permission, cost, stop/revoke, and recoverable failures into clear -bounded actions. There are no known blockers. +Phase 27 is OPEN 7/10. Bounded-run and program control rooms now share the pure +`delivery-workbench-live-progress@1` and +`delivery-workbench-bounded-actions@1` projections. The default action center +shows affected work, exact current choices, permission scope, ceilings, +expiry/stops, current use, forbidden effects, and distinct consequences before +the existing exact confirmation. Zero, finite, unbounded, unknown, and not +applicable values remain different. Pause, saved repair, resume, permanent +revoke, cancel, and rejection do not collapse into one generic control. +Structured refusals explain what happened, what stayed unchanged, effect +uncertainty, and the safe reload/inspection path; notifications and Telegram +carry responses without manufacturing authority. Exact identities, tokens, +controls, and receipts remain under `Technical details`. The 88-render +wide/narrow harness, 23-state journey contract, focused action/refusal/parity +tests, fresh Python 3.9 wheel, and autonomous/no-program consumers are green. +WLA-27-08 is next: make every everyday renderer use the same product terms. +There are no known blockers. ## Active risks @@ -222,6 +221,27 @@ bounded actions. There are no known blockers. disconnects retain the last verified view and recovery names both preserved work and duplicate protection - progressive inspection and recovery contract - WLA-27-06. +- 2026-07-25 - Add `delivery-workbench-bounded-actions@1` as one pure + application projection over existing run/program controls, requests, + blockers, permission, usage, failures, and receipts; it may explain and + group facts but never select/apply an action, start work, write events, grant + authority, change retry policy, or notify - source-of-truth boundary - + WLA-27-07. +- 2026-07-25 - Put allowed effects, affected scope, ceilings, expiry/stops, + measured consumption, remaining capacity, and permanent exclusions before + actions; preserve finite, zero, explicitly unbounded, unknown, and not + applicable as different measurement states - informed-permission boundary - + WLA-27-07. +- 2026-07-25 - Keep continue/repair, pause, resume, permanent revoke, cancel, + reject, unavailable retry, and separate permission elevation materially + distinct; explain their effects before the existing exact preview and show + readable receipts after completion - consequence-first action contract - + WLA-27-07. +- 2026-07-25 - Treat notifications and Telegram as response carriers only: + they may present and carry an exact closed response, while local principal, + outstanding request, response-set, freshness, ledger/generation, and exact + confirmation checks remain decisive - transport-is-not-authority boundary - + WLA-27-07. ## Decisions deferred diff --git a/pmo-roadmap/pm/roadmap/work-log-automation/phase-27-usability-improvements/evidence-story-07.md b/pmo-roadmap/pm/roadmap/work-log-automation/phase-27-usability-improvements/evidence-story-07.md new file mode 100644 index 0000000..b4b1569 --- /dev/null +++ b/pmo-roadmap/pm/roadmap/work-log-automation/phase-27-usability-improvements/evidence-story-07.md @@ -0,0 +1,186 @@ +# Evidence - WLA-27-07 + +- **Story:** WLA-27-07 - Turn decisions, blockers, permissions, and cost into + actions +- **Status:** done +- **Date:** 2026-07-25 + +## One source-backed bounded-actions model + +The new pure +[`bounded_actions.py`](../../../../lib/dw_pmo/bounded_actions.py) projection +attaches `delivery-workbench-bounded-actions@1` to both the canonical +bounded-run and program views. Workbench, CLI JSON, MCP, HTTP, and SSE +therefore receive the same action facts rather than reconstructing safety +semantics in each renderer. + +The projection consumes only already-derived controls, outstanding requests, +blockers, permission, limits, progress, failures, and receipts. It reports +false for starting work, writing events, selecting an action or next work, +granting authority, changing retry policy, and sending notifications. + +## Decisions and blockers become bounded choices + +Every inbox item names: + +1. the affected work; +2. why it cannot proceed; +3. who or what can resolve it; +4. every currently valid choice and its consequence; and +5. what happens when no choice is made. + +Decision choices come only from the exact current run request or program +checkpoint controls. A response retains its exact request binding for the +existing preview and apply boundary. Stale, closed, mismatched, unauthorized, +and duplicate responses continue to refuse in the canonical core; the +application neither widens the response set nor suggests an ambiguous retry. + +## Permission, cost, and consequence-first controls + +The action center puts permission before action: concrete allowed effects, +scope, ceilings, expiry and stop conditions, current consumption, and still +forbidden effects. Each usage row keeps limit, estimate, actual use, and +remaining capacity separate. Finite values, zero, explicit unbounded state, +unknown values, and not-applicable measures cannot collapse into one display. +Incomparable units are never added. + +Continue and saved repair, pause, resume, permanent revoke, cancel, rejection, +unavailable retry, and separate permission elevation all have distinct labels +and consequences. Available mutations first open the existing exact preview. +No state change occurs until its fresh token is confirmed. Completion then +shows a readable receipt with its exact reference under **Technical details**. +Read-only reload, inspect-limits, inspect-failure, decide-later, leave, and +technical-inspection paths require no confirmation and start no work. + +## Refusal and recovery language + +Failures state what happened, what stayed unchanged, whether an effect may +already have occurred, the safe next step, and how to inspect exact evidence. +A conclusive stale or inapplicable preview says no effect occurred. A lost or +inconclusive transport result says the effect is unknown until canonical +history is reloaded; it never recommends a blind retry. + +Run and program receipts remain ledger-derived. Browser snapshots preserve the +last verified state, exact-action previews remain pure, and already-applied +outcomes are discovered by replay rather than inferred from transport success. + +## Notifications and remote response boundary + +Run and program request notifications now carry affected work, the exact +closed choices, and what follows each choice. Telegram can route a typed +program checkpoint response as well as a bounded-run response, but the remote +message supplies no token or authority. Canonical local principal, outstanding +request, response-set, ledger, generation, freshness, and exact apply checks +remain decisive. Foreign principals, stale requests, and altered responses +refuse locally. + +## Device and journey proof + +The browser harness renders 88 canonical viewports at 1440x900 and 390x844. +Six new wide/narrow capture pairs cover: + +- bounded-run decision actions; +- a decision consequence preview; +- a structured stale refusal; +- program remaining permission and cost; +- a program pause preview; and +- a program stop receipt. + +The usability inventory now contains 23 reachable states. Manual snapshot +inspection confirms that the decision inbox is usable without raw JSON, +permission and usage precede state-changing controls, dangerous consequences +are visible before confirmation, stale responses give a safe reload path, and +exact tokens and receipts remain under **Technical details**. + +## Regression and distribution proof + +- Six focused bounded-actions and run/program surface parity tests pass in + 5.969 seconds. +- The Telegram suite passes all 153 tests (nine optional Pillow cases skipped), + including owner, stranger, stale-correlation, and program response-carrier + paths. +- The complete core suite passes all 488 tests in 956.214 seconds with the new + projection and unchanged exact authority behavior. +- The Workbench browser suite passes all 88 desktop/mobile renders. A retained + `.tmp/wla27-story07-final` pass was visually inspected after correcting the + fixture to use a real outstanding decision and making snapshot focus + independent of browser scroll behavior. +- The package smoke builds and installs the Python 3.9 wheel, requires the + packaged bounded-actions module/export, and keeps guided status, deliberate + step, bounded orchestration, outward signals, autonomous delivery, and the + dormant no-program consumer green. +- The packaged autonomous exam completes three stories across two phases with + 203 replayed/streamed events, nine conductor and eighteen delivery-boundary + crash recoveries, three commits/pushes, one repair round, and zero duplicate + starts. +- Product-language, usability-journey, source/installed HTTP explorer, + Telegram, docs/snippets/canon, syntax, source/vendor byte identity, + update-alignment, and diff checks pass. The executable inventories report + ten product concepts, eighteen surfaces, eighteen reserved terms, thirteen + language fixtures, thirteen journeys, twenty-three reachable states, and + six red journey fixtures. + +The certification command and its exact output are recorded by the story's +captured evidence run and guarded commit contract. + +### Captured run — 2026-07-25T16:14:54Z + +- **Command:** `bash -o pipefail -c set -e +python3 pmo-roadmap/tests/dw-core-tests.py BoundedActionsProjectionTest OrchestrationConductorTest.test_run_view_is_pure_rich_and_excludes_private_semantics OrchestrationConductorTest.test_run_view_static_contract_has_consent_privacy_and_no_poller ProgramSurfaceTest.test_cli_mcp_http_view_tail_and_sse_are_one_canonical_document +python3 pmo-roadmap/tests/telegram-interface-tests.py NotificationDecisionTest +python3 pmo-roadmap/tests/product-language-contract.py +python3 pmo-roadmap/tests/usability-journey-contract.py +bash pmo-roadmap/tests/docs-lint.sh +bash pmo-roadmap/tests/canon-lint.sh +node --check pmo-roadmap/workbench/app.js +bash -n pmo-roadmap/tests/workbench-ui-smoke.sh +python3 -m py_compile pmo-roadmap/lib/dw_pmo/bounded_actions.py pmo-roadmap/lib/dw_pmo/notifications.py pmo-roadmap/lib/dw_pmo/orchestration_surface.py pmo-roadmap/lib/dw_pmo/program_run.py pmo-roadmap/lib/dw_pmo/program_surface.py integrations/telegram/dw_telegram/interface.py integrations/telegram/dw_telegram/rails.py +cmp pmo-roadmap/lib/dw_pmo/bounded_actions.py .githooks/dw_pmo/bounded_actions.py +cmp pmo-roadmap/lib/dw_pmo/__init__.py .githooks/dw_pmo/__init__.py +cmp pmo-roadmap/lib/dw_pmo/notifications.py .githooks/dw_pmo/notifications.py +cmp pmo-roadmap/lib/dw_pmo/orchestration_surface.py .githooks/dw_pmo/orchestration_surface.py +cmp pmo-roadmap/lib/dw_pmo/program_run.py .githooks/dw_pmo/program_run.py +cmp pmo-roadmap/lib/dw_pmo/program_surface.py .githooks/dw_pmo/program_surface.py +cmp pmo-roadmap/workbench/app.js .githooks/workbench/app.js +cmp pmo-roadmap/workbench/style.css .githooks/workbench/style.css +.githooks/dw check work-log-automation +.githooks/dw rider docs --check +bash pmo-roadmap/update.sh . --check +git diff --check` +- **Cwd:** . +- **Exit code:** 0 +- **Index-tree:** b238ce00cefe48dd09f8c8a9f45277a83c7678fc + +```text +test_measurements_never_confuse_zero_unbounded_unknown_or_na (__main__.BoundedActionsProjectionTest.test_measurements_never_confuse_zero_unbounded_unknown_or_na) ... ok +test_program_request_and_remote_guidance_never_mint_authority (__main__.BoundedActionsProjectionTest.test_program_request_and_remote_guidance_never_mint_authority) ... ok +test_run_decisions_blockers_permission_and_actions_are_closed (__main__.BoundedActionsProjectionTest.test_run_decisions_blockers_permission_and_actions_are_closed) ... ok +test_run_view_is_pure_rich_and_excludes_private_semantics (__main__.OrchestrationConductorTest.test_run_view_is_pure_rich_and_excludes_private_semantics) ... ok +test_run_view_static_contract_has_consent_privacy_and_no_poller (__main__.OrchestrationConductorTest.test_run_view_static_contract_has_consent_privacy_and_no_poller) ... ok +test_cli_mcp_http_view_tail_and_sse_are_one_canonical_document (__main__.ProgramSurfaceTest.test_cli_mcp_http_view_tail_and_sse_are_one_canonical_document) ... dw-workbench: 127.0.0.1 "GET /api/programs/program-ebdb26de7b7d4f89fbf0acf8/events?from=0&follow=0 HTTP/1.1" 200 - +ok + +---------------------------------------------------------------------- +Ran 6 tests in 4.879s + +OK +test_decision_applies_through_the_rails_for_the_owner (__main__.NotificationDecisionTest.test_decision_applies_through_the_rails_for_the_owner) ... ok +test_decision_from_a_stranger_is_refused (__main__.NotificationDecisionTest.test_decision_from_a_stranger_is_refused) ... ok +test_decision_refuses_stale_correlation_and_bad_usage (__main__.NotificationDecisionTest.test_decision_refuses_stale_correlation_and_bad_usage) ... ok +test_program_response_is_carried_to_the_local_exact_boundary (__main__.NotificationDecisionTest.test_program_response_is_carried_to_the_local_exact_boundary) ... ok +test_push_pass_sends_outbound_and_records_delivery (__main__.NotificationDecisionTest.test_push_pass_sends_outbound_and_records_delivery) ... ok +test_push_pass_without_pairing_sends_nothing (__main__.NotificationDecisionTest.test_push_pass_without_pairing_sends_nothing) ... ok + +---------------------------------------------------------------------- +Ran 6 tests in 0.115s + +OK +product-language-contract: ok (10 concepts, 18 surfaces, 18 reserved terms, 13 fixtures) +usability-journey-contract: ok (13 journeys, 23 reachable states, 6 red fixtures; baseline 88 steps, 38 decisions, 81 engineering terms, 13 dead ends, 26 context switches) +docs-lint: ok (470 markdown files) +docs-lint.sh: ok (1s) +canon-lint.sh: ok +dw check: ok +dw rider docs: all rendered surfaces match canon +update.sh: up to date (vendored rails match source v1.14.0) +``` diff --git a/pmo-roadmap/pm/roadmap/work-log-automation/phase-27-usability-improvements/story-07-turn-decisions-blockers-permissions-and-cost-into-actions.md b/pmo-roadmap/pm/roadmap/work-log-automation/phase-27-usability-improvements/story-07-turn-decisions-blockers-permissions-and-cost-into-actions.md index 2553cc9..1e1506f 100644 --- a/pmo-roadmap/pm/roadmap/work-log-automation/phase-27-usability-improvements/story-07-turn-decisions-blockers-permissions-and-cost-into-actions.md +++ b/pmo-roadmap/pm/roadmap/work-log-automation/phase-27-usability-improvements/story-07-turn-decisions-blockers-permissions-and-cost-into-actions.md @@ -2,7 +2,7 @@ - **Project:** work-log-automation - **Phase:** 27 -- **Status:** backlog +- **Status:** done - **Depends on:** WLA-27-03, WLA-27-05, WLA-27-06 - **Unblocks:** WLA-27-08, WLA-27-09, WLA-27-10 - **Owner:** unassigned @@ -32,25 +32,25 @@ or already-applied requests. ## Acceptance criteria -- [ ] Every blocker says what work is affected, why it cannot proceed, who or +- [x] Every blocker says what work is affected, why it cannot proceed, who or what can resolve it, which choices are currently valid, and what will happen after each choice. -- [ ] Decision controls are generated from the exact outstanding request and +- [x] Decision controls are generated from the exact outstanding request and closed response set; stale, revoked, already-applied, unauthorized, or mismatched responses refuse without an ambiguous retry. -- [ ] Before granting or consuming permission, the application states the +- [x] Before granting or consuming permission, the application states the concrete effects allowed, scope, ceilings, expiry/stop conditions, current consumption, and what remains forbidden in ordinary language. -- [ ] Cost and progress distinguish limits, estimates, actual consumption, +- [x] Cost and progress distinguish limits, estimates, actual consumption, remaining capacity, and unknown/not-applicable values; zero and unbounded cannot be confused. -- [ ] Pause, stop, revoke, cancel, reject, retry, and resume are distinct +- [x] Pause, stop, revoke, cancel, reject, retry, and resume are distinct actions with consequences shown before confirmation and exact receipts after completion. -- [ ] Errors and refusals state what happened, what state remained unchanged, +- [x] Errors and refusals state what happened, what state remained unchanged, whether an effect may already have occurred, the safe next step, and how to inspect exact technical evidence. -- [ ] Notification/remote presentation may carry or draft a response but +- [x] Notification/remote presentation may carry or draft a response but cannot manufacture authority; canonical principal, token, freshness, and request checks remain decisive. diff --git a/pmo-roadmap/tests/dw-core-tests.py b/pmo-roadmap/tests/dw-core-tests.py index 2958819..16618bb 100755 --- a/pmo-roadmap/tests/dw-core-tests.py +++ b/pmo-roadmap/tests/dw-core-tests.py @@ -7378,6 +7378,14 @@ def test_notifications_derive_ack_and_correlate(self): correlation = entry["request"]["correlation_id"] self.assertRegex(correlation, r"^req-[0-9a-f]{24}$") self.assertNotEqual(correlation, entry["id"]) + guidance = entry["request"]["guidance"] + self.assertEqual( + [item["decision"] for item in guidance["choices"]], + ["approve", "reject"], + ) + self.assertFalse(guidance["transport_grants_authority"]) + self.assertIn("affected work:", entry["outbound"]) + self.assertIn("chat does not grant permission", entry["outbound"]) for excluded in ("sha256:", "--expect", "apply_command"): self.assertNotIn(excluded, entry["outbound"]) self.assertIn("ack: " + entry["id"], entry["outbound"]) @@ -8799,6 +8807,33 @@ def test_run_view_is_pure_rich_and_excludes_private_semantics(self): "decides_recovery", "grants_authority", ): self.assertFalse(progress[key], key) + bounded = first["bounded_actions"] + self.assertEqual( + bounded["kind"], "delivery-workbench-bounded-actions" + ) + self.assertEqual(bounded["context"], "bounded-run") + self.assertEqual( + { + item["action"]: item["available"] + for item in bounded["actions"] + if item.get("action") in {"tick", "pause", "revoke", "cancel"} + }, + { + item["action"]: item["available"] + for item in first["controls"] + if item["action"] in {"tick", "pause", "revoke", "cancel"} + }, + ) + self.assertEqual( + bounded["permission"]["scope"]["story_id"], + first["story"]["id"], + ) + for key in ( + "starts_work", "writes_events", "selects_action", + "selects_next_work", "grants_authority", + "changes_retry_policy", "sends_notifications", + ): + self.assertFalse(bounded[key], key) keys: set[str] = set() def collect(value): @@ -8912,8 +8947,10 @@ def test_run_view_static_contract_has_consent_privacy_and_no_poller(self): for token in ( "Live delivery", "Technical details", "fail checks", "Artifact metadata and lineage", "failure routes", "human checkpoints", "hash-chained receipts", - "preview exact grant", "confirm this exact act", "no automatic continuation", - "No certification, commit, elevation, retry", "close explicit stream", + "preview exact grant", "Actions and decisions", "Before any action", + "Decision and blocker inbox", "Could an effect already have occurred?", + "view.bounded_actions", "exact control catalog", + "close explicit stream", ): self.assertIn(token, run_source) self.assertNotIn("setInterval", run_source) @@ -8922,6 +8959,12 @@ def test_run_view_static_contract_has_consent_privacy_and_no_poller(self): self.assertIn("aria-labelledby=\"run-graph-title\"", run_source) self.assertIn("@media (max-width: 520px)", css) self.assertIn(".run-node.state-active", css) + for token in ( + ".bounded-action-center", ".bounded-permission", + ".bounded-inbox-grid", ".bounded-action-grid", + ".bounded-failure", ".bounded-usage-table", + ): + self.assertIn(token, css) class AgentHooksTest(unittest.TestCase): @@ -13988,6 +14031,343 @@ def test_repair_and_recovery_use_only_canonical_run_facts(self): ) +class BoundedActionsProjectionTest(unittest.TestCase): + """WLA-27-07: readable actions retain exact canonical boundaries.""" + + def test_measurements_never_confuse_zero_unbounded_unknown_or_na(self): + from dw_pmo.bounded_actions import classify_measurement + + cases = [ + (0, {}, "zero", 0), + (17, {}, "finite", 17), + ("unbounded", {}, "unbounded", None), + (None, {}, "unknown", None), + (None, {"applicable": False}, "not-applicable", None), + ] + for value, kwargs, state, normalized in cases: + with self.subTest(state=state): + measured = classify_measurement( + "limit", value, unit="checks", **kwargs + ) + self.assertEqual(measured["state"], state) + self.assertEqual(measured["value"], normalized) + self.assertNotEqual( + classify_measurement("limit", 0)["state"], + classify_measurement("limit", "unbounded")["state"], + ) + + def test_run_decisions_blockers_permission_and_actions_are_closed(self): + from dw_pmo.bounded_actions import build_run_bounded_actions + + correlation = "req-" + "1" * 24 + projection = { + "run_id": "run-" + "2" * 24, + "state": "awaiting-approval", + "project": "sample", + "story": {"id": "SMP-1-01", "title": "Repair checkout"}, + "expires_at": "2026-07-26T00:00:00Z", + "capabilities": ["repository-read", "repository-write"], + "permanent_exclusions": ["publication", "release"], + "budgets": { + "max_agent_starts": { + "used": 2, "limit": 3, "remaining": 1, + }, + "max_nudges": {"used": 0, "limit": 0, "remaining": 0}, + }, + "outstanding_requests": [{ + "correlation_id": correlation, + "kind": "checkpoint", + "origin": "human", + "origin_node": "review-repair", + "schema_summary": "Approve or reject the repaired checkout.", + "response_schema": { + "decision": ["approve", "reject"], + }, + }], + "request_refusals": [{ + "seq": 7, + "correlation_id": "req-" + "3" * 24, + "origin_node": "old-review", + "reason": "correlation-mismatch", + "response_hash": "sha256:" + "4" * 64, + }], + } + decision = { + "blocked": [{ + "node_id": "repair-checkout", + "reason": "failed-check", + }], + } + graph = [{ + "id": "repair-checkout", + "title": "Repair checkout", + "state": "blocked", + }] + controls = [ + { + "action": "tick", "decision": "", "correlation_id": "", + "available": True, "issues": [], "reason_required": False, + "preview_required": True, "starts_work": True, + }, + { + "action": "pause", "decision": "", "correlation_id": "", + "available": True, "issues": [], "reason_required": True, + "preview_required": True, "starts_work": False, + }, + { + "action": "resume", "decision": "", "correlation_id": "", + "available": False, "issues": [ + "cannot resume a run in state awaiting-approval", + ], "reason_required": False, "preview_required": True, + "starts_work": False, + }, + { + "action": "revoke", "decision": "", "correlation_id": "", + "available": True, "issues": [], "reason_required": True, + "preview_required": True, "starts_work": False, + }, + { + "action": "cancel", "decision": "", "correlation_id": "", + "available": True, "issues": [], "reason_required": True, + "preview_required": True, "starts_work": False, + }, + *[ + { + "action": "request", "decision": option, + "correlation_id": correlation, "available": True, + "issues": [], "reason_required": False, + "preview_required": True, "starts_work": False, + } + for option in ("approve", "reject") + ], + { + "action": "retry", "decision": "", "available": False, + "issues": [ + "retry is governed only by the immutable score failure policy", + ], "reason_required": False, "preview_required": False, + "starts_work": False, + }, + ] + progress = { + "progress": {"known_total": 3, "completed": 1}, + "limits": { + "permission": { + "status": "available", + "may_still_use": ["repository read", "repository write"], + "will_not_use": ["publication", "release"], + "summary": "Allowed change types are bounded.", + }, + "counts": [ + { + "id": "max_agent_starts", "label": "work starts", + "unit": "starts", "primary": True, + }, + { + "id": "max_nudges", "label": "follow-up signals", + "unit": "signals", "primary": True, + }, + ], + "expires_at": projection["expires_at"], + }, + "next_step": {"kind": "repair", "action": "tick"}, + "review": {"failed_evidence": [{"work": "Checkout check"}]}, + } + events = [{ + "seq": 5, + "event": "run_paused", + "ts": "2026-07-25T00:00:00Z", + "event_hash": "sha256:" + "5" * 64, + "detail": {"reason": "Review failure."}, + }] + model = build_run_bounded_actions( + projection, decision, graph, controls, progress, events + ) + + exact_decision = next( + item for item in model["inbox"] + if item["id"] == f"decision:{correlation}" + ) + self.assertEqual( + [item["decision"] for item in exact_decision["valid_choices"]], + ["approve", "reject"], + ) + for item in model["inbox"]: + for key in ( + "affected_work", "why", "resolver", "valid_choices", + "after_no_choice", + ): + self.assertTrue(item[key], (item["id"], key)) + + action_by_name = { + str(item["action"]): item + for item in model["actions"] + if item.get("action") and item["action"] != "request" + } + self.assertEqual( + action_by_name["tick"]["label"], "Retry the bounded repair" + ) + self.assertEqual(action_by_name["tick"]["action"], "tick") + self.assertNotEqual( + action_by_name["pause"]["consequences"]["effect"], + action_by_name["revoke"]["consequences"]["effect"], + ) + self.assertNotEqual( + action_by_name["revoke"]["consequences"]["effect"], + action_by_name["cancel"]["consequences"]["effect"], + ) + self.assertFalse(action_by_name["retry"]["available"]) + self.assertIn( + "does not change retry policy", + action_by_name["tick"]["consequences"]["unchanged"], + ) + + permission = model["permission"] + self.assertEqual(permission["scope"]["story_id"], "SMP-1-01") + self.assertTrue(permission["allowed_effects"]) + self.assertTrue(permission["stop_conditions"]) + self.assertIn("publication", permission["forbidden_effects"]) + usage = { + item["id"]: item for item in model["usage"]["items"] + } + self.assertEqual( + usage["max_nudges"]["measurements"]["limit"]["state"], "zero" + ) + self.assertEqual( + usage["money-cost"]["measurements"]["actual"]["state"], "unknown" + ) + self.assertEqual( + usage["money-cost"]["measurements"]["limit"]["state"], + "not-applicable", + ) + self.assertTrue(model["receipts"][0]["exact_reference"]) + for key in ( + "starts_work", "writes_events", "selects_action", + "selects_next_work", "grants_authority", + "changes_retry_policy", "sends_notifications", + ): + self.assertFalse(model[key], key) + + def test_program_request_and_remote_guidance_never_mint_authority(self): + from dw_pmo.bounded_actions import ( + build_program_bounded_actions, + build_response_guidance, + ) + + request_id = "claim-" + "6" * 24 + authority = { + "run_id": "program-" + "7" * 24, + "state": "checkpoint", + "selection": {"story": "SMP-1-02"}, + "scope": { + "project": "sample", "phases": [1], + "story_ids": ["SMP-1-02"], + }, + "expires_at": "2026-07-26T00:00:00Z", + "capabilities": ["agent:dispatch"], + "permanent_exclusions": ["deployment", "release"], + "stop_conditions": [ + "checkpoint-required", "budget-exhausted", "grant-revoked", + ], + "budgets": { + "max_tokens": { + "used": 8_000, "limit": 12_000, "remaining": 4_000, + }, + }, + "outstanding_requests": [{ + "claim_id": request_id, + "port": "phase-boundary", + "status": "open", + }], + "blocking_obligations": [{ + "id": "debt-1", "target": "SMP-1-02", + "statement": "Verify the migration receipt.", + "accountable_role": "verifier", "blocking": True, + }], + } + frontier = {"stop": "checkpoint", "state": "checkpoint"} + controls = [ + *[ + { + "action": "request", "decision": option, + "request_id": request_id, "available": True, + "reason_required": True, "preview_required": True, + "starts_work": False, + } + for option in ("approve", "reject") + ], + { + "action": "pause", "decision": None, "request_id": None, + "available": True, "reason_required": True, + "preview_required": True, "starts_work": False, + }, + { + "action": "revoke", "decision": None, "request_id": None, + "available": True, "reason_required": True, + "preview_required": True, "starts_work": False, + }, + ] + progress = { + "progress": {"known_total": 1, "completed": 0}, + "limits": { + "permission": { + "status": "available", + "may_still_use": ["agent dispatch"], + "will_not_use": ["deployment", "release"], + "summary": "One exact program scope.", + }, + "counts": [{ + "id": "max_tokens", "label": "model tokens", + "unit": "tokens", "primary": True, + }], + }, + "next_step": {"kind": "decision"}, + "review": {}, + } + model = build_program_bounded_actions( + authority, frontier, controls, progress, [], + refusal=None, receipts=[], + ) + decision = next( + item for item in model["inbox"] + if item["id"] == f"decision:{request_id}" + ) + self.assertEqual( + [item["decision"] for item in decision["valid_choices"]], + ["approve", "reject"], + ) + self.assertEqual( + model["permission"]["stop_conditions"], + ["Checkpoint required", "Budget exhausted", "Grant revoked"], + ) + tokens = next( + item for item in model["usage"]["items"] + if item["id"] == "max_tokens" + ) + self.assertEqual( + tokens["measurements"]["actual"]["value"], 8_000 + ) + self.assertEqual( + tokens["measurements"]["remaining"]["value"], 4_000 + ) + + guidance = build_response_guidance( + context="program", + affected_work="SMP-1-02", + correlation_id=request_id, + decisions=["approve", "reject"], + ) + self.assertEqual( + [item["decision"] for item in guidance["choices"]], + ["approve", "reject"], + ) + self.assertFalse(guidance["transport_grants_authority"]) + self.assertFalse(guidance["grants_authority"]) + self.assertIn( + "the local preview token is fresh for the current ledger and generation", + guidance["decisive_checks"], + ) + + class ProgramSurfaceTest(unittest.TestCase): """WLA-26-11: one exact program control room across every adapter.""" @@ -14257,6 +14637,34 @@ def test_cli_mcp_http_view_tail_and_sse_are_one_canonical_document(self): "decides_recovery", "grants_authority", ): self.assertFalse(progress[key], key) + bounded = expected["bounded_actions"] + self.assertEqual( + bounded["kind"], "delivery-workbench-bounded-actions" + ) + self.assertEqual(bounded["context"], "program") + self.assertEqual( + bounded["permission"]["scope"], expected["scope"] + ) + self.assertEqual( + len(bounded["permission"]["stop_conditions"]), + len(expected["stop_conditions"]), + ) + self.assertTrue(all( + item[:1].isupper() + for item in bounded["permission"]["stop_conditions"] + )) + self.assertEqual( + expected["stop_conditions"], + self.authority.core.replay_program( + self.root, run_id + )["stop_conditions"], + ) + for key in ( + "starts_work", "writes_events", "selects_action", + "selects_next_work", "grants_authority", + "changes_retry_policy", "sends_notifications", + ): + self.assertFalse(bounded[key], key) team_review = expected["team_review"] self.assertEqual( team_review["kind"], "delivery-workbench-team-review" @@ -14553,6 +14961,18 @@ def test_typed_program_request_and_notification_never_confer_authority(self): notification["request"]["response_schema"]["decision"], ["approve", "reject"], ) + self.assertEqual( + [ + item["decision"] + for item in notification["request"]["guidance"]["choices"] + ], + ["approve", "reject"], + ) + self.assertFalse( + notification["request"]["guidance"][ + "transport_grants_authority" + ] + ) self.assertNotIn("act_token", notification["outbound"]) self.assertEqual( ntf.resolve_correlation(self.root, request_id)["id"], diff --git a/pmo-roadmap/tests/fixtures/usability/states-v1.json b/pmo-roadmap/tests/fixtures/usability/states-v1.json index 1586269..7932ac2 100644 --- a/pmo-roadmap/tests/fixtures/usability/states-v1.json +++ b/pmo-roadmap/tests/fixtures/usability/states-v1.json @@ -116,6 +116,7 @@ "capture_id": "orchestration-run-active", "canonical_models": [ "delivery-workbench-live-progress", + "delivery-workbench-bounded-actions", "delivery-workbench-run-view", "delivery-workbench-conductor-decision", "delivery-workbench-run-act-preview" @@ -125,6 +126,7 @@ "view.live_progress.progress", "view.live_progress.next_step", "view.live_progress.recovery", + "view.bounded_actions.actions", "view.graph.scheduled_on_confirm" ] }, @@ -136,6 +138,7 @@ "capture_id": "orchestration-run-stale", "canonical_models": [ "delivery-workbench-live-progress", + "delivery-workbench-bounded-actions", "delivery-workbench-run-view", "delivery-workbench-run-event" ], @@ -162,6 +165,8 @@ "view.live_progress.status", "view.live_progress.review.repair", "view.live_progress.next_step", + "view.bounded_actions.permission", + "view.bounded_actions.actions", "view.routes", "check.outcome" ] @@ -174,6 +179,7 @@ "capture_id": "orchestration-run-terminal", "canonical_models": [ "delivery-workbench-live-progress", + "delivery-workbench-bounded-actions", "delivery-workbench-run-view", "delivery-workbench-decision", "delivery-workbench-run-act-preview" @@ -181,6 +187,8 @@ "fact_paths": [ "view.live_progress.decision", "view.live_progress.next_step", + "view.bounded_actions.inbox", + "view.bounded_actions.actions", "view.outstanding_request", "decision.options", "decision.status" @@ -194,6 +202,7 @@ "capture_id": "program-active", "canonical_models": [ "delivery-workbench-live-progress", + "delivery-workbench-bounded-actions", "delivery-workbench-program-view", "delivery-workbench-program-frontier", "delivery-workbench-program-grant", @@ -204,6 +213,8 @@ "view.live_progress.progress", "view.live_progress.next_step", "view.live_progress.recovery", + "view.bounded_actions.permission", + "view.bounded_actions.actions", "frontier.next_actions" ] }, @@ -215,12 +226,14 @@ "capture_id": "program-revoked", "canonical_models": [ "delivery-workbench-live-progress", + "delivery-workbench-bounded-actions", "delivery-workbench-program-view", "delivery-workbench-program-frontier" ], "fact_paths": [ "view.live_progress.status", "view.live_progress.next_step", + "view.bounded_actions.receipts", "view.current.stop", "frontier.stop", "frontier.next_actions" @@ -344,6 +357,114 @@ "projection.state", "event.previous_hash" ] + }, + { + "id": "bounded-decision-actions", + "title": "Affected work, decision owner, and exact valid choices", + "capability_tier": "bounded-run", + "route": "?orchview=run&boundedfocus=inbox#/orchestration/terminal-visual", + "capture_id": "run-decision-actions", + "canonical_models": [ + "delivery-workbench-bounded-actions", + "delivery-workbench-run-view", + "delivery-workbench-decision" + ], + "fact_paths": [ + "view.bounded_actions.inbox[].affected_work", + "view.bounded_actions.inbox[].resolver", + "view.bounded_actions.inbox[].valid_choices", + "view.outstanding_requests" + ] + }, + { + "id": "bounded-decision-preview", + "title": "Decision consequence before exact confirmation", + "capability_tier": "bounded-run", + "route": "?orchview=run&boundedpreview=decision&boundedfocus=preview#/orchestration/terminal-visual", + "capture_id": "run-decision-preview", + "canonical_models": [ + "delivery-workbench-bounded-actions", + "delivery-workbench-run-act-preview", + "delivery-workbench-run-view" + ], + "fact_paths": [ + "view.bounded_actions.actions", + "preview.action", + "preview.correlation_id", + "preview.act_token" + ] + }, + { + "id": "bounded-action-refusal", + "title": "Structured stale action refusal and safe recovery", + "capability_tier": "bounded-run", + "route": "?orchview=run&boundederror=stale&boundedfocus=error#/orchestration/repair-visual", + "capture_id": "run-action-refusal", + "canonical_models": [ + "delivery-workbench-bounded-actions", + "delivery-workbench-run-act-preview", + "delivery-workbench-run-event" + ], + "fact_paths": [ + "view.bounded_actions.error_contract", + "error.what_happened", + "error.what_stayed_unchanged", + "error.safe_next_step" + ] + }, + { + "id": "program-permission-cost", + "title": "Program permission, consumption, limits, and exclusions", + "capability_tier": "program", + "route": "?boundedfocus=limits#/programs/{run_id}", + "capture_id": "program-remaining-limits", + "canonical_models": [ + "delivery-workbench-bounded-actions", + "delivery-workbench-program-view", + "delivery-workbench-program-grant" + ], + "fact_paths": [ + "view.bounded_actions.permission", + "view.bounded_actions.usage", + "view.budgets", + "view.permanent_exclusions" + ] + }, + { + "id": "program-pause-preview", + "title": "Reversible pause consequence before confirmation", + "capability_tier": "program", + "route": "?boundedpreview=pause&boundedfocus=preview#/programs/{run_id}", + "capture_id": "program-pause-preview", + "canonical_models": [ + "delivery-workbench-bounded-actions", + "delivery-workbench-program-act-preview", + "delivery-workbench-program-view" + ], + "fact_paths": [ + "view.bounded_actions.actions", + "preview.action", + "preview.operation.effect", + "preview.act_token" + ] + }, + { + "id": "program-stop-receipt", + "title": "Permanent stop consequence and exact receipt", + "capability_tier": "program", + "route": "?boundedfocus=receipts#/programs/{run_id}", + "capture_id": "program-stop-receipt", + "canonical_models": [ + "delivery-workbench-bounded-actions", + "delivery-workbench-program-view", + "delivery-workbench-program-event" + ], + "fact_paths": [ + "view.bounded_actions.receipts", + "view.state", + "view.timeline", + "event.event_hash" + ] } ] } diff --git a/pmo-roadmap/tests/orchestration-packaged-exam.py b/pmo-roadmap/tests/orchestration-packaged-exam.py index e2411b6..ec7b47b 100755 --- a/pmo-roadmap/tests/orchestration-packaged-exam.py +++ b/pmo-roadmap/tests/orchestration-packaged-exam.py @@ -596,8 +596,12 @@ def same_observation(*readers): app.index("function runStateBadge"): app.index("/* ── optional Program / Workflow Studio") ] - for token in ("Live delivery", "Technical details", "fail checks", - "failure routes", "human checkpoints", "confirm this exact act"): + for token in ( + "Live delivery", "Technical details", "fail checks", + "failure routes", "human checkpoints", "Actions and decisions", + "Before any action", "Could an effect already have occurred?", + "view.bounded_actions", + ): assert token in run_source assert "setInterval" not in run_source assert "driver_config" not in run_source and "argv:" not in run_source diff --git a/pmo-roadmap/tests/package-smoke.sh b/pmo-roadmap/tests/package-smoke.sh index 191425a..c2f09f5 100755 --- a/pmo-roadmap/tests/package-smoke.sh +++ b/pmo-roadmap/tests/package-smoke.sh @@ -133,6 +133,7 @@ git -C "$FIXTURE" config user.email "package-smoke@example.test" [ -f "$FIXTURE/.githooks/dw_pmo/plan_authoring.py" ] || fail "wheel omitted the task-shaped delivery-plan authoring view" [ -f "$FIXTURE/.githooks/dw_pmo/team_review.py" ] || fail "wheel omitted the understandable team-and-review projection" [ -f "$FIXTURE/.githooks/dw_pmo/live_progress.py" ] || fail "wheel omitted the understandable live-progress projection" +[ -f "$FIXTURE/.githooks/dw_pmo/bounded_actions.py" ] || fail "wheel omitted the understandable bounded-action projection" [ -f "$FIXTURE/.githooks/dw_pmo/program_verdict.py" ] || fail "wheel omitted the governed verdict and quality-gate core" [ -f "$FIXTURE/.githooks/dw_pmo/program_run.py" ] || fail "wheel omitted the finite program grant and ledger core" [ -f "$FIXTURE/.githooks/dw_pmo/program_delivery.py" ] || fail "wheel omitted the exact autonomous program delivery rails" diff --git a/pmo-roadmap/tests/product-language-fixtures-v1.json b/pmo-roadmap/tests/product-language-fixtures-v1.json index 925d1b2..46676bf 100644 --- a/pmo-roadmap/tests/product-language-fixtures-v1.json +++ b/pmo-roadmap/tests/product-language-fixtures-v1.json @@ -179,6 +179,32 @@ }, "expected": [] }, + { + "id": "mixed-bounded-actions", + "classification": "mixed", + "technical_label": "Technical details", + "technical_explicit": true, + "regions": [ + { + "classification": "everyday", + "text": "Blocker: checkout review cannot proceed until Maya approves or rejects the repair. Permission may update checkout in this story until the finite limit or expiry, but cannot merge or release. Cost shows a zero signal limit, unknown money use, and four thousand measured tokens remaining. Review what pause, resume, permanent stop, cancel, and reject each do before confirming. If a response fails, reload the saved state instead of guessing whether to retry." + }, + { + "classification": "technical_audit", + "text": "projection=delivery-workbench-bounded-actions; act_token=sha256:0123; correlation_id=req-01; ledger_head=sha256:4567; receipt_hash=sha256:89ab; grant_generation=2" + } + ], + "concept_names": { + "work": "work", + "review": "review", + "decision": "decision", + "blocker": "blocker", + "permission": "permission", + "cost": "cost", + "next_step": "next step" + }, + "expected": [] + }, { "id": "technical-architecture-language", "classification": "technical_audit", diff --git a/pmo-roadmap/tests/telegram-interface-tests.py b/pmo-roadmap/tests/telegram-interface-tests.py index 3b3aaf9..024b2bb 100644 --- a/pmo-roadmap/tests/telegram-interface-tests.py +++ b/pmo-roadmap/tests/telegram-interface-tests.py @@ -2303,6 +2303,7 @@ def __init__(self, notifications=None, decide_result=None): self.doc = {"notifications": notifications or []} self.decide_result = decide_result or {"state": "active"} self.decisions = [] + self.program_decisions = [] self.deliveries = [] def notifications(self, _repo): @@ -2319,6 +2320,15 @@ def checkpoint_decide(self, _repo, run_id, correlation_id, decision): }) return self.decide_result, "ok" + def program_request_decide( + self, _repo, run_id, correlation_id, decision + ): + self.program_decisions.append({ + "run_id": run_id, "correlation_id": correlation_id, + "decision": decision, + }) + return self.decide_result, "ok" + def _pending_notification(unread=True): return { @@ -2343,6 +2353,28 @@ def _pending_notification(unread=True): } +def _program_pending_notification(unread=True): + request_id = "claim-abc123abc123abc123abc123" + return { + **_pending_notification(unread=unread), + "kind": "program-intervention-required", + "run_id": "program-0123456789abcdef01234567", + "request": { + "correlation_id": request_id, + "response_schema": {"decision": ["approve", "reject"]}, + "boundary": "dw program request (fresh exact act token)", + "guidance": { + "affected_work": "SMP-1-02", + "choices": [ + {"decision": "approve"}, + {"decision": "reject"}, + ], + "transport_grants_authority": False, + }, + }, + } + + class NotificationDecisionTest(InterfaceCase): """WLA-25-06: typed checkpoint responses and the bounded push pass.""" @@ -2364,7 +2396,9 @@ def test_decision_applies_through_the_rails_for_the_owner(self): "decision": "approve", }], ) - self.assertIn("request approve applied", self.last_text()) + self.assertIn( + "exact request response recorded: approve", self.last_text() + ) def test_decision_refuses_stale_correlation_and_bad_usage(self): self.pair() @@ -2374,7 +2408,7 @@ def test_decision_refuses_stale_correlation_and_bad_usage(self): OWNER, "/decision req-abc123abc123abc123abc123 approve" )) self.assertEqual(stub.decisions, []) - self.assertIn("stale or unknown checkpoint correlation", self.last_text()) + self.assertIn("request is stale, closed, or unknown", self.last_text()) self.iface.handle_update(message(OWNER, "/decision onlyone")) self.assertIn("usage: /decision", self.last_text()) stub.doc = {"notifications": [_pending_notification()]} @@ -2383,6 +2417,31 @@ def test_decision_refuses_stale_correlation_and_bad_usage(self): )) self.assertIn("usage: /decision", self.last_text()) + def test_program_response_is_carried_to_the_local_exact_boundary(self): + self.pair() + stub = _StubRails( + notifications=[_program_pending_notification()], + decide_result={ + "state": "checkpoint", + "receipt_hash": "sha256:" + "a" * 64, + }, + ) + self.iface.rails = stub + self.iface.handle_update(message( + OWNER, + "/decision claim-abc123abc123abc123abc123 approve", + )) + self.assertEqual(stub.decisions, []) + self.assertEqual( + stub.program_decisions, + [{ + "run_id": "program-0123456789abcdef01234567", + "correlation_id": "claim-abc123abc123abc123abc123", + "decision": "approve", + }], + ) + self.assertIn("Receipt: sha256:", self.last_text()) + def test_decision_from_a_stranger_is_refused(self): token = new_pairing_token(self.state, self.clock()) self.iface.handle_update(message(OWNER, f"/pair {token}", user=777)) diff --git a/pmo-roadmap/tests/workbench-ui-smoke.sh b/pmo-roadmap/tests/workbench-ui-smoke.sh index e7c9704..fd693b8 100755 --- a/pmo-roadmap/tests/workbench-ui-smoke.sh +++ b/pmo-roadmap/tests/workbench-ui-smoke.sh @@ -79,14 +79,19 @@ for token in ("Live delivery", "What happens next?", "Scope and progress", "Live updates interrupted", "last verified view", "saved delivery state", "live_progress", "fail checks", "failure routes", "human checkpoints", "hash-chained receipts", - "confirm this exact act", "no automatic continuation", + "Actions and decisions", "Before any action", + "Decision and blocker inbox", "Could an effect already have occurred?", + "view.bounded_actions", "exact control catalog", "close explicit stream"): assert token in run, token assert "setInterval" not in run and "driver_config" not in run and "argv:" not in run assert 'aria-labelledby="run-graph-title"' in run assert "@media (max-width: 520px)" in css and ".run-node.state-active" in css for token in (".live-answers", ".live-next", ".live-work-groups", - ".live-two-column", ".live-recovery", ".live-technical"): + ".live-two-column", ".live-recovery", ".live-technical", + ".bounded-action-center", ".bounded-permission", + ".bounded-inbox-grid", ".bounded-action-grid", + ".bounded-failure", ".bounded-usage-table"): assert token in css, token for token in ("Program control room", "liveProgressShell(view.live_progress", "Check for updates", "why this frontier", "team and review", @@ -95,8 +100,9 @@ for token in ("Program control room", "liveProgressShell(view.live_progress", "nested execution", "quality, dissent, and gates", "obligations / debt", "phase progress", "permanently excluded", "operator notifications", "transport ≠ authority", - "Preview, inspect, then confirm", "supervise tick ceiling", - "confirm this exact act", "close explicit stream", + "boundedActionCenterHtml(view.bounded_actions", + "exact control catalog", "program-max-ticks", + "program-act-confirm", "close explicit stream", "/api/programs", "program-ledger", "from=${cursor}"): assert token in program, token assert "setInterval" not in program and "driver_config" not in program @@ -204,7 +210,7 @@ DW="$PMO_DIR/bin/dw" "$PMO_DIR/install.sh" "$REPO" --skip-bootstrap >/dev/null DW="$REPO/.githooks/dw" "$DW" --root "$REPO" story status sample 0 SMP-0-02 in-progress >/dev/null -python3 - "$REPO/pm/orchestration/repair-visual.json" "$REPO/pm/orchestration/terminal-visual.json" <<'PY' +python3 - "$REPO/pm/orchestration/repair-visual.json" "$REPO/pm/orchestration/terminal-visual.json" "$REPO/pm/orchestration/decision-visual.json" <<'PY' import json import sys @@ -248,7 +254,17 @@ terminal = { "layout": {"nodes": {"handoff": {"x": 180, "y": 100}}, "viewport": {"x": 0, "y": 0, "zoom": 1}}, } -for path, document in zip(sys.argv[1:], (repair, terminal)): +decision = { + "kind": "delivery-workbench-orchestration", "schema_version": 1, + "slug": "decision-visual", "title": "Human decision", "project": "sample", + "nodes": [{ + "id": "review", "type": "approval", + "prompt": "Approve or reject the reviewed checkout repair.", + }], + "layout": {"nodes": {"review": {"x": 180, "y": 100}}, + "viewport": {"x": 0, "y": 0, "zoom": 1}}, +} +for path, document in zip(sys.argv[1:], (repair, terminal, decision)): with open(path, "w", encoding="utf-8") as handle: json.dump(document, handle, indent=2) handle.write("\n") @@ -296,6 +312,8 @@ tick_run(root, repair["run_id"], driver_config=config, adapters={"fixture": fixture}, now=now + timedelta(seconds=1)) terminal = start("terminal-visual", 2) tick_run(root, terminal["run_id"], now=now + timedelta(seconds=2)) +decision = start("decision-visual", 3) +tick_run(root, decision["run_id"], now=now + timedelta(seconds=3)) PY PORT=$(( (RANDOM % 2000) + 21000 )) @@ -483,6 +501,17 @@ for spec in $VIEWS; do shot "$name-mobile" 390,844 "$BASE/?snapshot=1$extra$route" done +# WLA-27-07 action journeys: focus the real canonical decision model, its +# exact pure preview, and the structured stale refusal without applying an +# action. Both viewport sizes start at the action context rather than the +# top-of-page delivery recap. +shot "run-decision-actions-desktop" 1440,900 "$BASE/?snapshot=1&orchview=run&boundedfocus=inbox#/orchestration/decision-visual" +shot "run-decision-actions-mobile" 390,844 "$BASE/?snapshot=1&orchview=run&boundedfocus=inbox#/orchestration/decision-visual" +shot "run-decision-preview-desktop" 1440,900 "$BASE/?snapshot=1&orchview=run&boundedpreview=decision&boundedfocus=preview#/orchestration/decision-visual" +shot "run-decision-preview-mobile" 390,844 "$BASE/?snapshot=1&orchview=run&boundedpreview=decision&boundedfocus=preview#/orchestration/decision-visual" +shot "run-action-refusal-desktop" 1440,900 "$BASE/?snapshot=1&orchview=run&boundederror=stale&boundedfocus=error#/orchestration/repair-visual" +shot "run-action-refusal-mobile" 390,844 "$BASE/?snapshot=1&orchview=run&boundederror=stale&boundedfocus=error#/orchestration/repair-visual" + # Program planning remains a deliberately entered optional workspace. These # renders exercise the policy inventory and pure finite-grant form without # creating local program authority. @@ -607,5 +636,11 @@ shot "program-revoked-desktop" 1440,900 "$BASE/?snapshot=1#/programs/$PROGRAM_RE shot "program-revoked-mobile" 390,844 "$BASE/?snapshot=1#/programs/$PROGRAM_REVOKED" shot "program-certified-desktop" 1440,900 "$BASE/?snapshot=1#/programs/$PROGRAM_CERTIFIED" shot "program-certified-mobile" 390,844 "$BASE/?snapshot=1#/programs/$PROGRAM_CERTIFIED" - -echo "workbench-ui-smoke.sh: ok (76 viewport renders: 29 data views + delivery setup/review + program planning/active/technical/certified/revoked + attention + ambiguity, desktop+mobile)" +shot "program-remaining-limits-desktop" 1440,900 "$BASE/?snapshot=1&boundedfocus=limits#/programs/$PROGRAM_ACTIVE" +shot "program-remaining-limits-mobile" 390,844 "$BASE/?snapshot=1&boundedfocus=limits#/programs/$PROGRAM_ACTIVE" +shot "program-pause-preview-desktop" 1440,900 "$BASE/?snapshot=1&boundedpreview=pause&boundedfocus=preview#/programs/$PROGRAM_ACTIVE" +shot "program-pause-preview-mobile" 390,844 "$BASE/?snapshot=1&boundedpreview=pause&boundedfocus=preview#/programs/$PROGRAM_ACTIVE" +shot "program-stop-receipt-desktop" 1440,900 "$BASE/?snapshot=1&boundedfocus=receipts#/programs/$PROGRAM_REVOKED" +shot "program-stop-receipt-mobile" 390,844 "$BASE/?snapshot=1&boundedfocus=receipts#/programs/$PROGRAM_REVOKED" + +echo "workbench-ui-smoke.sh: ok (88 viewport renders: 29 data views + action decision/preview/refusal/limits/stop receipts + delivery setup/review + program planning/active/technical/certified/revoked + attention + ambiguity, desktop+mobile)" diff --git a/pmo-roadmap/workbench/app.js b/pmo-roadmap/workbench/app.js index 9996c29..a3a5642 100644 --- a/pmo-roadmap/workbench/app.js +++ b/pmo-roadmap/workbench/app.js @@ -22,6 +22,9 @@ function esc(s) { const SNAPSHOT_MODE = new URLSearchParams(location.search).has("snapshot"); const SNAPSHOT_LIVE_STATE = new URLSearchParams(location.search).get("liveconnection"); const LIVE_TECHNICAL_OPEN = new URLSearchParams(location.search).has("livetechnical"); +const SNAPSHOT_BOUNDED_FOCUS = new URLSearchParams(location.search).get("boundedfocus"); +const SNAPSHOT_BOUNDED_PREVIEW = new URLSearchParams(location.search).get("boundedpreview"); +const SNAPSHOT_BOUNDED_ERROR = new URLSearchParams(location.search).get("boundederror"); function syncGet(path) { const xhr = new XMLHttpRequest(); @@ -1179,7 +1182,7 @@ let orchState = { name: "", score: null, exists: false, selected: null, view: "design", preview: null, inventory: [], validationTimer: null, jsonDraft: "", runInventory: [], runs: [], runId: "", runView: null, runLoading: false, - runError: "", runPlan: null, runAct: null, runStream: null, + runError: "", runPlan: null, runAct: null, runResult: null, runStream: null, runConnection: { status: SNAPSHOT_LIVE_STATE === "stale" ? "stale" : "checking" }, grantDraft: { project: "", story: "", operator: "", minutes: 60 }, controlReason: "", @@ -1567,25 +1570,15 @@ function runTimelineHtml(view) { } function runControlsHtml(view) { - const available = (view.controls || []).filter((control) => control.available); - const unavailable = (view.controls || []).filter((control) => !control.available); - const requestControls = available.filter((control) => control.action === "request"); - if (view.terminal && !requestControls.length) return `
terminal handoff${esc(view.state)}

${esc(view.terminal_meaning)}

No certification, commit, elevation, retry, or apply control is exposed in this state.

`; - const shown = view.terminal ? requestControls : available; - return `
${view.terminal ? `
terminal handoff with a typed request${esc(view.state)}

${esc(view.terminal_meaning)}

Only the correlated request response is available; certification, commit, elevation, and retry remain absent.

` : ""}
separate act boundaryPreview, inspect, then confirm exactly one control
${badge("no automatic continuation", "warn")}
- ${available.some((control) => control.reason_required) ? `` : ""} -
${shown.map((control) => ``).join("") || 'No bounded control is applicable.'}
-
${unavailable.map((control) => `
${esc(control.action)}${control.decision ? ` · ${esc(control.decision)}` : ""}${esc((control.issues || []).join("; "))}
`).join("")}
+ return `
exact control catalogApplicability copied from the current saved run
${badge("inspection only", "ok")}
+
${(view.controls || []).map((control, index) => `
${esc(control.action)}${control.decision ? ` · ${esc(control.decision)}` : ""}${control.available ? "available through the ordinary action review above" : esc((control.issues || []).join("; ") || "not applicable in the current state")}/controls/${esc(index)}
`).join("")}
`; } function runActPreviewHtml(preview) { if (!preview) return ""; - return ``; + return `
Exact run preview
state + intent token${esc(preview.act_token)}

Observed ${esc(preview.state)} at generation ${esc(preview.control_generation)} and ledger ${esc(preview.ledger_head)}.

+ ${preview.correlation_id ? `

bound request: ${esc(preview.correlation_id)} · ${esc(preview.response_outcome)}

` : ""}${preview.reason ? `

bound reason: ${esc(preview.reason)}

` : ""}${(preview.issues || []).map((issue) => `

${esc(issue)}

`).join("")}
`; } function runStreamHtml(stream) { @@ -1693,15 +1686,147 @@ function liveLimitsHtml(progress) { return `
Remaining permission and costWhat this delivery may still use
Change permission${esc(permission.status || "unknown")}

${esc(permission.summary || "")}

${(permission.will_not_use || []).length ? `Will not use: ${esc(permission.will_not_use.join(", "))}` : ""}
Money cost${esc(cost.status || "unknown")}

${esc(cost.summary || "")}

${primaryCounts.map((item) => `
${esc(item.label)}${esc(item.remaining)} ${esc(item.unit)} left${esc(item.used)} used of ${esc(item.limit)}
`).join("")}
${limits.expires_at ? `

Permission ends ${esc(limits.expires_at)}.

` : ""}
`; } +function boundedScopeText(scope) { + if (!scope || typeof scope !== "object") return String(scope || "No scope is recorded."); + return Object.entries(scope) + .filter(([, value]) => value !== null && value !== undefined && value !== "") + .map(([key, value]) => `${key.replaceAll("_", " ")}: ${Array.isArray(value) ? value.join(", ") : typeof value === "object" ? JSON.stringify(value) : value}`) + .join(" · ") || "No scope is recorded."; +} + +function boundedMeasurementHtml(measurement) { + const item = measurement || { state: "unknown", unit: "units" }; + if (item.state === "finite" || item.state === "zero") return `${esc(item.value)} ${esc(item.unit)}${esc(item.state)}`; + const label = item.state === "not-applicable" ? "Not applicable" + : item.state === "unbounded" ? "Unbounded" + : "Unknown"; + return `${label}${esc(item.state)}`; +} + +function boundedMeasurementText(measurement) { + const item = measurement || { state: "unknown", unit: "units" }; + if (item.state === "finite" || item.state === "zero") return `${item.value} ${item.unit}`; + return item.state === "not-applicable" ? "not applicable" + : item.state === "unbounded" ? "unbounded" : "unknown"; +} + +function boundedUsageTable(model, all = false) { + const items = (model?.usage?.items || []).filter((item) => all || item.primary !== false); + return `
${items.map((item) => `${["limit", "estimate", "actual", "remaining"].map((kind) => ``).join("")}`).join("")}
MeasureLimitEstimateActualRemaining
${esc(item.label)}${esc(item.category)}${boundedMeasurementHtml(item.measurements?.[kind])}
`; +} + +function boundedPermissionHtml(model) { + const permission = model?.permission || {}; + const allowed = permission.allowed_effects || []; + const forbidden = permission.forbidden_effects || []; + const current = permission.current_use || []; + return `
Before any action

Permission, scope, limits, and cost

${badge(permission.status || "unknown", permission.status === "available" ? "ok" : "warn")}
+
Allowed effects

${allowed.map((item) => badge(String(item).replace(/[_:.-]/g, " "), "ok")).join(" ") || "No change effect is currently available."}

Affected scope

${esc(boundedScopeText(permission.scope))}

Expiry and stops

${permission.expires_at ? `Permission ends ${esc(permission.expires_at)}.` : "No expiry value is recorded."}

    ${(permission.stop_conditions || []).map((item) => `
  • ${esc(item)}
  • `).join("")}
Still forbidden

${forbidden.map((item) => badge(String(item).replace(/[_:.-]/g, " "), "issue")).join(" ") || "No explicit exclusion is recorded."}

+

Current consumption: ${current.slice(0, 8).map((item) => `${esc(item.label)} — ${esc(boundedMeasurementText(item.actual))} used, ${esc(boundedMeasurementText(item.remaining))} remaining`).join(" · ") || "No counted consumption is recorded."}

+ ${boundedUsageTable(model)} +
Every limit and measurement${boundedUsageTable(model, true)}

${esc(model?.usage?.legend?.zero || "")} ${esc(model?.usage?.legend?.unbounded || "")} Unknown and not applicable remain separate.

+
`; +} + +function boundedInboxHtml(model) { + const inbox = model?.inbox || []; + return `
Decision and blocker inbox

${inbox.length ? `${esc(inbox.length)} item${inbox.length === 1 ? "" : "s"} need attention` : "Nothing needs a decision right now"}

${badge(inbox.length ? "attention" : "clear", inbox.length ? "warn" : "ok")}
+
${inbox.map((item) => `
${esc(item.kind)}${badge(item.status, item.kind === "refusal" ? "issue" : "warn")}

${esc(item.affected_work)}

Why it cannot proceed
${esc(item.why)}
Who or what resolves it
${esc(item.resolver)}
Valid choices and what follows
    ${(item.valid_choices || []).map((choice) => `
  • ${esc(choice.label)}${choice.available ? "" : " — unavailable"}${esc(choice.effect)} ${esc(choice.after)}
  • `).join("") || "
  • Inspect onlyNo state-changing choice is currently valid; review exact evidence.
  • "}

If you do nothing: ${esc(item.after_no_choice)}

${item.explanation ? boundedFailureDetailsHtml(item.explanation, "Recorded refusal") : ""}
Technical details${esc(item.technical_reference || "no exact reference")}
`).join("") || '

The saved state has no blocker, pending decision, or refusal.

'}
+
`; +} + +function boundedFailureDetailsHtml(explanation, label = "Action could not be completed") { + const item = explanation || {}; + const effect = item.effect_answer === "no" || item.effect_may_have_occurred === false + ? "No — this refusal records no effect." + : item.effect_answer === "yes" || item.effect_may_have_occurred === true + ? "Yes — inspect the saved receipt before another action." + : "Unknown — reload the saved history before another action."; + return ``; +} + +function boundedErrorHtml(message) { + if (!message) return ""; + const refusedBeforeEffect = /before work|no event was appended|no decision was applied|no grant was created/i.test(message); + return boundedFailureDetailsHtml({ + what_happened: message, + what_stayed_unchanged: refusedBeforeEffect + ? "No work or saved event changed at this refusal boundary." + : "The last verified view remains visible; no alternative action was inferred.", + effect_may_have_occurred: refusedBeforeEffect ? false : null, + safe_next_step: "Reload the saved state, inspect the exact history, and preview only a currently available action.", + technical_evidence: { message }, + }); +} + +function boundedActionMatch(model, preview, target) { + if (!preview) return null; + return (model?.actions || []).find((item) => item.action === preview.action + && String(item.decision || "") === String(preview.decision || "") + && String(item.correlation_id || "") === String(target === "program" ? preview.request_id || "" : preview.correlation_id || "")); +} + +function boundedPreviewHtml(model, preview, target) { + if (!preview) return ""; + const action = boundedActionMatch(model, preview, target); + const consequences = action?.consequences || {}; + const applicable = Boolean(preview.applicable); + const exact = target === "run" ? runActPreviewHtml(preview) : `
Exact program preview
state + ledger + parameter token${esc(preview.act_token)}

Observed ${esc(preview.state)} at generation ${esc(preview.generation)} and ledger ${esc(preview.ledger_head)}.

lane: ${esc(preview.operation?.lane || "—")} · next: ${esc(programScalar(preview.operation?.next_action))}

${(preview.issues || []).map((issue) => `

${esc(issue)}

`).join("")}
`; + const refusal = applicable ? "" : boundedFailureDetailsHtml({ + what_happened: (preview.issues || []).join("; ") || "The current saved state refused this preview.", + what_stayed_unchanged: "Previewing changed no work, permission, cost, or saved event.", + effect_may_have_occurred: false, + safe_next_step: "Close this preview and choose only a currently available action.", + technical_evidence: { action: preview.action, issues: preview.issues || [] }, + }, "Preview refused"); + return `
Review before confirmation

${esc(action?.label || preview.action)}

${badge(preview.starts_work ? "may start bounded work" : "one saved action", preview.starts_work ? "warn" : "ok")}
What this will do
${esc(consequences.effect || "Apply only this exact reviewed operation.")}
What it will not do
${esc(consequences.unchanged || "It will not broaden permission or select a different action.")}
What follows
${esc(consequences.after || "The saved state and receipt will be reloaded.")}
${refusal}${exact}
`; +} + +function boundedReceiptsHtml(model, result) { + const receipts = model?.receipts || []; + const resultHtml = result ? `
Just completed${badge("recorded", "ok")}

${esc(result.kind || "Bounded action completed")}

${esc(result.stop || result.state || result.result || result.decision || "The saved operation completed.")}

Exact receipt
${esc(JSON.stringify(result, null, 2))}
` : ""; + return `
After completion

Readable receipts

${badge(`${receipts.length + (result ? 1 : 0)} shown`, "ok")}
${resultHtml}${receipts.map((item) => `
${esc(item.action)}${badge(item.outcome || "recorded", "ok")}

${esc(item.label)}

${esc(item.at || "time recorded in exact history")}

Exact receipt${esc(item.exact_reference || "see ordered history")}
`).join("") || (!result ? '

No bounded action receipt has been recorded yet.

' : "")}
`; +} + +function boundedActionButtonsHtml(model, target) { + const actions = model?.actions || []; + const read = actions.filter((item) => item.kind === "read"); + const controls = actions.filter((item) => item.kind !== "read"); + const controlButton = (item) => { + const attrs = target === "run" + ? `data-run-act="${esc(item.action)}" data-run-decision="${esc(item.decision || "")}" data-run-correlation="${esc(item.correlation_id || "")}"` + : `data-program-act="${esc(item.action)}" data-program-decision="${esc(item.decision || "")}" data-program-request="${esc(item.correlation_id || "")}"`; + return `
${esc(item.kind)}${badge(item.available ? "available" : "unavailable", item.available ? "ok" : "warn")}

${esc(item.label)}

${esc(item.consequences?.effect)}

Then: ${esc(item.consequences?.after)}${item.available ? `` : `

${esc(item.issue)}

`}
`; + }; + return `
${read.map((item) => ``).join("")}
${controls.map(controlButton).join("")}
`; +} + +function boundedActionCenterHtml(model, preview, error, result, target) { + if (!model) return ""; + const available = (model.actions || []).filter((item) => item.available); + const needsReason = available.some((item) => item.reason_required); + const hasSupervise = available.some((item) => item.action === "supervise"); + const reason = target === "run" ? orchState.controlReason : programState.reason; + return `
Actions and decisions

Understand the consequence, then review one exact action

${esc(model.summary)}

${badge("nothing applies without confirmation", "warn")}
+ ${boundedInboxHtml(model)} + ${boundedPermissionHtml(model)} +
Available choices

Pause, resume, stop, cancel, reject, and continue stay distinct

${needsReason ? `` : ""}${hasSupervise ? `
` : ""}${boundedActionButtonsHtml(model, target)}
+ ${boundedErrorHtml(error)} + ${boundedPreviewHtml(model, preview, target)} + ${boundedReceiptsHtml(model, result)} +
`; +} + function liveActivityHtml(progress) { const activity = progress.activity || []; return `
Readable activityRelated work and outcomes grouped together
${badge(`${activity.length} groups`)}
    ${activity.map((item) => `
  1. ${esc(item.title)}${badge(item.status, ["active", "complete"].includes(item.status) ? "ok" : ["blocked"].includes(item.status) ? "issue" : "warn")}

    ${esc(item.summary || "")}

    ${(item.outcomes || []).length ? `Outcomes: ${esc(item.outcomes.join(", "))}` : ""}
  2. `).join("") || "
  3. No delivery activity has been recorded yet.

  4. "}
`; } -function liveProgressShell(progress, connection, toolbar, technicalHtml, technicalOpen = false) { +function liveProgressShell(progress, connection, toolbar, actionHtml, technicalHtml, technicalOpen = false) { const ordinary = `
Delivery state${esc(progress.status?.label)}

${esc(progress.status?.meaning)}

Current scope${esc(progress.delivery?.scope || "")}

${esc(progress.delivery?.current_story || progress.delivery?.work_id || "")}

${liveAnswerGrid(progress)} ${liveNextHtml(progress)} + ${actionHtml} ${liveProgressGroups(progress)}
${livePeopleHtml(progress)}${liveReviewHtml(progress)}
${liveLimitsHtml(progress)} @@ -1726,11 +1851,73 @@ function openLiveTechnical() { details.querySelector("summary")?.focus(); } +async function handleBoundedRead(action, target) { + if (action === "reload") { + if (target === "run") await refreshRunData(); + else await refreshProgramView(); + return; + } + if (action === "technical") { + openLiveTechnical(); + return; + } + if (action === "leave") { + if (target === "run") { + orchState.runAct = null; orchState.runError = ""; + renderOrchestration(); + } else { + programState.act = null; programState.error = ""; + renderPrograms(); + } + return; + } + const selector = action === "limits" + ? '[data-bounded-section="limits"]' + : '[data-bounded-section="failure"]'; + const section = document.querySelector(selector); + section?.scrollIntoView({ + behavior: SNAPSHOT_MODE ? "auto" : "smooth", + block: "start", + }); + section?.querySelector("h3, h4")?.setAttribute("tabindex", "-1"); + section?.querySelector("h3, h4")?.focus(); +} + +function focusBoundedSnapshot() { + if (!SNAPSHOT_MODE || !SNAPSHOT_BOUNDED_FOCUS) return; + const selectors = { + actions: ".bounded-action-center", + inbox: ".bounded-inbox", + limits: ".bounded-permission", + preview: ".bounded-preview", + error: ".bounded-failure", + receipts: ".bounded-receipts", + }; + const focus = () => { + const target = document.querySelector( + selectors[SNAPSHOT_BOUNDED_FOCUS] || ".bounded-action-center" + ); + if (!target) return; + const center = target.closest(".bounded-action-center"); + const live = center?.closest(".live-delivery"); + const hero = center?.querySelector(".bounded-action-hero"); + if (center && target !== center && hero) hero.after(target); + const header = live?.querySelector(".live-header"); + if (center && header) header.after(center); + const top = target.getBoundingClientRect().top + window.scrollY - 8; + window.scrollTo({ top: Math.max(0, top), behavior: "auto" }); + }; + focus(); + requestAnimationFrame(() => requestAnimationFrame(focus)); + setTimeout(focus, 100); +} + function runViewHtml() { if (orchState.runLoading) return `
${stateHtml("Replaying the authoritative run ledger…")}
`; const error = orchState.runError ? `` : ""; if (!orchState.runs.length || !orchState.runView) return `
${error}${runEmptyHtml()}
`; const view = orchState.runView; + const actions = boundedActionCenterHtml(view.bounded_actions, orchState.runAct, orchState.runError, orchState.runResult, "run"); const toolbar = `
`; const technical = `
exact state${esc(view.state)}${esc(view.terminal_meaning)}
ledger${esc(view.ledger_events)} events${esc(view.ledger_head)}
attempts${esc(view.attempts.active.length)} active · ${esc(view.attempts.completed.length)} completegeneration ${esc(view.control_generation)}
authority${view.dispatch_allowed ? "dispatch permitted" : "dispatch stopped"}${view.expired ? "grant expired" : "grant fresh by time"}
${runBudgetHtml(view.budgets)} @@ -1739,10 +1926,10 @@ function runViewHtml() {
declared output conventionsArtifact metadata and lineage
${runArtifactHtml(view)}
typed human request portsOutstanding requests, age, origin, schemas, and checkpoint lineage
${badge("inspect-only history", "ok")}
${runRequestsHtml(view)}
${runRoutesHtml(view)}
- ${runControlsHtml(view)}${runActPreviewHtml(orchState.runAct)} + ${runControlsHtml(view)}
operator notificationsDerived from the ledger and signal chains; ack is receipted
${badge("previews, never tokens", "ok")}
${runNotificationsHtml(view)}
hash-chained receiptsRun ledger timeline
${badge("content-safe metadata", "ok")}
${runTimelineHtml(view)}
`; - return `
${error}${liveProgressShell(view.live_progress, orchState.runConnection, toolbar, technical, Boolean(orchState.runAct || orchState.runStream))}
`; + return `
${liveProgressShell(view.live_progress, orchState.runConnection, toolbar, actions, technical, Boolean(orchState.runStream))}
`; } function runNotificationsHtml(view) { @@ -2119,7 +2306,7 @@ async function confirmRunGrant() { async function previewRunAct(action, decision, correlation) { const control = (orchState.runView?.controls || []).find((item) => item.action === action && String(item.decision || "") === String(decision || "") && String(item.correlation_id || "") === String(correlation || "")); const reason = control?.reason_required ? orchState.controlReason.trim() : ""; - orchState.runAct = null; orchState.runError = ""; renderOrchestration(); + orchState.runAct = null; orchState.runError = ""; orchState.runResult = null; renderOrchestration(); const { status, body } = await postJson("/api/runs/preview", { run_id: orchState.runId, action, ...(reason ? { reason } : {}), ...(decision ? { decision } : {}), ...(correlation ? { correlation_id: correlation } : {}) }); if (status >= 400 || body.ok === false) { orchState.runError = (body.issues && body.issues[0]) || `run preview failed (${status})`; } else orchState.runAct = body.data; @@ -2135,7 +2322,7 @@ async function confirmRunAct() { orchState.runLoading = false; if (status === 409) { orchState.runAct = null; orchState.runError = "Stale run act refused before work or ledger change. Refresh once and preview the current state."; renderOrchestration(); return; } if (status >= 400 || body.ok === false) { orchState.runError = (body.issues && body.issues[0]) || `run act failed (${status})`; renderOrchestration(); return; } - orchState.runAct = null; orchState.controlReason = ""; await refreshRunData(); + orchState.runResult = body.data; orchState.runAct = null; orchState.controlReason = ""; await refreshRunData(); } async function openRunStream(button) { @@ -2149,7 +2336,7 @@ async function openRunStream(button) { function wireRunView() { document.getElementById("run-refresh")?.addEventListener("click", refreshRunData); document.querySelector("[data-live-technical]")?.addEventListener("click", openLiveTechnical); - document.getElementById("run-select")?.addEventListener("change", async (event) => { orchState.runId = event.target.value; orchState.runAct = null; orchState.runStream = null; await refreshRunData(); }); + document.getElementById("run-select")?.addEventListener("change", async (event) => { orchState.runId = event.target.value; orchState.runAct = null; orchState.runResult = null; orchState.runStream = null; await refreshRunData(); }); document.getElementById("run-grant-form")?.addEventListener("submit", (event) => { event.preventDefault(); previewRunGrant(event.currentTarget); }); document.getElementById("run-start-confirm")?.addEventListener("click", confirmRunGrant); document.getElementById("run-plan-close")?.addEventListener("click", () => { orchState.runPlan = null; renderOrchestration(); }); @@ -2157,6 +2344,7 @@ function wireRunView() { document.querySelectorAll("[data-run-act]").forEach((button) => button.addEventListener("click", () => previewRunAct(button.dataset.runAct, button.dataset.runDecision, button.dataset.runCorrelation))); document.getElementById("run-act-confirm")?.addEventListener("click", confirmRunAct); document.getElementById("run-act-close")?.addEventListener("click", () => { orchState.runAct = null; renderOrchestration(); }); + document.querySelectorAll("[data-bounded-read]").forEach((button) => button.addEventListener("click", () => handleBoundedRead(button.dataset.boundedRead, "run"))); document.querySelectorAll("[data-run-stream]").forEach((button) => button.addEventListener("click", () => openRunStream(button))); document.querySelectorAll("[data-ntf-ack]").forEach((button) => button.addEventListener("click", () => ackNotification(button.dataset.ntfAck))); document.getElementById("run-stream-close")?.addEventListener("click", () => { orchState.runStream = null; renderOrchestration(); }); @@ -2206,6 +2394,7 @@ async function viewOrchestration(name) { orchState.score = minimalScore(); orchState.name = orchState.score.slug; orchState.exists = false; orchState.preview = null; } orchState.selected = null; orchState.jsonDraft = ""; + orchState.runAct = null; orchState.runResult = null; orchState.runError = ""; selectScoreRuns(); const requestedView = new URLSearchParams(location.search).get("orchview"); if (["design", "validate", "json", "run"].includes(requestedView)) orchState.view = requestedView; @@ -2213,12 +2402,41 @@ async function viewOrchestration(name) { try { orchState.runView = (await api(`/api/runs/${encodeURIComponent(orchState.runId)}/view`)).data; orchState.runConnection.status = SNAPSHOT_LIVE_STATE === "stale" ? "stale" : SNAPSHOT_MODE ? "verified" : "checking"; + if (SNAPSHOT_MODE && SNAPSHOT_BOUNDED_PREVIEW) { + const control = (orchState.runView.controls || []).find((item) => item.available && ( + SNAPSHOT_BOUNDED_PREVIEW === "decision" + ? item.action === "request" && item.decision === "approve" + : item.action === SNAPSHOT_BOUNDED_PREVIEW + )); + if (control) { + orchState.controlReason = control.reason_required + ? "Review this deterministic viewport action." + : ""; + const response = await postJson("/api/runs/preview", { + run_id: orchState.runId, + action: control.action, + ...(orchState.controlReason ? { reason: orchState.controlReason } : {}), + ...(control.decision ? { decision: control.decision } : {}), + ...(control.correlation_id ? { correlation_id: control.correlation_id } : {}), + }); + if (response.status < 400 && response.body.ok !== false) { + orchState.runAct = response.body.data; + } + } + } + if (SNAPSHOT_MODE && SNAPSHOT_BOUNDED_ERROR) { + orchState.runError = SNAPSHOT_BOUNDED_ERROR === "stale" + ? "Stale run action refused before work or saved event change. Reload once and review the current action." + : "The action response ended without a confirmed receipt."; + } } catch (err) { orchState.runError = err.message; orchState.runView = null; } startRunLive(); } renderOrchestration(); + focusBoundedSnapshot(); await refreshOrchValidation(); + focusBoundedSnapshot(); } /* ── autonomous program control room (WLA-26-11) ───────────────── @@ -2345,24 +2563,14 @@ function programQualityHtml(view) { } function programControlsHtml(view) { - const available = (view.controls || []).filter((item) => item.available); - const unavailable = (view.controls || []).filter((item) => !item.available); - return `
separate act boundaryPreview, inspect, then confirm one exact program operation
${badge("no auto-start daemon", "warn")}
- ${available.some((item) => item.reason_required) ? `` : ""} - ${available.some((item) => item.action === "supervise") ? `
` : ""} -
${available.map((item) => ``).join("") || 'No control is applicable in this authority state.'}
-
${unavailable.map((item) => `
${esc(item.action)}${esc(item.issue || "not applicable in the current authority state")}
`).join("")}
- ${programActHtml(programState.act)} + return `
exact control catalogApplicability copied from the current saved program
${badge("inspection only", "ok")}
+
${(view.controls || []).map((item, index) => `
${esc(item.action)}${item.decision ? ` · ${esc(item.decision)}` : ""}${item.available ? "available through the ordinary action review above" : esc(item.issue || "not applicable in the current authority state")}/controls/${esc(index)}
`).join("")}
`; } function programActHtml(preview) { if (!preview) return ""; - return ``; + return `
Exact program preview
state + ledger + parameter token${esc(preview.act_token)}

Observed ${esc(preview.state)} at generation ${esc(preview.generation)} and ledger ${esc(preview.ledger_head)}.

lane: ${esc(preview.operation?.lane || "—")} · next: ${esc(programScalar(preview.operation?.next_action))}

${(preview.issues || []).map((issue) => `

${esc(issue)}

`).join("")}
`; } function programTimelineHtml(view) { @@ -2381,6 +2589,7 @@ function programNotificationsHtml(view) { function programRunHtml(view) { const runs = programState.inventory?.runs || []; const progress = view.phase_progress || {}; + const actions = boundedActionCenterHtml(view.bounded_actions, programState.act, programState.error, programState.result, "program"); const toolbar = `
`; const technical = `${programState.result ? `
bounded operation completed${esc(programState.result.kind)} · ${esc(programState.result.stop || programState.result.state || programState.result.result || "recorded")}
` : ""}
authority${esc(view.state)}${esc(view.terminal_meaning)}
operational frontier${esc(view.operational_state)}${esc(view.current?.stop || "ready")}
ledger${esc(view.event_count)} events${esc(view.ledger_head)}
scope progress${esc((progress.selected_stories || []).length)} selected${esc(programScalar(progress.scope_completion))}
@@ -2393,7 +2602,7 @@ function programRunHtml(view) { ${programControlsHtml(view)}
phase and authority boundaryGranted scope, selected progress, capabilities, and permanent exclusions

phase progress

${esc(JSON.stringify(progress, null, 2))}

capabilities

${(view.capabilities || []).map((item) => badge(item, "ok")).join(" ") || "none"}

permanently excluded

${(view.permanent_exclusions || []).map((item) => badge(item, "warn")).join(" ") || "none"}

hash-chained receiptsProgram authority timeline
${badge("content-safe metadata", "ok")}
${programTimelineHtml(view)}
`; - return `
${programState.error ? `` : ""}${liveProgressShell(view.live_progress, programState.connection, toolbar, technical, Boolean(programState.act || programState.stream))}
`; + return `
${liveProgressShell(view.live_progress, programState.connection, toolbar, actions, technical, Boolean(programState.stream))}
`; } function renderPrograms() { @@ -2535,7 +2744,9 @@ async function confirmProgramAct() { const { status, body } = await postJson(`/api/programs/${encodeURIComponent(preview.action)}`, request); if (status >= 400 || body.ok === false) { programState.act = null; - programState.error = (body.issues && body.issues[0]) || `program act failed (${status})`; + programState.error = status === 409 + ? "Stale program action refused before work or saved event change. Reload once and review the current action." + : (body.issues && body.issues[0]) || `program act failed (${status})`; renderPrograms(); return; } programState.result = body.data; programState.act = null; programState.reason = ""; @@ -2573,6 +2784,7 @@ function wirePrograms() { document.querySelectorAll("[data-program-act]").forEach((button) => button.addEventListener("click", () => previewProgramAct(button))); document.getElementById("program-act-confirm")?.addEventListener("click", confirmProgramAct); document.getElementById("program-act-close")?.addEventListener("click", () => { programState.act = null; renderPrograms(); }); + document.querySelectorAll("[data-bounded-read]").forEach((button) => button.addEventListener("click", () => handleBoundedRead(button.dataset.boundedRead, "program"))); document.querySelectorAll("[data-program-stream]").forEach((button) => button.addEventListener("click", () => openProgramStream(button))); document.querySelectorAll("[data-program-ntf-ack]").forEach((button) => button.addEventListener("click", () => ackProgramNotification(button.dataset.programNtfAck))); document.getElementById("program-stream-close")?.addEventListener("click", () => { programState.stream = null; renderPrograms(); }); @@ -2590,9 +2802,40 @@ async function viewPrograms(runId = "") { if (runId) { programState.view = (await api(`/api/programs/${encodeURIComponent(runId)}/view`)).data; programState.connection.status = SNAPSHOT_LIVE_STATE === "stale" ? "stale" : SNAPSHOT_MODE ? "verified" : "checking"; + if (SNAPSHOT_MODE && SNAPSHOT_BOUNDED_PREVIEW) { + const control = (programState.view.controls || []).find((item) => item.available && ( + SNAPSHOT_BOUNDED_PREVIEW === "decision" + ? item.action === "request" && item.decision === "approve" + : item.action === SNAPSHOT_BOUNDED_PREVIEW + )); + if (control) { + programState.reason = control.reason_required + ? "Review this deterministic viewport action." + : ""; + const response = await postJson("/api/programs/preview", { + run_id: programState.runId, + action: control.action, + ...(programState.reason ? { reason: programState.reason } : {}), + ...(control.decision ? { decision: control.decision } : {}), + ...(control.request_id ? { request_id: control.request_id } : {}), + ...(["tick", "supervise"].includes(control.action) ? { + max_ticks: 100, max_seconds: 300, + } : {}), + }); + if (response.status < 400 && response.body.ok !== false) { + programState.act = response.body.data; + } + } + } + if (SNAPSHOT_MODE && SNAPSHOT_BOUNDED_ERROR) { + programState.error = SNAPSHOT_BOUNDED_ERROR === "stale" + ? "Stale program action refused before work or saved event change. Reload once and review the current action." + : "The action response ended without a confirmed receipt."; + } startProgramLive(); } renderPrograms(); + focusBoundedSnapshot(); } /* ── delivery-shaped front door (WLA-27-03) ──────────────────────── diff --git a/pmo-roadmap/workbench/style.css b/pmo-roadmap/workbench/style.css index ddf878b..44fd582 100644 --- a/pmo-roadmap/workbench/style.css +++ b/pmo-roadmap/workbench/style.css @@ -1152,6 +1152,136 @@ details.blane.closed[open] summary { margin-bottom: 8px; } } .live-technical-intro { color: var(--muted); } +/* ── understandable bounded actions (WLA-27-07) ──────────────── */ +.bounded-action-center { + margin-top: 10px; padding: 14px; border: 1px solid var(--warn); + border-radius: 10px; + background: + radial-gradient(circle at 100% 0, rgba(214,164,75,.11), transparent 31%), + linear-gradient(130deg, rgba(108,182,255,.045), var(--panel) 48%); +} +.bounded-action-hero, .bounded-section-head { + display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; +} +.bounded-action-hero > div, .bounded-section-head > div { min-width: 0; } +.bounded-action-hero > div > span, .bounded-section-head span, +.bounded-permission-grid article > span, .bounded-inbox-item > div > span, +.bounded-action-card > div > span, .bounded-receipt-grid article > div > span { + display: block; color: var(--muted); font: 9px/1.25 var(--mono); + letter-spacing: .07em; text-transform: uppercase; +} +.bounded-action-hero h2 { margin: 4px 0; font-size: 19px; } +.bounded-action-hero p { margin: 3px 0 0; color: var(--muted); } +.bounded-action-center > section { + margin-top: 10px; padding: 11px; border: 1px solid var(--line); + border-radius: 7px; background: rgba(16,20,24,.48); +} +.bounded-section-head h3 { margin: 3px 0 0; font-size: 14px; } +.bounded-inbox-grid, .bounded-action-grid, .bounded-receipt-grid { + display: grid; grid-template-columns: repeat(auto-fit, minmax(235px, 1fr)); + gap: 8px; margin-top: 9px; +} +.bounded-inbox-item, .bounded-action-card, .bounded-receipt-grid article { + min-width: 0; padding: 10px; border: 1px solid var(--line); + border-left: 3px solid var(--warn); border-radius: 6px; background: var(--panel-2); +} +.bounded-inbox-item.kind-refusal, .bounded-action-card.severity-danger { + border-left-color: var(--err); +} +.bounded-inbox-item > div, .bounded-action-card > div, +.bounded-receipt-grid article > div { + display: flex; align-items: center; justify-content: space-between; gap: 7px; +} +.bounded-inbox-item h4, .bounded-action-card h4, +.bounded-receipt-grid h4 { margin: 7px 0 5px; } +.bounded-inbox-item h5 { + margin: 9px 0 4px; color: var(--muted); font: 10px/1.3 var(--mono); + text-transform: uppercase; +} +.bounded-inbox-item dl, .bounded-failure dl, .bounded-consequence { + display: grid; gap: 6px; margin: 7px 0; +} +.bounded-inbox-item dl > div, .bounded-failure dl > div, +.bounded-consequence > div { + padding: 7px; border: 1px solid var(--line); border-radius: 4px; + background: rgba(12,16,20,.35); +} +.bounded-inbox-item dt, .bounded-failure dt, .bounded-consequence dt { + color: var(--muted); font: 9px/1.25 var(--mono); text-transform: uppercase; +} +.bounded-inbox-item dd, .bounded-failure dd, .bounded-consequence dd { + margin: 4px 0 0; overflow-wrap: anywhere; +} +.bounded-inbox-item ul { margin: 5px 0; padding-left: 18px; } +.bounded-inbox-item li { margin: 5px 0; } +.bounded-inbox-item li strong, .bounded-inbox-item li span { display: block; } +.bounded-inbox-item li span, .bounded-inbox-item p, +.bounded-inbox-item details { color: var(--muted); font-size: 10px; } +.bounded-permission { border-color: var(--link) !important; } +.bounded-permission-grid { + display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 7px; margin-top: 9px; +} +.bounded-permission-grid article { + min-width: 0; padding: 9px; border: 1px solid var(--line); + border-radius: 5px; background: var(--panel-2); +} +.bounded-permission-grid p { margin: 6px 0; overflow-wrap: anywhere; } +.bounded-permission-grid ul { margin: 5px 0 0; padding-left: 18px; color: var(--muted); } +.bounded-permission-grid .badge { margin: 0 3px 4px 0; } +.bounded-current-use { + margin: 8px 0; padding: 8px; border-left: 3px solid var(--link); + color: var(--muted); background: rgba(108,182,255,.04); +} +.bounded-current-use strong, .bounded-current-use small { display: inline; } +.bounded-usage-table { margin-top: 8px; } +.bounded-usage-table table { min-width: 720px; } +.bounded-usage-table td strong, .bounded-usage-table td small { + display: block; overflow-wrap: anywhere; +} +.bounded-usage-table td small { color: var(--muted); } +.bounded-all-usage { margin-top: 8px; color: var(--muted); } +.bounded-all-usage summary, .bounded-exact-preview summary, +.bounded-receipt-grid summary, .bounded-inbox-item summary, +.bounded-failure summary { + cursor: pointer; color: var(--link); font: 10px/1.3 var(--mono); +} +.bounded-read-actions { + display: flex; flex-wrap: wrap; gap: 6px; margin: 9px 0; +} +.bounded-read-actions button { + display: grid; max-width: 260px; gap: 3px; text-align: left; +} +.bounded-read-actions button span { color: var(--muted); font-size: 9px; } +.bounded-action-card p { margin: 5px 0; color: var(--muted); } +.bounded-action-card > small { display: block; min-height: 35px; color: var(--muted); } +.bounded-action-card button { width: 100%; margin-top: 9px; } +.bounded-action-card .starts-work { color: var(--warn); border-color: var(--warn); } +.bounded-action-card .danger, .bounded-preview .danger { + color: var(--err); border-color: var(--err); +} +.bounded-action-card.unavailable { opacity: .78; border-left-color: #52606e; } +.bounded-action-issue { + padding-top: 6px; border-top: 1px solid var(--line); color: var(--warn) !important; +} +.bounded-preview { border-color: var(--warn) !important; } +.bounded-preview.refused, .bounded-failure { + border-color: var(--err) !important; background: rgba(229,83,75,.055) !important; +} +.bounded-exact-preview { + margin-top: 9px; padding: 8px; border: 1px solid var(--line); + border-radius: 5px; background: var(--bg); +} +.bounded-exact-preview code, .bounded-receipt-grid code { + display: block; overflow-wrap: anywhere; +} +.bounded-failure { margin-top: 10px; padding: 10px; border: 1px solid var(--err); border-radius: 6px; } +.bounded-failure h4 { margin: 0 0 7px; color: var(--err); } +.bounded-failure pre { max-height: 180px; overflow: auto; white-space: pre-wrap; } +.bounded-result { border-left-color: var(--accent) !important; } +.bounded-receipt-grid article p { color: var(--muted); } +.exact-control-audit code { display: block; color: var(--muted); } + @media (max-width: 900px) { .orch-toolbar { align-items: flex-start; flex-direction: column; } .orch-score-actions { justify-content: flex-start; } @@ -1169,6 +1299,7 @@ details.blane.closed[open] summary { margin-bottom: 8px; } .live-answers { grid-template-columns: repeat(2, minmax(0, 1fr)); } .live-answers article[data-answer] { grid-column: span 1; } .live-next, .live-two-column { grid-template-columns: 1fr; } + .bounded-permission-grid { grid-template-columns: 1fr; } } @media (max-width: 520px) { @@ -1214,6 +1345,10 @@ details.blane.closed[open] summary { margin-bottom: 8px; } .live-work-groups { grid-template-columns: 1fr; } .live-panel { padding: 10px; } .live-recovery > div { align-items: flex-start; flex-direction: column; } + .bounded-action-center { padding: 9px; } + .bounded-action-hero, .bounded-section-head { align-items: flex-start; flex-direction: column; } + .bounded-inbox-grid, .bounded-action-grid, .bounded-receipt-grid { grid-template-columns: 1fr; } + .bounded-read-actions, .bounded-read-actions button { width: 100%; max-width: none; } } /* ── autonomous program control room (WLA-26-11) ──────────────── */