Skip to content

feat(web): operator-facing webUX MVP (Starlette+HTMX over PlainweaveService)#1

Merged
tachyon-beep merged 31 commits into
mainfrom
worktree-webux-mvp
Jun 25, 2026
Merged

feat(web): operator-facing webUX MVP (Starlette+HTMX over PlainweaveService)#1
tachyon-beep merged 31 commits into
mainfrom
worktree-webux-mvp

Conversation

@tachyon-beep

Copy link
Copy Markdown
Contributor

Summary

Adds Plainweave's operator-facing webUX — the human-facing seam of Weft — as a working local MVP. A thin Starlette + Jinja2 + HTMX web tier under src/plainweave/web/ sits on the existing PlainweaveService (no business logic in the web layer; the service stays the single source of truth). Shipped as an optional plainweave[web] extra, so a bare pip install plainweave stays one-dep.

⚠️ Owner-gated, vision-level bet. A human web UI is a new direction not yet in vision.md/roadmap.md. This PR delivers the MVP for review; adopting the human-facing surface as a standing product bet (and any release of it) remains the owner's call.

What's in it

  • Author — corpus (HTMX-expandable table + search/status/orphan filters), requirement detail (current vs draft side-by-side), new/edit drafts with conflict-preserves-text UX.
  • Review (the authority seam) — one unified queue where the operator ratifies agent proposals: two-step approve (irreversible-confirm), accept/reject trace links (required reject reason), the drift-confirm safety step (§10.2 future hook), with the full WCAG-2.2 AA scaffold (live region, post-action focus management, per-item aria-labels, focusable empty state).
  • Survey — intent dashboard (the intent_coverage north-star + orphans, with an honest no-silent-clean degraded-peer banner) and goals + laddering.
  • Human-attributed writes (kind="human" operator actor — the authority linchpin), CSRF, centralized PlainweaveError→HTTP mapping. One additive service read (list_goals()).
  • New CLI: plainweave web [--actor … --host --port --no-open] (friendly hint + non-zero exit if the extra isn't installed).

Process & quality

  • Spec hardened by two UX SMEs (accessibility + interaction) before implementation.
  • Built as 16 TDD tasks, each independently reviewed (spec + quality); a final whole-branch review caught and fixed one real blocker — a CSRF cold-start 403 (the cookie was set on the response but the form read it pre-set) — now fixed with the token minted before render and a cold-flow regression test. CSRF validation still compares form-token vs cookie (no bypass).
  • make ci green: 298 passed, 90.72% coverage, mypy --strict + ruff clean, output pristine. Wardline clean on the form-input boundary.

Docs

  • Design spec: docs/superpowers/specs/2026-06-25-plainweave-webux-design.md
  • Implementation plan: docs/superpowers/plans/2026-06-25-plainweave-webux-mvp.md

Deferred (documented, non-blocking)

SEI-binding display on detail, similarity hint, verification/baseline surfaces, multi-user/auth; full-page (non-HTMX) error styling; tightening redirect-status assertions; wiring real drift detection (§10.2). A manual NVDA/VoiceOver AT pass is recorded as a pre-ship gate for the review surface.

🤖 Generated with Claude Code

tachyon-beep and others added 27 commits June 25, 2026 21:33
…lpers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…lope

Producer-side conformance for plainweave's weft.plainweave.preflight_facts.v1
envelope (ADR-006). Re-captures the golden from the REAL producer
PlainweaveMcpSurface.plainweave_preflight_facts_get over a fixed seeded project
(replacing the stale hand-authored fixture — only producer.version 0.0.1->1.0.0
differed) + a Layer-1 byte-pin + a NON-CIRCULAR producer-source recheck that
re-invokes the live producer and asserts == golden. The 2 non-deterministic
fields (generated_at, producer.version) are validated against the live producer
(aware ISO-8601 UTC; == plainweave.__version__) BEFORE normalizing, so drift
reds non-circularly. Consumer side is legis (ringfenced/absent) -> producer-only.
2 tests pass; byte-pin + recheck both red on tamper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…SRF middleware

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace ``await request.form()`` in the CSRF BaseHTTPMiddleware with
``await request.body()`` + ``parse_qsl`` so Starlette's _CachedRequest
can replay the raw bytes to downstream POST handlers. The old form() call
consumed the body stream, leaving every downstream request.form() empty.

Add regression test (RED->GREEN verified) that splices a /echo route and
asserts the submitted field value arrives intact after CSRF validation.
Also type the test fixtures properly (drop type:ignore suppressions) and
extend test_error_status_mapping to cover all 10 ErrorCode members.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
plainweave (ex-charter, 1.0.0) consumes loomweave SEI via loomweave_adapter but
was the SEI laggard (no §8 oracle). Add tests/conformance/test_sei_oracle.py:
vendor loomweave's sei-conformance-oracle.json BYTE-IDENTICAL (same upstream pin
0ea5770...) + Layer-1 UPSTREAM_BLOB_SHA byte-pin + Layer-2 sei_drift recheck
(PLAINWEAVE_SEI_DRIFT_REQUIRED arming, skip-clean absent) + the 6 §8 scenarios
driving the REAL LoomweaveAdapter resolve path via injected fetch (non-circular).

Load-bearing production fix (loomweave_adapter.py, +32 lines, additive): add
_probe_sei_capability() gating the HTTP resolve on the /api/v1/_capabilities
sei.supported wire — so capability_absent degrades HONESTLY (reason='unsupported')
instead of conflating 'no SEI capability' with 'down' (reason='unreachable').
This closes the §8 capability_absent gap genuinely, not by faking it.

All 6 scenarios pass; byte-identical to loomweave authority; byte-pin reds on
tamper. Full suite 248 passed (+12 conformance), only the pre-existing stale
uv-tool version test fails (unrelated). ruff+mypy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…requirements call

- _resolve_titles now takes (svc, records) instead of ctx, removing the
  duplicate search_requirements() call in corpus()
- Add test_corpus_approved_req_shows_version_title to cover the approved
  version title path in _resolve_titles (requirements.py now 100% covered)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds test_requirement_detail_approved_version_block which creates a
requirement, approves it (clears active_draft), then GETs /req/{id} and
asserts the "Current approved" heading and statement are present and the
draft section is absent.  Includes a comment explaining why the
both-blocks case is unreachable via the public API (approve_requirement
and supersede_requirement both null out active_draft_id; no verb opens a
draft on an already-approved requirement).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…rsisted

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…state and focus script

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…result

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…d OOB result

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…-13 coverage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…l warning text)

The previous assertion `"drifted" in confirm.text.lower()` matched only the
CSS class `queue-item--drifted`. Strengthen to verify the real badge text
("CODE DRIFTED"), the role=alert paragraph phrase, the CSRF hidden input,
the hx-post accept target, and the hx-get card-restore Cancel target.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add tests/web/test_a11y_contracts.py locking the §4.1/§12 a11y
contracts: SR status live region + skip-link in base.html, visible
<label> for the corpus search input, and per-item interpolated
aria-label on every Approve button in the review queue.  Append an
NVDA/VoiceOver manual AT gate checklist to the README web section.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…a request.state

On the very first browser request (no pw_csrf cookie yet), csrf.html was reading
request.cookies.get('pw_csrf', ''), which returns '' because the cookie is set on
the *response*, not the incoming *request*.  The form then embedded an empty _csrf
value; the next POST compared '' against the newly-set cookie → 403.

Fix: mint the token before call_next and store it as request.state.csrf_token
(scope-backed, shared into the handler).  The template now reads
request.state.csrf_token.  The cookie is still set on the response when absent.

Adds regression tests:
- test_csrf_cold_start_first_render_embeds_real_token (cold flow, RED before fix)
- test_wrong_csrf_token_still_403 (gate still fires on wrong token)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…hygiene

FIX 2 — Authority attribution tests (tests/web/test_review.py):
  - test_approve_attributes_operator_as_approver: asserts approved_by == "human:alice"
  - test_accept_link_attributes_operator: asserts accepted_by == "human:alice"
  These lock the product's core human-authority-in-the-audit-trail invariant.

FIX 3 — Malformed form input → 400, not 500:
  - Added _require_int/_require_str/_optional_int helpers in review.py/requirements.py
    that raise PlainweaveError(VALIDATION) → 400 instead of crashing with KeyError/ValueError
  - test_approve_non_integer_expected_version_returns_400 asserts the gate

FIX 4 — Remove dead "Deprecated" status filter option from corpus_filter.html;
  intent_corpus() only returns draft+approved so the toggle was always empty/misleading.

FIX 5 — Warning hygiene (315 → 0 under make ci):
  - Fixed 2 tests that passed cookies= per-request (now use client.cookies.set())
  - Added filterwarnings in pyproject.toml for httpx/httpx2 StarletteDeprecationWarning
    (third-party, unfixable) and pre-existing SQLite ResourceWarning under --cov
    (store-layer connection leak, tracked separately)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6727ac4fe4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

<p>Approve <strong>{{ title }}</strong> as <strong>version {{ next_version }}</strong>? This cannot be undone — there is no un-approve.</p>
<form hx-post="/req/{{ req_id }}/approve" hx-target="#queue-item-{{ req_id }}" hx-swap="outerHTML">
{% include "_partials/csrf.html" %}
<input type="hidden" name="expected_version" value="{{ current_version }}">

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Check the draft revision before approval

When an operator opens this confirmation and another tab edits the active draft before the submit, current_version remains unchanged, so the POST still passes approve_requirement(... expected_version=...) and approves the newer, unseen draft while the dialog described the older content. Include and validate the draft revision (or recompare the draft being approved) before ratifying it.

Useful? React with 👍 / 👎.

Comment on lines +190 to +192
return templates.TemplateResponse(
request,
"_partials/edit_conflict.html",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Render a full conflict page for non-HTMX submits

This conflict branch is reachable from the full-page edit form, which submits with normal method="post" rather than an HTMX request. On a stale revision, the browser navigates to this partial without base.html or the htmx script, so the follow-up “Save my edits” form in edit_conflict.html depends on hx-post but cannot run after the navigation; render a full page when HX-Request is absent or make the original edit form an HTMX target.

Useful? React with 👍 / 👎.

Comment on lines +213 to +214
item = _link_item(ctx.service, link_id)
ctx.service.accept_trace_link(link_id, actor=ctx.operator.actor_id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require drift acknowledgement on accept

If a proposed Loomweave link is rendered as current and then drifts before the operator clicks Accept (or a valid-CSRF POST is sent directly), _link_item now reports item.drifted, but this path ignores it and calls accept_trace_link anyway. That bypasses the extra confirmation and hidden acknowledgement in link_accept_drifted_confirm.html, ratifying changed code without the operator acknowledging the drift.

Useful? React with 👍 / 👎.

# self-registration is permitted; once an attester exists, only an existing
# attester may (re)register a human — surface that clearly rather than crashing.
try:
service.register_actor(actor_id, kind="human", display_name=display, actor=actor_id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid registering the actor on every request

Because ctx_factory() calls this for each routed page load, even a read-only GET for /, /review, or /intent invokes register_actor, which always writes an actor_registered event even when the same actor is already present. This makes ordinary browsing mutate the append-only audit log and fills it with duplicate privileged registration events; only the initial missing-actor path should perform the registration.

Useful? React with 👍 / 👎.


@classmethod
def from_root(cls, root: Path | None, *, actor: str | None) -> RequestContext:
service = PlainweaveService(plainweave_db_path(root), root=root)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle an uninitialized store before serving

When the web UI is launched in a fresh checkout before plainweave init, this constructs a service for a database under a missing .plainweave/ directory; the first page request then reaches actor registration and raises sqlite3.OperationalError instead of a PlainweaveError with recovery guidance. The new README web launch path does not mention a required init step, so the operator console fails as a 500 unless this path initializes the store or mirrors the CLI's NOT_FOUND hint.

Useful? React with 👍 / 👎.

remaining = _pending_count(ctx.service)
return templates.TemplateResponse(
request,
"_partials/queue_action_result.html",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refresh the dossier after approving there

The approve endpoint is also invoked from the requirement detail page, but after a successful approval it always returns the review-queue action partial. From /req/{id} that only removes the temporary confirmation card, leaving the surrounding draft section and old statement visible even though the requirement has just been approved; return a refreshed dossier/redirect for non-queue callers instead of the queue-only partial.

Useful? React with 👍 / 👎.

(function () {
document.body.addEventListener('htmx:afterSettle', function (evt) {
var elt = evt.detail.requestConfig && evt.detail.requestConfig.elt;
if (!elt || !elt.closest('.qi-actions')) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Move focus after confirmed actions too

The focus-management code only runs when the element that triggered the settled HTMX request is inside .qi-actions. The actual approve-confirm, drift-confirm, and reject-confirm forms are rendered outside that container, so completing those actions removes the card but skips the documented focus move to the next queue action or empty heading for keyboard/screen-reader users.

Useful? React with 👍 / 👎.

@tachyon-beep

Copy link
Copy Markdown
Contributor Author

Consolidated the other outstanding work into this branch so nothing is stranded: merged feat/weft-sei-conformance (SEI conformance oracle + legis preflight-facts wire golden, 2 commits). The other feature branches (feat/doctor-fix, feat/intent-coverage-followups, feat/intent-coverage-primitive, release/1.0.0) were already contained in main and therefore in this branch. Combined gate is green: 312 passed, 90.75% coverage, mypy-strict + ruff clean.

tachyon-beep and others added 2 commits June 26, 2026 06:00
…HANGELOG, changelog URL

Working set on feat/weft-sei-conformance, committed so it isn't stranded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@tachyon-beep tachyon-beep merged commit 2c831c6 into main Jun 25, 2026
1 check passed

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4b1a3990c4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +144 to +145
@pytest.mark.sei_drift
def test_vendored_oracle_matches_loomweave_source() -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Gate the drift oracle outside default pytest runs

This marker does not make the test opt-in: checked pytest --help, and -m is the option that filters marked tests, while the repo's make ci runs plain pytest/pytest --cov with no marker exclusion. On any developer or CI machine where /home/john/loomweave (or LOOMWEAVE_REPO) points at a non-identical oracle, the ordinary gate will fail here even though the docstring says this is an opt-in release-gate recheck; skip unless explicitly armed or configure the default test command to exclude sei_drift.

Useful? React with 👍 / 👎.

Comment on lines +34 to +35
except ModuleNotFoundError:
print(WEB_EXTRA_HINT)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Catch missing Jinja2 when the web extra is absent

In a normal install without plainweave[web], the locked base dependency mcp already brings in starlette and uvicorn but not jinja2, so _serve() can get past its imports and then fail when create_app() constructs Jinja2Templates; Starlette reports that missing template engine as a non-ModuleNotFoundError, so this handler does not print the promised web-extra hint. Preflight/import jinja2 explicitly here or catch that missing-Jinja failure too.

Useful? React with 👍 / 👎.

@@ -0,0 +1,11 @@
{% for row in rows %}
<tr class="corpus-row" hx-get="/req/{{ row.req_id }}/inline" hx-target="#req-detail-{{ row.req_id }}" hx-swap="innerHTML" style="cursor:pointer">

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Provide a keyboard-operable corpus row action

The requirement title/status row is the only visible control that expands a requirement and reveals the “Full dossier” link, but a <tr> with hx-get is not focusable or keyboard-activatable. In the corpus table, keyboard and screen-reader users cannot open a row or reach the dossier unless they already know the URL; make the title a real link/button or add equivalent keyboard semantics.

Useful? React with 👍 / 👎.

tachyon-beep added a commit that referenced this pull request Jun 26, 2026
Bump version 1.0.0 → 1.1.0 and add the [1.1.0] CHANGELOG section.

The 1.1.0 code already landed on main via PR #1 (operator-facing webUX MVP
+ SEI conformance consolidation); this commit carries the release ceremony so
release/1.1.0 is a reviewable release gate. Additive and backward-compatible:
the 1.0 CLI, MCP surface, store schema, and JSON envelopes are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant