Skip to content

Projects: beyond-parity queue WS-27u–z + Tasks↔Projects UI continuity - #408

Merged
vjvarada merged 13 commits into
mainfrom
claude/paca-research-task-management-a1f6zd
Aug 10, 2026
Merged

Projects: beyond-parity queue WS-27u–z + Tasks↔Projects UI continuity#408
vjvarada merged 13 commits into
mainfrom
claude/paca-research-task-management-a1f6zd

Conversation

@vjvarada

Copy link
Copy Markdown
Contributor

Builds the entire beyond-parity queue minted from the Plane research (specs/project_management_app.md §9.1) plus the owner-directed UI-continuity pass between the Tasks and Projects apps. Six tickets, built by six parallel agents in isolated worktrees and integrated in two waves. ⚠️ All interaction designs are re-derived in this repo's idiom — nothing is copied from AGPL-licensed Plane (see the AGPL wall in specs/plane_pm_research_2026-08.md).

What's in here

WS-27u — intake/triage (migration 164)

  • pm_intake wrapper table (status pending/accepted/declined/duplicate/snoozed, provenance permanent) + triage status category.
  • ONE exclusion predicate in core.py beside the visibility clause: triage-parked tasks appear on no board/list/calendar/timeline/search surface unless include_triage is passed; the parameter-coverage structural test now enforces it can't be dropped per-surface.
  • POST /projects/intake (task+wrapper in one transaction) and the four rulings; accept flips status in place, decline/duplicate archive with provenance, snooze reappears past snoozed_until. All rulings write pm_activities. TriageRail UI with lane/duplicate/date pickers.

WS-27v — watchers & mentions (migration 165)

  • pm_task_watchers (casefolded, agents fenced; seeds task authors so the old audience survives the switch).
  • Auto-subscribe on comment/edit/assign/mention (delivered mentions only), watch/unwatch endpoints, audience = watchers ∪ assignees minus actor, still gated by resolve_visibility_for.
  • Mention diffing: editing notifies only newly-added mentions (proven by an edit-twice test). Unread endpoint returns {total, mentions}; the bell renders mentions distinctly.

WS-27w — read-path/history hardening (no migration)

  • Archive endpoints did not exist (archived_at had no writer) — added POST …/archive + /unarchive with the 422 guard naming the category.
  • record_field_change single door: FK-valued history carries {field, old_id, new_id, old_label, new_label}, enforced by an AST structural test over call sites.
  • Same-actor consecutive description/comment edits coalesce. Semantic status sort (category rank, then lane position) and a structurally-asserted (created_at, id) tiebreaker on every TASK_SORTS entry.
  • GET /projects/search?exclude_relatives_of= filters self/ancestors/descendants/already-related so pickers can't offer what writes will 422. Human task refs on cards/panel with copy-deep-link.

WS-27x — spreadsheet layout + shown-fields contract

  • Per-view shown_fields joins the saved-view config (client round-trip + server normalise_view_config, old views stay byte-identical). Chip rendering on every surface gates on it.
  • TableView: columns = shown fields, inline cell editors over the existing PATCH/PUT paths, header sorts verified against TASK_SORTS by a cross-language test, indented sub-task expansion, bottom quick-add rows, arrow-key cell cursor (Enter edits, Esc cancels).

WS-27y — board/list interaction upgrades

  • Swimlanes (second grouping axis), per-lane collapse + show-empty persisted with the view (server preserves the keys — gap found and closed during integration).
  • Group-context quick-add on list groups, board columns/lane cells, and calendar days, pre-filled with the group's value. Drop-refusal overlays naming the reason; post-drop/quick-add scroll + flash (reduced-motion aware). Keyboard row cursor; Shift+Arrow extends the existing selection into BulkBar unchanged.

WS-27z — lifecycle policy (migration 166, default off)

  • Root pm_projects gains archive_after_months / close_after_months / timezone; root's policy governs the subtree, child writes 422.
  • run_lifecycle_sweep: archives closed-category tasks and closes stale open ones, cutoffs at midnight in the project's own timezone, triage exempt, idempotent, all writes through the ordinary service.
  • automation: true on record_activity for workflow/sweep writes, rendered distinctly in the timeline. New config-free pm_lifecycle workflow-engine node mirroring pm_task's wiring. Activation (authoring the scheduled workflow, enabling per-project) is an owner step — documented in HANDOVER §1.

Continuity backport (owner directive)

  • WS-27y's machinery promoted to shared code: src/lib/cursor.ts, src/components/QuickAdd.tsx, src/components/useFlash.ts — Projects re-exports shims, both apps run one implementation.
  • Tasks app cards now draw the shared WS-27s chip vocabulary (taskMeta()/<TaskMeta>) alongside their GTD badges; Tasks board/list gain the keyboard cursor, group-prefilled quick-add, and drop-refusal/flash grammar; Projects list headers gain Tasks' collapse + count-pill affordances.
  • Remaining divergences (Tasks' modal selection vs shift-range, board chrome, calendar asymmetry) are recorded in HANDOVER for the next round.

Verification

  • Backend: uv run python -m pytest tests/unit/ -q5789 passed (49 skipped are the documented needs-live-Postgres checks); ruff --select F821,F601,F602,F502,F7,B006 clean.
  • Frontend: npx vitest run1278 passed incl. the theme-conformance suite (no new colour debt); tsc --noEmit clean.
  • Migrations 164/165/166 are idempotent and follow the 161 tenancy pattern; the tenancy ratchets (test_tenant_coverage, test_tenancy_boundary) pass with the new tables auto-scoped.

Not in this PR / owner-gated

🤖 Generated with Claude Code

https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W


Generated by Claude Code

claude and others added 13 commits August 9, 2026 20:56
…lit unread

Spec §9.1 WS-27v (plane_pm_research_2026-08.md §3.2, P-2 + P-20). Shape
re-derived in this repo's idiom behind the AGPL wall; the one deliberate
divergence from Plane is load-bearing: delivery stays gated per recipient by
resolve_visibility_for, never by membership — a watcher row is an intent to
hear, not a right to see.

Migration 165: pm_task_watchers(task_id, watcher, organization_id),
UNIQUE(task_id, watcher), watcher folded and human (R10 + WS-27j rule 2 as
CHECKs, mirroring pm_notifications), tenant filled and verified by 161's
pm_organization_from_parent trigger. Seeds task creators as watchers so the
old assignees-plus-author audience survives the switch.

Gateway:
* watchers.py — ensure_watchers (the ONE idempotent subscribe helper),
  PUT/DELETE /projects/tasks/{id}/watch, GET .../watchers; R5 throughout
  (invisible task is 404, never 403), identity from the session only (R3).
* Auto-subscribe from all four triggers: commenting, editing (only when
  something actually changed), being assigned (added set only, agents
  fenced), being @mentioned (delivered mentions only).
* task_audience becomes watchers ∪ assignees; the actor-exclusion and the
  per-recipient visibility gate re-assert unchanged over it.
* Mention diffing: editing a comment or a description notifies only the
  NEWLY added mentions (new_mentions, set-differenced on the folded
  address). Description edits fan out to the diffed mentions and nobody
  else.
* The unread count splits into {total, mentions} in one FILTERed query so
  the two numbers can never describe different row sets.

UI: bell badge = total with a distinct @ marker and panel count for unread
mentions (tokens only); TaskPanel gains a watch/unwatch toggle (Button
primitive, optimistic, hidden until the state is read); unreadSplit/afterRead
pure and tested, tolerating the pre-split payload during a deploy window.

Tests: shared fake learns pm_task_watchers and mirrors the audience UNION
arm-by-arm off the statement text; test_projects_watchers.py proves the four
triggers idempotent, watch/unwatch + R5, audience minus actor gated by the
recipient's visibility (including a watcher who LOST visibility), the
edit-twice mention diff, and pins migration 165 as text (found by content,
R1). 910 projects/tenancy tests green; vitest + tsc green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
…, activity labels, coalescing, semantic sorts, picker exclusions, human IDs)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
…t provenance

A captured task is real from birth: POST /projects/intake writes an ordinary
pm_tasks row plus a pm_intake wrapper in ONE transaction, parked in a
triage-category status (provisioned per root on first use, never is_default).

* Migration 164: pm_intake (task_id UNIQUE, status CHECK pending|accepted|
  declined|duplicate|snoozed, snoozed_until, duplicate_of_task_id, source,
  source_ref, organization_id per D-MT-3 with 161's fill/refuse trigger on
  BOTH task FKs), and `triage` joins the pm_task_statuses.category CHECK via
  migration 150's find-by-shape DROP/ADD idiom.
* ONE default-list exclusion predicate — core.triage_exclusion_clause beside
  the visibility helpers. Board/list (/tasks), calendar/timeline (/calendar)
  and search all route through it and accept include_triage; a structural
  test pins that no surface can silently drop the flag (§11.16 extended) and
  that the clause has exactly one copy.
* Rulings: accept flips the task's status IN PLACE through
  apply_status_transition (never copies); decline archives; duplicate sets
  duplicate_of_task_id and archives; snooze hides until snoozed_until and
  reappears by being read — no worker, no cron. All four write pm_activities
  via record_activity; the wrapper is never deleted (pinned behaviourally and
  structurally). Terminal wrappers refuse a second ruling (422).
* Visibility: queue and rulings ride task_visibility_clause /
  load_visible_task — R5 404s (never 403), R10 case-folded identity, grants
  identical to the tasks the queue wraps.
* UI: TriageRail (pending items + the four rulings: accept→lane picker,
  duplicate→search-backed task picker, snooze→date) with pure logic in
  lib/intake.ts; page.tsx wiring kept to one component block.
* Tests: fake mirrors pm_intake, the queue join and the exclusion predicate
  (applied only when the statement carries it); the category-vocabulary pins
  now read the LAST migration to constrain the CHECK, not the file that
  created it.

Out of scope per the ticket: routing rules / auto-accept (workflows nodes, D6).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
…dd, drop feedback, keyboard cursor

Four interaction upgrades to the Projects board, list and calendar
(spec §9.1 WS-27y; P-11, P-13, P-17, P-18), keeping the house shape:
pure logic in lib/*.ts with vitest coverage, thin components on top.

1. Sub-grouping: the board takes a second axis drawn as swimlanes
   (lib/swimlanes.ts — lane order and membership reuse groupTasks, so
   two-owner tasks land in both lanes and status lanes keep configured
   order). Per-lane collapse + show-empty travel with the saved view
   via toConfig/fromConfig (round-trip extended and tested). NOTE: the
   gateway's normalise_view_config still strips the new keys — see the
   follow-up called out in the code and ticket report.

2. Group-context quick-add: every list group, board column, lane cell
   and calendar day carries an inline title-only add pre-filled with
   that group's value. lib/quickAdd.ts owns the surface-agnostic
   (axis, key) → payload mapping — create-body fragment plus the
   assignees follow-up PUT — for WS-27x's spreadsheet row to reuse.

3. Drop feedback: dragging is always offered; a refused target
   overlays dropRefusal's reason (many-valued axis, grant boundary)
   instead of silently disabling. A lane-cell drop patches BOTH axes
   in one write (buildCellDropPatch skips axes already satisfied).
   After any drop or quick-add the card scrolls into view and flashes
   (flash.module.css, theme tokens only, reduced-motion aware).

4. Keyboard cursor: ArrowUp/Down walk list and board rows, Shift+Arrow
   extends the WS-27n selection additively through selection.range,
   Enter opens the panel; BulkBar feeds unchanged. Transitions pure in
   lib/cursor.ts.

Verification: npx vitest run (1172 passed, incl. theme conformance),
npx tsc --noEmit clean, eslint identical to baseline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
Conflicts resolved as unions: search.py carries both the triage
exclusion (WS-27u) and the picker exclusion (WS-27w) in one WHERE;
the fake's search mirror keeps both statement-keyed arms.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
Conflicts resolved by taking WS-27y's restructured board/list (swimlanes,
quick-add, cursor) and grafting WS-27w's taskRef into the new card helper
and the list's # column. Dropped WS-27w's now-duplicate row block —
WS-27y applies sortForView inside sections.

Also closes the gap WS-27y flagged: normalise_view_config now carries
sub_group_by / collapsed_lanes / show_empty_lanes with the client's own
value rules, so lane state survives the server round-trip (3 new tests).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
Two pieces, one ticket, because the column set IS the contract:

- shown_fields joins the saved-view config. lib/shownFields.ts is the ONE
  vocabulary (core keys + custom.<key> by shape) that both the table's
  columns and the chip gate read; toConfig/fromConfig round-trip it (set
  semantics, default omitted, explicit [] kept), and the gateway's
  normalise_view_config mirrors the discipline exactly — unknown/non-string
  dropped, absent stays absent, tests beside WS-27y's lane-state ones.

- Chip gating: card.visibleChips is the VISIBILITY layer over taskCard.ts's
  fact layer — CHIP_FIELD maps chip kind → field key, a hidden field earns
  no chip, and board, list, calendar and timeline all draw through it.

- TableView: one row per task, columns = shown fields (+ Title), inline
  editors driving the EXISTING write paths (PATCH for status/dates/priority/
  custom fields, PUT for assignees — no table-only write path). Header
  clicks map onto the gateway's TASK_SORTS keys (asc → desc → the view's
  own order); status stays the WS-27w semantic sort because the server
  does the sorting. Sub-tasks nest under an on-page parent via
  table.treeRows (orphans surface flat, collapse local, cycle-safe).

- Quick-add rows per group bottom, reusing WS-27y's QuickAdd +
  quickAddPrefill + useFlash unchanged.

- tableCursor.ts: the 2-D cell cursor — arrows move a visible ring, Enter
  edits (or opens the panel where a cell has no editor), Esc cancels;
  entry/clamping semantics deliberately match lib/cursor.ts, not a fork.

- Wired as a third-ish layout ("table" beside board/list/calendar/
  timeline), persisted with views like the others; FilterBar grows the
  shown-fields picker and counts a non-default set as saveable.

DESIGN_SYSTEM: tokens only, Icon/Button/Input primitives, theme suite green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
…ep, automation-flagged activities

Migration 166 gives root pm_projects archive_after_months /
close_after_months (NULL = off, CHECK > 0) and an IANA timezone (NOT NULL
DEFAULT 'UTC') so "a month untouched" counts from the project's own
midnight. The columns ride the ordinary project PATCH/read path, root-only
(422 on a child — the root's policy governs its subtree, like statuses),
validated the way workflows validates schedule timezones.

automation.run_lifecycle_sweep applies every enabled root's policy once,
idempotently: closed-category tasks untouched past the archive window are
archived (WS-27w's guard held by construction — only closed lanes are
candidates), stale open tasks close to the project's cancelled-else-done
lane through apply_status_transition, triage-category tasks (WS-27u) are
exempt. Every write goes through the ordinary service as
system:workflow:<id> and carries the new meta.automation flag, which
record_activity/record_field_change/apply_status_transition now accept and
the TaskPanel timeline renders distinctly (Bot marker). Automated
description edits still coalesce; human and automated runs never fold into
each other.

Engine wiring mirrors _pm_task_updater: a config-free pm_lifecycle node
type (graph, handler, timeout, catalog metadata) reaching the sweep through
a closure-imported NodeServices seam — the scheduled workflow supplies
nothing but the trigger and the node; all policy lives in the DB columns
(D6). Fakes learn the new columns and the bound `< :param` cutoff shape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
… feedback across Tasks and Projects

The Tasks app and the Projects app must feel like one product, so the
Plane-inspired interaction upgrades that landed in Projects (WS-27y —
patterns only, no Plane code) now reach /tasks, through ONE shared
implementation of each piece.

1. Promotion: the keyboard cursor moves to src/lib/cursor.ts (tests with
   it), the QuickAdd component and useFlash + flash.module.css to
   src/components/. app/projects keeps thin re-export shims (lib/cursor.ts,
   components/QuickAdd.tsx, components/useFlash.ts) so no Projects
   component changes an import. The shared cursor carries its own copy of
   the eight-line sweep range (selection.range stays the Projects
   selection model's API — a shared lib must not import app code).

2. Shared chip vocabulary on Tasks cards: TaskCard's hand-rolled
   due/duration/attachments/subtasks chips are replaced by the one
   TaskMeta renderer over taskMeta() descriptors, adapted from GtdItem by
   the new app/tasks/lib/cardMeta.ts (tested — GtdItem knows a subtask
   COUNT, not done/total, so the adapter adds a count-only chip in the
   shared slot instead of a "0/5" lie). GTD-only badges (context, deep,
   energy, source, stage, priority) stay: one grammar for the shared
   facts, not an erased identity.

3. Keyboard cursor in the Tasks grouped list and board: ArrowUp/Down walk
   the rows with the Projects ring classes, Enter opens the focus modal,
   the cursor scrolls its row into view. Plain cursor + Enter only —
   /tasks has no anchor/range selection model, so the shared cursor's
   shift-sweep stays dormant rather than half-forking taskStore.

4. Group-context quick-add: every board column and every grouped-list
   section carries the shared QuickAdd, prefilled by the new
   app/tasks/lib/quickAdd.ts (tested) and created through the new
   taskStore.quickAddNext — born a clarified NEXT action IN that stage /
   @context / energy / depth group, flashing where it lands. The computed
   lenses (priority, mode) offer no box: no create payload can promise
   the landing, and an add that files itself into a sibling group reads
   as a lie.

5. Drop feedback on the Tasks board: dragging over a target the drop
   would refuse (same column under a field sort; select mode) overlays
   the reason — app/tasks/lib/dropRules.ts (tested), naming the sort from
   the toolbar's own SORT_LABEL map (moved to lib/ordering.ts) — instead
   of the old silent snap-back. Every successful drop (board and list)
   scrolls + flashes the moved card via the shared useFlash. And the
   Projects list gains the Tasks header grammar: collapsible sections
   (chevron + local Set, cursor skips folded rows) and a count pill, so
   the two grouped lists read identically.

Verification: npx vitest run (1278 passed, incl. theme conformance),
npx tsc --noEmit clean, eslint clean on every touched file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
…n steps

Spec §9.1 gains the build record (migration numbers 164/165/166, the
no-archive-endpoint discovery, the pm_lifecycle owner step, continuity
gaps). Work plan WS-27 row and HANDOVER top box updated to the restarted
branch's state.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
…u-z build news

Conflict was work_plan's WS-26/WS-27 rows only - git followed the ai-company-brain -> project-docs rename (D26) automatically for the rest. WS-26 takes main's truth (D5 merged #403, mig 163); WS-27 keeps this branch's u-z paragraph with the state cell corrected to 'a-t merged, u-z on PR #408'. Migrations 164/165/166 do not collide with main's 163. Fences green: tenancy boundary, purge cascade map, tenant coverage, projects migration.
@vjvarada
vjvarada merged commit 0251089 into main Aug 10, 2026
9 checks passed
vjvarada pushed a commit that referenced this pull request Aug 10, 2026
…e mint dropped

An audit re-derived all 31 Plane findings from the CODE rather than from the
write-ups. Two classes of defect, both fixed here.

**Stale facts.** The board row said u-z were "on PR #408" (they are on main),
that ab/ac were "deferred by owner priority" (both built), that ae was
"minted not dispatched" (both halves built), and listed migrations only to
166 — omitting 167 and 168, which under R1 is exactly the omission that
produces renumber collisions. Sixteen places across the spec still claimed
"on branch, NOT merged" for work that has since merged, several naming
branches a reader would now chase and not find.

**Two structural holes the board could not show.**

1. Slices S1-S6 belong to WS-27 and were never listed on it. Six built
   slices, one of which (S3) reversed a recorded WS-27ad decision by owner
   ruling, were invisible to anyone reading the dispatch board.

2. Minting WS-27u-z from the Plane research silently lost P-8, P-22, the
   inbox half of P-20, and the whole of research §4 item 14 — and the
   MECHANISM matters more than the four items. The research numbered its
   findings P-1..P-31 and its frontend list 1-14 independently, and §8's
   verdict table (the one minting read) maps only P-numbers. So item 14 was
   never eligible to be minted, and no `P-\d+` grep over the two documents
   could ever reveal its absence. That is why the loss stayed invisible
   through three readings of the same research.

Minted as §9.3: WS-27ah (the four dropped items), WS-27ai (the descoped
notifications inbox — whose only trace was the word "part" in a
parenthetical), WS-27aj (two adoption triggers naming tickets that do not
exist; one half is an owner decision because it would touch D12's two-axis
visibility model).

Recorded with it: `clickup_id` is globally unique, so two organisations
cannot import the same ClickUp workspace, and nothing on the board said so.
Cheap to fix now, a retrofit after the second tenant exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
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.

2 participants