From f4b82260cb6fdb8eee401487029d7ade776edc43 Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 6 Aug 2026 09:35:52 +0000
Subject: [PATCH 01/10] =?UTF-8?q?feat(WS-27e):=20one=20task=20store=20?=
=?UTF-8?q?=E2=80=94=20the=20personal=20manager=20becomes=20a=20lens,=20no?=
=?UTF-8?q?t=20a=20copy?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
D-PM-6 is REVISED, owner-directed: "the personal task manager should be a
proper extension of the project system … a cohesive whole that should fit
within each other." The spec had it mirroring pm_tasks into gtd_items as
SYNCED rows. A mirror is two rows for one fact, and every feature built
afterwards — search, calendar, agents, reporting, the weekly review — would
have had to know about both. So there is now one task table.
Three consequences, and they are the design:
- Assignment is not a sync. A task assigned to a member IS the row in their
inbox. Completing it there moves the shared status at the same instant,
because there is one row — a personal-only "done" would be a member
quietly ticking off a team task while the board still shows it open, which
is exactly the drift a mirror produces.
- Private work is a personal project: an ordinary pm_projects row carrying
`personal_owner`, granted to that one address. Nothing about tasks,
boards, timelines, automation or agent dispatch needs a special case. They
are excluded from team reads because "My tasks" is not a department — that
is presentation, not access; the grant already scopes it to one person.
- The GTD overlay is PER MEMBER (pm_task_personal). Two people assigned the
same task hold different dispositions: the one doing it says NEXT, the one
who delegated it says WAITING. A single column on pm_tasks could not
express that, and it is what delegation looks like rather than an edge
case.
`disposition` is NULL until a member triages, and the read derives one from
the task's status using the same lens routes/tasks/sync.py has always
applied. So "never looked at" and "deliberately filed to INBOX" stay
distinguishable — the only question the Weekly Review exists to ask, and a
column defaulting to 'INBOX' would have destroyed it silently. Filtering
matches the EFFECTIVE disposition, or "show me my next actions" would answer
empty for somebody with twenty assigned tasks.
The overlay route cannot write pm_tasks at all, and the task routes cannot
write the overlay. That makes "the overlay is never clobbered" — a contract
the ClickUp sync has honoured by discipline since migration 48 — structural,
in both directions.
Cost accepted and recorded: gtd_items becomes legacy and WS-27h retires it
(spec §7.5) — a second retirement running beside ClickUp's. items.py's 27
owner-scoped predicates are untouched here on purpose; they belong to that
ticket.
Also mints WS-28 (specs/people_center_app.md) — the People Center's UI/UX,
scope owner-set to directory, skills, org chart, capacity and seats. The
fact it exists to settle: there are TWO people stores deliberately —
app_user answers "can they sign in", gtd_people answers "who are they and
what can they do" — and the directory must include people with no login,
which is why the assignee is a plain string. It also records a real defect
to fix before the join is relied on: migration 49 made `name` UNIQUE and
left `email` unconstrained, so two rows may share an address today.
Verification: 364 passed across the projects, org-access and CRM suites; 31
new hermetic cases. Six mutants measured red and reverted byte-identical.
⚠️ TWO of them first SURVIVED, and the fake was at fault rather than the
tests: it applied the inbox's arms unconditionally instead of keying them
off the statement text — the exact mirror failure _projects_fakes.py's own
docstring warns about, found by mutation and not by review.
Not deployed: migration 147 has not been applied anywhere.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
---
ai-company-brain/AGENTS.md | 3 +-
ai-company-brain/specs/people_center_app.md | 303 ++++++++++
.../specs/project_management_app.md | 126 +++-
ai-company-brain/work_plan.md | 4 +-
.../gateway/routes/projects/__init__.py | 1 +
.../gateway/gateway/routes/projects/core.py | 4 +
.../gateway/routes/projects/personal.py | 571 ++++++++++++++++++
.../gateway/gateway/routes/projects/tree.py | 8 +-
infra/postgres/147_projects_personal.sql | 115 ++++
tests/unit/_projects_fakes.py | 89 +++
tests/unit/test_projects_personal.py | 527 ++++++++++++++++
11 files changed, 1724 insertions(+), 27 deletions(-)
create mode 100644 ai-company-brain/specs/people_center_app.md
create mode 100644 apps/services/gateway/gateway/routes/projects/personal.py
create mode 100644 infra/postgres/147_projects_personal.sql
create mode 100644 tests/unit/test_projects_personal.py
diff --git a/ai-company-brain/AGENTS.md b/ai-company-brain/AGENTS.md
index 69d28a8c..dfee53c9 100644
--- a/ai-company-brain/AGENTS.md
+++ b/ai-company-brain/AGENTS.md
@@ -112,7 +112,8 @@ to it and to `competitive_hardening_2026-07.md` (CH-*) rather than re-describe t
| [`email_app_master_plan.md`](specs/email_app_master_plan.md) | **Email master** — consolidated state + prioritized completion roadmap (absorbed the inventory, parity plan, tool plan; evidence in `specs/archive/email_feature_review_2026-07.md` — archived 2026-08-01) | 🔄 Phase 1 "stop the lying" open; #113 sweep armistice done |
| [`task_manager_app.md`](specs/task_manager_app.md) | **Task Manager (GTD)** — client + `task-manager` agent + provider layer | 🔄 capture/clarify/organize + provider **sync-pull** + Engage "Now" live; **Waiting-For live 2026-08-02** (grouped view + overdue/stale flags; `gtd_waiting.expected_by` = an explicit promise only — NULL ⇒ the overdue line reads the item's live `due_at`, never a derived copy); open: follow-up nudges (owner-gated), Action-Broker-gated push, Weekly Review, Horizons |
| [`task_manager_harness_2026-07.md`](specs/task_manager_harness_2026-07.md) | Task-manager × harness engineering (app-layer sibling) | 🔄 Tier 1 shipped (2026-07-03); Tier 2 planned |
-| [`project_management_app.md`](specs/project_management_app.md) | **Projects app (WS-27)** — native org-level project management in the People Center, sliced into every Center as (app + scope): departments→projects→subprojects→tasks→subtasks (`pm_*`, Paca-shape two-self-FK hierarchy), grant-based scoping on the shipped `email\|group:\|org` vocabulary, per-view fractional ordering, single activity spine; ClickUp two-way coexistence sync (three-way field merge, broker-gated push — **blocked on BO-1a/BO-1b**) then cutover + retirement; personal `/tasks` mirror via an internal provider; `pm.*` events feed `/workflows` and assign-to-`agent:` dispatches real runs. **Read §8 D-PM-8/9/10 (owner-answered 2026-08-06) before building:** no portfolio layer (grants are the only grouping axis) · agent edits to ClickUp-linked tasks are treated exactly like human edits, and D-PM-9's Cost paragraph names what that does and does not guarantee · Spaces map to Centers explicitly from agent-proposed suggestions, an agent may propose a mapping and must never apply one, and an unmapped Space still imports in full | 🟢 **a + b + d BUILT 2026-08-06** (schema + grant read model; ClickUp importer + mapping plan; `/projects` UI + Center projections) — **not deployed, and neither import endpoint has been run**; c gated on BO-1a/BO-1b, e–g open |
+| [`project_management_app.md`](specs/project_management_app.md) | **Projects app (WS-27)** — native org-level project management in the People Center, sliced into every Center as (app + scope): departments→projects→subprojects→tasks→subtasks (`pm_*`, Paca-shape two-self-FK hierarchy), grant-based scoping on the shipped `email\|group:\|org` vocabulary, per-view fractional ordering, single activity spine; ClickUp two-way coexistence sync (three-way field merge, broker-gated push — **blocked on BO-1a/BO-1b**) then cutover + retirement; personal `/tasks` mirror via an internal provider; `pm.*` events feed `/workflows` and assign-to-`agent:` dispatches real runs. **Read §8 D-PM-8/9/10 (owner-answered 2026-08-06) before building:** no portfolio layer (grants are the only grouping axis) · agent edits to ClickUp-linked tasks are treated exactly like human edits, and D-PM-9's Cost paragraph names what that does and does not guarantee · Spaces map to Centers explicitly from agent-proposed suggestions, an agent may propose a mapping and must never apply one, and an unmapped Space still imports in full | 🟢 **a + b + d + e BUILT 2026-08-06** (schema + grant read model; ClickUp importer + mapping plan; `/projects` UI + Center projections; the personal lens). ⚠️ **D-PM-6 was REVISED 2026-08-06** — one task store, not a mirror: read it before touching `/tasks`. **Not deployed, and neither import endpoint has been run**; c gated on BO-1a/BO-1b; f, g, h open |
+| [`people_center_app.md`](specs/people_center_app.md) | **People Center (WS-28)** — the directory, person page, org chart, capability search and seats matrix, plus the four seams where People meets Projects. Owns **surfaces, not facts** — every fact is cited to its owning spec. Read §2 first: there are **two people stores on purpose** (`app_user` = can they sign in; `gtd_people` = who are they and what can they do), the directory must include people with no login, and migration 49's key shape (UNIQUE on `name`, nothing on `email`) makes the join ambiguous until WS-28a fixes it | 🔲 spec'd 2026-08-06, nothing built |
| [`paca_pm_research_2026-08.md`](specs/paca_pm_research_2026-08.md) | **Paca PM-platform research** — `Paca-AI/paca` v0.11.0 deep dive (Apache-2.0; patterns, no code): hierarchy/statuses/ordering/views data model, trigger→condition→action automation graph, assignment→agent dispatch chain, MCP tool-design lessons, with a 14-row adopt/adapt/refuse table annealed into WS-27 | 🟢 research complete (reference-only, owns no work) |
| [`llm_caching_memory.md`](specs/llm_caching_memory.md) | Prompt caching (ADR-008) + session memory | 🔄 caching **shipped & wired**; session-memory shipped but **inert by default** (→ BO-21); Phase 7 open |
| [`mcp_plugin_integration.md`](specs/mcp_plugin_integration.md) | MCP servers vs Claude plugins vs REST | 🔄 MCP half **built** (`_inject_mcp_servers`); plugin store not started |
diff --git a/ai-company-brain/specs/people_center_app.md b/ai-company-brain/specs/people_center_app.md
new file mode 100644
index 00000000..1e52ce1c
--- /dev/null
+++ b/ai-company-brain/specs/people_center_app.md
@@ -0,0 +1,303 @@
+# People Center — the directory, the org chart, and the assignment seam
+
+> **Product:** CommandCenter · **Feature:** People Center (`/centers/people` and the
+> `/people` app behind it) · **Created:** 2026-08-06 · **Status:** 🔲 **SPEC, nothing built**
+> · **Owner:** vjvarada · **Board row: WS-28**
+>
+> **Scope, owner-set 2026-08-06:** directory, skills, org chart, capacity, and the
+> seats/roles view that decides which Center somebody belongs to — *exactly what assignment
+> and planning need*. Leave, onboarding and hiring are named as later phases in §8 and are
+> deliberately not designed here.
+>
+> **This spec owns SURFACES, not facts.** Every fact about people, permissions and Centers
+> is owned elsewhere and cited, never restated:
+> - `specs/task_manager_hr_planning_and_memory.md` — HR intelligence, résumé ingestion,
+> capability vectors. **Owns the data**; this doc owns how it is seen and edited.
+> - `specs/org_access_control.md` — members, roles, per-user overrides. **Owns identity.**
+> - `specs/colleague_onboarding.md` — the invite runbook and the role × app capability
+> matrix. **Owns the process.**
+> - `specs/department_centers.md` — Centers, groups, the five-place registration checklist.
+> **Owns the projection model.**
+> - `specs/project_management_app.md` — projects, tasks, assignment. **Owns the work.**
+>
+> A reader who wants "what is a manager allowed to see" goes to `colleague_onboarding.md`
+> §3. This doc answers "where do I click to change who someone reports to".
+
+---
+
+## 1. Why this exists
+
+Assignment is the whole reason. The Projects app (WS-27) can hand a task to
+`alice@fracktal.in` today, but nothing in the product answers the questions a person asks
+*before* they assign:
+
+- Who is there? Who is in Operations?
+- Who knows about extruder firmware?
+- Who has capacity this week, and who is already at 40 hours?
+- Who does this person report to, and whose approval does this need?
+
+Those answers exist in the database already (`gtd_people` has skills, capacity, a manager
+link and a 1536-dim capability vector) and are reachable through exactly one narrow surface
+— a table inside the Tasks app. **The People Center is where they become a first-class
+place**, and the Projects app reads them rather than growing its own copy.
+
+**Non-goals (v1):** payroll, performance reviews, compensation, time-off balances,
+applicant tracking. §8 records where those would go if they are ever wanted.
+
+---
+
+## 2. The two people stores, and why there are two
+
+This is the single most important thing to understand before building anything here, and it
+is not a defect to be tidied away.
+
+| | `app_user` (+ `user_role`, `org_group_member`) | `gtd_people` (+ `gtd_person_resumes`) |
+|---|---|---|
+| Answers | *Can they sign in, and what may they see?* | *Who are they, what can they do, who do they report to?* |
+| Created by | An invite (`POST /admin/members`) or a sign-in request | An import, a résumé upload, or a hand-added row |
+| Owned by | `org_access_control.md` | `task_manager_hr_planning_and_memory.md` |
+| Key | `email` | `name` (UNIQUE), with `email` nullable |
+| Includes people who never sign in | No | **Yes** — contractors, a new hire before day one, a vendor contact |
+
+**They are joined on lowercased email, and the join is deliberately partial.** A person can
+exist in the directory with no login (a contractor you assign work to but who has no seat),
+and a login can exist with no directory row (a service identity). Collapsing them into one
+table would force every contractor to become a member — which is a *licensing and access*
+decision, not a directory one.
+
+⚠️ **`gtd_people.name` is UNIQUE and `email` is not.** That is migration 49's shape and it
+is wrong for a directory that has to join on email: two people cannot share a name, and
+nothing stops two rows carrying the same address. **P-1 in §7 fixes this** before the
+directory becomes the assignment source, because an ambiguous email→person join would
+silently attribute one person's capacity to another.
+
+**The rule this doc adds:** the People Center *renders both* and never creates a third
+store. WS-13's board row already says it — "build the read view here, not a parallel store"
+— and this is that instruction, made concrete.
+
+---
+
+## 3. The surfaces
+
+Route: **`/people`**, gated on its own feature slug `people` (§6). The People Center's
+landing page (`/centers/people`) links to it, and it is one app, not one per Center — the
+same (app + scope) rule the Projects app follows.
+
+### 3.1 Directory — the default view
+
+A searchable list of everybody, one row per person, with a card/table toggle.
+
+**Row:** avatar (initials fallback), name, title, department + team, status pill
+(`active` / `contractor` / `alumni`), and a compact skills strip (top 3 + "+4").
+**Search** matches name, title, department and skills in one box — the Projects app's
+assignee picker uses the same endpoint, so a person findable in one is findable in both.
+**Filters:** department, team, status, skill, "has capacity".
+
+⚠️ **The HR strip is permission-dependent and already enforced.** WS-24 N4 shipped
+*directory open, HR fields restricted*: without `admin:members:read`, `skills`,
+`resume_summary`, `years_experience` and the capacity trio are projected to null, and `?q=`
+drops its skills clause so search cannot become an oracle. **The UI must render the
+projected shape, never re-fetch a richer one** — and the empty state should say "restricted"
+rather than "none", because a blank skills strip that means "you may not see this" and one
+that means "nobody filled it in" are different facts.
+
+### 3.2 Person page
+
+One person, four panels:
+
+1. **Identity** — name, title, email, department, team, manager, status. The email carries a
+ badge saying whether this person has a **login** (`app_user` exists) or is
+ **directory-only**. That badge is the visible half of §2 and stops "why can't they see
+ the board" being a mystery.
+2. **Skills** — editable chips, each showing its **source**: `stated` (typed by a human) or
+ `résumé` (extracted). `gtd_people.skills_source` already carries this. A skill nobody
+ stated and the parser inferred should not look like a claim the person made.
+3. **Capacity** — `capacity_hours_per_week`, `current_load_hours_per_week`, and the derived
+ `available`. Rendered as one bar, not three numbers. **Load is computed from open
+ assigned tasks** (§5.2), so the bar moves when work is assigned — a hand-typed load
+ figure is stale the moment anyone assigns anything.
+4. **Work** — this person's open tasks across every project the *viewer* may see, with a
+ link to each. Scoped by the viewer's grants, not the subject's: a Sales lead looking at
+ an Operations colleague sees the Sales work they share, not that person's whole life.
+
+**Writes** are gated on `admin:members:manage`, which WS-24 N4 already put on all four
+people-write routes. A viewer without it sees the page read-only, with no disabled-button
+theatre — the controls are absent.
+
+### 3.3 Org chart
+
+`gtd_people.manager_id` is a self-FK, so the chart is the same recursive render the project
+tree already uses — and the same cycle guard applies (a manager loop is a hang, not a
+diagram).
+
+- **Layout:** vertical tree, collapsible, with search-to-focus.
+- **Unmanaged people** surface as roots. That is not an error state to hide: "nobody is
+ recorded as this person's manager" is exactly what an org chart should make obvious.
+- **Drag to re-parent** writes `manager_id` (gated), with the cycle refused client-side
+ before the request so the tree does not optimistically render an impossible shape.
+- **Center overlay:** each node can be tinted by the person's `org_group` membership, which
+ is what makes "who is actually in Operations" answerable — and shows the mismatches
+ between `gtd_people.department` (free text) and group membership (the real scoping).
+ **That mismatch is the point of the overlay**, not a rendering bug to smooth over.
+
+### 3.4 Seats & roles
+
+The bridge to `org_access_control.md`, rendered here because "who is in Sales" is a People
+question that today requires visiting `/settings/groups`.
+
+A matrix: people down the side, the six Centers across the top, a checkbox at each
+intersection reflecting `org_group_member`. Toggling one is a **group membership write** —
+already an owner gate (`work_plan.md` §6 (d)), so this surface **proposes and does not
+apply** for anyone but the owner: a non-owner's toggle produces a request in the existing
+access-request queue rather than a silent 403.
+
+Beside it, each person's **role** (`owner`/`admin`/`manager`/`member`/`guest`) as a
+read-only pill linking to `/settings/members`. Roles are not edited here — one editor for a
+thing, and that editor already exists.
+
+### 3.5 Capability search — "who should do this?"
+
+A single box: *"Who can help with extruder firmware?"* Answers from three signals, most
+defensible first, each labelled in the result:
+
+1. **Stated skills** — an exact/fuzzy match on `skills[]`. Deterministic.
+2. **Résumé evidence** — a match in `gtd_person_resumes.extracted`, quoting the line.
+3. **Capability vector** — cosine similarity on `capability_embedding` (1536-dim, already
+ populated by `POST /tasks/people/embed`), for the cases the first two miss.
+
+Each result shows **why it matched and how loaded that person is**, because a perfect skill
+match at 45/40 hours is usually the wrong answer. This is a *suggester*: it never assigns.
+The same rule as the ClickUp Space mapper (D-PM-10) and for the same reason — a system that
+auto-assigns work to people is making a management decision it is not entitled to make.
+
+---
+
+## 4. Where the People Center meets the Projects app
+
+Four seams. All are **reads from Projects into People**, or **reads from People into
+Projects** — neither app writes the other's tables.
+
+### 4.1 The assignee picker (Projects → People)
+Assigning a task opens a picker backed by the directory endpoint, not by a list of
+`app_user` rows. It shows name, title, top skills and a capacity bar, and it lists
+**agents** in the same picker under a separate heading — D-PM-4's one-vocabulary decision
+made visible: handing work to an agent is the same gesture as handing it to a colleague.
+
+⚠️ The picker must offer **directory-only people** (no login). They can hold a task and
+appear on a board; they simply cannot sign in to see it. Hiding them would make the
+directory's whole point — contractors — unusable, and the assignee column is a plain string
+precisely so this works (§3.6 of the Projects spec).
+
+### 4.2 Capacity (Projects → People)
+`current_load_hours_per_week` is **derived**, not typed: the sum of `estimate_mins` over
+open tasks assigned to that person, divided into a week. Recomputed on assignment change and
+on a schedule. Until estimates are widely filled in, the bar shows *task count* with an
+honest "no estimates" label rather than a load figure invented from nothing.
+
+### 4.3 The person's work panel (Projects → People)
+`GET /projects/tasks?assignee=`, already shipped, scoped by the *viewer's* grants.
+
+### 4.4 Delegation (People → Projects)
+From a person page: **"Assign work"** opens task creation with the assignee pre-filled. And
+from the capability search: **"Assign to…"** on a result. Both land in the ordinary task
+create flow — no second write path, so every rule the Projects app enforces (visibility,
+status, activity) applies unchanged.
+
+---
+
+## 5. Data model
+
+**No new people tables.** The People Center reads `gtd_people`, `gtd_person_resumes`,
+`app_user`, `org_group` and `org_group_member`. Two additive changes only, both in §7:
+
+- **P-1** — fix `gtd_people`'s key shape (§2): drop the UNIQUE on `name`, add a partial
+ UNIQUE on `lower(email) WHERE email IS NOT NULL`. Without it the email join is ambiguous.
+- **P-2** — `gtd_people.status` gains a CHECK (`active`/`contractor`/`alumni`/`invited`) and
+ `gtd_people.has_login` becomes a *derived* read, never a column: two columns that must
+ agree are two columns that can disagree.
+
+Capacity is computed (§4.2), not stored beyond the existing columns.
+
+---
+
+## 6. Registration
+
+The four-place checklist the Projects app followed (`project_management_app.md` §5):
+
+1. `acb_auth.permissions.FEATURES` gains `"people"`.
+2. A `feature_catalog` row at the next free migration number: `('people', 'People',
+ 'Directory, skills and org chart', '/people', 'apps', 57, false)`.
+3. `nav.ts` `PANES` + `access.ts` `HREF_FEATURES` → `/people` → `people`.
+4. The both-ways catalog↔FEATURES invariant picks it up; add the named
+ `test_people_is_registered_on_both_sides`.
+
+Plus the Center projection: `centers.ts` People Center's **"Directory & org chart"** sub-app
+flips to `{status: "live", href: "/people"}` — closing WS-13's outstanding item, which asked
+for exactly this read view.
+
+**Visibility posture:** `is_default false`, like `crm` and `projects`. The directory is
+open to holders; the HR fields inside it are restricted by `admin:members:read` — a
+restriction that already exists and must not be re-implemented here.
+
+---
+
+## 7. Tickets
+
+**WS-28a — the key-shape fix (P-1, P-2).** 🟢 AGENT-SAFE.
+Done when: `gtd_people` no longer uniquely constrains `name`; a partial unique index exists
+on `lower(email)`; a status CHECK exists; and a test proves two people may share a name and
+may not share an address.
+
+**WS-28b — directory + person page.** 🟢 AGENT-SAFE.
+Done when: `/people` lists and filters; the person page renders all four panels; the HR
+projection is honoured with a "restricted" empty state distinct from "none"; writes are
+gated on `admin:members:manage` and absent (not disabled) without it.
+
+**WS-28c — org chart.** 🟢 AGENT-SAFE.
+Done when: the tree renders from `manager_id`, unmanaged people surface as roots, a
+re-parent that would create a cycle is refused before the request, and the Center overlay
+shows department/group mismatches rather than hiding them.
+
+**WS-28d — capability search.** 🟢 AGENT-SAFE to build; the ranking prompt is **EVAL-LOCKED**.
+Done when: all three signals are queried, each result names which matched and shows load,
+and the surface never writes an assignment.
+
+**WS-28e — the Projects seams.** 🟢 AGENT-SAFE.
+Done when: the assignee picker is directory-backed and lists agents and directory-only
+people; capacity is derived from open assigned tasks with an honest no-estimates state; and
+"Assign work" routes through the ordinary task-create flow.
+
+**WS-28f — seats & roles matrix.** 🔴 **OWNER-GATE** for the write half: group membership
+writes are already registered in `work_plan.md` §6 (d). Building the read matrix and the
+propose-a-change path is agent-safe; applying a membership change is the owner's act.
+
+---
+
+## 8. Later phases, named so their absence is a decision
+
+- **Onboarding** — checklists that provision accounts and first-week tasks. Would bind to
+ `colleague_onboarding.md`'s runbook and create tasks in the Projects app, not a new store.
+- **Leave & attendance** — needs a policy model (accrual, approval chains) that nothing in
+ the platform has, and an approval path that should reuse the Action Broker inbox.
+- **Hiring pipeline** — structurally a second CRM (candidates as leads, stages as statuses).
+ If wanted, it should reuse the `crm_*` shape rather than invent a third pipeline.
+- **Performance, compensation, payroll** — out of scope, and each carries data-sensitivity
+ questions (§2's HR restriction is the *floor*, not the answer) that need deciding before
+ any of it is designed.
+
+---
+
+## 9. Verification
+
+⚠️ Never `uv run pytest tests/unit/` bare — name the files.
+
+```bash
+uv run pytest tests/unit/test_people_directory.py tests/unit/test_people_org_chart.py \
+ tests/unit/test_people_capability.py tests/unit/test_people_migration.py \
+ tests/unit/test_tasks_people_scoping.py tests/unit/test_org_access_control.py
+cd workbench/control_plane && npx tsc --noEmit && npm test
+```
+
+`test_tasks_people_scoping.py` is in the list deliberately: WS-24 N4's 35 cases are the
+fence around the HR projection, and any new read path over `gtd_people` must leave them
+green rather than route around them.
diff --git a/ai-company-brain/specs/project_management_app.md b/ai-company-brain/specs/project_management_app.md
index ee7c2131..097e9e9d 100644
--- a/ai-company-brain/specs/project_management_app.md
+++ b/ai-company-brain/specs/project_management_app.md
@@ -4,14 +4,15 @@
> module, sliced into every other Center) · **Created:** 2026-08-05 · **Updated:** 2026-08-06
> (owner pass — §8's three open questions are answered as **D-PM-8/9/10**; §7.1 gains the
> Space→Center mapping step and WS-27b's done-whens grew with it) ·
-> **Status:** 🟢 **WS-27a + WS-27b + WS-27d BUILT** (2026-08-06, branch
+> **Status:** 🟢 **WS-27a + WS-27b + WS-27d + WS-27e BUILT** (2026-08-06, branch
> `claude/paca-research-task-management-a1f6zd`, PR #367) — migration `146_projects.sql`
> (§3.1–§3.10), `feature:projects` registered on both sides, the `routes/projects/` API (§4
> minus `sync.py`) live behind the feature gate on the `gateway/db.py` seam, the ClickUp
> importer with its Space→Center mapping plan (§7.1), and the `/projects` UI with its Center
-> projections (§5). **Not deployed and never run** — the migration has not been applied
-> anywhere and neither import endpoint has been executed against the live tenant. ·
-> **WS-27c, e, f, g: 🟡 SPEC, nothing built.** ·
+> projections (§5), and the personal lens (§3.11-§3.12, §6.1) on migration
+> `147_projects_personal.sql`. **Not deployed and never run** — neither migration has been
+> applied anywhere and neither import endpoint has been executed against the live tenant. ·
+> **WS-27c, f, g, h: 🟡 SPEC, nothing built.** ·
> **Owner:** vjvarada · **Board row: WS-27**
>
> **Verified 2026-08-06:** 140 hermetic cases across
@@ -25,10 +26,16 @@
> Space, a plan that writes, and a re-import that duplicates), and WS-27d's six (an unknown
> Center yielding an empty forest, `planDrop` never materialising, a board drop hard-coding
> status, unpositioned tasks sorting to the top, a missing nav pane, a Center linking at a
-> forked route).
+> forked route). **WS-27e adds six more:** an overlay keyed by task rather than per member,
+> a personal-only completion that leaves the board behind, `is_triaged` always true, an
+> inbox that drops its personal-project arm, a disposition filter matching only the stored
+> value, and a tickler that ignores `defer_until`. ⚠️ Two of those first **survived** and
+> the fake was at fault, not the tests — it applied the inbox's arms unconditionally instead
+> of keying them off the statement, the exact mirror failure `_projects_fakes.py`'s own
+> docstring warns about. Found by mutation, not by review.
>
-> **Not built, on purpose:** no sync (WS-27c — blocked on BO-1a/BO-1b), no personal mirror
-> (WS-27e), no automation/agent dispatch (WS-27f), and
+> **Not built, on purpose:** no sync (WS-27c — blocked on BO-1a/BO-1b), no automation or
+> agent dispatch (WS-27f), no `gtd_items` retirement (WS-27h), and
> `schema.generated.sql` was NOT regenerated — it needs a migrated live DB and is stale
> repo-wide, so it stays an owner-run chore (the WS-26a precedent).
>
@@ -287,7 +294,8 @@ on tasks). No engine 13.
| `activities.py` | `GET /projects/tasks/{id}/timeline` · `POST /projects/tasks/{id}/comments` · `PATCH/DELETE /projects/comments/{id}` · `POST /projects/activities/{id}/revert` (field_change only) |
| `admin.py` | statuses + types CRUD per root project (`RESTRICT` delete answers 409 naming the count in use) |
| `views.py` | views CRUD · `PUT /projects/views/{id}/positions` (bulk upsert) |
-| `me.py` | `GET /projects/assigned-to-me` — the personal lens's read (§6.1) |
+| `me.py` | `GET /projects/assigned-to-me` — the flat "what is mine" read |
+| `personal.py` (WS-27e) | `GET /projects/my/inbox` · `GET/POST /projects/my/project` · `POST /projects/my/tasks` · `PATCH /projects/tasks/{id}/personal` · `POST /projects/tasks/{id}/{complete,defer}` · `GET /projects/my/contexts` |
| `mapping.py` (WS-27b) | no routes — the three suggestion signals and their combination, kept apart from the importer because a proposal and an application are different acts (D-PM-10) |
| `import_clickup.py` (WS-27b) | `POST /projects/import/clickup/plan` (proposes a Center per Space, writes nothing) · `POST /projects/import/clickup` (applies the confirmed mapping) |
| `sync.py` (WS-27c) | `POST /projects/sync` · `GET /projects/sync/status` · `GET /projects/sync/conflicts` |
@@ -361,7 +369,10 @@ upsert per drop (the board's cross-column drag patches whatever field `column_by
### 6.1 Personal tasks (`/tasks`) — the org↔personal seam this spec exists for
The requirement: a `pm_task` assigned to a member appears in their personal GTD system, and
-completing it in either place is one fact. Mechanism is **D-PM-6** (§8): the Tasks app's
+completing it in either place is one fact. **Since 2026-08-06 that is true by construction
+rather than by synchronisation** — there is one row, and the personal view is a lens over
+it (D-PM-6 revised). What follows describes the superseded mirror; it is kept for the
+reader who needs to know what was rejected. ~~Mechanism is the Tasks app's
existing provider machinery mirrors `pm_tasks` where `lower(assignee) = user` into
`gtd_items` as `source='SYNCED'` rows (internal provider `commandcenter`, no credentials,
no broker gate — it is not an outward write). The GTD overlay (disposition, context,
@@ -488,6 +499,25 @@ Final import + parity counts per Space · flip the sync to **pull-only mirror**
edits still land; pushes stop) · a soak window where the org works in `/projects` · then
stop the pull.
+### 7.5 The `gtd_items` retirement (WS-27h) — the cost D-PM-6's revision accepted
+
+One store means the old one goes. Sequenced **after** WS-27e (which is the destination) and
+independent of the ClickUp work, because it is a move between two tables we own:
+
+1. `/tasks` reads a **union** of `gtd_items` and `pm_tasks` during coexistence, so the app
+ keeps working while rows move.
+2. Every `gtd_items` row migrates: `LOCAL` rows into the owner's personal project; `SYNCED`
+ rows onto their `pm_tasks` counterpart by `clickup_id`, with the GTD overlay landing in
+ `pm_task_personal`. The disposition vocabulary is **unchanged on purpose** (§3.12), so
+ this is a copy rather than a translation.
+3. `items.py`'s 27 `user_id` predicates retire with the table they scope. They are untouched
+ by WS-27e, deliberately — the blast radius WS-14 C1 measured belongs to this ticket.
+4. `gtd_projects`, `gtd_spaces`, `gtd_folders` retire with it; `gtd_people` does **not** —
+ that is the People Center's store (`specs/people_center_app.md`).
+
+⚠️ **Not started, and it is the largest single piece of WS-27 remaining.** Until it lands
+there are two personal task stores, which is the state this decision exists to end.
+
### 7.4 Retirement inventory (WS-27g, second half)
System A ClickUp arm: `ingestion/sources/clickup/` (client, normaliser, webhook),
`scheduler.py`'s ClickUp job, `scripts/clickup_sync.py`, `/webhooks/clickup` from
@@ -547,16 +577,51 @@ slice. **Rejected:** `gtd_item_sort_key`-style single order (one global order ca
N views) . **Cost:** one side table and materialise-on-first-drag semantics the UI must
implement faithfully.
-**D-PM-6 — The personal connection is the Tasks app's provider seam, run internally.**
+~~**D-PM-6 — The personal connection is the Tasks app's provider seam, run internally.**
`DECISION (agent-proposed, owner may overrule).` §6.1's mechanism: `pm_tasks` mirrored into
-`gtd_items` as `source='SYNCED'` under an internal `commandcenter` provider — every GTD
-feature works unchanged, the overlay contract already exists, and the mirror is in-DB
-(cheap, transactional, no broker). **Rejected:** (a) a read-union inside `/tasks` (touches
-the 27-predicate blast radius C1 already measured, and the GTD overlay has no home for
-un-mirrored rows); (b) linking `gtd_items` rows by hand (two sources of truth with no
-reconcile discipline). **Cost:** row duplication inside one database, and the internal
-provider must be exempted from `_broker_gate` (it is not an outward write — assert that in
-its tests).
+`gtd_items` as `source='SYNCED'` under an internal `commandcenter` provider…~~
+— **SUPERSEDED 2026-08-06.** Kept struck rather than deleted because the replacement is
+only legible against what it replaces: the mirror was the thing rejected, and a reader who
+finds `pm_task_personal` without this will wonder why the obvious answer was not taken.
+
+**D-PM-6 (revised) — ONE task store. The personal manager is a lens, not a copy.**
+`DECISION (owner-directed 2026-08-06: "the personal task manager should be a proper
+extension of the project system … a cohesive whole that should fit within each other".)`
+
+`pm_tasks` is **the** task table. Three consequences, and they are the whole design:
+
+1. **Assignment is not a sync.** A task assigned to a member is the row in their inbox.
+ Completing it there completes it for the project at the same instant, because there is
+ one row and one status. The mirror would have had two rows for one fact, and every
+ feature built afterwards — search, calendar, agents, reporting, the weekly review —
+ would have had to know about both.
+2. **Private work is a personal project.** An ordinary `pm_projects` row carrying
+ `personal_owner`, granted to that one address (§3.11). Nothing about tasks, boards,
+ timelines, automation or agent dispatch needs a special case; it is a project whose
+ grant happens to name a person. Personal projects are excluded from every *team* read —
+ "My tasks" is not a department — which is presentation, not access.
+3. **The GTD overlay is per-member** (§3.12, `pm_task_personal`). Two people assigned the
+ same task hold different dispositions: the person doing it says NEXT, the person who
+ delegated it says WAITING. A single column on `pm_tasks` could not express that, and it
+ is what delegation looks like rather than an edge case.
+
+**Rejected:** (a) the mirror, above — cohesion was the owner's stated requirement and a
+mirror is by construction two things; (b) keeping `gtd_items` for private todos and merging
+in the UI (owner-answered: two task tables forever, and every future feature has to handle
+both — the seam that quietly drifts); (c) rewriting `/tasks` onto `pm_tasks` in one pass
+(same end state, but ~11.8k lines and 68 endpoints at once, and a regression there breaks
+the owner's daily driver).
+
+**Cost, and it is real:** `gtd_items` becomes legacy and needs its own retirement
+(**WS-27h**, §7.5) — a second retirement project running beside ClickUp's. `/tasks` reads a
+union of both stores until it lands. The 27 owner-scoped predicates in `items.py` are
+untouched by this ticket and are WS-27h's problem, deliberately.
+
+**A property worth stating because it falls out rather than being built:** `disposition` is
+NULL until a member triages, and the read *derives* one from the task's status. So "never
+looked at" and "deliberately filed to INBOX" stay distinguishable — which is the only
+question the Weekly Review exists to ask, and a column defaulting to `'INBOX'` would have
+destroyed it silently.
**D-PM-7 — Sync conflicts: three-way merge, newest-wins per field, conflicts logged to the
timeline.** `DECISION (agent-proposed, owner may overrule).` §7.2. **Rejected:** whole-row
@@ -682,12 +747,18 @@ and cross-column drags patch the `column_by` field; (3) nav/access registration
slice pre-filters by its group — with a vitest asserting the `?center=` param filters
presentation only.
-**WS-27e — personal-task connection + people binding.** 🟢 AGENT-SAFE.
-Done when: (1) an assigned `pm_task` appears in the assignee's `/tasks` inbox as a
-`SYNCED` row with the correct GTD disposition mapping; (2) re-sync never clobbers the
-overlay (the existing contract's test extended to the internal provider); (3) completion
-round-trips both directions; (4) the internal provider is asserted broker-exempt; (5)
-assignee suggestions read the capability layer with N4's projection intact.
+**WS-27e — the personal lens (one store).** ✅ **BUILT 2026-08-06**
+(migration `147_projects_personal.sql`, `routes/projects/personal.py`; 31 hermetic cases,
+6 mutants red). **Its shape changed with D-PM-6's revision** — this was specced as a mirror
+into `gtd_items` and is built as a lens over `pm_tasks`, so the done-whens below are
+restated against what was actually built rather than what the mirror would have owed.
+Done when: (1) an assigned `pm_task` appears in the assignee's inbox **with no sync** —
+same row, same id — with the correct derived disposition; (2) a member's triage cannot move
+the team's board and a status change cannot overwrite a stated disposition, both proven
+structurally; (3) completing from the inbox moves the shared status and writes the
+transition's three effects; (4) two assignees hold independent dispositions; (5) a personal
+project is created once, granted to its owner alone, and excluded from every team read;
+(6) no route here accepts a `?member=` in any form.
**WS-27f — automation + agent dispatch.** 🟢 AGENT-SAFE (the node types land in
`workflows_app.md`'s tree per D6 and are recorded there in the same PR).
@@ -698,6 +769,13 @@ immediate `agent_run` activity, and a closing activity on completion/failure; (4
`skill-projects` tool family lets an agent read/update its assigned task under its own
identity, permission-intersected.
+**WS-27h — `gtd_items` retirement.** 🟡 sequenced after WS-27e; the data move itself is
+🔴 **OWNER-GATE** (it rewrites the owner's live task store).
+Done when: (1) `/tasks` serves a union with no visible regression; (2) every `gtd_items`
+row has a `pm_tasks` counterpart and the counts match per disposition; (3) the overlay
+landed in `pm_task_personal` with dispositions preserved exactly; (4) `items.py`'s
+owner-scoped predicates and the `gtd_*` task tables are gone. See §7.5.
+
**WS-27g — cutover + ClickUp retirement.** 🔴 **OWNER-GATE end-to-end** (final import,
parity sign-off, sync flips, consumer repoint, token revocation, constraint-8 amendment —
each registered in `work_plan.md` §6).
diff --git a/ai-company-brain/work_plan.md b/ai-company-brain/work_plan.md
index 4ff3f4a1..9539d359 100644
--- a/ai-company-brain/work_plan.md
+++ b/ai-company-brain/work_plan.md
@@ -148,7 +148,8 @@ looks. Full statements live in `FOUNDATION_BUILDOUT_CHECKLIST.md`.
| WS-21 | **Calendar F2/F3** (`gtd_time_blocks`, email windows, mobile timeline, external sync) | `calendar_focus_os.md` **§9** (canonical for all F2/F3 acceptance; **§5** canonical for `gtd_time_blocks`) + `calendar_timeboxing.md` **§13** (canonical for P4) — both rewritten 2026-08-03 | 🟡 partial | **Re-audited 2026-08-03 → GO-NARROWED.** P3 roll-over was already shipped (released-to-unscheduled, mig 78 + `start_auto_rollover`). ~~"ideal week"~~ **struck — substantially shipped** (mig 98 + settings round-trip + editor + grid render + packer honouring + 2 unit tests); only the unused-focus-window / template-adherence gap remains (§9.6). **Breaks-in-the-packer SHIPPED 2026-07-23** (`80722e17`, mig **97**) as *packer geometry* — a widened buffer plus lunch protection, **a gap, not a `kind='break'` row**, which is exactly why F2 survives (§5 residual 4, now closed). **The 2026-08-01 acceptance was satisfiable by doing nothing** — 2 of its 3 `gtd_time_blocks` clauses were already green against shipped code; they are deleted and replaced with four that all fail today. **`gtd_time_blocks` is 4 slices, not 1 PR** (§9.1 S1–S4): the "non-breaking `TimeBlock[]` swap" claim was **FALSE** — the measured blast radius is 17 TS files + 3 gateway modules + `apps/skills/skill-task-gtd/` + `apps/agents/agent-task-manager/`. **Focus Shield is AGENT-SAFE, not owner-gated** (§9.5) — it needs a design, not a credential; do not dispatch on §4.1 prose alone. **Top-5 outcomes (Horizons) — DO NOT DISPATCH:** it collides with WS-18; §4 assigns it here, and WS-18's title keeps it struck. **Verify by naming test files — never `pytest tests/unit -k calendar`**: `-k` still collects the whole directory, and whole-directory collection hangs on the Windows box. **Dispatchable today:** §9.1 S1 · the ritual-stamp localStorage residue (§9.1 done-when 4, independently shippable) · §9.6 · §9.7. **OWNER-GATE:** external sync (§9.11 / timeboxing §13 P4) needs Google Calendar and/or Microsoft Graph OAuth client credentials provisioned on the VPS. |
| WS-22 | **draw.io** (all 13 tickets open, nothing built) | `drawio_integration.md` | 🟡 owner | Best acceptance structure in the corpus; needs an owner and re-verified anchors (~5 weeks stale). ST-DRW-02 is a decision gate. |
| **WS-26** | **CRM app — native CRM + Zoho retirement** *(minted 2026-08-05)* | `specs/crm_app.md` | ✅ **a + b + c BUILT** · 🟢 d dispatchable | Research pass 2026-08-05: `frappe/crm` (AGPL — **concepts only, no code**), `trycompai/crm` (MIT), full-tree Zoho sweep. **Zoho today is a read-only nightly mirror** into the Phase-0 graph tables (`person`/`customer`/`deal`) with no UI, no write path, and **no Leads pull** — so leaving Zoho is import-and-retire, not a live cutover. Spine: Frappe's lead→convert→deal+contact+organization with **statuses-as-data** (color/position/type/probability); trycompai's single activity-spine table + `source` provenance + `last_activity_at` discipline. **BO-10 contribution: WS-26a adds the shared engine seam (`gateway/db.py::get_engine()`, tasks converted as proof) instead of engine 13.** Tickets: **a** schema + feature registration + core API — **BUILT 2026-08-05** (mig `144_crm.sql`, `feature:crm`, `gateway/db.py` seam + tasks converted, `routes/crm/`; **not deployed — the migration has not been applied anywhere**) · **b** **Zoho two-way sync — BUILT 2026-08-05** (branch `ws-26b-zoho-sync`: `list_leads` + `list_deleted` on the read client, the single write client `ingestion/sources/zoho/writer.py` with one grep-asserted caller, mig `145_crm_zoho_sync.sql` (dirty columns + `crm_zoho_tombstones` + `crm_sync_cursors`), `routes/crm/{import_zoho,sync_zoho,broker_handlers}.py`, `crm.zoho_*` broker handlers registered from `main.py`, 80 new hermetic tests). *(Re-scoped 2026-08-05, owner-directed D-CRM-7: "faithful two way sync until we do away with Zoho entirely" — coexistence is bidirectional, not import-once.)* **Built, never run: `CRM_ZOHO_SYNC` ships OFF, mig 145 is unapplied, and enabling/backfilling/hand-running against prod is OWNER-GATE §6 — it writes the live Zoho tenant.** WS-1's "no Zoho write path anywhere" clause was corrected in the same change (done-when 6) · **c** UI + the API addendum — **BUILT 2026-08-05** on branch `ws-26c-crm-ui` atop 26a and **merged with b into `ws-26-crm-app` 2026-08-06** (`/crm` app + BFF proxy; the three frontend registration points with `CenterApp` re-typed so `live ⇒ href` is a compile error; `routes/crm/deal_contacts.py` with one-primary-per-deal enforced on the shared `core.link_deal_contact` seam the convert path now also uses — 26b's importer is the one excepted writer and computes `is_primary` in-statement so a backfill can never demote a hand-set primary; `organization_name` on the deal list + board via a derived-table LEFT JOIN; the three review residuals — `?status_id` on a pipeline-less entity → 422, explicit `null` on a defaulted NOT NULL column → 422 not a driver 500, and a hand-edited `lead_name` surviving a name-field PATCH. **Still not deployed:** migrations 144 and 145 have not been applied anywhere, so live rendering, drag persistence and deep links are owner-verified after they apply) · **d** integrations — email address-join timeline, WhatsApp `entity_ref`, `agent-crm`; `CRM_AUTO_LEAD` ships OFF (🟢 except the flip) · **e** cutover + retirement inventory + **Zoho refresh-token revoke, which executes part of WS-2's standing P0** (🔴 OWNER-GATE end-to-end). Data-visibility departure recorded: org-visible to `feature:crm` holders in v1, `owner_email` is assignment not ACL (D-CRM-3; workflows v1 is the precedent) — revisit at WS-14 `group:` grants / colleague #1. |
-| **WS-27** | **Projects app — native project management + ClickUp retirement** *(minted 2026-08-05)* | `specs/project_management_app.md` | ✅ **a + b + d BUILT 2026-08-06** · 🟢 e, f dispatchable · 🟡 c gated | Research pass 2026-08-05: `Paca-AI/paca` v0.11.0 (Apache-2.0 — **patterns adopted, no code translated**; findings + the adopt/adapt/refuse table live in `specs/paca_pm_research_2026-08.md`, reference-only), plus a full-tree ClickUp sweep. **ClickUp today is TWO independent systems** — the Phase-0 graph mirror (read-only, shallow) *and* the per-user Tasks-app connector with a **live broker-gated write path** — so leaving ClickUp is coexistence-sync-then-invert, **not** WS-26's import-and-retire; the constraint-8 inversion is staged and recorded in spec §7. Spine: Paca's two-self-FK hierarchy (departments→projects→subprojects→tasks→subtasks as `pm_projects` + `pm_tasks`, types-as-data with the Epic-root rule), statuses-as-data with a semantic `category` (D-CRM-2 convergence), per-view fractional ordering (`pm_view_task_positions` — what lets People-Center and Center-slice boards order the same task differently), and a single activity spine. **First data-scoped app:** `pm_project_grants` on the shipped `email\|group:\|org` vocabulary (D12; sibling of C1's D13, which is unchanged), 404-not-403, and the full-portfolio view gives D14's zero-consumer `data:org:read` its **first consumer**. **Three owner answers recorded 2026-08-06 as D-PM-8/9/10, and two of them changed the build:** **D-PM-8** no portfolio/program layer — grants are the only grouping axis, a cross-department project simply carries several (a `pm_programs` table stays purely additive if wanted later); **D-PM-9** agent edits to ClickUp-linked tasks are treated **exactly like human edits** (*the agent proposed queueing agent-originated pushes for approval and was overruled*) — so during coexistence a mistaken agent edit reaches the live workspace with no human in between while `ACTION_BROKER_ENFORCE` is off; bounded by attribution (`agent:`), timeline-reversibility, and the fact that the enforce flip converts the whole class to queue-on-approval. Read D-PM-9's Cost paragraph before building WS-27f; **D-PM-10** ClickUp Spaces map to Centers **explicitly**, from agent-proposed suggestions (assignee-overlap → name match → EVAL-LOCKED content classification), owner-confirmed, applied as `group:` grants — and an **unmapped Space still imports in full with no group grant**, staying reachable in `/projects` for `data:org:read` holders and its assignees. This supersedes the "pilot vs all Spaces" framing: scope is now a per-Space decision the plan step surfaces, so a pilot and a full import are one code path. Tickets: **a** schema + `feature:projects` both sides + core API on the `gateway/db.py` seam — **BUILT 2026-08-06** (mig `146_projects.sql`, `routes/projects/` with zero `create_async_engine` calls, 115 hermetic cases + 5 mutants measured red; **not deployed — the migration has not been applied anywhere**) · **b** ClickUp org importer **+ the Space→Center mapping plan** — **BUILT 2026-08-06** (`routes/projects/mapping.py` + `import_clickup.py`; `POST /projects/import/clickup/plan` proposes and writes nothing, `POST /projects/import/clickup` applies the confirmed mapping; 25 hermetic cases, 4 mutants red incl. *applying the suggestion instead of the confirmed mapping*; **neither endpoint has been run — prod execution stays OWNER-GATE, §6**) · **c** two-way coexistence sync — three-way field merge, conflicts logged to the timeline (🟡 **blocked on WS-1's BO-1a + BO-1b, named prerequisites**; enabling push is OWNER-GATE) · **d** UI + Center (app + scope) projections, no forks — **BUILT 2026-08-06** (`src/app/projects/` tree + board + list + task panel + timeline, BFF proxy, nav/access registration, all six Centers linking at the SAME `/projects` path and differing only by `?center=`; 34 vitest cases incl. a registration fence, 6 mutants red) · **e** personal-task connection — `pm_tasks` mirrored into `gtd_items` via an internal `commandcenter` provider, GTD overlay contract intact (🟢) · **f** automation + agent dispatch — `pm.*` events into the existing `emit_event → dispatch_event` path; node types land in `workflows_app.md` per D6; assign-to-`agent:` dispatches an orchestrator run visible on the task timeline (🟢) · **g** cutover + retirement inventory (both ClickUp systems) + token revoke + the root-`AGENTS.md` constraint-8 amendment (🔴 OWNER-GATE end-to-end). |
+| **WS-27** | **Projects app — native project management + ClickUp retirement** *(minted 2026-08-05)* | `specs/project_management_app.md` | ✅ **a + b + d + e BUILT 2026-08-06** · 🟢 f dispatchable · 🟡 c gated · 🟡 h sequenced | Research pass 2026-08-05: `Paca-AI/paca` v0.11.0 (Apache-2.0 — **patterns adopted, no code translated**; findings + the adopt/adapt/refuse table live in `specs/paca_pm_research_2026-08.md`, reference-only), plus a full-tree ClickUp sweep. **ClickUp today is TWO independent systems** — the Phase-0 graph mirror (read-only, shallow) *and* the per-user Tasks-app connector with a **live broker-gated write path** — so leaving ClickUp is coexistence-sync-then-invert, **not** WS-26's import-and-retire; the constraint-8 inversion is staged and recorded in spec §7. Spine: Paca's two-self-FK hierarchy (departments→projects→subprojects→tasks→subtasks as `pm_projects` + `pm_tasks`, types-as-data with the Epic-root rule), statuses-as-data with a semantic `category` (D-CRM-2 convergence), per-view fractional ordering (`pm_view_task_positions` — what lets People-Center and Center-slice boards order the same task differently), and a single activity spine. **First data-scoped app:** `pm_project_grants` on the shipped `email\|group:\|org` vocabulary (D12; sibling of C1's D13, which is unchanged), 404-not-403, and the full-portfolio view gives D14's zero-consumer `data:org:read` its **first consumer**. **Three owner answers recorded 2026-08-06 as D-PM-8/9/10, and two of them changed the build:** **D-PM-8** no portfolio/program layer — grants are the only grouping axis, a cross-department project simply carries several (a `pm_programs` table stays purely additive if wanted later); **D-PM-9** agent edits to ClickUp-linked tasks are treated **exactly like human edits** (*the agent proposed queueing agent-originated pushes for approval and was overruled*) — so during coexistence a mistaken agent edit reaches the live workspace with no human in between while `ACTION_BROKER_ENFORCE` is off; bounded by attribution (`agent:`), timeline-reversibility, and the fact that the enforce flip converts the whole class to queue-on-approval. Read D-PM-9's Cost paragraph before building WS-27f; **D-PM-10** ClickUp Spaces map to Centers **explicitly**, from agent-proposed suggestions (assignee-overlap → name match → EVAL-LOCKED content classification), owner-confirmed, applied as `group:` grants — and an **unmapped Space still imports in full with no group grant**, staying reachable in `/projects` for `data:org:read` holders and its assignees. This supersedes the "pilot vs all Spaces" framing: scope is now a per-Space decision the plan step surfaces, so a pilot and a full import are one code path. Tickets: **a** schema + `feature:projects` both sides + core API on the `gateway/db.py` seam — **BUILT 2026-08-06** (mig `146_projects.sql`, `routes/projects/` with zero `create_async_engine` calls, 115 hermetic cases + 5 mutants measured red; **not deployed — the migration has not been applied anywhere**) · **b** ClickUp org importer **+ the Space→Center mapping plan** — **BUILT 2026-08-06** (`routes/projects/mapping.py` + `import_clickup.py`; `POST /projects/import/clickup/plan` proposes and writes nothing, `POST /projects/import/clickup` applies the confirmed mapping; 25 hermetic cases, 4 mutants red incl. *applying the suggestion instead of the confirmed mapping*; **neither endpoint has been run — prod execution stays OWNER-GATE, §6**) · **c** two-way coexistence sync — three-way field merge, conflicts logged to the timeline (🟡 **blocked on WS-1's BO-1a + BO-1b, named prerequisites**; enabling push is OWNER-GATE) · **d** UI + Center (app + scope) projections, no forks — **BUILT 2026-08-06** (`src/app/projects/` tree + board + list + task panel + timeline, BFF proxy, nav/access registration, all six Centers linking at the SAME `/projects` path and differing only by `?center=`; 34 vitest cases incl. a registration fence, 6 mutants red) · **e** the personal lens — **BUILT 2026-08-06** and **its shape changed**: `D-PM-6` was revised (owner-directed — *"the personal task manager should be a proper extension … a cohesive whole"*) from a mirror into **one store**. `pm_tasks` is THE task table; private work is a personal project (`pm_projects.personal_owner`); the GTD overlay is **per-member** (`pm_task_personal`, mig `147_projects_personal.sql`) so two assignees can hold different dispositions. Assignment is no longer a sync — the inbox row IS the project row, and completing it there moves the shared status. 31 hermetic cases, 6 mutants red. **Cost accepted: `gtd_items` becomes legacy and WS-27h retires it** · **f** automation + agent dispatch — `pm.*` events into the existing `emit_event → dispatch_event` path; node types land in `workflows_app.md` per D6; assign-to-`agent:` dispatches an orchestrator run visible on the task timeline (🟢) · **g** cutover + retirement inventory (both ClickUp systems) + token revoke + the root-`AGENTS.md` constraint-8 amendment (🔴 OWNER-GATE end-to-end) · **h** `gtd_items` retirement — the cost D-PM-6's revision accepted: union read, row migration into `pm_tasks` + `pm_task_personal`, then `items.py`'s 27 owner-scoped predicates retire with the table they scope (🟡 after e; the data move is 🔴 OWNER-GATE — it rewrites the owner's live task store). |
+| **WS-28** | **People Center — directory, org chart, and the assignment seam** *(minted 2026-08-06)* | `specs/people_center_app.md` | 🔲 spec'd · 🟢 a–e dispatchable · 🔴 f owner-gate | Scope owner-set 2026-08-06: **directory, skills, org chart, capacity, seats/roles — exactly what assignment and planning need**; leave/onboarding/hiring are named as later phases so their absence is a decision. **The fact this spec exists to settle:** there are TWO people stores and that is deliberate — `app_user` answers *can they sign in and what may they see*, `gtd_people` answers *who are they and what can they do*, and the directory must include people with **no login** (contractors), which is why the Projects app's assignee is a plain string. They join on lowercased email, and **P-1 fixes that join before it is relied on**: migration 49 made `name` UNIQUE and left `email` unconstrained, so today two rows may share an address and an email→person join is ambiguous. Surfaces: directory (honouring WS-24 N4's HR projection, with a *restricted* empty state distinct from *none*) · person page · org chart from `manager_id` with a Center overlay that **shows** department/group mismatches rather than smoothing them · capability search over stated skills → résumé evidence → the existing `capability_embedding`, which **suggests and never assigns** · seats & roles matrix (read + propose; applying a membership change stays owner-gated per §6 (d)). Closes WS-13's outstanding *People directory read view* item. Tickets **a** key-shape fix (🟢) · **b** directory + person page (🟢) · **c** org chart (🟢) · **d** capability search (🟢, ranking EVAL-LOCKED) · **e** the Projects seams — directory-backed assignee picker listing agents and directory-only people, capacity derived from open assigned tasks (🟢) · **f** seats & roles writes (🔴 OWNER-GATE). |
---
@@ -380,6 +381,7 @@ taken and dated.
| Memory compartments + clearance (incl. `subject:`) | **`docs/multiplayer/memory-clearance.md` §7** (surface design §7.1); dispatched as **WS-10 S1** | memory_architecture §9 `3a′` (link-only since 2026-08-02) · multiplayer README §6.3/§8 Phase 3 (index only) · prior-art §QM-D1 (reference only) |
| Native CRM + the Zoho retirement path | **WS-26 — `specs/crm_app.md`** (minted 2026-08-05) | `department_centers.md` Sales Center "Pipeline" app (a projection of `/crm`, flipped live by WS-26c) · WS-1 interplay **settled 2026-08-05 (D-CRM-7/D-CRM-8) — the writer now EXISTS** (branch `ws-26b-zoho-sync`): `ingestion/sources/zoho/writer.py`, the sync engine's **single, broker-gated** writer with one grep-asserted caller (`routes/crm/sync_zoho.py::execute_push`) and three registered `crm.zoho_*` handlers that auto-apply while `ACTION_BROKER_ENFORCE` is off, retired at WS-26e. WS-1's "no Zoho write path anywhere in the repo" sentence was corrected in that same change (done-when 6) — this row and the WS-1 row now agree, and neither should be re-softened · WS-2 (the Zoho-token P0's endgame is WS-26e's **revoke**) · WS-20 §11's "Odoo/Zoho-bound items" (bind to `crm` `entity_ref` per WS-26d instead) · `orchestrator/sales_views.py` + `scripts/reconciler.py` + `skills/sales\|reconciler/*` keep reading the graph mirror until WS-26e repoints them |
| Native project management + the ClickUp retirement path | **WS-27 — `specs/project_management_app.md`** (minted 2026-08-05) | `task_manager_app.md` (the personal GTD lens — untouched as an app; its ClickUp provider **arm** retires at WS-27g while the provider *interface* stays, becoming the seam WS-27e's internal `commandcenter` provider uses) · `department_centers.md` C1/WS-13 (the tasks team slice and the People Center sub-app list; C1's `gtd_project_grant` = D13 stays C1's own — `pm_project_grants` is a sibling on the same subject vocabulary, never a replacement) · `task_manager_hr_planning_and_memory.md` (people/capability layer — WS-27 reads it, never rebuilds it) · `workflows_app.md` owns the automation engine WS-27f feeds (D6; the Paca-grade uplifts — multi-branch switch, step snapshots, dependency map — are recorded there as backlog, not here) · `paca_pm_research_2026-08.md` (reference-only, owns no work) · WS-1's BO-1a/BO-1b are **named prerequisites** of WS-27c, not discoveries |
+| The People Center's surfaces (directory, org chart, capability search, seats) | **WS-28 — `specs/people_center_app.md`** (minted 2026-08-06) | It owns **surfaces, not facts**: `task_manager_hr_planning_and_memory.md` owns the HR data and the capability vectors · `org_access_control.md` owns identity, roles and overrides · `colleague_onboarding.md` owns the invite process and the role × app matrix · `department_centers.md` owns Centers and groups · `project_management_app.md` owns the work. WS-13's *People directory read view* is closed by WS-28b rather than staying open in Centers B |
| Tenancy boundary + visibility model (who can see what) | **`specs/tenancy_and_visibility.md`** (D11 §1 · D12 §3–§4 · the app-by-app gap table §5 · TV-1 §2) | `department_centers.md` (the "separate deployment is for a separate org, never a department" rule) · `org_access_control.md` §8 Ph2 · `multi_user_organization_research.md` §5/§7/§8/§9/§17 (**research only, and superseded for planning by the new spec**) · `groups_sessions_authority.md` §3 (the intersection rule it constrains) · D9 (the twelve "second tenant deployment" sites) |
## 5. Documentation remediation backlog (WS-0)
diff --git a/apps/services/gateway/gateway/routes/projects/__init__.py b/apps/services/gateway/gateway/routes/projects/__init__.py
index 2cc2cc25..cbfb52ef 100644
--- a/apps/services/gateway/gateway/routes/projects/__init__.py
+++ b/apps/services/gateway/gateway/routes/projects/__init__.py
@@ -25,6 +25,7 @@
from gateway.routes.projects import admin as _admin # noqa: F401
from gateway.routes.projects import import_clickup as _import_clickup # noqa: F401
from gateway.routes.projects import me as _me # noqa: F401
+from gateway.routes.projects import personal as _personal # noqa: F401
from gateway.routes.projects import tasks as _tasks # noqa: F401
from gateway.routes.projects import tree as _tree # noqa: F401
from gateway.routes.projects import views as _views # noqa: F401
diff --git a/apps/services/gateway/gateway/routes/projects/core.py b/apps/services/gateway/gateway/routes/projects/core.py
index a192dbbb..26ffbf51 100644
--- a/apps/services/gateway/gateway/routes/projects/core.py
+++ b/apps/services/gateway/gateway/routes/projects/core.py
@@ -292,6 +292,10 @@ def offset(self) -> int:
TIMESTAMP_COLUMNS: frozenset[str] = frozenset({
"due_at", "completed_at", "archived_at", "clickup_synced_at",
+ # The personal overlay's instants (147). Same rule, same reason: bare
+ # `text()` declares no column type, so an ISO string would arrive at a
+ # timestamptz as text.
+ "defer_until", "clarified_at",
})
DATE_COLUMNS: frozenset[str] = frozenset({"start_date"})
diff --git a/apps/services/gateway/gateway/routes/projects/personal.py b/apps/services/gateway/gateway/routes/projects/personal.py
new file mode 100644
index 00000000..fc0b6d34
--- /dev/null
+++ b/apps/services/gateway/gateway/routes/projects/personal.py
@@ -0,0 +1,571 @@
+"""Projects · the personal lens — one task store, seen as my own work.
+
+Spec: ``ai-company-brain/specs/project_management_app.md`` §3.11-§3.12, §6.1 ·
+**D-PM-6 (revised 2026-08-06)** · ticket WS-27e.
+
+ GET /projects/my/inbox → my work, with my overlay
+ GET /projects/my/project → my personal project
+ POST /projects/my/project → …creating it if absent
+ POST /projects/my/tasks → quick capture into it
+ PATCH /projects/tasks/{task_id}/personal → set MY overlay on a task
+ GET /projects/my/contexts → the contexts I actually use
+
+**There is no sync here, and that is the whole point.** A task assigned to a
+member is not copied into their inbox — it *is* the row in their inbox. So
+completing it in the personal view completes it for the project, because there
+is one row and one status; and a project manager watching the board sees the
+same fact at the same instant.
+
+What is per-member is the **overlay**: disposition, context, energy, defer.
+Two people assigned the same task legitimately hold different ones — the person
+doing it says NEXT, the person who delegated it says WAITING — which a single
+column on ``pm_tasks`` could not express. That is not an edge case; it is what
+delegation looks like.
+
+**Identity comes from the session, never from a parameter.** Every route here
+resolves the caller and scopes to them; there is deliberately no `?member=`,
+so no request can be made to read or write somebody else's practice.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from acb_auth import UserContext, get_current_user
+from fastapi import Depends, HTTPException
+from gateway.routes.projects.core import (
+ CLOSING_CATEGORIES,
+ ListResponse,
+ Page,
+ TaskModel,
+ _get_db,
+ actor,
+ clean_payload,
+ coerce_write_values,
+ emit,
+ insert_row,
+ load_default_status,
+ load_visible_task,
+ next_task_number,
+ now,
+ record_activity,
+ resolve_visibility,
+ router,
+ row_to_dict,
+)
+from pydantic import BaseModel
+from sqlalchemy import text
+
+#: Migration 48's vocabulary, unchanged — WS-27h has to move every gtd_items row
+#: onto these and a renamed disposition would make that a translation.
+DISPOSITIONS: tuple[str, ...] = (
+ "INBOX", "NEXT", "WAITING", "SOMEDAY", "PROJECT", "REFERENCE", "DONE", "TRASH",
+)
+
+ENERGIES: tuple[str, ...] = ("low", "medium", "high")
+
+#: The name a member's personal project is created with. Not shown as a project
+#: in team lists — it carries `personal_owner`, which every team read excludes.
+PERSONAL_PROJECT_NAME = "My tasks"
+
+
+class PersonalIn(BaseModel):
+ """The overlay a member may set on a task. All optional; `null` clears."""
+
+ disposition: str | None = None
+ next_action: str | None = None
+ context: str | None = None
+ energy: str | None = None
+ time_estimate_mins: int | None = None
+ is_two_minute: bool | None = None
+ defer_until: str | None = None
+
+
+class CaptureIn(BaseModel):
+ title: str
+ next_action: str | None = None
+ context: str | None = None
+ due_at: str | None = None
+
+
+# ── The derived disposition ─────────────────────────────────────────────────
+
+def derive_disposition(
+ *, status_category: str, is_mine: bool, has_assignee: bool,
+) -> str:
+ """The disposition a member has NOT stated, read off the task itself.
+
+ The same lens the ClickUp pull has always applied (``routes/tasks/sync.py``),
+ lifted here so both halves of the app agree about what an untriaged task
+ means:
+
+ closed in the tool → DONE
+ backlog-ish → SOMEDAY
+ assigned to me → NEXT
+ assigned to somebody else → WAITING
+ unassigned → INBOX
+
+ Derived, never written. Storing it on first read would turn "never triaged"
+ into "triaged to NEXT" and quietly empty the Weekly Review — the one
+ question that review exists to ask is which tasks the member has not looked
+ at, and that is exactly the rows with no stated disposition.
+ """
+ if status_category in CLOSING_CATEGORIES:
+ return "DONE"
+ if status_category == "backlog":
+ return "SOMEDAY"
+ if is_mine:
+ return "NEXT"
+ if has_assignee:
+ return "WAITING"
+ return "INBOX"
+
+
+# ── The personal project ────────────────────────────────────────────────────
+
+async def _load_personal_project(db: Any, email: str) -> Any | None:
+ return (await db.execute(
+ text(
+ "SELECT * FROM pm_projects WHERE lower(personal_owner) = :who"
+ ),
+ {"who": email},
+ )).fetchone()
+
+
+async def ensure_personal_project(db: Any, email: str) -> Any:
+ """This member's personal project, created on first use.
+
+ Idempotent by the partial unique index on ``lower(personal_owner)``: two
+ concurrent captures cannot mint two personal projects, and the loser of that
+ race re-reads the winner's rather than failing the capture.
+
+ It is an ordinary project — it gets statuses, a counter and a grant like any
+ other. That is what makes a private todo a first-class task: the board, the
+ timeline, automation and agent dispatch all work on it with no special case.
+ """
+ existing = await _load_personal_project(db, email)
+ if existing is not None:
+ return existing
+
+ project = await insert_row(db, "pm_projects", {
+ "name": PERSONAL_PROJECT_NAME,
+ "description": "Work only you can see. Tasks assigned to you from team "
+ "projects appear in your inbox without living here.",
+ "personal_owner": email,
+ "created_by": email,
+ "source": "manual",
+ })
+ project_id = str(project.id)
+
+ # The grant is what the visibility model reads; `personal_owner` is only the
+ # fast path to finding it. Both, so neither is load-bearing alone.
+ await db.execute(
+ text(
+ "INSERT INTO pm_project_grants (project_id, subject, created_by) "
+ "VALUES (CAST(:pid AS uuid), :who, :who) "
+ "ON CONFLICT (project_id, subject) DO NOTHING"
+ ),
+ {"pid": project_id, "who": email},
+ )
+ for name, category, position, is_default in (
+ ("Inbox", "backlog", 10, True),
+ ("Next", "todo", 20, False),
+ ("Doing", "in_progress", 30, False),
+ ("Done", "done", 40, False),
+ ):
+ await insert_row(db, "pm_task_statuses", {
+ "project_id": project_id, "name": name, "category": category,
+ "position": position, "is_default": is_default,
+ })
+ return project
+
+
+@router.get("/my/project")
+async def get_my_project(user: UserContext = Depends(get_current_user)) -> dict:
+ """My personal project, or 404 if I have never captured anything."""
+ email = actor(user).lower()
+ db = await _get_db()
+ try:
+ row = await _load_personal_project(db, email)
+ if row is None:
+ raise HTTPException(status_code=404, detail="No personal project yet")
+ return {"id": str(row.id), "name": row.name}
+ finally:
+ await db.close()
+
+
+@router.post("/my/project", status_code=201)
+async def create_my_project(user: UserContext = Depends(get_current_user)) -> dict:
+ email = actor(user).lower()
+ db = await _get_db()
+ try:
+ row = await ensure_personal_project(db, email)
+ await db.commit()
+ return {"id": str(row.id), "name": row.name}
+ finally:
+ await db.close()
+
+
+@router.post("/my/tasks", status_code=201)
+async def capture(
+ payload: CaptureIn, user: UserContext = Depends(get_current_user),
+) -> dict:
+ """Quick capture — a thought into my personal project, assigned to me.
+
+ GTD's first discipline is that capture must be frictionless, so this takes a
+ title and nothing else is required: no project to choose, no status to pick.
+ The task it creates is an ordinary ``pm_tasks`` row, which is what lets a
+ captured thought later be moved into a team project without being recreated.
+ """
+ title = (payload.title or "").strip()
+ if not title:
+ raise HTTPException(status_code=422, detail="A task needs a title.")
+
+ email = actor(user).lower()
+ db = await _get_db()
+ try:
+ project = await ensure_personal_project(db, email)
+ project_id = str(project.id)
+ status = await load_default_status(db, project_id)
+ task = await insert_row(db, "pm_tasks", {
+ "project_id": project_id,
+ "root_project_id": project_id,
+ "task_number": await next_task_number(db, project_id),
+ "status_id": str(status.id),
+ "title": title,
+ "due_at": payload.due_at,
+ "created_by": email,
+ "source": "manual",
+ })
+ task_id = str(task.id)
+ await db.execute(
+ text(
+ "INSERT INTO pm_task_assignees (task_id, assignee, assigned_by) "
+ "VALUES (CAST(:tid AS uuid), :who, :who) "
+ "ON CONFLICT (task_id, assignee) DO NOTHING"
+ ),
+ {"tid": task_id, "who": email},
+ )
+ if payload.next_action or payload.context:
+ await _upsert_personal(db, task_id, email, {
+ "next_action": payload.next_action,
+ "context": payload.context,
+ })
+ await record_activity(
+ db, activity_type="system", created_by=email, task_id=task_id,
+ body="Captured",
+ )
+ await db.commit()
+ result = row_to_dict(task, TaskModel)
+ finally:
+ await db.close()
+
+ await emit("pm.task.created", {"task_id": task_id, "project_id": project_id,
+ "title": title})
+ return result
+
+
+# ── The overlay ─────────────────────────────────────────────────────────────
+
+async def _upsert_personal(
+ db: Any, task_id: str, email: str, values: dict[str, Any],
+) -> Any:
+ """Write MY overlay row for a task. Never anybody else's."""
+ columns = ["task_id", "member_email", *values]
+ assignments = ", ".join(f"{c} = EXCLUDED.{c}" for c in values)
+ # `pm_task_personal` has a COMPOSITE key, so the shared `update_row` helper
+ # — which keys on `id` — cannot serve it; this upsert is written out. The
+ # values still go through the shared coercion so an ISO `defer_until`
+ # becomes a real instant.
+ bound = coerce_write_values(values)
+ return (await db.execute(
+ text(
+ f"INSERT INTO pm_task_personal ({', '.join(columns)}) "
+ f"VALUES (CAST(:task_id AS uuid), :member_email, "
+ f"{', '.join(f':{c}' for c in values)}) "
+ f"ON CONFLICT (task_id, member_email) DO UPDATE "
+ f"SET {assignments}, updated_at = now() "
+ f"RETURNING *"
+ ),
+ {"task_id": task_id, "member_email": email, **bound},
+ )).fetchone()
+
+
+@router.patch("/tasks/{task_id}/personal")
+async def set_personal(
+ task_id: str, payload: PersonalIn,
+ user: UserContext = Depends(get_current_user),
+) -> dict:
+ """Set my overlay on a task.
+
+ Writes **only** to ``pm_task_personal``. It cannot touch the task's shared
+ columns — a member filing something as SOMEDAY must not move it on the
+ team's board — which is the structural half of "the overlay is never
+ clobbered", now true in both directions.
+ """
+ values = clean_payload(payload)
+ if values.get("disposition") is not None and values["disposition"] not in DISPOSITIONS:
+ raise HTTPException(
+ status_code=422,
+ detail=f"Unknown disposition. One of: {list(DISPOSITIONS)}.",
+ )
+ if values.get("energy") is not None and values["energy"] not in ENERGIES:
+ raise HTTPException(
+ status_code=422, detail=f"Unknown energy. One of: {list(ENERGIES)}.",
+ )
+
+ email = actor(user).lower()
+ db = await _get_db()
+ try:
+ vis = await resolve_visibility(db, user)
+ # Seeing the task is the floor. Assignment already satisfies it
+ # (`load_visible_task`), so a task delegated across a Center boundary is
+ # triageable by the person asked to do it — which is the case that would
+ # otherwise be unusable.
+ await load_visible_task(db, vis, task_id)
+
+ # Triage is recorded even when nothing changed: "when did I last look at
+ # this" is the Weekly Review's question, and a no-op PATCH is still a
+ # member looking at it.
+ values["clarified_at"] = now()
+ row = await _upsert_personal(db, task_id, email, values)
+ await db.commit()
+ return _personal_to_dict(row)
+ finally:
+ await db.close()
+
+
+def _personal_to_dict(row: Any) -> dict[str, Any]:
+ return {
+ "task_id": str(getattr(row, "task_id", "")),
+ "disposition": getattr(row, "disposition", None),
+ "next_action": getattr(row, "next_action", None),
+ "context": getattr(row, "context", None),
+ "energy": getattr(row, "energy", None),
+ "time_estimate_mins": getattr(row, "time_estimate_mins", None),
+ "is_two_minute": bool(getattr(row, "is_two_minute", False)),
+ "defer_until": (
+ getattr(row, "defer_until", None).isoformat()
+ if getattr(row, "defer_until", None) is not None
+ else None
+ ),
+ }
+
+
+# ── The inbox ───────────────────────────────────────────────────────────────
+
+#: My work: everything assigned to me, plus everything in my personal project.
+#:
+#: The second arm matters — a task I captured and then unassigned is still mine
+#: to see; without it, clearing my own name off a private todo would make it
+#: vanish from the only place it exists.
+_MY_TASKS_SQL = """
+SELECT t.*,
+ s.category AS status_category,
+ p.disposition AS p_disposition,
+ p.next_action AS p_next_action,
+ p.context AS p_context,
+ p.energy AS p_energy,
+ p.time_estimate_mins AS p_time_estimate_mins,
+ p.is_two_minute AS p_is_two_minute,
+ p.defer_until AS p_defer_until,
+ (SELECT count(*) FROM pm_task_assignees a2 WHERE a2.task_id = t.id)
+ AS assignee_count,
+ EXISTS (SELECT 1 FROM pm_task_assignees a3
+ WHERE a3.task_id = t.id AND lower(a3.assignee) = :who)
+ AS is_mine
+FROM pm_tasks t
+JOIN pm_task_statuses s ON s.id = t.status_id
+LEFT JOIN pm_task_personal p
+ ON p.task_id = t.id AND lower(p.member_email) = :who
+LEFT JOIN pm_projects proj ON proj.id = t.project_id
+WHERE t.archived_at IS NULL
+ AND (
+ EXISTS (SELECT 1 FROM pm_task_assignees a
+ WHERE a.task_id = t.id AND lower(a.assignee) = :who)
+ OR lower(proj.personal_owner) = :who
+ )
+"""
+
+
+@router.get("/my/inbox")
+async def my_inbox(
+ user: UserContext = Depends(get_current_user),
+ disposition: str | None = None,
+ context: str | None = None,
+ include_deferred: bool = False,
+ include_done: bool = False,
+ page: Page = Depends(),
+) -> ListResponse:
+ """My work — the org's tasks and my own, as one list, with my overlay.
+
+ **No visibility clause**, deliberately, and for the same reason
+ ``/assigned-to-me`` has none: assignment is itself the strongest claim to a
+ task, so scoping this by project grants would hide work from the person
+ asked to do it.
+
+ Filtering by ``disposition`` matches the *effective* one — stated where the
+ member has triaged, derived otherwise — so "show me my next actions" answers
+ the same way whether or not they have been through the inbox. Filtering on
+ the stored column alone would show an empty Next list to somebody with
+ twenty assigned tasks.
+ """
+ if disposition is not None and disposition not in DISPOSITIONS:
+ raise HTTPException(
+ status_code=422,
+ detail=f"Unknown disposition. One of: {list(DISPOSITIONS)}.",
+ )
+
+ email = actor(user).lower()
+ clauses: list[str] = []
+ params: dict[str, Any] = {"who": email}
+ if not include_deferred:
+ # The tickler: a deferred task is not in the inbox until its date.
+ clauses.append("(p.defer_until IS NULL OR p.defer_until <= now())")
+ if context:
+ clauses.append("lower(p.context) = :context")
+ params["context"] = context.strip().lower()
+
+ sql = _MY_TASKS_SQL + ("".join(f" AND {c}" for c in clauses))
+ db = await _get_db()
+ try:
+ rows = (await db.execute(text(sql), params)).fetchall()
+ finally:
+ await db.close()
+
+ items: list[dict[str, Any]] = []
+ for row in rows:
+ effective = getattr(row, "p_disposition", None) or derive_disposition(
+ status_category=str(getattr(row, "status_category", "") or ""),
+ is_mine=bool(getattr(row, "is_mine", False)),
+ has_assignee=int(getattr(row, "assignee_count", 0) or 0) > 0,
+ )
+ if not include_done and effective in ("DONE", "TRASH"):
+ continue
+ if disposition is not None and effective != disposition:
+ continue
+ task = row_to_dict(row, TaskModel)
+ task["disposition"] = effective
+ # Whether the member has actually triaged it — the Weekly Review reads
+ # this, and it is the distinction a stored default would have destroyed.
+ task["is_triaged"] = getattr(row, "p_disposition", None) is not None
+ task["next_action"] = getattr(row, "p_next_action", None)
+ task["context"] = getattr(row, "p_context", None)
+ task["energy"] = getattr(row, "p_energy", None)
+ task["is_two_minute"] = bool(getattr(row, "p_is_two_minute", False))
+ items.append(task)
+
+ total = len(items)
+ window = items[page.offset : page.offset + page.limit]
+ return ListResponse(rows=window, total=total)
+
+
+@router.get("/my/contexts")
+async def my_contexts(user: UserContext = Depends(get_current_user)) -> dict:
+ """The contexts this member actually uses, with counts.
+
+ Derived from their own rows rather than from a configured list: a context
+ vocabulary somebody has to maintain is a context vocabulary that goes stale,
+ and GTD contexts are personal by nature.
+ """
+ email = actor(user).lower()
+ db = await _get_db()
+ try:
+ rows = (await db.execute(
+ text(
+ "SELECT p.context AS context, count(*) AS total "
+ "FROM pm_task_personal p "
+ "JOIN pm_tasks t ON t.id = p.task_id "
+ "WHERE lower(p.member_email) = :who AND p.context IS NOT NULL "
+ " AND t.archived_at IS NULL "
+ "GROUP BY p.context ORDER BY count(*) DESC, p.context"
+ ),
+ {"who": email},
+ )).fetchall()
+ return {
+ "rows": [
+ {"context": r.context, "total": int(r.total)} for r in rows
+ ],
+ "total": len(rows),
+ }
+ finally:
+ await db.close()
+
+
+# ── Completion, from the personal side ──────────────────────────────────────
+
+@router.post("/tasks/{task_id}/complete")
+async def complete_task(
+ task_id: str, user: UserContext = Depends(get_current_user),
+) -> dict:
+ """Tick a task off from my inbox.
+
+ This moves the task's SHARED status into its project's done lane — it is not
+ a personal-only "done". That is the cohesion the one-store design buys:
+ finishing something in your own list finishes it for the project, at the
+ same instant, because there is one row. A personal-only completion would be
+ a member quietly marking a team task finished while the board still shows it
+ open, which is the exact drift a mirror produces.
+ """
+ from gateway.routes.projects.core import apply_status_transition
+
+ email = actor(user).lower()
+ db = await _get_db()
+ try:
+ vis = await resolve_visibility(db, user)
+ task = await load_visible_task(db, vis, task_id)
+ done = (await db.execute(
+ text(
+ "SELECT * FROM pm_task_statuses "
+ "WHERE project_id = CAST(:root AS uuid) AND category = 'done' "
+ "ORDER BY position LIMIT 1"
+ ),
+ {"root": str(task.root_project_id)},
+ )).fetchone()
+ if done is None:
+ raise HTTPException(
+ status_code=422,
+ detail="This project has no done status; add one first.",
+ )
+ moved = await apply_status_transition(
+ db, task, str(done.id), created_by=email,
+ )
+ # And the member's own view of it follows, so a completed task does not
+ # sit in their Next list contradicting the board.
+ await _upsert_personal(db, task_id, email, {"disposition": "DONE"})
+ await db.commit()
+ result = row_to_dict(moved["row"], TaskModel)
+ finally:
+ await db.close()
+
+ await emit("pm.task.status_changed", {
+ "task_id": task_id, "from": moved["from"].name, "to": moved["to"].name,
+ "to_category": moved["to"].category,
+ })
+ return result
+
+
+class DeferIn(BaseModel):
+ until: str
+
+
+@router.post("/tasks/{task_id}/defer")
+async def defer_task(
+ task_id: str, payload: DeferIn,
+ user: UserContext = Depends(get_current_user),
+) -> dict:
+ """Hide a task from my inbox until a date. Mine only — the team's board is
+ unaffected, because deferring is a statement about my attention, not about
+ the work."""
+ email = actor(user).lower()
+ db = await _get_db()
+ try:
+ vis = await resolve_visibility(db, user)
+ await load_visible_task(db, vis, task_id)
+ row = await _upsert_personal(db, task_id, email, {
+ "defer_until": payload.until, "disposition": "SOMEDAY",
+ })
+ await db.commit()
+ return _personal_to_dict(row)
+ finally:
+ await db.close()
diff --git a/apps/services/gateway/gateway/routes/projects/tree.py b/apps/services/gateway/gateway/routes/projects/tree.py
index 5f7a885f..4727a1c7 100644
--- a/apps/services/gateway/gateway/routes/projects/tree.py
+++ b/apps/services/gateway/gateway/routes/projects/tree.py
@@ -99,9 +99,15 @@ class DeleteResponse(BaseModel):
async def _visible_projects(db: Any, user: UserContext) -> list[Any]:
vis = await resolve_visibility(db, user)
+ # Personal projects are excluded from every TEAM read (147/§3.11). They are
+ # ordinary projects the grant model already scopes to one person, so this is
+ # not a security filter — it is that "My tasks" does not belong in a
+ # department tree beside Sales and Operations. The personal surface reads
+ # them through `/projects/my/*`.
return (await db.execute(
text(
- f"SELECT * FROM pm_projects WHERE {vis.project_clause()} "
+ f"SELECT * FROM pm_projects "
+ f"WHERE {vis.project_clause()} AND personal_owner IS NULL "
f"ORDER BY position NULLS LAST, name"
),
vis.params,
diff --git a/infra/postgres/147_projects_personal.sql b/infra/postgres/147_projects_personal.sql
new file mode 100644
index 00000000..91fb08f7
--- /dev/null
+++ b/infra/postgres/147_projects_personal.sql
@@ -0,0 +1,115 @@
+-- ============================================================================
+-- 147_projects_personal.sql — the personal lens, on the SAME task store.
+--
+-- What: spec ai-company-brain/specs/project_management_app.md §3.11–§3.12 and
+-- §6.1 (WS-27e). Two additions to migration 146's spine:
+-- * pm_projects.personal_owner — a project that belongs to one member
+-- * pm_task_personal — that member's GTD overlay on a task
+--
+-- Why: **D-PM-6 was superseded on 2026-08-06** (owner-directed: "the personal
+-- task manager should be a proper extension of the project system … a
+-- cohesive whole"). The original decision mirrored pm_tasks into gtd_items as
+-- SYNCED rows. A mirror is two rows for one fact, and every feature built on
+-- top of it — search, calendar, agents, reporting — then has to know about
+-- both. So there is now ONE task table: assigning a task to somebody does not
+-- *copy* it into their inbox, it IS the row in their inbox.
+--
+-- The two things that made a separate personal store look necessary, and how
+-- they are answered here instead:
+--
+-- 1. "Private todos aren't org work." They are now a **personal project** — an
+-- ordinary pm_projects row with `personal_owner` set, granted to that one
+-- email. Nothing about the task, board, timeline or automation machinery
+-- needs a special case; it is a project like any other whose grant happens
+-- to name a person.
+-- 2. "The GTD overlay is mine, not the team's." It is, so it lives in a
+-- **per-member** side table. Two people assigned the same task can hold
+-- different dispositions — one is doing it (NEXT), one is waiting on it
+-- (WAITING) — which a single column on pm_tasks could never express. That
+-- is not an edge case; it is what delegation looks like.
+--
+-- gtd_items is now LEGACY. `/tasks` reads a union of both during coexistence
+-- and WS-27h retires it (spec §7.5). This migration does not touch it.
+--
+-- Idempotent per infra/postgres/README.md. Pinned by
+-- tests/unit/test_projects_personal.py, which reads this file as TEXT.
+--
+-- Depends on: 146_projects.sql (pm_projects, pm_tasks).
+-- ============================================================================
+
+-- ── A project that belongs to one person ───────────────────────────────── §3.11
+--
+-- NULL = an ordinary team project. Non-null = somebody's personal project, and
+-- the address IS the meaning — there is no `kind` column beside it, because two
+-- columns that must agree are two columns that can disagree.
+--
+-- The row still gets an ordinary `pm_project_grants` entry naming the same
+-- email. This column is not the ACL; it is how "find MY personal project"
+-- resolves in one indexed read rather than by scanning grants.
+
+ALTER TABLE pm_projects
+ ADD COLUMN IF NOT EXISTS personal_owner TEXT;
+
+-- One personal project per member, enforced case-insensitively because every
+-- read compares folded (R10). Partial, so the millions of team projects that
+-- will never carry the column cost nothing.
+CREATE UNIQUE INDEX IF NOT EXISTS uq_pm_projects_personal_owner
+ ON pm_projects (lower(personal_owner))
+ WHERE personal_owner IS NOT NULL;
+
+-- ── The per-member GTD overlay ─────────────────────────────────────────── §3.12
+--
+-- The vocabulary is migration 48's, unchanged and deliberately so: WS-27h has
+-- to move ~every gtd_items row onto these columns, and a renamed disposition
+-- would make that migration a translation instead of a copy.
+--
+-- Every column is NULLABLE and NULL means "not stated". That is what preserves
+-- the contract the ClickUp sync has always kept — the overlay is never
+-- clobbered — now made structural: the org side of a task cannot write here at
+-- all, and the personal side cannot write to pm_tasks' shared columns except
+-- through the ordinary task routes.
+--
+-- ⚠️ `disposition` NULL is not INBOX. A member who has never triaged a task
+-- assigned to them has no disposition, and the read DERIVES one from the task's
+-- status category (the same lens the ClickUp pull applies today). Defaulting
+-- the column to 'INBOX' would make "never triaged" and "deliberately filed to
+-- the inbox" the same state, and the weekly review cannot tell them apart.
+
+CREATE TABLE IF NOT EXISTS pm_task_personal (
+ task_id UUID NOT NULL REFERENCES pm_tasks (id) ON DELETE CASCADE,
+ -- An email, folded on write. Never `agent:`: an agent has no GTD
+ -- practice, and a CHECK is not used because the API is the writer and a
+ -- constraint here would duplicate a rule that lives with the identity.
+ member_email TEXT NOT NULL,
+ disposition TEXT CHECK (disposition IN (
+ 'INBOX', 'NEXT', 'WAITING', 'SOMEDAY',
+ 'PROJECT', 'REFERENCE', 'DONE', 'TRASH')),
+ -- The clarified physical next action, in this member's words.
+ next_action TEXT,
+ context TEXT,
+ energy TEXT CHECK (energy IN ('low', 'medium', 'high')),
+ time_estimate_mins INT,
+ is_two_minute BOOLEAN NOT NULL DEFAULT false,
+ -- Tickler: hidden from the active inbox until this instant.
+ defer_until TIMESTAMPTZ,
+ -- When this member last triaged it. The Weekly Review's "what have I not
+ -- looked at" question is this column, and it is why triage is recorded even
+ -- when the member changes nothing.
+ clarified_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ PRIMARY KEY (task_id, member_email)
+);
+
+-- The inbox read is "my rows, by disposition, oldest first" — the same shape as
+-- migration 48's idx_gtd_items_user_disposition, for the same query.
+CREATE INDEX IF NOT EXISTS idx_pm_task_personal_member
+ ON pm_task_personal (member_email, disposition);
+-- The tickler sweep: "what has become due to resurface".
+CREATE INDEX IF NOT EXISTS idx_pm_task_personal_defer
+ ON pm_task_personal (defer_until) WHERE defer_until IS NOT NULL;
+-- Context lists ("@calls, next actions only") — partial, because that is the
+-- only disposition the context picker is ever asked about.
+CREATE INDEX IF NOT EXISTS idx_pm_task_personal_context
+ ON pm_task_personal (member_email, context)
+ WHERE disposition = 'NEXT';
diff --git a/tests/unit/_projects_fakes.py b/tests/unit/_projects_fakes.py
index 205aac75..14706283 100644
--- a/tests/unit/_projects_fakes.py
+++ b/tests/unit/_projects_fakes.py
@@ -226,6 +226,13 @@ def _now() -> datetime:
"project_id": None, "deleted_at": None},
"pm_views": {"view_type": "list", "config": {}, "position": None},
"pm_view_task_positions": {"group_key": None},
+ # Composite key, no `id` column — the upsert path keys on (task_id,
+ # member_email) via ON CONFLICT, which _insert already handles generically.
+ "pm_task_personal": {
+ "disposition": None, "next_action": None, "context": None,
+ "energy": None, "time_estimate_mins": None, "is_two_minute": False,
+ "defer_until": None, "clarified_at": None,
+ },
}
_TIMESTAMPED = {
@@ -447,6 +454,14 @@ def _select(self, statement: str, table: str, args: dict) -> _Result:
# answered explicitly rather than silently returning project rows with
# no `subject` attribute — which is how a "no previous mapping" answer
# would look identical to a broken query.
+ # The personal inbox (147/§6.1) joins pm_tasks to its status, to the
+ # caller's overlay row and to the project, and computes `is_mine` and
+ # `assignee_count`. That is past what a text mirror can read, so the
+ # shape is answered explicitly — like the mapping join below. The
+ # DISCRIMINATOR is the overlay join, which no other statement carries.
+ if "LEFT JOIN pm_task_personal p" in statement:
+ return _Result(self._inbox_rows(statement, args))
+
# Matched on the JOIN specifically: the visibility CTE also names both
# tables, and a looser check short-circuited every scoped read.
if "JOIN pm_project_grants g" in statement:
@@ -509,6 +524,72 @@ def _subtree_ids(self, root_id: str) -> set[str]:
changed = True
return out
+ def _inbox_rows(self, statement: str, args: dict) -> list[Any]:
+ """The personal inbox's joined shape, computed in Python.
+
+ Mirrors the SQL's own arms rather than restating them loosely: a task is
+ mine if I am assigned OR its project is my personal one — and the second
+ arm is the one that keeps a self-captured task visible after I clear my
+ own name off it.
+
+ The two optional clauses are keyed off the statement text, so a route
+ that stops emitting them stops being filtered here too.
+ """
+ who = str(args.get("who") or "").lower()
+ personal_projects = {
+ str(p["id"]) for p in self.rows("pm_projects")
+ if str(p.get("personal_owner") or "").lower() == who
+ }
+ statuses = {str(s["id"]): s for s in self.rows("pm_task_statuses")}
+ overlay = {
+ str(o["task_id"]): o for o in self.rows("pm_task_personal")
+ if str(o.get("member_email") or "").lower() == who
+ }
+
+ # Each arm is applied ONLY when the statement carries it. Applying them
+ # unconditionally is what let a mutant delete the personal-project arm
+ # from the SQL with every test still green — the exact failure this
+ # module's docstring warns about, caught by mutation rather than review.
+ wants_assigned = "lower(a.assignee) = :who" in statement
+ wants_personal = "lower(proj.personal_owner) = :who" in statement
+
+ out: list[Any] = []
+ for task in self.rows("pm_tasks"):
+ if task.get("archived_at") is not None:
+ continue
+ assignees = self._assignees_of(task["id"])
+ reached = (wants_assigned and who in assignees) or (
+ wants_personal and str(task.get("project_id")) in personal_projects
+ )
+ if not reached:
+ continue
+
+ mine = overlay.get(str(task["id"]), {})
+ if "p.defer_until IS NULL OR p.defer_until <= now()" in statement:
+ deferred = mine.get("defer_until")
+ if deferred is not None and _as_datetime(deferred) > _now():
+ continue
+ if "lower(p.context) = :context" in statement:
+ wanted = str(args.get("context") or "").lower()
+ if str(mine.get("context") or "").lower() != wanted:
+ continue
+
+ status = statuses.get(str(task.get("status_id")), {})
+ out.append(SimpleNamespace(
+ **task,
+ status_category=status.get("category", ""),
+ p_disposition=mine.get("disposition"),
+ p_next_action=mine.get("next_action"),
+ p_context=mine.get("context"),
+ p_energy=mine.get("energy"),
+ p_time_estimate_mins=mine.get("time_estimate_mins"),
+ p_is_two_minute=bool(mine.get("is_two_minute", False)),
+ p_defer_until=mine.get("defer_until"),
+ assignee_count=len(assignees),
+ is_mine=who in assignees,
+ ))
+ return out
+
def _assignees_of(self, task_id: str) -> set[str]:
return {
str(a.get("assignee") or "").lower()
@@ -663,6 +744,14 @@ def _paged(self, statement: str, rows: list[dict], args: dict) -> list[dict]:
return rows
+def _as_datetime(value: Any) -> datetime:
+ """A stored `defer_until` as an aware instant, however it was written."""
+ if isinstance(value, datetime):
+ return value if value.tzinfo else value.replace(tzinfo=UTC)
+ parsed = datetime.fromisoformat(str(value))
+ return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
+
+
def _sortable(value: Any) -> Any:
"""A total order across the mixed types one column can hold in a fake."""
if value is None:
diff --git a/tests/unit/test_projects_personal.py b/tests/unit/test_projects_personal.py
new file mode 100644
index 00000000..0ae6fb5c
--- /dev/null
+++ b/tests/unit/test_projects_personal.py
@@ -0,0 +1,527 @@
+"""Projects · the personal lens — one store, seen as my own work.
+
+Spec: ``ai-company-brain/specs/project_management_app.md`` §3.11-§3.12, §6.1 ·
+**D-PM-6 (revised 2026-08-06)** · ticket WS-27e.
+
+The claims worth testing here are the *cohesion* ones, because they are what the
+one-store design bought and what a mirror could never have given:
+
+* assigning a task makes it appear in the assignee's inbox with **no sync**;
+* completing it from the inbox completes it for the **project**, same row;
+* the overlay is per-member, so two assignees hold different dispositions;
+* a member's triage can never move the team's board, and the team's board can
+ never overwrite a member's triage.
+
+Hermetic: no Postgres, no network.
+"""
+
+from __future__ import annotations
+
+import re
+from pathlib import Path
+
+import pytest
+from fastapi import HTTPException
+from gateway.routes.projects import core as pm_core
+from gateway.routes.projects import personal as pm_personal
+from gateway.routes.projects import tasks as pm_tasks
+from gateway.routes.projects import tree as pm_tree
+
+from tests.unit._projects_fakes import (
+ FakeProjectsDB,
+ bind_db,
+ member_user,
+ page,
+ projects_user,
+ silence_events,
+)
+
+MODULES = (pm_core, pm_tree, pm_tasks, pm_personal)
+
+ALICE = member_user("alice@fracktal.in")
+BOB = member_user("bob@fracktal.in")
+OWNER = projects_user()
+
+
+@pytest.fixture
+def db(monkeypatch: pytest.MonkeyPatch) -> FakeProjectsDB:
+ fake = FakeProjectsDB()
+ bind_db(monkeypatch, fake, MODULES)
+ silence_events(monkeypatch, MODULES)
+ return fake
+
+
+def _team_project(db: FakeProjectsDB) -> tuple:
+ project = db.seed_project(name="Sales", subject="org")
+ todo = db.seed_status(project.id, name="To do", category="todo", is_default=True)
+ done = db.seed_status(
+ project.id, name="Done", category="done", is_default=False, position=40,
+ )
+ return project, todo, done
+
+
+def _assign(db: FakeProjectsDB, task_id: str, *emails: str) -> None:
+ for email in emails:
+ db.seed(
+ "pm_task_assignees", task_id=task_id, assignee=email,
+ assigned_by="owner@fracktal.in",
+ )
+
+
+# ── The cohesion claims ─────────────────────────────────────────────────────
+
+async def test_assigning_a_task_puts_it_in_the_inbox_with_no_sync(
+ db: FakeProjectsDB,
+) -> None:
+ """The whole point of D-PM-6's revision.
+
+ A task assigned to Alice is not copied anywhere — it IS the row she sees. So
+ the inbox finds it with nothing in between, and there is no mirror table for
+ it to be missing from.
+ """
+ project, todo, _ = _team_project(db)
+ task = db.seed_task(project.id, todo.id, title="Ship the thing")
+ _assign(db, task.id, "alice@fracktal.in")
+
+ inbox = await pm_personal.my_inbox(user=ALICE, page=page())
+
+ assert [row["id"] for row in inbox.rows] == [str(task.id)]
+ # …and no second store was consulted or written.
+ assert "gtd_items" not in " ".join(db.statements)
+
+
+async def test_the_inbox_row_IS_the_project_row(db: FakeProjectsDB) -> None:
+ """Same id, same title, same status — because it is one row.
+
+ A mirror would have produced a different id here, which is exactly the
+ property that made every downstream feature have to know about both.
+ """
+ project, todo, _ = _team_project(db)
+ task = db.seed_task(project.id, todo.id, title="One row")
+ _assign(db, task.id, "alice@fracktal.in")
+
+ inbox = await pm_personal.my_inbox(user=ALICE, page=page())
+ row = inbox.rows[0]
+
+ assert row["id"] == str(task.id)
+ assert row["project_id"] == str(project.id)
+ assert row["title"] == "One row"
+
+
+async def test_completing_from_the_inbox_completes_it_for_the_project(
+ db: FakeProjectsDB,
+) -> None:
+ """The cohesion that a personal-only "done" would have destroyed: a member
+ ticking something off while the board still shows it open is the exact drift
+ a mirror produces."""
+ project, todo, done = _team_project(db)
+ task = db.seed_task(project.id, todo.id)
+ _assign(db, task.id, "alice@fracktal.in")
+
+ result = await pm_personal.complete_task(str(task.id), user=ALICE)
+
+ assert result["status_id"] == str(done.id)
+ assert result["completed_at"] is not None
+ # The shared row moved, and the timeline records it as a real transition.
+ assert db.rows("pm_tasks")[0]["status_id"] == str(done.id)
+ assert len(db.activities("status_change")) == 1
+
+
+async def test_two_assignees_hold_different_dispositions(
+ db: FakeProjectsDB,
+) -> None:
+ """The reason the overlay is per-member and not a column on the task.
+
+ Alice is doing it (NEXT); Bob delegated it and is waiting (WAITING). A single
+ column could not express that, and it is what delegation looks like.
+ """
+ project, todo, _ = _team_project(db)
+ task = db.seed_task(project.id, todo.id)
+ _assign(db, task.id, "alice@fracktal.in", "bob@fracktal.in")
+
+ await pm_personal.set_personal(
+ str(task.id), pm_personal.PersonalIn(disposition="NEXT"), user=ALICE,
+ )
+ await pm_personal.set_personal(
+ str(task.id), pm_personal.PersonalIn(disposition="WAITING"), user=BOB,
+ )
+
+ alice_inbox = await pm_personal.my_inbox(user=ALICE, page=page())
+ bob_inbox = await pm_personal.my_inbox(user=BOB, page=page())
+
+ assert alice_inbox.rows[0]["disposition"] == "NEXT"
+ assert bob_inbox.rows[0]["disposition"] == "WAITING"
+ assert len(db.rows("pm_task_personal")) == 2
+
+
+async def test_my_triage_cannot_move_the_teams_board(db: FakeProjectsDB) -> None:
+ """The structural half of "the overlay is never clobbered", in the direction
+ people forget: filing something SOMEDAY is a statement about my attention,
+ not about the work."""
+ project, todo, _ = _team_project(db)
+ task = db.seed_task(project.id, todo.id)
+ _assign(db, task.id, "alice@fracktal.in")
+
+ await pm_personal.set_personal(
+ str(task.id), pm_personal.PersonalIn(disposition="SOMEDAY"), user=ALICE,
+ )
+
+ assert db.rows("pm_tasks")[0]["status_id"] == str(todo.id)
+ # The overlay route never writes to the task table at all.
+ assert not [
+ s for s, _ in db.calls
+ if s.upper().startswith("UPDATE PM_TASKS")
+ ]
+
+
+async def test_a_status_change_does_not_overwrite_my_stated_disposition(
+ db: FakeProjectsDB,
+) -> None:
+ """The other direction of the same contract, and the one migration 48's
+ ClickUp sync has always honoured."""
+ project, todo, _ = _team_project(db)
+ doing = db.seed_status(
+ project.id, name="Doing", category="in_progress", is_default=False,
+ )
+ task = db.seed_task(project.id, todo.id)
+ _assign(db, task.id, "alice@fracktal.in")
+ await pm_personal.set_personal(
+ str(task.id), pm_personal.PersonalIn(disposition="SOMEDAY"), user=ALICE,
+ )
+
+ await pm_tasks.patch_task(
+ str(task.id), pm_tasks.TaskIn(status_id=str(doing.id)), user=OWNER,
+ )
+
+ inbox = await pm_personal.my_inbox(user=ALICE, include_done=True, page=page())
+ assert inbox.rows[0]["disposition"] == "SOMEDAY"
+
+
+# ── The derived disposition ─────────────────────────────────────────────────
+
+@pytest.mark.parametrize(
+ "category,is_mine,has_assignee,expected",
+ [
+ ("done", True, True, "DONE"),
+ ("cancelled", True, True, "DONE"),
+ ("backlog", True, True, "SOMEDAY"),
+ ("todo", True, True, "NEXT"),
+ ("in_progress", False, True, "WAITING"),
+ ("todo", False, False, "INBOX"),
+ ],
+)
+def test_the_derived_lens_matches_the_shipped_clickup_one(
+ category: str, is_mine: bool, has_assignee: bool, expected: str,
+) -> None:
+ """Lifted from `routes/tasks/sync.py` so both halves of the app agree about
+ what an untriaged task means."""
+ assert pm_personal.derive_disposition(
+ status_category=category, is_mine=is_mine, has_assignee=has_assignee,
+ ) == expected
+
+
+async def test_an_untriaged_task_is_distinguishable_from_a_triaged_one(
+ db: FakeProjectsDB,
+) -> None:
+ """`is_triaged` is the Weekly Review's whole question — which tasks have I
+ not looked at. Defaulting `disposition` to INBOX in the column would have
+ made "never seen" and "deliberately filed to the inbox" the same state."""
+ project, todo, _ = _team_project(db)
+ untouched = db.seed_task(project.id, todo.id, title="Never seen")
+ filed = db.seed_task(project.id, todo.id, title="Filed to inbox")
+ _assign(db, untouched.id, "alice@fracktal.in")
+ _assign(db, filed.id, "alice@fracktal.in")
+ await pm_personal.set_personal(
+ str(filed.id), pm_personal.PersonalIn(disposition="INBOX"), user=ALICE,
+ )
+
+ inbox = await pm_personal.my_inbox(user=ALICE, page=page())
+ by_title = {r["title"]: r for r in inbox.rows}
+
+ # The untriaged one DERIVES its disposition from the task — assigned to me
+ # in a todo lane, so NEXT. The filed one keeps the member's own answer even
+ # though the derivation would have said otherwise.
+ assert by_title["Never seen"]["disposition"] == "NEXT"
+ assert by_title["Filed to inbox"]["disposition"] == "INBOX"
+ # And `is_triaged` is what the Weekly Review reads: a stated INBOX and a
+ # never-looked-at task must not be the same state.
+ assert by_title["Never seen"]["is_triaged"] is False
+ assert by_title["Filed to inbox"]["is_triaged"] is True
+
+
+async def test_filtering_by_disposition_matches_the_effective_one(
+ db: FakeProjectsDB,
+) -> None:
+ """"Show me my next actions" must answer the same whether or not the member
+ has been through their inbox — filtering on the stored column alone would
+ show an empty Next list to somebody with twenty assigned tasks."""
+ project, todo, _ = _team_project(db)
+ task = db.seed_task(project.id, todo.id, title="Untriaged but mine")
+ _assign(db, task.id, "alice@fracktal.in")
+
+ inbox = await pm_personal.my_inbox(user=ALICE, disposition="NEXT", page=page())
+
+ assert [r["title"] for r in inbox.rows] == ["Untriaged but mine"]
+ assert db.rows("pm_task_personal") == [] # nothing was written to read it
+
+
+async def test_an_unknown_disposition_is_422(db: FakeProjectsDB) -> None:
+ project, todo, _ = _team_project(db)
+ task = db.seed_task(project.id, todo.id)
+
+ with pytest.raises(HTTPException) as exc:
+ await pm_personal.set_personal(
+ str(task.id), pm_personal.PersonalIn(disposition="LATER"), user=ALICE,
+ )
+ assert exc.value.status_code == 422
+
+
+# ── The personal project ────────────────────────────────────────────────────
+
+async def test_capture_creates_the_personal_project_once(
+ db: FakeProjectsDB,
+) -> None:
+ """GTD's first discipline is frictionless capture: a title, and nothing else
+ required — no project to choose, no status to pick."""
+ first = await pm_personal.capture(
+ pm_personal.CaptureIn(title="Call the supplier"), user=ALICE,
+ )
+ await pm_personal.capture(pm_personal.CaptureIn(title="Second thought"), user=ALICE)
+
+ personal = [p for p in db.rows("pm_projects") if p.get("personal_owner")]
+ assert len(personal) == 1
+ assert personal[0]["personal_owner"] == "alice@fracktal.in"
+ assert first["title"] == "Call the supplier"
+ assert len(db.rows("pm_tasks")) == 2
+
+
+async def test_a_captured_task_is_an_ordinary_task(db: FakeProjectsDB) -> None:
+ """Which is what lets a captured thought later move into a team project
+ without being recreated — and what makes the board, timeline, automation and
+ agent dispatch work on it with no special case."""
+ created = await pm_personal.capture(
+ pm_personal.CaptureIn(title="A thought"), user=ALICE,
+ )
+
+ row = db.rows("pm_tasks")[0]
+ assert row["source"] == "manual"
+ assert row["task_number"] == 1
+ assert str(row["status_id"])
+ # And it is assigned to the capturer, so it reaches their own inbox.
+ assert db.rows("pm_task_assignees")[0]["assignee"] == "alice@fracktal.in"
+ assert created["id"] == str(row["id"])
+
+
+async def test_a_personal_project_gets_its_own_statuses(db: FakeProjectsDB) -> None:
+ await pm_personal.create_my_project(user=ALICE)
+
+ lanes = {s["name"]: s["category"] for s in db.rows("pm_task_statuses")}
+ assert lanes == {
+ "Inbox": "backlog", "Next": "todo", "Doing": "in_progress", "Done": "done",
+ }
+
+
+async def test_a_personal_project_is_granted_to_its_owner_only(
+ db: FakeProjectsDB,
+) -> None:
+ """`personal_owner` is the fast path to finding it; the GRANT is what the
+ visibility model actually reads. Both, so neither is load-bearing alone."""
+ await pm_personal.create_my_project(user=ALICE)
+
+ grants = db.rows("pm_project_grants")
+ assert [g["subject"] for g in grants] == ["alice@fracktal.in"]
+
+
+async def test_personal_projects_stay_out_of_the_team_tree(
+ db: FakeProjectsDB,
+) -> None:
+ """"My tasks" does not belong in a department tree beside Sales. This is not
+ a security filter — the grant already scopes it to one person — it is that a
+ personal project is not a department."""
+ _team_project(db)
+ await pm_personal.create_my_project(user=ALICE)
+
+ tree = await pm_tree.get_tree(user=OWNER)
+
+ assert [row["name"] for row in tree["rows"]] == ["Sales"]
+
+
+async def test_my_own_captured_task_survives_unassigning_myself(
+ db: FakeProjectsDB,
+) -> None:
+ """The second arm of the inbox query. Without it, clearing your own name off
+ a private todo would make it vanish from the only place it exists."""
+ await pm_personal.capture(pm_personal.CaptureIn(title="Mine alone"), user=ALICE)
+ db.tables["pm_task_assignees"] = []
+
+ inbox = await pm_personal.my_inbox(user=ALICE, page=page())
+
+ assert [r["title"] for r in inbox.rows] == ["Mine alone"]
+
+
+# ── The tickler ─────────────────────────────────────────────────────────────
+
+async def test_deferring_hides_a_task_from_my_inbox_only(
+ db: FakeProjectsDB,
+) -> None:
+ project, todo, _ = _team_project(db)
+ task = db.seed_task(project.id, todo.id, title="Later")
+ _assign(db, task.id, "alice@fracktal.in", "bob@fracktal.in")
+
+ await pm_personal.defer_task(
+ str(task.id), pm_personal.DeferIn(until="2099-01-01T00:00:00+00:00"),
+ user=ALICE,
+ )
+
+ assert (await pm_personal.my_inbox(user=ALICE, page=page())).rows == []
+ # Bob's view is untouched, and so is the board.
+ assert len((await pm_personal.my_inbox(user=BOB, page=page())).rows) == 1
+ assert db.rows("pm_tasks")[0]["status_id"] == str(todo.id)
+
+
+async def test_include_deferred_brings_them_back(db: FakeProjectsDB) -> None:
+ project, todo, _ = _team_project(db)
+ task = db.seed_task(project.id, todo.id)
+ _assign(db, task.id, "alice@fracktal.in")
+ await pm_personal.defer_task(
+ str(task.id), pm_personal.DeferIn(until="2099-01-01T00:00:00+00:00"),
+ user=ALICE,
+ )
+
+ inbox = await pm_personal.my_inbox(
+ user=ALICE, include_deferred=True, include_done=True, page=page(),
+ )
+ assert len(inbox.rows) == 1
+
+
+# ── Identity ────────────────────────────────────────────────────────────────
+
+def test_no_personal_route_can_be_pointed_at_another_member() -> None:
+ """R3/R4, made structural. There is deliberately no `?member=` anywhere
+ here, so no request can read or write somebody else's practice."""
+ import inspect
+
+ for name in (
+ "my_inbox", "my_contexts", "set_personal", "capture",
+ "get_my_project", "create_my_project", "defer_task", "complete_task",
+ ):
+ params = set(inspect.signature(getattr(pm_personal, name)).parameters)
+ assert "user" in params, name
+ assert not (params & {"member", "member_email", "email", "who", "user_id"}), name
+
+
+async def test_the_overlay_write_is_scoped_to_the_caller(
+ db: FakeProjectsDB,
+) -> None:
+ """Structural: the upsert binds the caller's own address, so there is no
+ shape of request that writes another member's row."""
+ project, todo, _ = _team_project(db)
+ task = db.seed_task(project.id, todo.id)
+
+ await pm_personal.set_personal(
+ str(task.id), pm_personal.PersonalIn(context="@calls"), user=ALICE,
+ )
+
+ statement, params = next(
+ (s, p) for s, p in db.calls if "pm_task_personal" in s and s.startswith("INSERT")
+ )
+ assert params["member_email"] == "alice@fracktal.in"
+ assert "member_email" in statement
+
+
+async def test_seeing_the_task_is_the_floor_for_triaging_it(
+ db: FakeProjectsDB, monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """A task nobody granted you and nobody assigned you is 404, so the overlay
+ cannot be used to probe for tasks that exist."""
+ real = pm_core.resolve_visibility
+
+ async def _resolve(db_, user):
+ vis = await real(db_, user)
+ return pm_core.Visibility(unrestricted=False, email=vis.email, groups=())
+
+ for module in MODULES:
+ monkeypatch.setattr(module, "resolve_visibility", _resolve, raising=False)
+
+ project = db.seed_project(name="Finance", subject=None)
+ todo = db.seed_status(project.id)
+ task = db.seed_task(project.id, todo.id)
+
+ with pytest.raises(HTTPException) as exc:
+ await pm_personal.set_personal(
+ str(task.id), pm_personal.PersonalIn(disposition="NEXT"), user=ALICE,
+ )
+ assert exc.value.status_code == 404
+
+
+# ── The migration ───────────────────────────────────────────────────────────
+
+MIGRATIONS = Path(__file__).resolve().parents[2] / "infra" / "postgres"
+
+
+def _personal_migration() -> Path:
+ """Found by CONTENT, not by number (R1) — a test pinned to `147_` would be
+ the same mistake the 145 collision already made once."""
+ found = [
+ p for p in sorted(MIGRATIONS.glob("*.sql"))
+ if p.name != "schema.generated.sql"
+ and "CREATE TABLE IF NOT EXISTS pm_task_personal" in p.read_text(
+ encoding="utf-8",
+ )
+ ]
+ assert len(found) == 1, [p.name for p in found]
+ return found[0]
+
+
+@pytest.fixture(scope="module")
+def sql() -> str:
+ return _personal_migration().read_text(encoding="utf-8")
+
+
+@pytest.fixture(scope="module")
+def bare(sql: str) -> str:
+ return "\n".join(re.sub(r"--.*$", "", line) for line in sql.splitlines())
+
+
+def test_the_migration_is_idempotent(bare: str) -> None:
+ assert not re.search(r"CREATE\s+TABLE\s+(?!IF\s+NOT\s+EXISTS)", bare, re.I)
+ assert not re.search(r"CREATE\s+(UNIQUE\s+)?INDEX\s+(?!IF\s+NOT\s+EXISTS)", bare, re.I)
+ assert not re.search(r"ADD\s+COLUMN\s+(?!IF\s+NOT\s+EXISTS)", bare, re.I)
+
+
+def test_the_overlay_is_keyed_per_member(bare: str) -> None:
+ """The decision the whole design rests on: two assignees, two rows."""
+ assert re.search(
+ r"PRIMARY\s+KEY\s*\(\s*task_id\s*,\s*member_email\s*\)", bare, re.I,
+ )
+
+
+def test_disposition_has_no_default(bare: str) -> None:
+ """NULL means "not triaged" and must stay distinguishable from INBOX."""
+ block = bare.split("CREATE TABLE IF NOT EXISTS pm_task_personal", 1)[1].split(");", 1)[0]
+ disposition_line = next(
+ line for line in block.splitlines() if line.strip().startswith("disposition")
+ )
+ assert "DEFAULT" not in disposition_line.upper()
+
+
+def test_one_personal_project_per_member_case_insensitively(bare: str) -> None:
+ assert re.search(
+ r"CREATE\s+UNIQUE\s+INDEX\s+IF\s+NOT\s+EXISTS\s+\w+\s+"
+ r"ON\s+pm_projects\s*\(\s*lower\(personal_owner\)\s*\)",
+ bare, re.I,
+ )
+ assert "WHERE personal_owner IS NOT NULL" in bare
+
+
+def test_the_disposition_vocabulary_matches_migration_48(bare: str) -> None:
+ """WS-27h has to move every gtd_items row onto this column; a renamed
+ disposition would make that migration a translation instead of a copy."""
+ legacy = (MIGRATIONS / "48_task_manager_gtd.sql").read_text(encoding="utf-8")
+ legacy_values = set(
+ re.findall(
+ r"\b(INBOX|NEXT|WAITING|SOMEDAY|PROJECT|REFERENCE|DONE|TRASH)\b", legacy,
+ )
+ )
+ for value in legacy_values:
+ assert f"'{value}'" in bare, f"disposition {value} is missing"
From acc6d7ea6d569ab93f062b8a71bf10f3a19c80fc Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 6 Aug 2026 11:02:07 +0000
Subject: [PATCH 02/10] =?UTF-8?q?docs(workflows):=20the=20Paca=20engine=20?=
=?UTF-8?q?uplift=20backlog=20=E2=80=94=20U1=E2=80=93U8,=20with=20done-whe?=
=?UTF-8?q?ns?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The Projects app (WS-27) needed automation and deliberately did not build one:
ADR-028 and D6 make `/workflows` the only engine, so a Projects-owned rules
engine would have been a single-owner violation. WS-27 therefore contributes
events and node types, and the engine-shaped findings belonged in the engine's
own spec. Until now they were one sentence of demand ("multi-branch switch,
step snapshots, dependency map") with no acceptance standard behind it — which
is not enough for anyone to pick the work up without re-deriving it from Paca's
source.
`workflows_app.md` §13 writes it down. Eight items, each pairing Paca's design
(cited to `000027_add_automation_graph.sql` and `worker/automation_consumer.go`,
not to Paca's architecture docs — those describe a dropped v0.10 design and
claim the wrong web framework) with this engine's *measured* current state and a
done-when written to §8.3's standard: an assertion a test can make, never a
screenshot.
What the measurement turned up:
- §13.1 records that the binding already half-ships. Eleven `pm.*` topics reach
`dispatch_event` today via the seam the ClickUp receiver uses; the engine can
hear about projects and cannot act on them, because no node type touches an
internal app. That asymmetry is U1 and is all WS-27f's first half is.
- U3 is a promise already in writing that the schema does not keep: §1.2 G6 and
§2 F9 both say "per-node inputs/outputs", but `node_results` records only the
output. When a `{{ref}}` resolves wrong, the run history shows the wrong
output and no way to see the wrong input — the commonest debugging question a
maker has, currently unanswerable without a re-run.
- U6 splits a Paca feature in half rather than adopting it: their per-fire
bookkeeping tables are refused (CAS on `last_fired_at` is stronger — one
catch-up fire, clock skew included), but they also have a `due_date_reached`
trigger and we have no equivalent at all.
- U5 is marked blocked on §8.3b, on purpose. A `predecessor_done` join built
before 8.3b settles the merge shape would either invent a second join
semantics or quietly constrain the first.
§13.4 records the five refusals as decisions, so a later reader does not "fix"
them — including Paca's `call_api`, whose stored headers are visible to any
project reader, a gap its own source comments acknowledge.
Kept out of the slice sequence deliberately: folding U1–U8 into Slice 3 would
repeat exactly what §8.3's truth pass corrected, a one-line slice hiding several
unrelated problems. §13 changes neither Slice 3 nor Slice 4, and claims nothing
as shipped.
Cross-references updated both ways so the three docs agree: the research
appendix and the Projects spec now point at §13 by item, and §13 points back at
the evidence.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
---
.../specs/paca_pm_research_2026-08.md | 8 +
.../specs/project_management_app.md | 11 ++
ai-company-brain/specs/workflows_app.md | 152 ++++++++++++++++++
ai-company-brain/work_plan.md | 2 +-
4 files changed, 172 insertions(+), 1 deletion(-)
diff --git a/ai-company-brain/specs/paca_pm_research_2026-08.md b/ai-company-brain/specs/paca_pm_research_2026-08.md
index 44439057..5612125d 100644
--- a/ai-company-brain/specs/paca_pm_research_2026-08.md
+++ b/ai-company-brain/specs/paca_pm_research_2026-08.md
@@ -200,6 +200,14 @@ AND-join/idempotency discipline. WS-27 emits task events into the existing
types; deeper engine uplifts (multi-branch switch, step snapshots, dependency map) are
recorded as `workflows_app.md` backlog, not duplicated.
+**Written up 2026-08-06 → [`workflows_app.md`](workflows_app.md) §13.** This section's
+findings now have a home that owns work: eight items **U1–U8**, each pairing the Paca design
+above with that engine's *measured* current state and a done-when. Read §13, not this
+section, when implementing — §13 also records the five Paca features **deliberately refused**
+(`call_api`'s reader-visible headers, a sibling worker process, the WASM plugin runtime, a
+second engine, and a per-fire bookkeeping table where our CAS on `last_fired_at` is already
+better), so the refusals do not read as oversights to a later implementer.
+
## 5. Agent integration — the dispatch chain
The chain is fully event-driven; the HTTP handler never calls the agent runtime:
diff --git a/ai-company-brain/specs/project_management_app.md b/ai-company-brain/specs/project_management_app.md
index 097e9e9d..993d2847 100644
--- a/ai-company-brain/specs/project_management_app.md
+++ b/ai-company-brain/specs/project_management_app.md
@@ -401,6 +401,17 @@ per-step input/output snapshots, due-date-offset triggers, the derived dependenc
are **`workflows_app.md` backlog items** (single owner, D6); this spec records the demand
and stops.
+**Written down 2026-08-06 — `workflows_app.md` §13.** The demand is no longer only recorded
+here as a sentence: the engine spec now carries a full Paca-referenced uplift backlog,
+**U1–U8**, each with the Paca design, this engine's measured current state, and a done-when.
+The mapping from this section is exact: **U1 is the `pm.update_task` node** (WS-27f's first
+half) and **U7 is agent dispatch** (§6.4, WS-27f's second half); U2/U3/U6 are the switch,
+step snapshots and due-date trigger named above; **U4** (task retargeting over
+`parent|children|blocks|…`) is the item this section had not named and is what makes "when
+every child is Done, move the parent to Done" expressible at all. Nothing in §13 is built —
+it is the reference an implementer picks up, so WS-27f no longer has to re-derive the engine
+work from Paca's source.
+
### 6.4 Agents — assignment is dispatch
Assigning `agent:` (WS-27f): the `pm.task.assigned` event carries the agent target; a
consumer creates the run through the existing orchestrator dispatch (the same seam chat
diff --git a/ai-company-brain/specs/workflows_app.md b/ai-company-brain/specs/workflows_app.md
index 9a8f3547..4be49cd3 100644
--- a/ai-company-brain/specs/workflows_app.md
+++ b/ai-company-brain/specs/workflows_app.md
@@ -5,6 +5,7 @@
> **Slice 3 re-scoped 2026-08-03 (truth pass, §8.3).** The one-line Slice 3 asked for three things and **one of them is already shipped**: describe→generate→refine full-graph authoring landed as F14 (`39b1e17a`) and is **struck**. "Parallel fan-out" is also shipped (`engine/graph.py:17`; MAF's superstep scheduler routes it) — the unbuilt half is **fan-in/join**, restated as such. What genuinely remains is **fan-in/join (8.3b), loops (8.3c), and a template gallery (8.3a — nothing exists)**. Two owner decisions recorded the same day: Command Center is an **internal Fracktal tool** (§1.4) and **loops are approved** despite §11 R1 (§8.3c).
> **Parent RFC:** [`docs/workflow-editor/README.md`](../../docs/workflow-editor/README.md) — stack selection (React Flow), the compile-to-MAF-Workflows decision, data model, editor UX, trigger taxonomy. Read it for *how*; this doc is *what, why, and why now*. Interactive mockup: `docs/workflow-editor/mockup.html`.
> **Reference precedents:** [`task_manager_app.md`](task_manager_app.md) (app spec shape) · [`docs/app-workshop/README.md`](../../docs/app-workshop/README.md) §4.0 (the platform contract this app also enforces).
+> **Engine uplift backlog — §13 (added 2026-08-06).** A code-verified read of Paca's automation engine ([`paca_pm_research_2026-08.md`](paca_pm_research_2026-08.md) §4–§6) against this engine, as eight scoped items **U1–U8** with done-whens, plus the `pm.*` binding that already ships and the five Paca features deliberately refused. §13 is **backlog, not built work**; it does not change Slice 3 (§8.3) or Slice 4 (§8.4).
> **Policy amendment:** ADR-028 (see `system_architecture.md`) amends ADR-014 and `project_plan.md` C-09 / §2 non-goals — see §10.
---
@@ -219,6 +220,8 @@ Aligned to RFC §9, resequenced so each slice ships value:
- **Slice 3:** three items — **8.3a templates**, **8.3b fan-in/join**, **8.3c loops**. Fully specified with per-item acceptance, gate labels and verification in **§8.3**; the old one-line version was 16 words and asked for one thing that already shipped. (Workflow-as-tool for the orchestrator shipped early — F13, Slice 2.)
- **Slice 4:** blocked. Named dependencies and the reason in **§8.4** — it is *not* "post-BO‑20" in the vague sense.
+**Not a slice: §13 (Paca engine uplift, U1–U8).** Deliberately kept out of the slice sequence — the items are independent of one another and of the slices, and folding them into Slice 3 would repeat the mistake §8.3's truth pass corrected (a one-line slice hiding several unrelated problems). One of them, **U1**, is the near-term one: it is WS-27f's first half and the only item another workstream is waiting on. **U5 is blocked on 8.3b** and must not be started before it.
+
### 8.3 Slice 3 — specified (truth pass, verified against code 2026-08-03)
**What was struck.** *"Describe→generate→refine full-graph authoring"* is **DONE — delivered by F14 in commit `39b1e17a`** ("feat(workflows): Workflow Copilot + semantic capability search"). `POST /workflows/{id}/copilot` (`copilot.py:1-12`) emits the **FULL updated graph** — the system prompt says so literally at `copilot.py:51` (`"graph": {...} // FULL updated graph, or null if no change`) — with a named-issue repair round against the same validators publish uses, and auto-creates the modules the graph needs. §2 already records this twice (F12 *"Superseded by F14"*, F14 *"Must (shipped)"*). Dispatching it would have sent an implementer to rebuild a live endpoint. **Do not re-open it.**
@@ -343,3 +346,152 @@ Anything in Slice 4 that looks reachable today is reachable only because its dep
```
Green means **73 passed** in CI (`ubuntu-latest`). On Windows the honest expectation is **69 passed / 4 failed**, all four being the `preexec_fn` module-sandbox defect catalogued in §8.3 — see that section before reporting a regression. Items 1–5 above are Slice-1/2 criteria and are met; Slice 3's criteria are per-item in §8.3a/b/c, not here.
+
+---
+
+## 13. Paca automation-engine reference — the uplift backlog and the WS-27 binding
+
+> **Added 2026-08-06.** Source: [`paca_pm_research_2026-08.md`](paca_pm_research_2026-08.md) §4 (automation graph), §5 (agent dispatch), §6 (MCP tool design) — a code-verified read of `Paca-AI/paca` @ v0.11.0, Apache-2.0. **Reference-only:** that file owns no work; this section owns the work it implies for *this* app. Paca's stack (Go/chi + sqlx, Valkey streams, OpenHands sandboxes) does not survive translation — **we take schema shapes and execution discipline, never code.**
+>
+> **Why this section exists.** WS-27 (`project_management_app.md`) needed automation and, per **D6 (`work_plan.md` §3)** and ADR-028, did not build one — `/workflows` is the only engine, so a Projects-owned rules engine would have been a single-owner violation. WS-27 therefore contributes *events and node types* and files the engine-shaped findings **here**, where the engine is owned. Everything below is backlog with an acceptance standard, not built work. Nothing in §13 is claimed as shipped.
+>
+> ⚠️ **Two Paca documents will mislead a re-deriver.** Paca's `docs/architecture/repository-structure.md` says "Go + Gin" (the API is **chi v5**), and `docs/architecture/automation-workflows.md` documents the **v0.10 design that was dropped** — migration `000027` `DROP TABLE … CASCADE`d it and replaced it with the graph model described here. Re-derive from `000027_add_automation_graph.sql` and `worker/automation_consumer.go`, **never** from Paca's architecture docs.
+
+### 13.1 The binding that already exists (verified against code, 2026-08-06)
+
+The Projects app is **already wired to this engine**, on the seam the ClickUp receiver uses. No new bus was built, and none should be:
+
+| Link | Where | State |
+|---|---|---|
+| Projects emits | `routes/projects/core.py:909` `emit()` → `ingestion.event_hooks.emit_event("projects", …)` | **Shipped.** Best-effort by construction — a workflow that cannot run must never fail the task write that triggered it |
+| The seam is registered | `gateway/main.py:1122-1125` — `register_event_sink(workflows.triggers.dispatch_event)` at startup | **Shipped** (pre-existing; WS-27 added no transport) |
+| The engine listens | `triggers.py::dispatch_event` — one run per **published** workflow whose enabled `kind='event'` trigger matches `(source, event_type)` | **Shipped** |
+| The engine can act on tasks | *(nothing)* — the node catalog (`engine/graph.py:35-45`) has `trigger, agent, tool, module, condition, set, approval, wait, output`. **No task-mutation node type exists.** | **U1, below** |
+
+**The eleven `pm.*` topics live on the seam today** (`source="projects"`): `pm.task.created`, `pm.task.updated`, `pm.task.status_changed`, `pm.task.assigned`, `pm.task.moved`, `pm.task.deleted`, `pm.task.comment_added`, `pm.project.created`, `pm.project.updated`, `pm.project.moved`, `pm.project.deleted`. An event trigger with `{"source": "projects"}` and no `event_type` matches all eleven (`event_trigger_matches`, `triggers.py:35`).
+
+So the engine can already **hear** about projects and cannot yet **act on** them. That asymmetry is the whole near-term gap, and it is U1.
+
+**Two caveats on the existing path, both pre-existing and both recorded elsewhere** — restated because a `pm.*` automation inherits them: a workflow fires only when **published** (`triggers.py:57`), and `dispatch_event` **swallows every error** by design, so a failed dispatch is currently invisible (BO‑20b slice 2's scope, §8.4).
+
+### 13.2 What Paca's engine is, in one paragraph
+
+`automations` (`draft|active|archived`) + `automation_nodes(kind ∈ trigger|condition|action, type, config jsonb, pos_x, pos_y)` + `automation_edges(source_handle NULL)` + `automation_runs` + `automation_run_steps` (per-node `input_snapshot` / `output_snapshot` / `error`), plus at-most-once bookkeeping tables for due-date and cron fires and hashed webhook tokens (`pacahk_` prefix; rotation revokes the prior token). **One JSONB `config` per node** serves 9 trigger types, conditions, 3 action types, and unbounded plugin-contributed types with no wide null-column set. The consumer (`worker/automation_consumer.go`, 1602 lines) reads the **ordinary activity stream** — the engine is "a sibling reader, not a special case wired into the HTTP handler" — maps field changes to candidate trigger types (zero candidates ⇒ cheap ack), re-fetches the authoritative task, walks the graph with a `visited` set, records a step row per node, and **mutates through the ordinary task service** so an automation's edit gets identical validation and writes an `automation.applied` activity with a nil actor.
+
+Structurally we already agree with all of that: our graph is DB-persisted config (D1), our node config is JSONB, our runs are rows, and our engine reads the same event seam the receivers write. **The gaps are in the vocabulary and the trace, not the architecture** — which is why this is an uplift backlog and not a rewrite.
+
+### 13.3 The uplift items
+
+Each item states Paca's design, **what this repo actually has today** (cited, verified — not assumed), the gap, and a done-when written to §8.3's standard: *an assertion a test can make, on a validator or a status code, never a screenshot.*
+
+Sequencing note: **U1 is the only item WS-27 is waiting on.** U2–U8 are independent of Projects and can be picked up in any order. None of them is a Slice-3 item — §8.3a/b/c stand unchanged, and U5 in particular is **downstream of 8.3b** and must not be started before it.
+
+#### U1 — A task-mutation action node (`pm.update_task`) ✅ **AGENT-SAFE** · *this is WS-27f*
+
+**Paca:** three action types only — `update_task`, `trigger_ai_agent`, `call_api`. `update_task` is itself a **consolidation**: it merged five prior single-field actions (`set_status`, `set_assignee`, `set_priority`, …) into one multi-field patch. That consolidation is the lesson, recorded by Paca as an explicit one; a per-field node set is the thing to *not* build.
+
+**Here:** no node type touches an internal app. The `tool` node reaches *external* systems through the Integration Registry; there is no in-platform equivalent, so an automation can currently observe a `pm.*` event and do nothing about the task.
+
+**Design constraints, non-negotiable:**
+- **One multi-field node, not one per field** — Paca's consolidation lesson, adopted before we make its mistake.
+- **Mutate through the ordinary task service**, never raw SQL. The automation's edit must take the same validation path a human's PATCH takes (status-transition effects, cycle guards, `pm_activities` write) — this is Paca's discipline and it is also how the edit stays auditable.
+- **Actor string `system:workflow:`**, inside the existing `email | agent:` vocabulary (research §2.5 / table row 5). A new actor shape would fork the vocabulary the whole platform reads.
+- **"Already in target state" check before writing** (research §9): what makes a crashed walk safe to retry. Skip-because-already-there is a recorded step outcome, not a silent no-op.
+- **Write-class?** A `pm.*` mutation is an *internal* write, so it does **not** need the `write_without_approval` publish gate that outward integration writes need (§3.2). State this explicitly in the node's catalog metadata, because the default reading of "write" would gate it and nobody wants an approval on "move the task to Done".
+
+**Done when:**
+1. A published workflow triggered by `{"source": "projects", "event_type": "pm.task.status_changed"}` mutates a second task through `pm.update_task`, and the target task carries a `pm_activities` row whose actor is `system:workflow:`.
+2. The node validates at publish: unknown field ⇒ named `GraphIssue`; missing task reference ⇒ named `GraphIssue`. Neither reaches run time.
+3. Re-running the same node against a task already in the target state records a step outcome (skipped/no-op) and writes **no** activity row — asserted, so idempotency is pinned rather than hoped for.
+4. The node appears in `GET /workflows/catalog` (D7 — served, never hard-coded in the UI).
+5. An automation edit is **indistinguishable in validation** from a human edit: a transition the API would refuse from a human is refused from the node too, with the same error.
+
+#### U2 — N-branch switch conditions ✅ **AGENT-SAFE**
+
+**Paca:** a condition is an ordered N-branch switch — first-true-wins, with a **reserved `else` handle**. Each branch is a **flat single comparison** (field × operator); there is deliberately no AND/OR nesting. A `validOperatorsByField` table rejects unimplemented field/operator combos **at validation time** instead of silently evaluating false at run time.
+
+**Here:** the condition node is exactly two handles. `BRANCHING_TYPES = frozenset({"condition"})` (`graph.py:50`) and the edge check refuses any handle that is not `"true"` or `"false"` (`graph.py:275-278`). Evaluation is a single closed-vocabulary comparison — `evaluate_condition(left, op, right)` (`handlers.py:81`), no `eval()`, operators `equals|contains|is_empty|…` and their negations.
+
+**The gap is fan-out of branches, not the comparison.** Our flat-single-comparison choice already matches Paca's; a five-way status router today needs four chained condition nodes, which is unreadable on canvas and quadruples the node count in run history.
+
+Two things to steal beyond the shape: **first-true-wins ordering must be explicit in the serialized run-model** (relying on JSON array order in the edit-model repeats 8.3b's "edge order is not stable" defect), and **`else` must be reserved** — a maker branch literally named `else` has to be rejected at validation.
+
+**Done when:** a condition node with N branches + `else` validates, publishes, and routes down exactly one handle; branch order is explicit in `workflow_versions.serialized` and survives a re-draw of the edges; an unreachable branch (one no comparison can select) is a named non-blocking publish `warning`; and the existing two-handle graphs still validate byte-identically (the change is additive — pinned by the existing engine tests staying green unchanged).
+
+#### U3 — Per-node **input** snapshots in run history ✅ **AGENT-SAFE** · *closes a real G6 gap*
+
+**Paca:** `automation_run_steps` persists **both** `input_snapshot` and `output_snapshot` per node, plus `error`.
+
+**Here:** `workflow_runs.node_results` (`132_workflows.sql:81`) is a JSONB **column**, one slot per node: `{status, output|error, duration_ms}` (`runner.py:44,180`). **The input is not recorded.** F9's drill-in therefore replays what each node *produced*, never what it *received*.
+
+**Why this matters more than it sounds.** §2 F9 and §1.2 G6 both promise "per-node **inputs**/outputs"; the schema does not deliver the first half. When a `{{ref}}` resolves to something unexpected, the run history shows the wrong output and gives no way to see the wrong input that caused it — the single most common debugging question a maker will ask, and today it is unanswerable without re-running.
+
+**Done when:** each entry in `node_results` carries the node's resolved config/input alongside its output; **secret-shaped values are redacted on the way in** (reuse `SECRET_PATTERNS`, `graph.py:52-58` — a resolved integration argument must never be persisted in a run row); the payload is size-bounded like the module output bound; the editor's history drill-in shows input and output side by side; and **old runs without inputs still render** (the field is additive and nullable — asserted against a fixture of the current shape, because a migration that breaks history drill-in for existing runs is worse than the gap).
+
+#### U4 — Task retargeting (`self | parent | children | blocks | …`) ⚠️ **AGENT-SAFE, but sequence it after U1**
+
+**Paca:** a condition *or* an action can aim at `self | parent | children | blocks | is_blocked_by | relates_to | duplicates | other(id)`. Multi-valued targets **fan out** — an action runs per resolved task; a condition combines via all/any.
+
+**Here:** nothing analogous, because U1 does not exist yet. Our data model already supports every one of those targets: `pm_tasks.parent_task_id` (self-FK) and `pm_task_links` with the `blocks|relates_to|duplicates` vocabulary (migration `146_projects.sql`).
+
+**This is what makes automations useful rather than toy-like** — "when every child is Done, move the parent to Done" is the canonical PM automation and needs `children` + an all-combiner. Note it is **not** the same feature as 8.3b's graph-level fan-in: retargeting fans out over *rows*, inside one node; 8.3b fans in over *edges*, between nodes. Do not conflate them, and do not let one ticket claim both.
+
+**Done when:** each target keyword resolves to the right row set with a bounded fan-out cap (a named failure when exceeded, never an unbounded walk); a condition over a multi-valued target combines by an explicit `all`/`any` chosen in config, never an implicit default; a target resolving to **zero** tasks is a recorded no-op step, not an error; and each fanned-out action records its own step outcome so history shows *which* children were touched.
+
+#### U5 — Stateless AND-join (`predecessor_done`) 🚧 **BLOCKED on §8.3b**
+
+**Paca:** the `predecessor_done` trigger is an AND-join over watched tasks that is **stateless** — it re-derives every watched task's live status *category* on each fire rather than keeping a counter, which makes it idempotent under at-least-once redelivery. The **dependency map UI is derived on read** from active `predecessor_done` nodes and never separately maintained.
+
+**Here:** the engine refuses fan-in outright — `"a node may have only one incoming edge (v1)"` (`graph.py:303-311`).
+
+**Why blocked, precisely:** §8.3b must first settle the merge shape, quorum rule, and pause/replay interaction for graph-level joins. A `predecessor_done` trigger built before that decision would either invent a second join semantics or quietly constrain 8.3b's. **Do not start U5 before 8.3b is decided.**
+
+The transferable discipline is the *statelessness*, and it is transferable independently: **re-derive, don't count.** Our `pm_task_statuses.category` CHECK (`backlog|todo|in_progress|done|cancelled`) is exactly the machine-readable semantic Paca's join reads. A counter column would drift under redelivery; a live re-derivation cannot.
+
+**Done when:** 8.3b has landed; the trigger holds no persisted counter (asserted by schema — there is no column to drift); firing the same source event twice produces one downstream effect; and the dependency map is computed on read from active nodes with **no** maintained table.
+
+#### U6 — At-most-once fire bookkeeping for time-based triggers ✅ **AGENT-SAFE**
+
+**Paca:** dedicated bookkeeping tables record due-date and cron fires so a redelivery cannot double-fire.
+
+**Here:** we solve the cron half differently and, on the evidence, **better** — CAS on `workflow_triggers.last_fired_at` means exactly one worker wins each tick, clock skew included, and downtime collapses to one catch-up fire rather than a storm (§3.3a, D6). **No table needed; do not add one.**
+
+**The genuine gap is the other half: there is no due-date trigger at all.** Paca's `due_date_reached` (offset minutes, polled) has no equivalent here, and "ping the assignee 24h before a task is due" is the single most-requested PM automation there is. It belongs to the **schedule scanner** (`scheduler.py`) — the loop that already polls and already CAS-claims — not to the event seam, because no event fires when a due date merely *arrives*.
+
+**Done when:** a `pm.task.due_in(offset_minutes)` trigger fires once per task per offset (asserted across a simulated scanner restart, which is where a naive implementation double-fires); changing a task's due date re-arms rather than double-firing; and the claim rides the existing CAS discipline rather than introducing a second one.
+
+#### U7 — Agent dispatch from a node ✅ **AGENT-SAFE** · *the second half of WS-27f*
+
+**Paca:** `trigger_ai_agent` action → `{message, member_id}` → the assignment consumer writes an `agent_conversations` row and appends to `paca:agent:triggers`; an `agent.session.started` activity lands **on the task** so the handoff is visible in the timeline immediately; the agent then writes back **through the ordinary API under its own identity** (API key + `X-Agent-ID`, permission-checked as its own project member — a stated "Boundary Rule": the AI service never writes to Postgres directly).
+
+**Here:** the `agent` node already dispatches to the orchestrator (`orchestrator.executor.run_agent`, `source="workflow"`). **The gap is task context, not dispatch** — assigning a `pm_tasks` row to `agent:` emits `pm.task.assigned` and stops there.
+
+Two things to carry: the **session must be visible as a task activity** the instant it starts (not only in run history, which nobody browsing a task will open), and the agent's write-back must go through the ordinary gateway API under its own identity. We are stronger than Paca on the second — `EffectiveAccess.intersect()` already narrows an agent by the acting member; Paca has no equivalent — so this is adoption of a *shape* we can enforce harder than the source does.
+
+**Owner constraint, already settled and not re-openable here:** an agent's edit to a ClickUp-linked task is treated **exactly like a human edit** (owner decision, WS-27b). No approval queue for agent pushes.
+
+**Done when:** assigning a task to `agent:` starts an orchestrator run whose session is visible as a `pm_activities` row on the task within the same request; the agent's write-back arrives through the ordinary task API under its own actor string; and a failed dispatch marks the activity failed rather than leaving a session that appears to be running forever.
+
+#### U8 — Agent-facing tool surface: the collapse lesson ✅ **AGENT-SAFE** · *design guidance, not a ticket*
+
+**Paca's MCP server** exposed **16 automation tools**, found it confused calling agents, and deliberately collapsed to **4** (`get/create/update/delete_automation`) taking rich nested payloads with **per-item outcomes** (one bad entry doesn't block its siblings) and **lenient removes** (removing something absent is a no-op) so a partial-failure retry is safe. Also: **internal UUIDs are never agent-facing** — nodes are addressed by task id, transitions by status id, and the tool layer resolves, so the agent never needs a read round-trip before it can write. And `ListTools` is **permission-filtered** — the tool list is computed from the caller's actual permissions.
+
+**Here:** F13 already chose the collapsed shape independently — `list_workflows`/`run_workflow`/`get_workflow_run`, three generic tools rather than one per workflow, explicitly "so the catalog scales without bloating agent tool schemas". Paca's experience is **confirming evidence for a decision already made**, and the reason this item is guidance rather than a ticket.
+
+What is *not* yet adopted, and should bind any future agent-facing tool in this app: per-item outcomes on batch payloads, lenient removes, no internal UUIDs in the agent-facing contract, and permission-filtered tool listing. **Record it here so the next tool surface starts from the collapsed design rather than rediscovering it at 16 tools.**
+
+### 13.4 What is deliberately refused
+
+Not oversights — decisions, so a future reader does not "fix" them:
+
+| Paca feature | Refused because |
+|---|---|
+| A separate automation engine + its own tables | **ADR-028 / D6 (`work_plan.md` §3)** — `/workflows` is the only engine. Everything above is an uplift *to this app*, never a sibling |
+| `call_api` action node | We have integration `tool` nodes resolved through the Registry (§3.2). Paca's own `call_api` stores headers visible to any project reader — a gap its source comments acknowledge. **Do not reproduce it** |
+| A worker process consuming an activity stream | D6 (`workflows_app.md` §9): supervised asyncio loops in the gateway, not a worker daemon. BO‑20's durable consumer is the platform's answer, and it already exists in shape |
+| WASM plugin runtime contributing node types | ADR-028: no second runtime. App Workshop + the skills registry answer this concern differently and already |
+| Tags as a bare JSONB array | Research table row 13 — the weakest part of Paca's model; refused for Projects and equally here |
+
+### 13.5 Where the numbers are
+
+Effort-shaped grouping for whoever picks this up, so the section can be sequenced without re-reading it: **U1 is WS-27f's first half and the only item anything is waiting on. U7 is its second half.** U2 and U3 are self-contained engine work with no cross-app dependency — U3 is the one that closes a promise §1.2 G6 and §2 F9 already make in writing, which arguably ranks it first of the two. U4 waits on U1 by construction. U5 waits on §8.3b by decision. U6 is independent and is the highest-value *new trigger*. U8 is guidance that binds the next tool surface and consumes no ticket.
diff --git a/ai-company-brain/work_plan.md b/ai-company-brain/work_plan.md
index 9539d359..9e974ecd 100644
--- a/ai-company-brain/work_plan.md
+++ b/ai-company-brain/work_plan.md
@@ -380,7 +380,7 @@ taken and dated.
| Multiplayer prior art (`qm`, 2026-08-01) | **`multiplayer_prior_art_qm_2026-08.md` is reference-only** — it owns no work and no status; the specs it links stay authoritative | multiplayer README §4.6/§5.1/§6.4/§6.5 · memory-clearance §3.3/§7 · agent-kinds §9 Q1 · skills_scope_out §6 · WS-10 · WS-23 |
| Memory compartments + clearance (incl. `subject:`) | **`docs/multiplayer/memory-clearance.md` §7** (surface design §7.1); dispatched as **WS-10 S1** | memory_architecture §9 `3a′` (link-only since 2026-08-02) · multiplayer README §6.3/§8 Phase 3 (index only) · prior-art §QM-D1 (reference only) |
| Native CRM + the Zoho retirement path | **WS-26 — `specs/crm_app.md`** (minted 2026-08-05) | `department_centers.md` Sales Center "Pipeline" app (a projection of `/crm`, flipped live by WS-26c) · WS-1 interplay **settled 2026-08-05 (D-CRM-7/D-CRM-8) — the writer now EXISTS** (branch `ws-26b-zoho-sync`): `ingestion/sources/zoho/writer.py`, the sync engine's **single, broker-gated** writer with one grep-asserted caller (`routes/crm/sync_zoho.py::execute_push`) and three registered `crm.zoho_*` handlers that auto-apply while `ACTION_BROKER_ENFORCE` is off, retired at WS-26e. WS-1's "no Zoho write path anywhere in the repo" sentence was corrected in that same change (done-when 6) — this row and the WS-1 row now agree, and neither should be re-softened · WS-2 (the Zoho-token P0's endgame is WS-26e's **revoke**) · WS-20 §11's "Odoo/Zoho-bound items" (bind to `crm` `entity_ref` per WS-26d instead) · `orchestrator/sales_views.py` + `scripts/reconciler.py` + `skills/sales\|reconciler/*` keep reading the graph mirror until WS-26e repoints them |
-| Native project management + the ClickUp retirement path | **WS-27 — `specs/project_management_app.md`** (minted 2026-08-05) | `task_manager_app.md` (the personal GTD lens — untouched as an app; its ClickUp provider **arm** retires at WS-27g while the provider *interface* stays, becoming the seam WS-27e's internal `commandcenter` provider uses) · `department_centers.md` C1/WS-13 (the tasks team slice and the People Center sub-app list; C1's `gtd_project_grant` = D13 stays C1's own — `pm_project_grants` is a sibling on the same subject vocabulary, never a replacement) · `task_manager_hr_planning_and_memory.md` (people/capability layer — WS-27 reads it, never rebuilds it) · `workflows_app.md` owns the automation engine WS-27f feeds (D6; the Paca-grade uplifts — multi-branch switch, step snapshots, dependency map — are recorded there as backlog, not here) · `paca_pm_research_2026-08.md` (reference-only, owns no work) · WS-1's BO-1a/BO-1b are **named prerequisites** of WS-27c, not discoveries |
+| Native project management + the ClickUp retirement path | **WS-27 — `specs/project_management_app.md`** (minted 2026-08-05) | `task_manager_app.md` (the personal GTD lens — untouched as an app; its ClickUp provider **arm** retires at WS-27g while the provider *interface* stays, becoming the seam WS-27e's internal `commandcenter` provider uses) · `department_centers.md` C1/WS-13 (the tasks team slice and the People Center sub-app list; C1's `gtd_project_grant` = D13 stays C1's own — `pm_project_grants` is a sibling on the same subject vocabulary, never a replacement) · `task_manager_hr_planning_and_memory.md` (people/capability layer — WS-27 reads it, never rebuilds it) · `workflows_app.md` owns the automation engine WS-27f feeds (D6; the Paca-grade uplifts are recorded there as backlog, not here — **written up in full 2026-08-06 as `workflows_app.md` §13, items U1–U8**, where **U1** = the `pm.update_task` node and **U7** = agent dispatch, i.e. WS-27f's two halves, and U2–U6/U8 are engine work WS-27 does not wait on; §13 is backlog and changes neither Slice 3 nor Slice 4) · `paca_pm_research_2026-08.md` (reference-only, owns no work) · WS-1's BO-1a/BO-1b are **named prerequisites** of WS-27c, not discoveries |
| The People Center's surfaces (directory, org chart, capability search, seats) | **WS-28 — `specs/people_center_app.md`** (minted 2026-08-06) | It owns **surfaces, not facts**: `task_manager_hr_planning_and_memory.md` owns the HR data and the capability vectors · `org_access_control.md` owns identity, roles and overrides · `colleague_onboarding.md` owns the invite process and the role × app matrix · `department_centers.md` owns Centers and groups · `project_management_app.md` owns the work. WS-13's *People directory read view* is closed by WS-28b rather than staying open in Centers B |
| Tenancy boundary + visibility model (who can see what) | **`specs/tenancy_and_visibility.md`** (D11 §1 · D12 §3–§4 · the app-by-app gap table §5 · TV-1 §2) | `department_centers.md` (the "separate deployment is for a separate org, never a department" rule) · `org_access_control.md` §8 Ph2 · `multi_user_organization_research.md` §5/§7/§8/§9/§17 (**research only, and superseded for planning by the new spec**) · `groups_sessions_authority.md` §3 (the intersection rule it constrains) · D9 (the twelve "second tenant deployment" sites) |
From 2f205bf1bc31c5760f0ea174a2b56693aa8e83c9 Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 6 Aug 2026 11:10:46 +0000
Subject: [PATCH 03/10] =?UTF-8?q?feat(WS-27e):=20the=20personal=20lens=20g?=
=?UTF-8?q?ets=20a=20surface=20=E2=80=94=20My=20work,=20in=20the=20same=20?=
=?UTF-8?q?app?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
WS-27e shipped API-only. The one-store revision's whole point was cohesion —
one task row, so finishing something in your own list finishes it on the team's
board — and until now that was true in the schema and invisible to a member,
who still had no way to see their own work in this app at all.
"My work" sits ABOVE the project tree, in the same pane, reached from the same
nav entry. Putting it anywhere else would re-teach the exact split D-PM-6 was
revised to remove.
Four layout decisions, each of which could have gone the other way:
- Four lanes, not eight. INBOX/NEXT/WAITING/SOMEDAY are work states and get
lanes; PROJECT/REFERENCE are filing states and collapse into one "Filed" lane
shown only when occupied; DONE/TRASH the endpoint already excludes. Eight
lanes makes the daily view a filing cabinet.
- Empty work lanes still render. "You have triaged nothing into today" is a
real state and a lane that vanishes when empty cannot say it.
- Undated tasks sort BELOW dated ones. A task nobody dated is not more urgent
than one due tomorrow, and the opposite order is how a list stops being read.
- Untriaged is stated in the row and counted in the header, rather than implied
by a missing badge. That count is the Weekly Review's whole question, and it
is only answerable because the server derives dispositions instead of storing
them on first read.
The checkbox calls /tasks/{id}/complete, which moves the SHARED status, and
says so in its title — the cohesion is the feature, not a footnote. Triage
buttons call /tasks/{id}/personal and structurally cannot touch a shared field.
One repair the surface forced, and a real bug: TaskPanel read the *selected
project's* statuses. That is wrong for a task opened from My work, which may
belong to any project the member is assigned into — the panel would have
offered transitions to lanes that do not exist on that task's project. Statuses
are now resolved from the task's own root project, with the board and list
paths going through the same resolver.
Reloading is a dependency bump through one cancellable effect rather than a
callback, so the first fetch, a context switch and a post-mutation refresh all
take the same path and a slow response from a context the member has already
left cannot land. Mutations reload rather than patching local state: these
endpoints move shared status and derive dispositions server-side, so a
locally-guessed row would be a guess about two other systems.
Verification: 332 vitest cases green (was 315), tsc --noEmit clean, eslint adds
zero errors over the pre-existing WS-27d baseline. Seven mutants measured red
against lib/mywork.ts and reverted byte-identical — including one that survived
first time (untriagedCount counting the triaged instead) because the fixture was
balanced two-against-two; the fixture is now lopsided and the mutant dies.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
---
.../specs/project_management_app.md | 27 ++
ai-company-brain/work_plan.md | 2 +-
.../src/app/projects/components/MyWork.tsx | 296 ++++++++++++++++++
.../control_plane/src/app/projects/lib/api.ts | 60 ++++
.../src/app/projects/lib/mywork.test.ts | 180 +++++++++++
.../src/app/projects/lib/mywork.ts | 153 +++++++++
.../control_plane/src/app/projects/page.tsx | 73 ++++-
7 files changed, 781 insertions(+), 10 deletions(-)
create mode 100644 workbench/control_plane/src/app/projects/components/MyWork.tsx
create mode 100644 workbench/control_plane/src/app/projects/lib/mywork.test.ts
create mode 100644 workbench/control_plane/src/app/projects/lib/mywork.ts
diff --git a/ai-company-brain/specs/project_management_app.md b/ai-company-brain/specs/project_management_app.md
index 993d2847..b281b71f 100644
--- a/ai-company-brain/specs/project_management_app.md
+++ b/ai-company-brain/specs/project_management_app.md
@@ -771,6 +771,33 @@ transition's three effects; (4) two assignees hold independent dispositions; (5)
project is created once, granted to its owner alone, and excluded from every team read;
(6) no route here accepts a `?member=` in any form.
+**The surface landed 2026-08-06** (`src/app/projects/components/MyWork.tsx` +
+`lib/mywork.ts`; 17 vitest cases, 7 mutants red). WS-27e had shipped API-only, which meant
+the cohesion the revision bought was true in the schema and invisible to a member. **"My
+work" sits above the project tree in the same app** — not a second surface and not a second
+nav entry, because a personal lens reached from somewhere else re-teaches exactly the split
+D-PM-6 was revised to remove. Four decisions worth recording, each of which could
+reasonably have gone the other way:
+
+- **Four lanes, not eight.** `INBOX | NEXT | WAITING | SOMEDAY` are work states and get
+ lanes; `PROJECT | REFERENCE` are filing states and collapse into one "Filed" lane shown
+ only when occupied; `DONE | TRASH` the endpoint already excludes. Eight lanes would make
+ the daily view a filing cabinet.
+- **Empty work lanes still render.** "You have triaged nothing into today" is a real and
+ useful state, and a lane that vanishes when empty cannot say it. Only "Filed" hides.
+- **Undated tasks sort BELOW dated ones.** A task nobody dated is not more urgent than one
+ due tomorrow, and the opposite order is how a personal list stops being read.
+- **Untriaged is stated in the row, not implied by a missing badge**, and counted in the
+ header. That count is the Weekly Review's whole question and is only answerable because
+ the server derives dispositions instead of storing them on first read (§3.12).
+
+Completing from a row calls `POST /tasks/{id}/complete`, which moves the **shared** status
+— the checkbox carries a title saying so. Triage buttons call
+`PATCH /tasks/{id}/personal` and cannot touch a shared field. One repair the surface forced:
+`TaskPanel` previously read the *selected project's* statuses, which is wrong for a task
+opened from My work — it may belong to any project the member is assigned into — so the
+panel's statuses are now resolved from the task's own root project.
+
**WS-27f — automation + agent dispatch.** 🟢 AGENT-SAFE (the node types land in
`workflows_app.md`'s tree per D6 and are recorded there in the same PR).
Done when: (1) `pm.*` events reach `dispatch_event` (proven at the `emit_event` seam);
diff --git a/ai-company-brain/work_plan.md b/ai-company-brain/work_plan.md
index 9e974ecd..3afd86a9 100644
--- a/ai-company-brain/work_plan.md
+++ b/ai-company-brain/work_plan.md
@@ -148,7 +148,7 @@ looks. Full statements live in `FOUNDATION_BUILDOUT_CHECKLIST.md`.
| WS-21 | **Calendar F2/F3** (`gtd_time_blocks`, email windows, mobile timeline, external sync) | `calendar_focus_os.md` **§9** (canonical for all F2/F3 acceptance; **§5** canonical for `gtd_time_blocks`) + `calendar_timeboxing.md` **§13** (canonical for P4) — both rewritten 2026-08-03 | 🟡 partial | **Re-audited 2026-08-03 → GO-NARROWED.** P3 roll-over was already shipped (released-to-unscheduled, mig 78 + `start_auto_rollover`). ~~"ideal week"~~ **struck — substantially shipped** (mig 98 + settings round-trip + editor + grid render + packer honouring + 2 unit tests); only the unused-focus-window / template-adherence gap remains (§9.6). **Breaks-in-the-packer SHIPPED 2026-07-23** (`80722e17`, mig **97**) as *packer geometry* — a widened buffer plus lunch protection, **a gap, not a `kind='break'` row**, which is exactly why F2 survives (§5 residual 4, now closed). **The 2026-08-01 acceptance was satisfiable by doing nothing** — 2 of its 3 `gtd_time_blocks` clauses were already green against shipped code; they are deleted and replaced with four that all fail today. **`gtd_time_blocks` is 4 slices, not 1 PR** (§9.1 S1–S4): the "non-breaking `TimeBlock[]` swap" claim was **FALSE** — the measured blast radius is 17 TS files + 3 gateway modules + `apps/skills/skill-task-gtd/` + `apps/agents/agent-task-manager/`. **Focus Shield is AGENT-SAFE, not owner-gated** (§9.5) — it needs a design, not a credential; do not dispatch on §4.1 prose alone. **Top-5 outcomes (Horizons) — DO NOT DISPATCH:** it collides with WS-18; §4 assigns it here, and WS-18's title keeps it struck. **Verify by naming test files — never `pytest tests/unit -k calendar`**: `-k` still collects the whole directory, and whole-directory collection hangs on the Windows box. **Dispatchable today:** §9.1 S1 · the ritual-stamp localStorage residue (§9.1 done-when 4, independently shippable) · §9.6 · §9.7. **OWNER-GATE:** external sync (§9.11 / timeboxing §13 P4) needs Google Calendar and/or Microsoft Graph OAuth client credentials provisioned on the VPS. |
| WS-22 | **draw.io** (all 13 tickets open, nothing built) | `drawio_integration.md` | 🟡 owner | Best acceptance structure in the corpus; needs an owner and re-verified anchors (~5 weeks stale). ST-DRW-02 is a decision gate. |
| **WS-26** | **CRM app — native CRM + Zoho retirement** *(minted 2026-08-05)* | `specs/crm_app.md` | ✅ **a + b + c BUILT** · 🟢 d dispatchable | Research pass 2026-08-05: `frappe/crm` (AGPL — **concepts only, no code**), `trycompai/crm` (MIT), full-tree Zoho sweep. **Zoho today is a read-only nightly mirror** into the Phase-0 graph tables (`person`/`customer`/`deal`) with no UI, no write path, and **no Leads pull** — so leaving Zoho is import-and-retire, not a live cutover. Spine: Frappe's lead→convert→deal+contact+organization with **statuses-as-data** (color/position/type/probability); trycompai's single activity-spine table + `source` provenance + `last_activity_at` discipline. **BO-10 contribution: WS-26a adds the shared engine seam (`gateway/db.py::get_engine()`, tasks converted as proof) instead of engine 13.** Tickets: **a** schema + feature registration + core API — **BUILT 2026-08-05** (mig `144_crm.sql`, `feature:crm`, `gateway/db.py` seam + tasks converted, `routes/crm/`; **not deployed — the migration has not been applied anywhere**) · **b** **Zoho two-way sync — BUILT 2026-08-05** (branch `ws-26b-zoho-sync`: `list_leads` + `list_deleted` on the read client, the single write client `ingestion/sources/zoho/writer.py` with one grep-asserted caller, mig `145_crm_zoho_sync.sql` (dirty columns + `crm_zoho_tombstones` + `crm_sync_cursors`), `routes/crm/{import_zoho,sync_zoho,broker_handlers}.py`, `crm.zoho_*` broker handlers registered from `main.py`, 80 new hermetic tests). *(Re-scoped 2026-08-05, owner-directed D-CRM-7: "faithful two way sync until we do away with Zoho entirely" — coexistence is bidirectional, not import-once.)* **Built, never run: `CRM_ZOHO_SYNC` ships OFF, mig 145 is unapplied, and enabling/backfilling/hand-running against prod is OWNER-GATE §6 — it writes the live Zoho tenant.** WS-1's "no Zoho write path anywhere" clause was corrected in the same change (done-when 6) · **c** UI + the API addendum — **BUILT 2026-08-05** on branch `ws-26c-crm-ui` atop 26a and **merged with b into `ws-26-crm-app` 2026-08-06** (`/crm` app + BFF proxy; the three frontend registration points with `CenterApp` re-typed so `live ⇒ href` is a compile error; `routes/crm/deal_contacts.py` with one-primary-per-deal enforced on the shared `core.link_deal_contact` seam the convert path now also uses — 26b's importer is the one excepted writer and computes `is_primary` in-statement so a backfill can never demote a hand-set primary; `organization_name` on the deal list + board via a derived-table LEFT JOIN; the three review residuals — `?status_id` on a pipeline-less entity → 422, explicit `null` on a defaulted NOT NULL column → 422 not a driver 500, and a hand-edited `lead_name` surviving a name-field PATCH. **Still not deployed:** migrations 144 and 145 have not been applied anywhere, so live rendering, drag persistence and deep links are owner-verified after they apply) · **d** integrations — email address-join timeline, WhatsApp `entity_ref`, `agent-crm`; `CRM_AUTO_LEAD` ships OFF (🟢 except the flip) · **e** cutover + retirement inventory + **Zoho refresh-token revoke, which executes part of WS-2's standing P0** (🔴 OWNER-GATE end-to-end). Data-visibility departure recorded: org-visible to `feature:crm` holders in v1, `owner_email` is assignment not ACL (D-CRM-3; workflows v1 is the precedent) — revisit at WS-14 `group:` grants / colleague #1. |
-| **WS-27** | **Projects app — native project management + ClickUp retirement** *(minted 2026-08-05)* | `specs/project_management_app.md` | ✅ **a + b + d + e BUILT 2026-08-06** · 🟢 f dispatchable · 🟡 c gated · 🟡 h sequenced | Research pass 2026-08-05: `Paca-AI/paca` v0.11.0 (Apache-2.0 — **patterns adopted, no code translated**; findings + the adopt/adapt/refuse table live in `specs/paca_pm_research_2026-08.md`, reference-only), plus a full-tree ClickUp sweep. **ClickUp today is TWO independent systems** — the Phase-0 graph mirror (read-only, shallow) *and* the per-user Tasks-app connector with a **live broker-gated write path** — so leaving ClickUp is coexistence-sync-then-invert, **not** WS-26's import-and-retire; the constraint-8 inversion is staged and recorded in spec §7. Spine: Paca's two-self-FK hierarchy (departments→projects→subprojects→tasks→subtasks as `pm_projects` + `pm_tasks`, types-as-data with the Epic-root rule), statuses-as-data with a semantic `category` (D-CRM-2 convergence), per-view fractional ordering (`pm_view_task_positions` — what lets People-Center and Center-slice boards order the same task differently), and a single activity spine. **First data-scoped app:** `pm_project_grants` on the shipped `email\|group:\|org` vocabulary (D12; sibling of C1's D13, which is unchanged), 404-not-403, and the full-portfolio view gives D14's zero-consumer `data:org:read` its **first consumer**. **Three owner answers recorded 2026-08-06 as D-PM-8/9/10, and two of them changed the build:** **D-PM-8** no portfolio/program layer — grants are the only grouping axis, a cross-department project simply carries several (a `pm_programs` table stays purely additive if wanted later); **D-PM-9** agent edits to ClickUp-linked tasks are treated **exactly like human edits** (*the agent proposed queueing agent-originated pushes for approval and was overruled*) — so during coexistence a mistaken agent edit reaches the live workspace with no human in between while `ACTION_BROKER_ENFORCE` is off; bounded by attribution (`agent:`), timeline-reversibility, and the fact that the enforce flip converts the whole class to queue-on-approval. Read D-PM-9's Cost paragraph before building WS-27f; **D-PM-10** ClickUp Spaces map to Centers **explicitly**, from agent-proposed suggestions (assignee-overlap → name match → EVAL-LOCKED content classification), owner-confirmed, applied as `group:` grants — and an **unmapped Space still imports in full with no group grant**, staying reachable in `/projects` for `data:org:read` holders and its assignees. This supersedes the "pilot vs all Spaces" framing: scope is now a per-Space decision the plan step surfaces, so a pilot and a full import are one code path. Tickets: **a** schema + `feature:projects` both sides + core API on the `gateway/db.py` seam — **BUILT 2026-08-06** (mig `146_projects.sql`, `routes/projects/` with zero `create_async_engine` calls, 115 hermetic cases + 5 mutants measured red; **not deployed — the migration has not been applied anywhere**) · **b** ClickUp org importer **+ the Space→Center mapping plan** — **BUILT 2026-08-06** (`routes/projects/mapping.py` + `import_clickup.py`; `POST /projects/import/clickup/plan` proposes and writes nothing, `POST /projects/import/clickup` applies the confirmed mapping; 25 hermetic cases, 4 mutants red incl. *applying the suggestion instead of the confirmed mapping*; **neither endpoint has been run — prod execution stays OWNER-GATE, §6**) · **c** two-way coexistence sync — three-way field merge, conflicts logged to the timeline (🟡 **blocked on WS-1's BO-1a + BO-1b, named prerequisites**; enabling push is OWNER-GATE) · **d** UI + Center (app + scope) projections, no forks — **BUILT 2026-08-06** (`src/app/projects/` tree + board + list + task panel + timeline, BFF proxy, nav/access registration, all six Centers linking at the SAME `/projects` path and differing only by `?center=`; 34 vitest cases incl. a registration fence, 6 mutants red) · **e** the personal lens — **BUILT 2026-08-06** and **its shape changed**: `D-PM-6` was revised (owner-directed — *"the personal task manager should be a proper extension … a cohesive whole"*) from a mirror into **one store**. `pm_tasks` is THE task table; private work is a personal project (`pm_projects.personal_owner`); the GTD overlay is **per-member** (`pm_task_personal`, mig `147_projects_personal.sql`) so two assignees can hold different dispositions. Assignment is no longer a sync — the inbox row IS the project row, and completing it there moves the shared status. 31 hermetic cases, 6 mutants red. **Cost accepted: `gtd_items` becomes legacy and WS-27h retires it** · **f** automation + agent dispatch — `pm.*` events into the existing `emit_event → dispatch_event` path; node types land in `workflows_app.md` per D6; assign-to-`agent:` dispatches an orchestrator run visible on the task timeline (🟢) · **g** cutover + retirement inventory (both ClickUp systems) + token revoke + the root-`AGENTS.md` constraint-8 amendment (🔴 OWNER-GATE end-to-end) · **h** `gtd_items` retirement — the cost D-PM-6's revision accepted: union read, row migration into `pm_tasks` + `pm_task_personal`, then `items.py`'s 27 owner-scoped predicates retire with the table they scope (🟡 after e; the data move is 🔴 OWNER-GATE — it rewrites the owner's live task store). |
+| **WS-27** | **Projects app — native project management + ClickUp retirement** *(minted 2026-08-05)* | `specs/project_management_app.md` | ✅ **a + b + d + e BUILT 2026-08-06** · 🟢 f dispatchable · 🟡 c gated · 🟡 h sequenced | Research pass 2026-08-05: `Paca-AI/paca` v0.11.0 (Apache-2.0 — **patterns adopted, no code translated**; findings + the adopt/adapt/refuse table live in `specs/paca_pm_research_2026-08.md`, reference-only), plus a full-tree ClickUp sweep. **ClickUp today is TWO independent systems** — the Phase-0 graph mirror (read-only, shallow) *and* the per-user Tasks-app connector with a **live broker-gated write path** — so leaving ClickUp is coexistence-sync-then-invert, **not** WS-26's import-and-retire; the constraint-8 inversion is staged and recorded in spec §7. Spine: Paca's two-self-FK hierarchy (departments→projects→subprojects→tasks→subtasks as `pm_projects` + `pm_tasks`, types-as-data with the Epic-root rule), statuses-as-data with a semantic `category` (D-CRM-2 convergence), per-view fractional ordering (`pm_view_task_positions` — what lets People-Center and Center-slice boards order the same task differently), and a single activity spine. **First data-scoped app:** `pm_project_grants` on the shipped `email\|group:\|org` vocabulary (D12; sibling of C1's D13, which is unchanged), 404-not-403, and the full-portfolio view gives D14's zero-consumer `data:org:read` its **first consumer**. **Three owner answers recorded 2026-08-06 as D-PM-8/9/10, and two of them changed the build:** **D-PM-8** no portfolio/program layer — grants are the only grouping axis, a cross-department project simply carries several (a `pm_programs` table stays purely additive if wanted later); **D-PM-9** agent edits to ClickUp-linked tasks are treated **exactly like human edits** (*the agent proposed queueing agent-originated pushes for approval and was overruled*) — so during coexistence a mistaken agent edit reaches the live workspace with no human in between while `ACTION_BROKER_ENFORCE` is off; bounded by attribution (`agent:`), timeline-reversibility, and the fact that the enforce flip converts the whole class to queue-on-approval. Read D-PM-9's Cost paragraph before building WS-27f; **D-PM-10** ClickUp Spaces map to Centers **explicitly**, from agent-proposed suggestions (assignee-overlap → name match → EVAL-LOCKED content classification), owner-confirmed, applied as `group:` grants — and an **unmapped Space still imports in full with no group grant**, staying reachable in `/projects` for `data:org:read` holders and its assignees. This supersedes the "pilot vs all Spaces" framing: scope is now a per-Space decision the plan step surfaces, so a pilot and a full import are one code path. Tickets: **a** schema + `feature:projects` both sides + core API on the `gateway/db.py` seam — **BUILT 2026-08-06** (mig `146_projects.sql`, `routes/projects/` with zero `create_async_engine` calls, 115 hermetic cases + 5 mutants measured red; **not deployed — the migration has not been applied anywhere**) · **b** ClickUp org importer **+ the Space→Center mapping plan** — **BUILT 2026-08-06** (`routes/projects/mapping.py` + `import_clickup.py`; `POST /projects/import/clickup/plan` proposes and writes nothing, `POST /projects/import/clickup` applies the confirmed mapping; 25 hermetic cases, 4 mutants red incl. *applying the suggestion instead of the confirmed mapping*; **neither endpoint has been run — prod execution stays OWNER-GATE, §6**) · **c** two-way coexistence sync — three-way field merge, conflicts logged to the timeline (🟡 **blocked on WS-1's BO-1a + BO-1b, named prerequisites**; enabling push is OWNER-GATE) · **d** UI + Center (app + scope) projections, no forks — **BUILT 2026-08-06** (`src/app/projects/` tree + board + list + task panel + timeline, BFF proxy, nav/access registration, all six Centers linking at the SAME `/projects` path and differing only by `?center=`; 34 vitest cases incl. a registration fence, 6 mutants red) · **e** the personal lens — **BUILT 2026-08-06** and **its shape changed**: `D-PM-6` was revised (owner-directed — *"the personal task manager should be a proper extension … a cohesive whole"*) from a mirror into **one store**. `pm_tasks` is THE task table; private work is a personal project (`pm_projects.personal_owner`); the GTD overlay is **per-member** (`pm_task_personal`, mig `147_projects_personal.sql`) so two assignees can hold different dispositions. Assignment is no longer a sync — the inbox row IS the project row, and completing it there moves the shared status. 31 hermetic cases, 6 mutants red. **Its surface landed the same day** — "My work" above the project tree in the SAME app (`components/MyWork.tsx` + `lib/mywork.ts`, 17 vitest cases, 7 mutants red): capture-first, four work lanes that render even when empty, untriaged counted in the header (the Weekly Review's question, answerable only because dispositions are derived not stored), and a completion checkbox that moves the **shared** status. e had shipped API-only, so the cohesion the revision bought was true in the schema and invisible to a member. One repair it forced: `TaskPanel` read the *selected* project's statuses, wrong for a task opened from My work — now resolved from the task's own root project. **Cost accepted: `gtd_items` becomes legacy and WS-27h retires it** · **f** automation + agent dispatch — `pm.*` events into the existing `emit_event → dispatch_event` path; node types land in `workflows_app.md` per D6; assign-to-`agent:` dispatches an orchestrator run visible on the task timeline (🟢) · **g** cutover + retirement inventory (both ClickUp systems) + token revoke + the root-`AGENTS.md` constraint-8 amendment (🔴 OWNER-GATE end-to-end) · **h** `gtd_items` retirement — the cost D-PM-6's revision accepted: union read, row migration into `pm_tasks` + `pm_task_personal`, then `items.py`'s 27 owner-scoped predicates retire with the table they scope (🟡 after e; the data move is 🔴 OWNER-GATE — it rewrites the owner's live task store). |
| **WS-28** | **People Center — directory, org chart, and the assignment seam** *(minted 2026-08-06)* | `specs/people_center_app.md` | 🔲 spec'd · 🟢 a–e dispatchable · 🔴 f owner-gate | Scope owner-set 2026-08-06: **directory, skills, org chart, capacity, seats/roles — exactly what assignment and planning need**; leave/onboarding/hiring are named as later phases so their absence is a decision. **The fact this spec exists to settle:** there are TWO people stores and that is deliberate — `app_user` answers *can they sign in and what may they see*, `gtd_people` answers *who are they and what can they do*, and the directory must include people with **no login** (contractors), which is why the Projects app's assignee is a plain string. They join on lowercased email, and **P-1 fixes that join before it is relied on**: migration 49 made `name` UNIQUE and left `email` unconstrained, so today two rows may share an address and an email→person join is ambiguous. Surfaces: directory (honouring WS-24 N4's HR projection, with a *restricted* empty state distinct from *none*) · person page · org chart from `manager_id` with a Center overlay that **shows** department/group mismatches rather than smoothing them · capability search over stated skills → résumé evidence → the existing `capability_embedding`, which **suggests and never assigns** · seats & roles matrix (read + propose; applying a membership change stays owner-gated per §6 (d)). Closes WS-13's outstanding *People directory read view* item. Tickets **a** key-shape fix (🟢) · **b** directory + person page (🟢) · **c** org chart (🟢) · **d** capability search (🟢, ranking EVAL-LOCKED) · **e** the Projects seams — directory-backed assignee picker listing agents and directory-only people, capacity derived from open assigned tasks (🟢) · **f** seats & roles writes (🔴 OWNER-GATE). |
---
diff --git a/workbench/control_plane/src/app/projects/components/MyWork.tsx b/workbench/control_plane/src/app/projects/components/MyWork.tsx
new file mode 100644
index 00000000..904e2b04
--- /dev/null
+++ b/workbench/control_plane/src/app/projects/components/MyWork.tsx
@@ -0,0 +1,296 @@
+"use client";
+
+/**
+ * Projects · My work — the personal lens over the one task store.
+ *
+ * Spec: `ai-company-brain/specs/project_management_app.md` §3.11-§3.12, §6.1 ·
+ * **D-PM-6 (revised)** · ticket WS-27e.
+ *
+ * This is **not a second app**. The rows here are `pm_tasks` rows — the same
+ * ones on the team board — so ticking one off moves the project's status at the
+ * same instant, and a captured thought can later be dragged into a team project
+ * without being recreated. What is personal is the *overlay*: disposition,
+ * context, defer. That is why this file selects and triages but never edits
+ * shared fields, other than through `complete`, which is deliberately shared.
+ */
+import { useCallback, useEffect, useMemo, useState } from "react";
+
+import { myWorkApi, type TaskRow } from "../lib/api";
+import {
+ actionLine,
+ groupByDisposition,
+ isOverdue,
+ twoMinuteTasks,
+ untriagedCount,
+ type MyTaskRow,
+} from "../lib/mywork";
+
+const TRIAGE: Array<{ value: string; label: string }> = [
+ { value: "NEXT", label: "Next" },
+ { value: "WAITING", label: "Waiting" },
+ { value: "SOMEDAY", label: "Someday" },
+ { value: "REFERENCE", label: "File" },
+];
+
+interface Props {
+ onSelect: (task: TaskRow) => void;
+}
+
+export function MyWork({ onSelect }: Props) {
+ const [rows, setRows] = useState([]);
+ const [contexts, setContexts] = useState>([]);
+ const [context, setContext] = useState(null);
+ const [capture, setCapture] = useState("");
+ const [loading, setLoading] = useState(true);
+ const [busy, setBusy] = useState(null);
+ const [error, setError] = useState(null);
+
+ // Reloading is a dependency bump rather than a callback, so every fetch —
+ // the first one, a context switch, and the one after a mutation — runs
+ // through the same cancellable effect. The `live` guard is what keeps a slow
+ // response from a context the member has already left off the screen.
+ //
+ // The error clears on SUCCESS, not on entry: blanking it while the retry is
+ // in flight hides the failure at the moment it is being read.
+ const [reloadKey, setReloadKey] = useState(0);
+
+ useEffect(() => {
+ let live = true;
+ (async () => {
+ try {
+ const [inbox, ctx] = await Promise.all([
+ myWorkApi.inbox(context ? { context } : {}),
+ myWorkApi.contexts(),
+ ]);
+ if (!live) return;
+ setRows(inbox.rows as MyTaskRow[]);
+ setContexts(ctx.rows);
+ setError(null);
+ } catch (err) {
+ if (live) setError(String((err as Error).message));
+ } finally {
+ if (live) setLoading(false);
+ }
+ })();
+ return () => {
+ live = false;
+ };
+ }, [context, reloadKey]);
+
+ const lanes = useMemo(() => groupByDisposition(rows), [rows]);
+ const untriaged = useMemo(() => untriagedCount(rows), [rows]);
+ const quick = useMemo(() => twoMinuteTasks(rows), [rows]);
+ // Read fresh on every render, deliberately: "overdue" must not be pinned to
+ // whenever this pane first mounted.
+ const now = new Date();
+
+ // Every mutation reloads rather than patching local state. The endpoints here
+ // move SHARED status (complete) and derive dispositions server-side, so a
+ // locally-guessed row would be a guess about two other systems.
+ const run = useCallback(
+ async (taskId: string, work: () => Promise) => {
+ setBusy(taskId);
+ try {
+ await work();
+ setReloadKey((k) => k + 1);
+ } catch (err) {
+ setError(String((err as Error).message));
+ } finally {
+ setBusy(null);
+ }
+ },
+ []
+ );
+
+ async function submitCapture(event: React.FormEvent) {
+ event.preventDefault();
+ const title = capture.trim();
+ if (!title) return;
+ setCapture("");
+ await run("capture", () => myWorkApi.capture({ title }));
+ }
+
+ if (loading) {
+ return
Loading your work…
;
+ }
+
+ return (
+
+ {/* Capture first, and above everything: GTD's one non-negotiable is that
+ getting a thought out of your head must cost nothing. No project to
+ pick, no status to choose — a title and Enter. */}
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+
+ {untriaged > 0
+ ? `${untriaged} not yet triaged`
+ : "Everything here has been triaged"}
+
+ {contexts.length > 0 ? (
+
+
+ {contexts.map((c) => (
+
+ ))}
+
+ ) : null}
+
+
+
+ {quick.length > 0 ? (
+
+
+ Under two minutes ({quick.length})
+
+
+ The one part of the method that pays off before you stand up.
+
+ );
+}
diff --git a/workbench/control_plane/src/app/projects/lib/api.ts b/workbench/control_plane/src/app/projects/lib/api.ts
index ff32b56b..824f7f16 100644
--- a/workbench/control_plane/src/app/projects/lib/api.ts
+++ b/workbench/control_plane/src/app/projects/lib/api.ts
@@ -155,3 +155,63 @@ export const projectsApi = {
body: JSON.stringify({ positions }),
}),
};
+
+/**
+ * The personal lens (WS-27e).
+ *
+ * Same store, same rows, same proxy — there is no second task API, because
+ * there is no second task table. Identity comes from the session on the server
+ * side, so nothing here takes a member parameter: no request can be shaped to
+ * read or write somebody else's practice.
+ */
+export const myWorkApi = {
+ inbox: (params: Record = {}) => {
+ const qs = new URLSearchParams();
+ for (const [key, value] of Object.entries(params)) {
+ if (value !== undefined && value !== "") qs.set(key, String(value));
+ }
+ const query = qs.toString();
+ return call<{ rows: MyTaskApiRow[]; total: number }>(
+ `my/inbox${query ? `?${query}` : ""}`
+ );
+ },
+
+ contexts: () =>
+ call<{ rows: Array<{ context: string; total: number }>; total: number }>(
+ "my/contexts"
+ ),
+
+ capture: (payload: {
+ title: string;
+ next_action?: string | null;
+ context?: string | null;
+ due_at?: string | null;
+ }) => call("my/tasks", { method: "POST", body: JSON.stringify(payload) }),
+
+ setPersonal: (taskId: string, payload: Record) =>
+ call>(`tasks/${taskId}/personal`, {
+ method: "PATCH",
+ body: JSON.stringify(payload),
+ }),
+
+ // Completion moves the task's SHARED status — the cohesion the one-store
+ // design buys. Ticking something off here ticks it off on the team's board.
+ complete: (taskId: string) =>
+ call(`tasks/${taskId}/complete`, { method: "POST", body: "{}" }),
+
+ defer: (taskId: string, until: string) =>
+ call>(`tasks/${taskId}/defer`, {
+ method: "POST",
+ body: JSON.stringify({ until }),
+ }),
+};
+
+/** What `/my/inbox` returns: the task row with this member's overlay merged on. */
+export interface MyTaskApiRow extends TaskRow {
+ disposition: string;
+ is_triaged: boolean;
+ next_action?: string | null;
+ context?: string | null;
+ energy?: string | null;
+ is_two_minute?: boolean;
+}
diff --git a/workbench/control_plane/src/app/projects/lib/mywork.test.ts b/workbench/control_plane/src/app/projects/lib/mywork.test.ts
new file mode 100644
index 00000000..2ecfdf03
--- /dev/null
+++ b/workbench/control_plane/src/app/projects/lib/mywork.test.ts
@@ -0,0 +1,180 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ DISPOSITION_LANES,
+ OTHER_LANE,
+ actionLine,
+ groupByDisposition,
+ isOverdue,
+ sortMyTasks,
+ twoMinuteTasks,
+ untriagedCount,
+ type MyTaskRow,
+} from "./mywork";
+
+function task(over: Partial & { id: string }): MyTaskRow {
+ return {
+ project_id: "p",
+ root_project_id: "p",
+ status_id: "s",
+ title: `task ${over.id}`,
+ disposition: "NEXT",
+ is_triaged: true,
+ ...over,
+ };
+}
+
+describe("isOverdue", () => {
+ const now = new Date("2026-08-06T12:00:00Z");
+
+ it("is false for a task with no due date", () => {
+ expect(isOverdue(task({ id: "a" }), now)).toBe(false);
+ });
+
+ it("is true only once the date has passed", () => {
+ expect(isOverdue({ due_at: "2026-08-06T11:59:00Z" }, now)).toBe(true);
+ expect(isOverdue({ due_at: "2026-08-06T12:01:00Z" }, now)).toBe(false);
+ });
+
+ it("is not overdue at the exact instant it falls due", () => {
+ // Pins `<` rather than `<=`: a task is late once the moment has passed,
+ // not at the moment itself.
+ expect(isOverdue({ due_at: "2026-08-06T12:00:00Z" }, now)).toBe(false);
+ });
+
+ it("treats an unparseable date as not overdue", () => {
+ // Better to under-alarm than to paint every task red because one row
+ // carried a malformed date.
+ expect(isOverdue({ due_at: "not a date" }, now)).toBe(false);
+ });
+});
+
+describe("sortMyTasks", () => {
+ it("orders dated work soonest-first", () => {
+ const rows = [
+ task({ id: "late", due_at: "2026-09-01T00:00:00Z" }),
+ task({ id: "soon", due_at: "2026-08-07T00:00:00Z" }),
+ ];
+ expect(sortMyTasks(rows).map((t) => t.id)).toEqual(["soon", "late"]);
+ });
+
+ it("puts undated work BELOW dated work, not above it", () => {
+ // The load-bearing half: a task nobody dated is not more urgent than one
+ // due tomorrow, and the opposite order is how the list stops being read.
+ const rows = [
+ task({ id: "undated", created_at: "2020-01-01T00:00:00Z" }),
+ task({ id: "dated", due_at: "2030-01-01T00:00:00Z" }),
+ ];
+ expect(sortMyTasks(rows).map((t) => t.id)).toEqual(["dated", "undated"]);
+ });
+
+ it("falls back to creation order for undated ties", () => {
+ const rows = [
+ task({ id: "newer", created_at: "2026-08-06T00:00:00Z" }),
+ task({ id: "older", created_at: "2026-01-01T00:00:00Z" }),
+ ];
+ expect(sortMyTasks(rows).map((t) => t.id)).toEqual(["older", "newer"]);
+ });
+
+ it("does not mutate its input", () => {
+ const rows = [task({ id: "b", due_at: "2030-01-01T00:00:00Z" }), task({ id: "a" })];
+ const before = rows.map((t) => t.id);
+ sortMyTasks(rows);
+ expect(rows.map((t) => t.id)).toEqual(before);
+ });
+});
+
+describe("groupByDisposition", () => {
+ it("shows every work lane even when empty", () => {
+ // An empty Next list means "you have triaged nothing into today" — a lane
+ // that vanishes when empty cannot say that.
+ const lanes = groupByDisposition([]);
+ expect(lanes.map((l) => l.lane)).toEqual([...DISPOSITION_LANES]);
+ });
+
+ it("routes each disposition to its own lane", () => {
+ const lanes = groupByDisposition([
+ task({ id: "i", disposition: "INBOX" }),
+ task({ id: "n", disposition: "NEXT" }),
+ task({ id: "w", disposition: "WAITING" }),
+ task({ id: "s", disposition: "SOMEDAY" }),
+ ]);
+ const byLane = Object.fromEntries(
+ lanes.map((l) => [l.lane, l.tasks.map((t) => t.id)])
+ );
+ expect(byLane).toMatchObject({
+ INBOX: ["i"],
+ NEXT: ["n"],
+ WAITING: ["w"],
+ SOMEDAY: ["s"],
+ });
+ });
+
+ it("files a non-lane disposition rather than dropping it", () => {
+ // REFERENCE is not a work lane, but a task that disappears from the only
+ // view a member has is worse than one shown in the wrong place.
+ const lanes = groupByDisposition([task({ id: "r", disposition: "REFERENCE" })]);
+ const other = lanes.find((l) => l.lane === OTHER_LANE);
+ expect(other?.tasks.map((t) => t.id)).toEqual(["r"]);
+ });
+
+ it("hides the filed lane when it is empty", () => {
+ expect(groupByDisposition([task({ id: "n" })]).some((l) => l.lane === OTHER_LANE)).toBe(
+ false
+ );
+ });
+
+ it("orders within each lane", () => {
+ const lanes = groupByDisposition([
+ task({ id: "late", disposition: "NEXT", due_at: "2030-01-01T00:00:00Z" }),
+ task({ id: "soon", disposition: "NEXT", due_at: "2026-08-07T00:00:00Z" }),
+ ]);
+ expect(lanes.find((l) => l.lane === "NEXT")?.tasks.map((t) => t.id)).toEqual([
+ "soon",
+ "late",
+ ]);
+ });
+});
+
+describe("untriagedCount", () => {
+ it("counts only the rows the member has never stated a disposition for", () => {
+ // The distinction the server preserves by deriving instead of storing: an
+ // assigned task reads as NEXT while still being untriaged.
+ // Deliberately lopsided — one untriaged against two triaged. A balanced
+ // fixture gives the same answer whichever side is counted, and lets an
+ // inverted predicate pass.
+ const rows = [
+ task({ id: "derived", disposition: "NEXT", is_triaged: false }),
+ task({ id: "stated", disposition: "NEXT", is_triaged: true }),
+ task({ id: "filed", disposition: "SOMEDAY", is_triaged: true }),
+ ];
+ expect(untriagedCount(rows)).toBe(1);
+ });
+});
+
+describe("twoMinuteTasks", () => {
+ it("selects only tasks explicitly marked two-minute", () => {
+ const rows = [
+ task({ id: "quick", is_two_minute: true }),
+ task({ id: "unset" }),
+ task({ id: "no", is_two_minute: false }),
+ ];
+ expect(twoMinuteTasks(rows).map((t) => t.id)).toEqual(["quick"]);
+ });
+});
+
+describe("actionLine", () => {
+ it("prefers the next action over the title", () => {
+ expect(actionLine(task({ id: "a", title: "Q3 pricing", next_action: "Email Priya" })))
+ .toBe("Email Priya");
+ });
+
+ it("falls back to the title when the next action is blank", () => {
+ expect(actionLine(task({ id: "a", title: "Q3 pricing", next_action: " " }))).toBe(
+ "Q3 pricing"
+ );
+ expect(actionLine(task({ id: "a", title: "Q3 pricing", next_action: null }))).toBe(
+ "Q3 pricing"
+ );
+ });
+});
diff --git a/workbench/control_plane/src/app/projects/lib/mywork.ts b/workbench/control_plane/src/app/projects/lib/mywork.ts
new file mode 100644
index 00000000..b1a18024
--- /dev/null
+++ b/workbench/control_plane/src/app/projects/lib/mywork.ts
@@ -0,0 +1,153 @@
+/**
+ * Projects · the personal lens — grouping and ordering for "My work".
+ *
+ * Spec: `ai-company-brain/specs/project_management_app.md` §3.11-§3.12, §6.1 ·
+ * **D-PM-6 (revised)** · ticket WS-27e.
+ *
+ * Pure functions only. The server already decided *which* rows are mine and
+ * what my effective disposition is (`GET /projects/my/inbox` derives it when I
+ * have not triaged); this module decides only how they are laid out. Keeping
+ * the derivation server-side and the layout here is what stops the browser
+ * growing a second, drifting answer to "what is my next action".
+ */
+
+import type { TaskRow } from "./api";
+
+/**
+ * A task as the personal lens sees it: the shared row plus **my** overlay.
+ *
+ * There is one task row and one status — `disposition` and the rest are mine
+ * alone, which is why two people can hold different ones for the same task.
+ */
+export interface MyTaskRow extends TaskRow {
+ /** Stated by me, or derived by the server when I have not triaged it. */
+ disposition: string;
+ /** False when the disposition above was derived rather than chosen. */
+ is_triaged: boolean;
+ next_action?: string | null;
+ context?: string | null;
+ energy?: string | null;
+ is_two_minute?: boolean;
+}
+
+/**
+ * The lanes My Work shows, in order.
+ *
+ * Deliberately four of the eight dispositions. `DONE`/`TRASH` are excluded by
+ * the endpoint unless asked for, and `PROJECT`/`REFERENCE` are filing states
+ * rather than work states — surfacing them as lanes would make the daily view
+ * a filing cabinet. They are still reachable, via {@link OTHER_LANE}.
+ */
+export const DISPOSITION_LANES = ["INBOX", "NEXT", "WAITING", "SOMEDAY"] as const;
+
+export type DispositionLane = (typeof DISPOSITION_LANES)[number];
+
+/** Where a disposition that is not a lane lands, so nothing is ever dropped. */
+export const OTHER_LANE = "OTHER";
+
+export const LANE_LABELS: Record = {
+ INBOX: "Inbox",
+ NEXT: "Next actions",
+ WAITING: "Waiting on",
+ SOMEDAY: "Someday",
+ OTHER: "Filed",
+};
+
+/**
+ * True when a task's due date has passed.
+ *
+ * `now` is a parameter rather than a `Date.now()` call so the boundary is
+ * testable — a function that reads the clock itself can only be tested by
+ * mocking time, and "is this overdue" is exactly the kind of off-by-a-day
+ * question that deserves a plain assertion.
+ */
+export function isOverdue(task: Pick, now: Date): boolean {
+ if (!task.due_at) return false;
+ const due = new Date(task.due_at);
+ return !Number.isNaN(due.getTime()) && due.getTime() < now.getTime();
+}
+
+/**
+ * Order within a lane: dated work first, soonest first; undated after it.
+ *
+ * Undated tasks sort **below** dated ones rather than above. A task nobody gave
+ * a date is not more urgent than one due tomorrow, and putting the undated pile
+ * on top is how a personal list stops being read.
+ */
+export function sortMyTasks(rows: readonly MyTaskRow[]): MyTaskRow[] {
+ return [...rows].sort((a, b) => {
+ const aDue = a.due_at ? new Date(a.due_at).getTime() : null;
+ const bDue = b.due_at ? new Date(b.due_at).getTime() : null;
+ const aHas = aDue !== null && !Number.isNaN(aDue);
+ const bHas = bDue !== null && !Number.isNaN(bDue);
+ if (aHas && bHas && aDue !== bDue) return (aDue as number) - (bDue as number);
+ if (aHas !== bHas) return aHas ? -1 : 1;
+ return String(a.created_at ?? "").localeCompare(String(b.created_at ?? ""));
+ });
+}
+
+/**
+ * Split my work into its lanes, each already ordered.
+ *
+ * Every lane is present even when empty — an empty Next list is a real and
+ * important state ("you have triaged nothing into today"), and a lane that
+ * disappears when empty cannot say that.
+ */
+export function groupByDisposition(
+ rows: readonly MyTaskRow[]
+): Array<{ lane: string; label: string; tasks: MyTaskRow[] }> {
+ const buckets = new Map();
+ for (const lane of DISPOSITION_LANES) buckets.set(lane, []);
+ buckets.set(OTHER_LANE, []);
+
+ for (const row of rows) {
+ const key = (DISPOSITION_LANES as readonly string[]).includes(row.disposition)
+ ? row.disposition
+ : OTHER_LANE;
+ buckets.get(key)?.push(row);
+ }
+
+ const lanes = [...DISPOSITION_LANES, OTHER_LANE].map((lane) => ({
+ lane,
+ label: LANE_LABELS[lane] ?? lane,
+ tasks: sortMyTasks(buckets.get(lane) ?? []),
+ }));
+ // "Filed" is the only lane hidden when empty: unlike the four work lanes, an
+ // empty one says nothing a member needs to know.
+ return lanes.filter((l) => l.lane !== OTHER_LANE || l.tasks.length > 0);
+}
+
+/**
+ * How many of my tasks I have never actually looked at.
+ *
+ * The Weekly Review's whole question, and the reason the server derives
+ * dispositions instead of storing them on first read: a stored default would
+ * make every untriaged task indistinguishable from a triaged one and this
+ * count would be permanently zero.
+ */
+export function untriagedCount(rows: readonly MyTaskRow[]): number {
+ return rows.filter((r) => !r.is_triaged).length;
+}
+
+/**
+ * Tasks I marked as taking under two minutes.
+ *
+ * GTD's two-minute rule is the one piece of the method that pays off inside a
+ * single sitting, so it gets its own surface rather than being a badge someone
+ * has to notice.
+ */
+export function twoMinuteTasks(rows: readonly MyTaskRow[]): MyTaskRow[] {
+ return sortMyTasks(rows.filter((r) => r.is_two_minute === true));
+}
+
+/**
+ * What to show as the actionable line for a task.
+ *
+ * The next action when I wrote one, the title otherwise. "Email Priya the
+ * revised quote" is a next action; "Q3 pricing" is a project title, and a list
+ * of project titles is a list nobody can start work from.
+ */
+export function actionLine(task: MyTaskRow): string {
+ const next = (task.next_action ?? "").trim();
+ return next || task.title;
+}
diff --git a/workbench/control_plane/src/app/projects/page.tsx b/workbench/control_plane/src/app/projects/page.tsx
index d6cb4ab7..71e50c99 100644
--- a/workbench/control_plane/src/app/projects/page.tsx
+++ b/workbench/control_plane/src/app/projects/page.tsx
@@ -21,6 +21,7 @@ import {
type TaskRow,
projectsApi,
} from "./lib/api";
+import { MyWork } from "./components/MyWork";
import { ProjectTree } from "./components/ProjectTree";
import { TaskBoard } from "./components/TaskBoard";
import { TaskList } from "./components/TaskList";
@@ -40,6 +41,12 @@ function ProjectsWorkspace() {
const [statuses, setStatuses] = useState([]);
const [tasks, setTasks] = useState([]);
const [openTask, setOpenTask] = useState(null);
+ // The panel's statuses are held apart from the selected project's, because a
+ // task opened from My work can belong to a project that is not selected —
+ // and a panel offering another project's statuses would offer transitions
+ // that do not exist.
+ const [panelStatuses, setPanelStatuses] = useState([]);
+ const [mine, setMine] = useState(false);
const [mode, setMode] = useState("board");
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
@@ -112,6 +119,28 @@ function ProjectsWorkspace() {
if (selected) void loadProject(selected);
}, [selected, loadProject]);
+ // Opening a task always resolves ITS project's statuses. From the board that
+ // is the set already loaded; from My work it may be any project the member
+ // is assigned into, so it is fetched.
+ const openWithStatuses = useCallback(
+ async (task: TaskRow) => {
+ setOpenTask(task);
+ if (selected && task.root_project_id === selected.id) {
+ setPanelStatuses(statuses);
+ return;
+ }
+ try {
+ const res = await projectsApi.statuses(task.root_project_id);
+ setPanelStatuses(res.rows);
+ } catch {
+ // A panel with no status options is degraded but usable; failing to
+ // open the task at all because its lanes could not be listed is not.
+ setPanelStatuses([]);
+ }
+ },
+ [selected, statuses]
+ );
+
async function handleDrop(
task: TaskRow,
writes: ReturnType,
@@ -151,10 +180,25 @@ function ProjectsWorkspace() {
{center ? `${center} Center's slice` : "Every department you can see"}
+ {/* My work sits ABOVE the tree, not in a separate app. The personal
+ lens is a view of the same store — putting it anywhere else would
+ re-teach the split that D-PM-6 was revised to remove. */}
+ setMine(true)}
+ className={`mb-2 w-full rounded-md px-2 py-1.5 text-left text-sm ${
+ mine ? "bg-accent text-accent-foreground" : "text-foreground hover:bg-muted"
+ }`}
+ >
+ My work
+ {
+ setMine(false);
+ setSelected(project);
+ }}
/>
@@ -162,15 +206,20 @@ function ProjectsWorkspace() {
- {selected?.name ?? "No project selected"}
+ {mine ? "My work" : selected?.name ?? "No project selected"}
- {selected?.description ? (
+ {mine ? (
+
+ Assigned to you, plus your own — one store, so finishing here
+ finishes it on the board.
+
@@ -215,7 +270,7 @@ function ProjectsWorkspace() {
{openTask ? (
setOpenTask(null)}
onChanged={(fresh) => {
setOpenTask(fresh);
From 87ce5907389751a04ce01f5779179393e37fb1db Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 6 Aug 2026 11:16:30 +0000
Subject: [PATCH 04/10] =?UTF-8?q?feat(WS-27d):=20the=20Projects=20UI=20can?=
=?UTF-8?q?=20create=20things=20=E2=80=94=20projects,=20tasks,=20subtasks,?=
=?UTF-8?q?=20assignees?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
WS-27d shipped a UI that could read and drag but never create. `createProject`,
`createTask` and `setAssignees` were in the client and wired to nothing, so a
member could only work with rows a ClickUp import had already put there — which
makes the app unusable for the thing it is for, and makes WS-27g's "stop using
ClickUp" impossible on its face.
Four surfaces, each placed where the answer already is:
- New department from the sidebar header; new subproject from a + on the node
itself. The parent is on screen — a dialog asking "which parent?" is how a
fifty-node tree acquires mis-parented rows.
- New task from a one-field row above the board. Status is deliberately not
sent: create_task picks the project's default, so the browser never has to
know which lane a new task starts in.
- Subtask from the task panel. A subtask is a task with a parent — one
endpoint, one table, so it inherits statuses, timeline and assignment whole.
- Assignees as removable chips plus one input.
The last one is where D-PM-4 stops being a schema note. An agent and a person
go in the same field and the only difference on screen is an icon, so handing
work to an agent is literally the same gesture as handing it to a colleague.
That is also the precondition for WS-27f's dispatch being reachable at all,
since pm.task.assigned is what it keys off.
Two details in lib/assignees.ts that are load-bearing rather than tidy:
- withAssignee returns the SAME array when the assignee is already present, and
the caller skips the PUT on that identity. A re-assert would emit
pm.task.assigned and re-dispatch an agent run.
- parseAssignees splits on commas, semicolons and newlines but never on spaces.
A pasted "Priya " would otherwise shred into tokens that assign
work to nobody.
classify() is a hint, not a rule: the server accepts any non-empty string, so
an unrecognised token gets a muted chip and a tooltip rather than a refusal —
inventing a client-side rule the API does not enforce is how a UI starts
rejecting valid data. The failure worth surfacing is the typo that silently
assigns nothing.
Also fixed while here: a new subtask was invisible on the board until you
switched projects, because onChanged merges one known task and a subtask is a
row the board has never seen. The panel now signals a real reload for that case
only, so a comment does not cost a project fetch.
Verification: 349 vitest cases green (was 332), tsc --noEmit clean, eslint adds
zero errors over the pre-existing baseline. Seven mutants measured red against
lib/assignees.ts and reverted byte-identical.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
---
.../specs/project_management_app.md | 26 +++
ai-company-brain/work_plan.md | 2 +-
.../app/projects/components/ProjectTree.tsx | 24 ++-
.../src/app/projects/components/TaskPanel.tsx | 150 +++++++++++++++++-
.../src/app/projects/lib/assignees.test.ts | 114 +++++++++++++
.../src/app/projects/lib/assignees.ts | 104 ++++++++++++
.../control_plane/src/app/projects/page.tsx | 120 +++++++++++++-
7 files changed, 524 insertions(+), 16 deletions(-)
create mode 100644 workbench/control_plane/src/app/projects/lib/assignees.test.ts
create mode 100644 workbench/control_plane/src/app/projects/lib/assignees.ts
diff --git a/ai-company-brain/specs/project_management_app.md b/ai-company-brain/specs/project_management_app.md
index b281b71f..b5fb0a54 100644
--- a/ai-company-brain/specs/project_management_app.md
+++ b/ai-company-brain/specs/project_management_app.md
@@ -758,6 +758,32 @@ and cross-column drags patch the `column_by` field; (3) nav/access registration
slice pre-filters by its group — with a vitest asserting the `?center=` param filters
presentation only.
+**Authoring landed 2026-08-06** (`lib/assignees.ts` + edits to `page.tsx`, `ProjectTree`,
+`TaskPanel`; 17 vitest cases, 7 mutants red). WS-27d shipped a UI that could **read and
+drag but never create**: `createProject`, `createTask` and `setAssignees` existed in the
+client and were wired to nothing, so a member could only work with rows a ClickUp import
+had put there. Four surfaces close it, each placed where the answer already is:
+
+- **New department** from the sidebar header, **new subproject** from a `+` on the node
+ itself — the parent is on screen, and a dialog that asks "which parent?" is how a
+ fifty-node tree acquires mis-parented rows.
+- **New task** from a one-field row above the board. Status is deliberately **not sent**:
+ the API picks the project's default (`create_task`), so the browser never has to know
+ which lane a new task starts in.
+- **Subtask** from the task panel. A subtask is a task with a parent (§3.5) — one endpoint,
+ one table, so it inherits statuses, timeline and assignment whole.
+- **Assignees** as removable chips plus one input. This is where **D-PM-4 stops being a
+ schema note**: an agent and a person go in the same field, and the only difference on
+ screen is an icon. Handing work to an agent is now literally the same gesture as handing
+ it to a colleague — which is the precondition for WS-27f's dispatch being reachable at
+ all, since `pm.task.assigned` is what it keys off.
+
+Two details worth keeping: `withAssignee` returns the **same array** when the assignee is
+already present, and the caller skips the PUT on that identity — a re-assert must not emit
+`pm.task.assigned` and re-dispatch an agent run. And `parseAssignees` splits on commas,
+semicolons and newlines but **never on spaces**, because a pasted `Priya `
+would otherwise shred into tokens that assign work to nobody.
+
**WS-27e — the personal lens (one store).** ✅ **BUILT 2026-08-06**
(migration `147_projects_personal.sql`, `routes/projects/personal.py`; 31 hermetic cases,
6 mutants red). **Its shape changed with D-PM-6's revision** — this was specced as a mirror
diff --git a/ai-company-brain/work_plan.md b/ai-company-brain/work_plan.md
index 3afd86a9..8fde2e25 100644
--- a/ai-company-brain/work_plan.md
+++ b/ai-company-brain/work_plan.md
@@ -148,7 +148,7 @@ looks. Full statements live in `FOUNDATION_BUILDOUT_CHECKLIST.md`.
| WS-21 | **Calendar F2/F3** (`gtd_time_blocks`, email windows, mobile timeline, external sync) | `calendar_focus_os.md` **§9** (canonical for all F2/F3 acceptance; **§5** canonical for `gtd_time_blocks`) + `calendar_timeboxing.md` **§13** (canonical for P4) — both rewritten 2026-08-03 | 🟡 partial | **Re-audited 2026-08-03 → GO-NARROWED.** P3 roll-over was already shipped (released-to-unscheduled, mig 78 + `start_auto_rollover`). ~~"ideal week"~~ **struck — substantially shipped** (mig 98 + settings round-trip + editor + grid render + packer honouring + 2 unit tests); only the unused-focus-window / template-adherence gap remains (§9.6). **Breaks-in-the-packer SHIPPED 2026-07-23** (`80722e17`, mig **97**) as *packer geometry* — a widened buffer plus lunch protection, **a gap, not a `kind='break'` row**, which is exactly why F2 survives (§5 residual 4, now closed). **The 2026-08-01 acceptance was satisfiable by doing nothing** — 2 of its 3 `gtd_time_blocks` clauses were already green against shipped code; they are deleted and replaced with four that all fail today. **`gtd_time_blocks` is 4 slices, not 1 PR** (§9.1 S1–S4): the "non-breaking `TimeBlock[]` swap" claim was **FALSE** — the measured blast radius is 17 TS files + 3 gateway modules + `apps/skills/skill-task-gtd/` + `apps/agents/agent-task-manager/`. **Focus Shield is AGENT-SAFE, not owner-gated** (§9.5) — it needs a design, not a credential; do not dispatch on §4.1 prose alone. **Top-5 outcomes (Horizons) — DO NOT DISPATCH:** it collides with WS-18; §4 assigns it here, and WS-18's title keeps it struck. **Verify by naming test files — never `pytest tests/unit -k calendar`**: `-k` still collects the whole directory, and whole-directory collection hangs on the Windows box. **Dispatchable today:** §9.1 S1 · the ritual-stamp localStorage residue (§9.1 done-when 4, independently shippable) · §9.6 · §9.7. **OWNER-GATE:** external sync (§9.11 / timeboxing §13 P4) needs Google Calendar and/or Microsoft Graph OAuth client credentials provisioned on the VPS. |
| WS-22 | **draw.io** (all 13 tickets open, nothing built) | `drawio_integration.md` | 🟡 owner | Best acceptance structure in the corpus; needs an owner and re-verified anchors (~5 weeks stale). ST-DRW-02 is a decision gate. |
| **WS-26** | **CRM app — native CRM + Zoho retirement** *(minted 2026-08-05)* | `specs/crm_app.md` | ✅ **a + b + c BUILT** · 🟢 d dispatchable | Research pass 2026-08-05: `frappe/crm` (AGPL — **concepts only, no code**), `trycompai/crm` (MIT), full-tree Zoho sweep. **Zoho today is a read-only nightly mirror** into the Phase-0 graph tables (`person`/`customer`/`deal`) with no UI, no write path, and **no Leads pull** — so leaving Zoho is import-and-retire, not a live cutover. Spine: Frappe's lead→convert→deal+contact+organization with **statuses-as-data** (color/position/type/probability); trycompai's single activity-spine table + `source` provenance + `last_activity_at` discipline. **BO-10 contribution: WS-26a adds the shared engine seam (`gateway/db.py::get_engine()`, tasks converted as proof) instead of engine 13.** Tickets: **a** schema + feature registration + core API — **BUILT 2026-08-05** (mig `144_crm.sql`, `feature:crm`, `gateway/db.py` seam + tasks converted, `routes/crm/`; **not deployed — the migration has not been applied anywhere**) · **b** **Zoho two-way sync — BUILT 2026-08-05** (branch `ws-26b-zoho-sync`: `list_leads` + `list_deleted` on the read client, the single write client `ingestion/sources/zoho/writer.py` with one grep-asserted caller, mig `145_crm_zoho_sync.sql` (dirty columns + `crm_zoho_tombstones` + `crm_sync_cursors`), `routes/crm/{import_zoho,sync_zoho,broker_handlers}.py`, `crm.zoho_*` broker handlers registered from `main.py`, 80 new hermetic tests). *(Re-scoped 2026-08-05, owner-directed D-CRM-7: "faithful two way sync until we do away with Zoho entirely" — coexistence is bidirectional, not import-once.)* **Built, never run: `CRM_ZOHO_SYNC` ships OFF, mig 145 is unapplied, and enabling/backfilling/hand-running against prod is OWNER-GATE §6 — it writes the live Zoho tenant.** WS-1's "no Zoho write path anywhere" clause was corrected in the same change (done-when 6) · **c** UI + the API addendum — **BUILT 2026-08-05** on branch `ws-26c-crm-ui` atop 26a and **merged with b into `ws-26-crm-app` 2026-08-06** (`/crm` app + BFF proxy; the three frontend registration points with `CenterApp` re-typed so `live ⇒ href` is a compile error; `routes/crm/deal_contacts.py` with one-primary-per-deal enforced on the shared `core.link_deal_contact` seam the convert path now also uses — 26b's importer is the one excepted writer and computes `is_primary` in-statement so a backfill can never demote a hand-set primary; `organization_name` on the deal list + board via a derived-table LEFT JOIN; the three review residuals — `?status_id` on a pipeline-less entity → 422, explicit `null` on a defaulted NOT NULL column → 422 not a driver 500, and a hand-edited `lead_name` surviving a name-field PATCH. **Still not deployed:** migrations 144 and 145 have not been applied anywhere, so live rendering, drag persistence and deep links are owner-verified after they apply) · **d** integrations — email address-join timeline, WhatsApp `entity_ref`, `agent-crm`; `CRM_AUTO_LEAD` ships OFF (🟢 except the flip) · **e** cutover + retirement inventory + **Zoho refresh-token revoke, which executes part of WS-2's standing P0** (🔴 OWNER-GATE end-to-end). Data-visibility departure recorded: org-visible to `feature:crm` holders in v1, `owner_email` is assignment not ACL (D-CRM-3; workflows v1 is the precedent) — revisit at WS-14 `group:` grants / colleague #1. |
-| **WS-27** | **Projects app — native project management + ClickUp retirement** *(minted 2026-08-05)* | `specs/project_management_app.md` | ✅ **a + b + d + e BUILT 2026-08-06** · 🟢 f dispatchable · 🟡 c gated · 🟡 h sequenced | Research pass 2026-08-05: `Paca-AI/paca` v0.11.0 (Apache-2.0 — **patterns adopted, no code translated**; findings + the adopt/adapt/refuse table live in `specs/paca_pm_research_2026-08.md`, reference-only), plus a full-tree ClickUp sweep. **ClickUp today is TWO independent systems** — the Phase-0 graph mirror (read-only, shallow) *and* the per-user Tasks-app connector with a **live broker-gated write path** — so leaving ClickUp is coexistence-sync-then-invert, **not** WS-26's import-and-retire; the constraint-8 inversion is staged and recorded in spec §7. Spine: Paca's two-self-FK hierarchy (departments→projects→subprojects→tasks→subtasks as `pm_projects` + `pm_tasks`, types-as-data with the Epic-root rule), statuses-as-data with a semantic `category` (D-CRM-2 convergence), per-view fractional ordering (`pm_view_task_positions` — what lets People-Center and Center-slice boards order the same task differently), and a single activity spine. **First data-scoped app:** `pm_project_grants` on the shipped `email\|group:\|org` vocabulary (D12; sibling of C1's D13, which is unchanged), 404-not-403, and the full-portfolio view gives D14's zero-consumer `data:org:read` its **first consumer**. **Three owner answers recorded 2026-08-06 as D-PM-8/9/10, and two of them changed the build:** **D-PM-8** no portfolio/program layer — grants are the only grouping axis, a cross-department project simply carries several (a `pm_programs` table stays purely additive if wanted later); **D-PM-9** agent edits to ClickUp-linked tasks are treated **exactly like human edits** (*the agent proposed queueing agent-originated pushes for approval and was overruled*) — so during coexistence a mistaken agent edit reaches the live workspace with no human in between while `ACTION_BROKER_ENFORCE` is off; bounded by attribution (`agent:`), timeline-reversibility, and the fact that the enforce flip converts the whole class to queue-on-approval. Read D-PM-9's Cost paragraph before building WS-27f; **D-PM-10** ClickUp Spaces map to Centers **explicitly**, from agent-proposed suggestions (assignee-overlap → name match → EVAL-LOCKED content classification), owner-confirmed, applied as `group:` grants — and an **unmapped Space still imports in full with no group grant**, staying reachable in `/projects` for `data:org:read` holders and its assignees. This supersedes the "pilot vs all Spaces" framing: scope is now a per-Space decision the plan step surfaces, so a pilot and a full import are one code path. Tickets: **a** schema + `feature:projects` both sides + core API on the `gateway/db.py` seam — **BUILT 2026-08-06** (mig `146_projects.sql`, `routes/projects/` with zero `create_async_engine` calls, 115 hermetic cases + 5 mutants measured red; **not deployed — the migration has not been applied anywhere**) · **b** ClickUp org importer **+ the Space→Center mapping plan** — **BUILT 2026-08-06** (`routes/projects/mapping.py` + `import_clickup.py`; `POST /projects/import/clickup/plan` proposes and writes nothing, `POST /projects/import/clickup` applies the confirmed mapping; 25 hermetic cases, 4 mutants red incl. *applying the suggestion instead of the confirmed mapping*; **neither endpoint has been run — prod execution stays OWNER-GATE, §6**) · **c** two-way coexistence sync — three-way field merge, conflicts logged to the timeline (🟡 **blocked on WS-1's BO-1a + BO-1b, named prerequisites**; enabling push is OWNER-GATE) · **d** UI + Center (app + scope) projections, no forks — **BUILT 2026-08-06** (`src/app/projects/` tree + board + list + task panel + timeline, BFF proxy, nav/access registration, all six Centers linking at the SAME `/projects` path and differing only by `?center=`; 34 vitest cases incl. a registration fence, 6 mutants red) · **e** the personal lens — **BUILT 2026-08-06** and **its shape changed**: `D-PM-6` was revised (owner-directed — *"the personal task manager should be a proper extension … a cohesive whole"*) from a mirror into **one store**. `pm_tasks` is THE task table; private work is a personal project (`pm_projects.personal_owner`); the GTD overlay is **per-member** (`pm_task_personal`, mig `147_projects_personal.sql`) so two assignees can hold different dispositions. Assignment is no longer a sync — the inbox row IS the project row, and completing it there moves the shared status. 31 hermetic cases, 6 mutants red. **Its surface landed the same day** — "My work" above the project tree in the SAME app (`components/MyWork.tsx` + `lib/mywork.ts`, 17 vitest cases, 7 mutants red): capture-first, four work lanes that render even when empty, untriaged counted in the header (the Weekly Review's question, answerable only because dispositions are derived not stored), and a completion checkbox that moves the **shared** status. e had shipped API-only, so the cohesion the revision bought was true in the schema and invisible to a member. One repair it forced: `TaskPanel` read the *selected* project's statuses, wrong for a task opened from My work — now resolved from the task's own root project. **Cost accepted: `gtd_items` becomes legacy and WS-27h retires it** · **f** automation + agent dispatch — `pm.*` events into the existing `emit_event → dispatch_event` path; node types land in `workflows_app.md` per D6; assign-to-`agent:` dispatches an orchestrator run visible on the task timeline (🟢) · **g** cutover + retirement inventory (both ClickUp systems) + token revoke + the root-`AGENTS.md` constraint-8 amendment (🔴 OWNER-GATE end-to-end) · **h** `gtd_items` retirement — the cost D-PM-6's revision accepted: union read, row migration into `pm_tasks` + `pm_task_personal`, then `items.py`'s 27 owner-scoped predicates retire with the table they scope (🟡 after e; the data move is 🔴 OWNER-GATE — it rewrites the owner's live task store). |
+| **WS-27** | **Projects app — native project management + ClickUp retirement** *(minted 2026-08-05)* | `specs/project_management_app.md` | ✅ **a + b + d + e BUILT 2026-08-06** · 🟢 f dispatchable · 🟡 c gated · 🟡 h sequenced | Research pass 2026-08-05: `Paca-AI/paca` v0.11.0 (Apache-2.0 — **patterns adopted, no code translated**; findings + the adopt/adapt/refuse table live in `specs/paca_pm_research_2026-08.md`, reference-only), plus a full-tree ClickUp sweep. **ClickUp today is TWO independent systems** — the Phase-0 graph mirror (read-only, shallow) *and* the per-user Tasks-app connector with a **live broker-gated write path** — so leaving ClickUp is coexistence-sync-then-invert, **not** WS-26's import-and-retire; the constraint-8 inversion is staged and recorded in spec §7. Spine: Paca's two-self-FK hierarchy (departments→projects→subprojects→tasks→subtasks as `pm_projects` + `pm_tasks`, types-as-data with the Epic-root rule), statuses-as-data with a semantic `category` (D-CRM-2 convergence), per-view fractional ordering (`pm_view_task_positions` — what lets People-Center and Center-slice boards order the same task differently), and a single activity spine. **First data-scoped app:** `pm_project_grants` on the shipped `email\|group:\|org` vocabulary (D12; sibling of C1's D13, which is unchanged), 404-not-403, and the full-portfolio view gives D14's zero-consumer `data:org:read` its **first consumer**. **Three owner answers recorded 2026-08-06 as D-PM-8/9/10, and two of them changed the build:** **D-PM-8** no portfolio/program layer — grants are the only grouping axis, a cross-department project simply carries several (a `pm_programs` table stays purely additive if wanted later); **D-PM-9** agent edits to ClickUp-linked tasks are treated **exactly like human edits** (*the agent proposed queueing agent-originated pushes for approval and was overruled*) — so during coexistence a mistaken agent edit reaches the live workspace with no human in between while `ACTION_BROKER_ENFORCE` is off; bounded by attribution (`agent:`), timeline-reversibility, and the fact that the enforce flip converts the whole class to queue-on-approval. Read D-PM-9's Cost paragraph before building WS-27f; **D-PM-10** ClickUp Spaces map to Centers **explicitly**, from agent-proposed suggestions (assignee-overlap → name match → EVAL-LOCKED content classification), owner-confirmed, applied as `group:` grants — and an **unmapped Space still imports in full with no group grant**, staying reachable in `/projects` for `data:org:read` holders and its assignees. This supersedes the "pilot vs all Spaces" framing: scope is now a per-Space decision the plan step surfaces, so a pilot and a full import are one code path. Tickets: **a** schema + `feature:projects` both sides + core API on the `gateway/db.py` seam — **BUILT 2026-08-06** (mig `146_projects.sql`, `routes/projects/` with zero `create_async_engine` calls, 115 hermetic cases + 5 mutants measured red; **not deployed — the migration has not been applied anywhere**) · **b** ClickUp org importer **+ the Space→Center mapping plan** — **BUILT 2026-08-06** (`routes/projects/mapping.py` + `import_clickup.py`; `POST /projects/import/clickup/plan` proposes and writes nothing, `POST /projects/import/clickup` applies the confirmed mapping; 25 hermetic cases, 4 mutants red incl. *applying the suggestion instead of the confirmed mapping*; **neither endpoint has been run — prod execution stays OWNER-GATE, §6**) · **c** two-way coexistence sync — three-way field merge, conflicts logged to the timeline (🟡 **blocked on WS-1's BO-1a + BO-1b, named prerequisites**; enabling push is OWNER-GATE) · **d** UI + Center (app + scope) projections, no forks — **BUILT 2026-08-06** (`src/app/projects/` tree + board + list + task panel + timeline, BFF proxy, nav/access registration, all six Centers linking at the SAME `/projects` path and differing only by `?center=`; 34 vitest cases incl. a registration fence, 6 mutants red) · **authoring landed 2026-08-06** — d shipped a UI that could read and drag but never **create**, so a member could only work with rows a ClickUp import had put there: new department / subproject (from the node, where the parent already is), new task (status not sent — the API picks the project's default), subtask from the panel, and **assignees as chips where an agent and a person share one field** (`lib/assignees.ts`, 17 vitest cases, 7 mutants red). That last one is where D-PM-4 stops being a schema note and is the precondition for WS-27f's dispatch being reachable at all · **e** the personal lens — **BUILT 2026-08-06** and **its shape changed**: `D-PM-6` was revised (owner-directed — *"the personal task manager should be a proper extension … a cohesive whole"*) from a mirror into **one store**. `pm_tasks` is THE task table; private work is a personal project (`pm_projects.personal_owner`); the GTD overlay is **per-member** (`pm_task_personal`, mig `147_projects_personal.sql`) so two assignees can hold different dispositions. Assignment is no longer a sync — the inbox row IS the project row, and completing it there moves the shared status. 31 hermetic cases, 6 mutants red. **Its surface landed the same day** — "My work" above the project tree in the SAME app (`components/MyWork.tsx` + `lib/mywork.ts`, 17 vitest cases, 7 mutants red): capture-first, four work lanes that render even when empty, untriaged counted in the header (the Weekly Review's question, answerable only because dispositions are derived not stored), and a completion checkbox that moves the **shared** status. e had shipped API-only, so the cohesion the revision bought was true in the schema and invisible to a member. One repair it forced: `TaskPanel` read the *selected* project's statuses, wrong for a task opened from My work — now resolved from the task's own root project. **Cost accepted: `gtd_items` becomes legacy and WS-27h retires it** · **f** automation + agent dispatch — `pm.*` events into the existing `emit_event → dispatch_event` path; node types land in `workflows_app.md` per D6; assign-to-`agent:` dispatches an orchestrator run visible on the task timeline (🟢) · **g** cutover + retirement inventory (both ClickUp systems) + token revoke + the root-`AGENTS.md` constraint-8 amendment (🔴 OWNER-GATE end-to-end) · **h** `gtd_items` retirement — the cost D-PM-6's revision accepted: union read, row migration into `pm_tasks` + `pm_task_personal`, then `items.py`'s 27 owner-scoped predicates retire with the table they scope (🟡 after e; the data move is 🔴 OWNER-GATE — it rewrites the owner's live task store). |
| **WS-28** | **People Center — directory, org chart, and the assignment seam** *(minted 2026-08-06)* | `specs/people_center_app.md` | 🔲 spec'd · 🟢 a–e dispatchable · 🔴 f owner-gate | Scope owner-set 2026-08-06: **directory, skills, org chart, capacity, seats/roles — exactly what assignment and planning need**; leave/onboarding/hiring are named as later phases so their absence is a decision. **The fact this spec exists to settle:** there are TWO people stores and that is deliberate — `app_user` answers *can they sign in and what may they see*, `gtd_people` answers *who are they and what can they do*, and the directory must include people with **no login** (contractors), which is why the Projects app's assignee is a plain string. They join on lowercased email, and **P-1 fixes that join before it is relied on**: migration 49 made `name` UNIQUE and left `email` unconstrained, so today two rows may share an address and an email→person join is ambiguous. Surfaces: directory (honouring WS-24 N4's HR projection, with a *restricted* empty state distinct from *none*) · person page · org chart from `manager_id` with a Center overlay that **shows** department/group mismatches rather than smoothing them · capability search over stated skills → résumé evidence → the existing `capability_embedding`, which **suggests and never assigns** · seats & roles matrix (read + propose; applying a membership change stays owner-gated per §6 (d)). Closes WS-13's outstanding *People directory read view* item. Tickets **a** key-shape fix (🟢) · **b** directory + person page (🟢) · **c** org chart (🟢) · **d** capability search (🟢, ranking EVAL-LOCKED) · **e** the Projects seams — directory-backed assignee picker listing agents and directory-only people, capacity derived from open assigned tasks (🟢) · **f** seats & roles writes (🔴 OWNER-GATE). |
---
diff --git a/workbench/control_plane/src/app/projects/components/ProjectTree.tsx b/workbench/control_plane/src/app/projects/components/ProjectTree.tsx
index 1d5c7390..0cc609e9 100644
--- a/workbench/control_plane/src/app/projects/components/ProjectTree.tsx
+++ b/workbench/control_plane/src/app/projects/components/ProjectTree.tsx
@@ -7,7 +7,7 @@
* one recursive list rather than three panes — the indentation IS the
* hierarchy.
*/
-import { ChevronDown, ChevronRight, FolderKanban } from "lucide-react";
+import { ChevronDown, ChevronRight, FolderKanban, Plus } from "lucide-react";
import { useState } from "react";
import type { ProjectRow } from "../lib/api";
@@ -16,6 +16,8 @@ interface Props {
roots: ProjectRow[];
selectedId: string | null;
onSelect: (project: ProjectRow) => void;
+ /** Start creating a subproject under this node. Omitted = read-only tree. */
+ onAddChild?: (parent: ProjectRow) => void;
}
function Node({
@@ -23,11 +25,13 @@ function Node({
depth,
selectedId,
onSelect,
+ onAddChild,
}: {
node: ProjectRow;
depth: number;
selectedId: string | null;
onSelect: (project: ProjectRow) => void;
+ onAddChild?: (parent: ProjectRow) => void;
}) {
const [open, setOpen] = useState(depth < 1);
const children = node.children ?? [];
@@ -75,6 +79,20 @@ function Node({
) : null}
+ {onAddChild ? (
+ // A subproject is created HERE rather than from a dialog that asks
+ // "which parent?" — the answer is already on screen, and asking for
+ // it again is how a tree with fifty nodes gets mis-parented rows.
+ onAddChild(node)}
+ className="shrink-0 rounded p-0.5 text-muted-foreground hover:bg-background/60"
+ >
+
+
+ ) : null}
@@ -93,7 +112,7 @@ function Node({
);
}
-export function ProjectTree({ roots, selectedId, onSelect }: Props) {
+export function ProjectTree({ roots, selectedId, onSelect, onAddChild }: Props) {
if (roots.length === 0) {
return (
@@ -110,6 +129,7 @@ export function ProjectTree({ roots, selectedId, onSelect }: Props) {
depth={0}
selectedId={selectedId}
onSelect={onSelect}
+ onAddChild={onAddChild}
/>
))}
diff --git a/workbench/control_plane/src/app/projects/components/TaskPanel.tsx b/workbench/control_plane/src/app/projects/components/TaskPanel.tsx
index 13bd7a5b..bf5086c6 100644
--- a/workbench/control_plane/src/app/projects/components/TaskPanel.tsx
+++ b/workbench/control_plane/src/app/projects/components/TaskPanel.tsx
@@ -7,7 +7,7 @@
* (§3.8) — the timeline shows a status change, an assignment, an agent run and
* a comment in the same stream, which is the point of the shared spine.
*/
-import { X } from "lucide-react";
+import { Bot, X } from "lucide-react";
import { useEffect, useState } from "react";
import {
@@ -16,12 +16,25 @@ import {
type TaskRow,
projectsApi,
} from "../lib/api";
+import {
+ assigneeLabel,
+ classify,
+ parseAssignees,
+ withAssignee,
+ withoutAssignee,
+} from "../lib/assignees";
interface Props {
task: TaskRow;
statuses: StatusRow[];
onClose: () => void;
onChanged: (task: TaskRow) => void;
+ /**
+ * Fired when this panel adds a row the surrounding list does not know about.
+ * `onChanged` merges one task; a new subtask is a task the board has never
+ * seen, so it needs a real reload rather than a merge.
+ */
+ onTaskAdded?: () => void;
}
function describe(activity: ActivityRow): string {
@@ -52,11 +65,20 @@ function describe(activity: ActivityRow): string {
}
}
-export function TaskPanel({ task, statuses, onClose, onChanged }: Props) {
+export function TaskPanel({
+ task,
+ statuses,
+ onClose,
+ onChanged,
+ onTaskAdded,
+}: Props) {
const [timeline, setTimeline] = useState([]);
const [comment, setComment] = useState("");
+ const [assignee, setAssignee] = useState("");
+ const [subtask, setSubtask] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
+ const assignees = task.assignees ?? [];
useEffect(() => {
let live = true;
@@ -93,6 +115,57 @@ export function TaskPanel({ task, statuses, onClose, onChanged }: Props) {
}
}
+ async function saveAssignees(next: string[]) {
+ setBusy(true);
+ setError(null);
+ try {
+ await projectsApi.setAssignees(task.id, next);
+ await reload();
+ } catch (err) {
+ setError(String((err as Error).message));
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ async function addAssignees() {
+ // A whole pasted list at once, not one at a time: the PUT replaces the set
+ // anyway, so batching them is one event rather than N.
+ let next = assignees;
+ for (const who of parseAssignees(assignee)) next = withAssignee(next, who);
+ // Identity means nothing changed — skipping the PUT is what stops a
+ // re-assert emitting pm.task.assigned and re-dispatching an agent run.
+ if (next === assignees) {
+ setAssignee("");
+ return;
+ }
+ setAssignee("");
+ await saveAssignees(next);
+ }
+
+ async function addSubtask() {
+ const title = subtask.trim();
+ if (!title) return;
+ setBusy(true);
+ setError(null);
+ try {
+ // A subtask is a task with a parent (§3.5) — no second endpoint and no
+ // second table, so it inherits statuses, timeline and assignment whole.
+ await projectsApi.createTask({
+ project_id: task.project_id,
+ parent_task_id: task.id,
+ title,
+ });
+ setSubtask("");
+ await reload();
+ onTaskAdded?.();
+ } catch (err) {
+ setError(String((err as Error).message));
+ } finally {
+ setBusy(false);
+ }
+ }
+
async function addComment() {
const body = comment.trim();
if (!body) return;
@@ -144,16 +217,79 @@ export function TaskPanel({ task, statuses, onClose, onChanged }: Props) {
))}
- {task.assignees?.length ? (
-
- Assigned to {task.assignees.join(", ")}
-
- ) : null}
+
+ Assignees
+
+ {assignees.map((who) => {
+ const kind = classify(who);
+ return (
+ " : who}
+ className={`flex items-center gap-1 rounded-md px-2 py-1 text-xs ${
+ kind === "unknown"
+ ? "border border-border text-muted-foreground"
+ : "bg-muted text-foreground"
+ }`}
+ >
+ {/* Agents and people are one vocabulary (D-PM-4), so the
+ difference is an icon, never a separate field. */}
+ {kind === "agent" ? : null}
+ {assigneeLabel(who)}
+ void saveAssignees(withoutAssignee(assignees, who))}
+ className="text-muted-foreground hover:text-foreground"
+ >
+
+
+
+ );
+ })}
+ {assignees.length === 0 ? (
+ Nobody yet
+ ) : null}
+
{error ? (
diff --git a/workbench/control_plane/src/app/projects/lib/assignees.test.ts b/workbench/control_plane/src/app/projects/lib/assignees.test.ts
new file mode 100644
index 00000000..b23133f3
--- /dev/null
+++ b/workbench/control_plane/src/app/projects/lib/assignees.test.ts
@@ -0,0 +1,114 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ assigneeLabel,
+ classify,
+ normalize,
+ parseAssignees,
+ withAssignee,
+ withoutAssignee,
+} from "./assignees";
+
+describe("normalize", () => {
+ it("matches the server: trimmed and lowercased", () => {
+ expect(normalize(" Priya@X.com ")).toBe("priya@x.com");
+ });
+});
+
+describe("classify", () => {
+ it("recognises the agent prefix", () => {
+ expect(classify("agent:researcher")).toBe("agent");
+ expect(classify("Agent:Researcher")).toBe("agent");
+ });
+
+ it("does not call a bare prefix an agent", () => {
+ // `agent:` names nobody, and treating it as an agent would show a chip for
+ // a dispatch target that cannot exist.
+ expect(classify("agent:")).toBe("unknown");
+ });
+
+ it("recognises an email", () => {
+ expect(classify("priya@fracktal.in")).toBe("person");
+ });
+
+ it("flags something that is neither, without rejecting it", () => {
+ // A hint, not a rule — the server accepts any non-empty string, and the
+ // failure worth surfacing is a typo that assigns work to nobody.
+ expect(classify("priya")).toBe("unknown");
+ expect(classify("priya@fracktal")).toBe("unknown");
+ });
+});
+
+describe("parseAssignees", () => {
+ it("splits on commas, semicolons and newlines", () => {
+ expect(parseAssignees("a@x.com, b@x.com; c@x.com\nd@x.com")).toEqual([
+ "a@x.com",
+ "b@x.com",
+ "c@x.com",
+ "d@x.com",
+ ]);
+ });
+
+ it("does NOT split on spaces", () => {
+ // A pasted list arrives as `Priya ` often enough that
+ // splitting on whitespace would shred it into tokens assigning nobody.
+ expect(parseAssignees("priya ")).toEqual(["priya "]);
+ });
+
+ it("drops empties left by trailing separators", () => {
+ expect(parseAssignees("a@x.com,, ,\n")).toEqual(["a@x.com"]);
+ });
+
+ it("dedupes after normalising, keeping first-seen order", () => {
+ expect(parseAssignees("B@x.com, a@x.com, b@X.com")).toEqual([
+ "b@x.com",
+ "a@x.com",
+ ]);
+ });
+
+ it("keeps agent targets alongside people", () => {
+ expect(parseAssignees("agent:Researcher, priya@x.com")).toEqual([
+ "agent:researcher",
+ "priya@x.com",
+ ]);
+ });
+});
+
+describe("withAssignee", () => {
+ it("appends a new assignee", () => {
+ expect(withAssignee(["a@x.com"], "B@x.com")).toEqual(["a@x.com", "b@x.com"]);
+ });
+
+ it("returns the SAME array when the assignee is already there", () => {
+ // Identity is the signal a caller uses to skip the PUT — and skipping it
+ // is what stops a re-assert emitting pm.task.assigned and re-dispatching
+ // an agent run.
+ const current = ["a@x.com"];
+ expect(withAssignee(current, "A@X.com")).toBe(current);
+ });
+
+ it("ignores blank input", () => {
+ const current = ["a@x.com"];
+ expect(withAssignee(current, " ")).toBe(current);
+ });
+});
+
+describe("withoutAssignee", () => {
+ it("removes case-insensitively", () => {
+ expect(withoutAssignee(["a@x.com", "b@x.com"], "A@X.com")).toEqual(["b@x.com"]);
+ });
+
+ it("is a no-op for somebody absent", () => {
+ expect(withoutAssignee(["a@x.com"], "z@x.com")).toEqual(["a@x.com"]);
+ });
+});
+
+describe("assigneeLabel", () => {
+ it("shows an agent by name, without the prefix", () => {
+ expect(assigneeLabel("agent:Researcher")).toBe("researcher");
+ });
+
+ it("shows a person by address", () => {
+ expect(assigneeLabel("Priya@x.com")).toBe("priya@x.com");
+ });
+});
diff --git a/workbench/control_plane/src/app/projects/lib/assignees.ts b/workbench/control_plane/src/app/projects/lib/assignees.ts
new file mode 100644
index 00000000..47c6c1a6
--- /dev/null
+++ b/workbench/control_plane/src/app/projects/lib/assignees.ts
@@ -0,0 +1,104 @@
+/**
+ * Projects · the assignee vocabulary, client side.
+ *
+ * Spec: `ai-company-brain/specs/project_management_app.md` §3.7 · **D-PM-4**.
+ *
+ * An assignee is an **email or `agent:`** — one vocabulary for both
+ * species. That is what makes handing work to an agent the same action as
+ * handing it to a colleague, rather than a parallel feature with its own field,
+ * its own picker and its own permission story.
+ *
+ * These functions mirror the server's normalisation (`tasks.py::set_assignees`
+ * strips and lowercases) so the chips a member sees before saving match the set
+ * that comes back after. They deliberately do **not** reject anything the
+ * server would accept: inventing a client-side rule the API does not enforce is
+ * how a UI starts refusing valid data.
+ */
+
+export type AssigneeKind = "person" | "agent" | "unknown";
+
+/** The prefix that makes an actor an agent. Lowercased by {@link normalize}. */
+export const AGENT_PREFIX = "agent:";
+
+/**
+ * One assignee, normalised the way the server stores it.
+ *
+ * Lowercased because `pm_task_assignees` holds one row per `(task, assignee)`
+ * and the API lowercases on write — leaving case here would show a member
+ * "Priya@…" and "priya@…" as two people until the next reload proved otherwise.
+ */
+export function normalize(raw: string): string {
+ return raw.trim().toLowerCase();
+}
+
+/**
+ * What kind of actor a token names.
+ *
+ * `unknown` is a **hint, not a rejection** — the server accepts any non-empty
+ * string, so the UI's job is to say "that does not look like an email or an
+ * agent" and let the member decide. A typo'd address that silently assigns
+ * work to nobody is the failure worth surfacing.
+ */
+export function classify(assignee: string): AssigneeKind {
+ const value = normalize(assignee);
+ if (value.startsWith(AGENT_PREFIX)) {
+ return value.length > AGENT_PREFIX.length ? "agent" : "unknown";
+ }
+ // Deliberately loose: something@something.something. A stricter regex would
+ // reject addresses that are legal and deliverable.
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? "person" : "unknown";
+}
+
+/**
+ * Turn typed text into the assignee set to send.
+ *
+ * Splits on commas, semicolons and newlines — never on spaces, because a
+ * pasted list often arrives as `"Priya , Sam"` and splitting on
+ * whitespace would shred it into tokens that assign work to nobody.
+ *
+ * Order is preserved and duplicates are dropped **after** normalisation, so
+ * `Priya@x.com, priya@x.com` is one assignee rather than a set the server
+ * silently collapses behind the member's back.
+ */
+export function parseAssignees(input: string): string[] {
+ const seen = new Set();
+ const out: string[] = [];
+ for (const part of input.split(/[,;\n]/)) {
+ const value = normalize(part);
+ if (!value || seen.has(value)) continue;
+ seen.add(value);
+ out.push(value);
+ }
+ return out;
+}
+
+/**
+ * Add one assignee to a set, or return the set unchanged.
+ *
+ * Returning the *same array* when nothing changed is deliberate: it lets a
+ * caller skip a PUT that would emit a `pm.task.assigned` event for an assignee
+ * who is already there — which WS-27f keys agent dispatch off, so a re-assert
+ * would re-dispatch a run.
+ */
+export function withAssignee(current: readonly string[], raw: string): string[] {
+ const value = normalize(raw);
+ if (!value) return current as string[];
+ const existing = current.map(normalize);
+ if (existing.includes(value)) return current as string[];
+ return [...existing, value];
+}
+
+/** Remove one assignee. Removing somebody absent is a no-op, not an error. */
+export function withoutAssignee(
+ current: readonly string[],
+ raw: string
+): string[] {
+ const value = normalize(raw);
+ return current.map(normalize).filter((a) => a !== value);
+}
+
+/** The label for a chip: an agent shows its name, a person their address. */
+export function assigneeLabel(assignee: string): string {
+ const value = normalize(assignee);
+ return value.startsWith(AGENT_PREFIX) ? value.slice(AGENT_PREFIX.length) : value;
+}
diff --git a/workbench/control_plane/src/app/projects/page.tsx b/workbench/control_plane/src/app/projects/page.tsx
index 71e50c99..d8371cf4 100644
--- a/workbench/control_plane/src/app/projects/page.tsx
+++ b/workbench/control_plane/src/app/projects/page.tsx
@@ -11,6 +11,7 @@
* hand-edited slug shows nothing the caller could not already reach (R9, and
* `lib/tree.filterByCenter`'s own test says so).
*/
+import { Plus } from "lucide-react";
import { Suspense, useCallback, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
@@ -51,6 +52,17 @@ function ProjectsWorkspace() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
+ // Creating a project: `undefined` = not creating, `null` = a new department
+ // at the root, a row = a subproject under it. Three states in one because
+ // "which parent" is the only question, and a separate boolean would let the
+ // two disagree.
+ const [creatingUnder, setCreatingUnder] = useState(
+ undefined
+ );
+ const [newName, setNewName] = useState("");
+ const [newTask, setNewTask] = useState("");
+ const [treeKey, setTreeKey] = useState(0);
+
// The tree, plus every root's grants — the grants are what the Center filter
// reads, and fetching them per root keeps `filterByCenter` a pure function
// over data the page already holds.
@@ -79,7 +91,7 @@ function ProjectsWorkspace() {
return () => {
live = false;
};
- }, []);
+ }, [treeKey]);
const visibleRoots = useMemo(
() => filterByCenter(roots, grants, center),
@@ -141,6 +153,47 @@ function ProjectsWorkspace() {
[selected, statuses]
);
+ async function submitProject(event: React.FormEvent) {
+ event.preventDefault();
+ const name = newName.trim();
+ if (!name) return;
+ setError(null);
+ try {
+ const created = await projectsApi.createProject({
+ name,
+ parent_project_id: creatingUnder ? creatingUnder.id : null,
+ });
+ setNewName("");
+ setCreatingUnder(undefined);
+ setTreeKey((k) => k + 1);
+ // A subproject is not selectable until the refreshed tree carries it, so
+ // only a new root is selected here — selecting a stale row would show an
+ // empty board and read as a failed create.
+ if (!creatingUnder) {
+ setMine(false);
+ setSelected(created);
+ }
+ } catch (err) {
+ setError(String((err as Error).message));
+ }
+ }
+
+ async function submitTask(event: React.FormEvent) {
+ event.preventDefault();
+ const title = newTask.trim();
+ if (!title || !selected) return;
+ setNewTask("");
+ setError(null);
+ try {
+ // Status is deliberately not sent: the API picks the project's default,
+ // so the browser never has to know which lane a new task starts in.
+ await projectsApi.createTask({ project_id: selected.id, title });
+ await loadProject(selected);
+ } catch (err) {
+ setError(String((err as Error).message));
+ }
+ }
+
async function handleDrop(
task: TaskRow,
writes: ReturnType,
@@ -174,12 +227,44 @@ function ProjectsWorkspace() {
return (