Skip to content

cockpit v3: threads per node and structural verbs with preview, apply and Undo (S11, S12) - #215

Merged
LittleChimera merged 3 commits into
stagingfrom
factory/v3-m2-verbs
Nov 4, 2026
Merged

LittleChimera merged 3 commits into
stagingfrom
factory/v3-m2-verbs

Conversation

@LittleChimera

@LittleChimera LittleChimera commented Nov 4, 2026 •

Copy link
Copy Markdown

Related issue

Closes # (cockpit v3 milestone 2, plan items S11 and S12; notes/factory-v3/final/plan.md)

Summary

The owner said "i don't know how i'm supposed to steer it". This PR adds the server half of steering at a node.

  • S11, threads per node. A need, feature, study or task gets its own Room thread. The agent that answers it sees the node as the cockpit shows it, and can read any node page with a new tool. Cards land in the thread of the node they are about.
  • S12, structural verbs. Every structural verb has a dry-run preview: what changes, a ghost in the parent's columns, the roll-up before and after, and who approves. Apply runs exactly what was previewed (409 stale when the facts it touches moved). Undo is available for 10 minutes. The verbs run today's cores (projects proposals, controls, task edits, initiative edits, the discovery create, definition versions), so they stay audited. A move never skips a level (422 wrong_level). Start now relaxes a wait to the change it really needs (PR-9876 · met).
  • A verb is served only where its level offers it, and by whom the level says. The definition's structure.verbs gates each verb, and structure.levels[].authority decides who may apply it. The node page offers exactly the verbs the routes serve.

v1 and v2 behave as before. The Room's own thread list, its cards and its badge are unchanged. By default the node header rides only on cockpit v3's per-node threads, so the threads v2 shares keep their envelope. Agents only act through proposals: the verb routes are human-origin only.

ELI5: you are on a task page. You type in its chat, and the task's project lead gets the message with a short card on top: what the task is, why it exists, what holds it and what asks are open. You press "Start now" on a waiting project. First you see the plan: "waits on assistant-core → needs only PR-9876 · merged (met)", the project moving to First, and "You, as owner. Applies now; Undo for 10 min." You confirm, and an Undo stays available for 10 minutes.

POST …/n/assistant-panel-ux/preview {verb:"start"}
  → {preview_id, title, effects:["Wait: waits on assistant-core (…) → needs only PR-9876 · merged (met).", …],
     columns_after:{…panel as a ghost in First…}, moved_from:{ref, col:1},
     rollup_delta:{nodes:[{ref,title,items:[{key,label,before,after}]}], text} | null,
     approver:"you", approver_note, can_apply, undo:true}
POST …/n/assistant-panel-ux/apply {verb, args, preview_id}
  → gate: structure.verbs lists it? authority of the level it changes lets you?
  → re-plan over fresh facts; same id? → file C-n (update depends_on → [PR-9876]) applied at once,
    start its lead, post a preview_applied card, {result: NodePage, undo:{token, expires_at (ISO)}, filed:"C-n", message}
POST …/undo/{token} → proposals.undo(C-n): depends_on back to [assistant-core]

Plan bullets and what this PR does

S11. Thread keys need:, feature:, study:, task: with a responder per level (omnigent/factory.py, chat.py, chat_routing.py)

  • thread_kind recognises the four node prefixes (THREAD_NODE_PREFIXES).
  • resolve_thread accepts each key only for a node of this factory. Unknown nodes return 404 unknown_node, never a guess:
    • tasks (task:T-16, or a PR's implicit task:adhoc:<id>);
    • opportunities (need:O-3) and asked messages (need:ask:<id>);
    • initiatives (feature:I-1);
    • implicit parents whose proposal or initiative exists (need:from-C-12, need:from-I-2);
    • by-<who> parents only when <who> started a project here (a need also when a feature was named under it).
    • study:R-n waits for S14's table.
    • Plain refs spell the same thread (T-16 → task:T-16).
  • chat_routing.routes answers node threads from the definition's structure.responders:
    • Atlas for features.
    • Scout for needs and studies; Atlas silently while discovery is off.
    • The node's project's PL for a task. Atlas stands in, with a note, while that PL is not up.
    • A level set to null routes to nobody, with a note saying to @mention an agent.
  • Task messages route to the lead with the task named. The envelope title is T-16 and its header names "Task T-16 · <title>".
  • No loops: agents' messages still route only by @mention, never by a thread default.
  • Who may post: a PL may post in its own tasks' threads; Scout in need and study threads.

S11. Header injection into each agent turn (node_context.py, new; chat.build_delivery, Room._deliver)

  • A delivery about a node carries its header: the path, why it exists, the status line (the blocker path in words), the roll-up, the open asks (blocking first), and sys_factory_node(op="read", ref=…) "when your tools have it".
  • It is built from the factory's tree as its own agents see it (full_tree, the 5 s memo). Any error leaves it out; a delivery never waits on it.
  • Per-factory flag (plan §4's containment): the definition's structure.node_header (owners only, like the rest of structure):
    • node_threads, the default: only the per-node threads only cockpit v3 posts in. So #factory, project and PR deliveries in v2 factories are unchanged.
    • all: also #factory, a project's and a PR's thread.
    • off: none.
    • It is left out of stored versions while it holds its default (SPARSE_FIELDS), so an older server still reads the definition.
  • The prompt size guard never cuts a delivered message. The header is capped at 1500 characters. What the server adds around the messages is held to ENVELOPE_MAX_CHARS (8000, the factory's message cap). It gives way in this order: the header's detail (its first line and Status: stay), the oldest thread context, then the whole header. Messages that alone pass the cap go out whole.

S11. sys_factory_node(op="read", ref) (tools/builtins/factory.py, tools/manager.py, runner/factory_dispatch.py, api/node_agent.py)

  • A new tool for leads, PLs, Scout and the planner, served by GET /v1/factory/agent/node. It returns the same NodePage as GET …/n/{ref}, as the runner's owner may read it, with verbs disabled ("agents propose changes").
  • Old hosts: an old host has no such tool and nothing requires it (the header names it as optional). Against an old server, the route's 404 becomes not_served: FastAPI's bare {"detail": "Not Found"}, or the web UI's {"error": {"code": "not_found"}} when the server mounts the SPA. unknown_node keeps its code. No policy path was added to any bundle.

S11. chat_cards.py: card routing by node ref and new card types

  • A decision whose subject names a task, need, feature or study is also carded in that node's own thread, and in no other node's thread. The Room thread above keeps its card exactly as before, so v2 is untouched.
  • An alarm the server closed (alarms.read) about a task is a closed_alarm card in the task's thread, in place of the decision's copy there. Its meta is {was, cause, real_ask}.
  • post_node_card posts the cockpit's own cards only in their node's thread:
    • why: a bare "why" in a node thread posts it and is not routed;
    • offer;
    • preview_applied: after every applied verb, with its Undo token; also when undone.
  • schemas.ChatCardType and ChatCardView.meta know why, closed_alarm, offer and preview_applied (ClosedAlarmMeta, OfferMeta, PreviewAppliedMeta). The web types gain preview_applied, the node thread kinds and ThreadView.node_ref.

S11. GET chat/threads adds node_ref

  • Every thread names its node: product, fx, a project's ref, PR-n or the node thread's ref.
  • ?nodes=1 also lists the node threads that have messages.
  • Preflight says node_threads: true, which switches cockpit v3's chat to per-node threads (W5 already reads it).

S12. POST …/n/{ref}/preview|apply, POST …/undo/{token} (node_verbs.py, api/node_verbs.py, new)

  • Preview returns:
    • {preview_id, verb, ref, title, args, effects[], columns_after (ChildPage, ghosts dashed), approver, approver_note, can_apply, who_may, undo, filed, warnings, based_on};
    • rollup_delta as {nodes[{ref, title, items[{key,label,before,after}]}], text}, or null when nothing in the roll-up changes;
    • the fields cockpit v3 reads beyond api.md (cockpit v3: verb previews with Undo, sheets, since last look (W6, W8, W10) #214's contract in docs/FACTORY.md): moved_from (Start now, Order, a pull forward), pulls_forward and apply_label (Add child), parents (Move, Name it).
  • Move and Name it, form read: with nothing picked, the preview is a 200 with the stored parents one level up and can_apply: false. Applying it is 400.
  • based_on / 409 stale: preview_id digests the plan (verb, normalised args) and the facts it touches. Apply re-plans over fresh facts and refuses with 409 stale (carrying the new preview) when the id moved. A based_on older than the page only adds a warning, because unrelated churn does not make a write stale.
  • Verbs per level are data: a verb is served only where structure.verbs lists it for the node's level, else 422 not_here naming the entry. Resume is offered with pause; reorder, split, merge and close are offered with move.
  • Authority per level is data: who applies comes from structure.levels[<level it changes>].authority. Empty seats mean nobody may (422 not_here). An owner approval, or seats with owner but not operator, leave it to owners. Otherwise editors may. The core's own owner rules still apply on top. The node page (Tree._verb_applies) offers exactly what is served: no add at the product, no move at a task.
  • Other errors: 422 wrong_level names the level a parent must be on. 403 authority carries who. 400 preview_required. 409 nothing_to_do. The cores' own errors pass through.
  • Undo: each apply records node.verb_applied (subject undo:<token>) with how to take it back. Undo works once, within 10 minutes, by the person who applied it or an owner. It uses each core's own way back: the proposal's Undo (falling back to cancelling a project whose lead already started), the inverse control, the initiative edit, the stored value, or the definition version before. Apply and Undo answer with a one-line message.

S12. The verbs

  • add child
    • Under a feature: files the person's projects proposal extending the feature and applies it at once. It joins the wave the feature builds now (analysis.initiative.same_wave), so planned later waves stay ahead: v2's next wave and v3's Later still agree. "Plan X now" goes to the feature's thread for its responder.
      • A title that matches a held item pulls it forward instead of copying it. The item leaves the wave that held it (an emptied wave goes too). Its Undo puts it back.
      • col: "later" holds it in a wave after the current one.
    • Under a project: a task (TASK ADD to its lead).
    • Under a need: a feature (owners).
  • start now: relaxes each unmet project dependency to that project's newest merged change, then starts the lead. depends_on may name a change ref (PR-9876), met once that PR merged. Supported in charter, start (validation and "PR-n: why" items), gating, waits, both dependency checks (unknown PR → 400; never part of a cycle), initiative_view, the v2 graph (draws no project edge) and the node chips ("depends on PR-9876 · met"). The chip uses the gate's own rule (gating.change_met: the dependent's repo first).
  • pause / resume: the controls of every lead under the node. A task gets a hold instead. The preview names the projects under the node that a pause cannot stop (no lead yet, or held).
  • move and reorder: new PROJECT_OPS:
    • move ({op, slug, to: "I-n"}): re-homes the project with projects.move_project_core; Undo puts its feature and plans back.
    • reorder ({op, slug, after?, reason?}): stored as the update of depends_on it makes among its feature's projects. An agent's reorder after a sibling needs a reason (400 dependency_reason_required, as for an update).
    • Atlas can file both with sys_factory_project; a PL may reorder its own project.
    • The move verb files a move; the reorder verb files the update. With after, the node lands in the column after that sibling's, and the preview's ghost, args and fingerprint say that column (a warning if col said otherwise). A column with two candidate siblings asks which one. A feature moves under another opportunity.
  • split / merge / close: the existing proposal ops, applied at once (no Undo; the preview says so). A feature closes through the initiative edit core (Undo reopens it).
  • name it: an implicit feature becomes an initiative row in place, or files its projects under a stored feature. An implicit need from a proposal becomes an opportunity through the discovery create (opportunity.created), or is filed under one.
  • team: the existing definition/preview and PUT …/definition (TeamEditor's changes, or {member, vendor, model}). Undo pins the version before only while the version this verb made is still in force. After a later definition change it is 409 undo_closed, so nothing newer is thrown away.

Deviations

  • Card copies: decision cards about a task, need, feature or study land in the node's thread and keep their old Room thread (project, factory), so v2's Room is unchanged. "Only in its node's thread" holds across node threads.
  • chat/threads opt-in: node threads are listed only with ?nodes=1, so v2's Room list and dock badge never count messages it cannot open. Cockpit v3's NodeChat still calls the shared useRoomThreads(), so its "children with news" lines need a web follow-up to pass nodes=1.
  • Pause scope: pause and resume fan out to the leads under the node through the existing control targets. No scope field was added to POST /control. A project whose lead has not started yet still starts once its dependencies are met (the gate does not know about pauses). The preview warns about it, by name.
  • The web wiring is a follow-up: cockpit v3: verb previews with Undo, sheets, since last look (W6, W8, W10) #214's SERVED_STEER is still empty. Attaching preview, apply and undo to the live source is the web PR after this one. This PR serves the fields cockpit v3: verb previews with Undo, sheets, since last look (W6, W8, W10) #214 documents. Its preview normalizer also reads this server's roll-up shape, so that wiring is one line.
  • No offer producer yet: the server posts no offer card on its own. The type, routing and meta are there for W6 and S13.
  • Not here: auto (S13) and research (S14) are not served by these routes (400 unknown_verb). Moving a task, and adding a need or goal, answer 422 not_here.
  • No migration: Undo records are events; the depends_on change refs live in the charter JSON; structure.node_header is a definition field.

Test Plan

  • tests/server/factory/test_node_verbs.py (52 tests, over a real store with an initiative):
    • each verb's preview is its apply, and Undo restores the stored rows, parametrized over 16 cases. Cases: add, pull forward, hold, add task, add feature, start, reorder, move project, move feature, close feature, name feature, file a feature's projects, name need, file need, pause task, team. Each case compares the preview's ghost columns and roll-up counts with the page after apply. It then undoes and checks projects, tasks, open features with their waves, needs, filings and the team equal the rows before.
    • regressions from review:
      • a reorder after a named sibling previews the column it lands in;
      • adds keep the feature's later waves ahead (v2 _next_wave and v3 Later agree after an add, a pull-forward and a hold);
      • the team Undo after a later definition change is 409 and keeps that change;
      • every verb a page offers is served there (no product add, no task move);
      • a verb its level does not list is 422; who applies follows the level's authority;
      • pausing a feature names what it cannot stop;
      • a preview with nothing in its roll-up has rollup_delta: null;
      • a by-<who> thread needs its node;
      • an agent's reorder after a sibling says why.
    • the web's contract: Move and Name it list the parents on a first read; approver_note, moved_from, pulls_forward, apply_label; apply's message and an ISO expires_at.
    • Kept from before: stale writes refused; a move skips no level; Start now on the panel turns its wait into PR-9001 · met; an editor applying an owner's verb gets 403; a reader never applies; agents file move and reorder.
  • tests/server/factory/test_node_threads.py (29 tests):
    • routing per level and no loops;
    • the header on node threads, off by setting, kept out of a project thread by default and in it with all;
    • the guard gives up the header and the context before any message (two 4000-char messages arrive whole; an 8000-char batch on staging's shape loses nothing);
    • each node card validates as a ChatMessageView;
    • plus the card routing, node_ref, preflight and agent page tests.
  • tests/runner/test_factory_node_tool.py: the SPA-style 404 is not_served too.
  • tests/server/factory/test_change_dependencies.py, golden definition defaults (the new field), the node page web fixture (no product add, no task move).
  • Web: nodeSteer.test.tsx reads the S12 roll-up shape and null.

Commands (worktree, rebased on staging @ 523a713, after #213 and #214, OMNIGENT_SKIP_WEB_UI=true CI=1):

  • uv run --frozen pytest -q -n 8 tests/server/factory tests/runner/test_factory_* tests/examples tests/policies tests/factory -p no:cacheprovider → 9046 passed, 1 failed: test_a_cold_build_with_history_answers_under_300ms took 0.99 s on a machine at load 12 (other agents building). It passes alone three times out of three.
  • uv run --frozen pytest tests/e2e_ui/factory_v3 --browser-channel chrome → 7 passed (SPA built from the rebased tree with npm run build, then --ui-skip-build).
  • Web: npm run type-check, npm run lint, npm run format:check, npm test -- src/components/factory src/pages/factory → 128 files, 2060 tests passed.
  • pre-commit run --files <changed>:
    • ruff format and check pass;
    • pyrefly reports the 28 errors already on staging, none in changed code;
    • the test lints, no-hardcoded-models, prettier, oxlint, tsc and the hygiene hooks pass.
  • FORBIDDEN_SAMPLE_TOKENS is green (tests/examples/test_factory_sample_tokens.py).
  • No migration.

How to try it (a person, on a server from this branch):

  1. Open a factory with ?cockpit=v3. Open a task page. Its chat now posts in task:T-n. The project's lead gets the message with a --- This thread is about Task T-n … --- header, a Status: line and the reply call. A message in #factory or a project's thread arrives as before.
  2. Optional: as an owner, PUT …/definition {"changes":[{"op":"replace","path":"structure.node_header","value":"all"}]}. Project threads then carry the header too.
  3. On a project that waits on another one whose PR merged, run curl -X POST …/v1/factory/factories/<fid>/n/<slug>/preview -d '{"verb":"start"}'. Read the effects ("needs only PR-n · merged (met)") and approver_note, then apply it with its preview_id. The project's chip reads "depends on · PR-n · met". POST …/undo/<token> brings the dependency back.
  4. {"verb":"move"} with no args lists the parents. {"verb":"move","args":{"to":"<a need ref>"}} is 422 wrong_level. {"verb":"add"} at product is 422 not_here.

Demo

N/A (server and API; cockpit v3's previews and Undo toast render in W6, wired in the next web PR).

Type of change

  • Bug fix
  • Feature
  • UI / frontend change
  • Refactor / chore
  • Docs
  • Test / CI
  • Breaking change

Test coverage

  • Unit tests added / updated
  • Integration tests added / updated
  • E2E tests added / updated
  • Manual verification completed
  • Existing tests cover this change
  • Not applicable

Coverage notes

The verbs run over a real SQL store through the routes. The node threads run through the Room with a recording mailer. The v3 e2e runs against a real server with the SPA built.

Changelog

Cockpit v3: every need, feature, study and task has its own chat thread whose agent sees the node's status, and structural changes (add, start now, pause, move, reorder, name, team) preview before they apply, with Undo for 10 minutes

🤖 Generated with Claude Code

@github-actions github-actions Bot added the size/XL Pull request size: XL label Nov 4, 2026


def _error(status: int, code: str, message: str) -> JSONResponse:
return JSONResponse(status_code=status, content={"error": {"code": code, "message": message}})
LittleChimera and others added 3 commits November 4, 2026 05:07
… and Undo (S11, S12)

S11. The Room gets a thread per need, feature, study and task (need:<ref>,
feature:<ref>, study:R-n, task:<ref>), each resolving only to a node of the
factory. Who answers is the definition's structure.responders per level:
Atlas for features, Scout for needs and studies (Atlas while discovery is
off), the project's lead for a task, with the task named; agents' messages
still route only by @mention, so node threads never loop. Every Room
delivery about a node carries its header (path, why, status line, roll-up,
open asks) within the factory's message cap (8000 chars; the header's
detail, then old context, then the longest message give way). A root label
omnigent.factory.node_header=off leaves it out. sys_factory_node(op="read",
ref) returns the node page to leads, PLs, Scout and the planner; an older
server answers not_served. Decision cards about a task, need, feature or
study are also carded in that node's thread, an alarm the server closed is a
closed_alarm card in its task's thread, and the cockpit's own cards (why,
offer, preview_applied) go only to their node's thread. chat/threads names
each thread's node (node_ref) and lists node threads with nodes=1; preflight
says node_threads.

S12. POST …/n/{ref}/preview plans a verb over the facts: effects, the
parent's columns with a ghost, the roll-up before and after, who approves,
and a preview_id over the facts it touches. POST …/apply runs exactly that
plan (409 stale otherwise), POST …/undo/{token} takes it back once within
10 minutes. Verbs: add (a project in a feature, pulling a held item forward;
a task; a feature), start now (depends_on may name a change, PR-9876, met
once merged), pause/resume, move and reorder (new PROJECT_OPS move and
reorder), split/merge/close, name it, team. 422 wrong_level keeps every
parent one level up; 403 authority names who may.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tch their apply, header only where v3 asks

- Reorder with `after` lands in the column after that sibling's, and the
  preview's ghost, args and fingerprint say that column; a column with two
  candidate siblings asks which one.
- Team Undo pins the version before only while the version the verb made is
  still in force (409 undo_closed otherwise), so a later definition change
  is never thrown away.
- A person's add under a feature joins the wave it builds now (initiative
  `same_wave`), so its planned later waves stay ahead; a held item pulled
  forward leaves its wave, and its Undo puts it back. A hold goes to a wave
  after the current one.
- plan_verb serves a verb only where structure.verbs lists it (resume with
  pause; reorder, split, merge and close with move) and reads
  structure.levels[].authority for who applies it; the node page offers
  exactly what is served (no add at the product, no move at a task).
- The envelope guard never cuts delivered messages: it gives up the header's
  detail, the oldest context, then the whole header.
- structure.node_header (node_threads by default, all, off) replaces the root
  label: v2's shared threads keep their envelope unless a factory asks.
- by-<who> thread keys resolve only for a real creator; from-I-n too.
- not_served also for the SPA's 404 body; an agent's reorder after a sibling
  needs a reason; the change chip uses the gate's repo rule; no empty
  roll-up delta; pause names what it cannot stop.
- Close, move and name a feature or need go through the initiative edit and
  discovery create cores, with their events.
- Chat card schemas and web types know the node cards, node thread kinds and
  node_ref.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ulls_forward, approver_note), apply says it in one line

- Move and Name it with nothing picked are the form's first read: a 200 with
  the stored parents one level up, can_apply false, nothing to apply.
- Previews add approver_note, moved_from (Start now, Order, a pull forward),
  pulls_forward and apply_label; apply and Undo answer with `message`, and
  Undo's expires_at is an ISO time (the web parses it as one).
- The web's preview normalizer reads the server's roll-up
  ({nodes: [{title, items}], text}, or null) as lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@LittleChimera
LittleChimera merged commit 89a3ac2 into staging Nov 4, 2026
45 of 51 checks passed
@LittleChimera
LittleChimera deleted the factory/v3-m2-verbs branch November 4, 2026 04:21
@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown

❌ UI Snapshot doesn't match the committed baseline.

If this UI change is intentional, update the baseline — each path renders in the same pinned image, so the result matches this gate:

  • Label the PR (recommended): add the update-ui-snapshot label — the bot regenerates the baseline in the pinned image, pushes it back here, and re-runs the checks.
  • Locally with Docker: run tests/e2e_ui/visual/regen_baseline_docker.sh, review the PNG, then commit + push.

Diff PNGs (expected_=baseline, actual_=your render, diff_) are in the run artifact. Full guide: tests/e2e_ui/visual/README.md.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/XL Pull request size: XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants